fix(web): stabilize archive confirmation surface (#3460)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-07 16:16:44 +01:00
committed by GitHub
parent 5ee5161166
commit 52948cd3f0
7 changed files with 578 additions and 69 deletions
@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRef } from "react";
import { useCallback, useRef, useState } from "react";
import { StateProvider } from "@/components/state-provider";
const getSubtaskCountMock = vi.hoisted(() => vi.fn());
@@ -21,6 +21,10 @@ vi.mock("@/hooks/use-responsive-breakpoint", () => ({
import { TaskArchiveConfirmation } from "./task-archive-confirmation";
afterEach(cleanup);
beforeEach(() => {
pointerState.isFinePointer = false;
getSubtaskCountMock.mockReset();
});
function ConfirmationHarness({
onConfirm,
@@ -37,6 +41,9 @@ function ConfirmationHarness({
<button ref={anchorRef} type="button" data-testid="archive-anchor">
Archive source
</button>
<button type="button" data-testid="outside-action">
Outside action
</button>
<TaskArchiveConfirmation
open
onOpenChange={onOpenChange}
@@ -52,6 +59,43 @@ function ConfirmationHarness({
);
}
function AnchorLifecycleHarness({
showAnchor,
onOpenChange,
}: {
showAnchor: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange(nextOpen);
},
[onOpenChange],
);
return (
<>
{showAnchor ? (
<button ref={anchorRef} type="button" data-testid="archive-anchor">
Archive source
</button>
) : null}
<TaskArchiveConfirmation
open={open}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
taskId="task-1"
taskTitle="Task One"
executorType="worktree"
onConfirm={vi.fn()}
/>
</>
);
}
function renderConfirmation(onConfirm = vi.fn(), onOpenChange = vi.fn(), forceDialog = false) {
return render(
<StateProvider>
@@ -64,12 +108,66 @@ function renderConfirmation(onConfirm = vi.fn(), onOpenChange = vi.fn(), forceDi
);
}
describe("TaskArchiveConfirmation classification", () => {
beforeEach(() => {
pointerState.isFinePointer = false;
getSubtaskCountMock.mockReset();
function deferredSubtaskCount() {
let resolve: (result: { count: number }) => void = () => {};
const promise = new Promise<{ count: number }>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("TaskArchiveConfirmation pending dismissal", () => {
it("dismisses the hidden desktop request on Escape and restores trigger focus", async () => {
pointerState.isFinePointer = true;
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
const onOpenChange = vi.fn();
renderConfirmation(vi.fn(), onOpenChange);
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
const outsideAction = screen.getByTestId("outside-action");
outsideAction.focus();
fireEvent.keyDown(outsideAction, { key: "Escape" });
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(document.activeElement).toBe(screen.getByTestId("archive-anchor"));
});
it("dismisses the hidden desktop request on outside pointer intent", async () => {
pointerState.isFinePointer = true;
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
const onOpenChange = vi.fn();
renderConfirmation(vi.fn(), onOpenChange);
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
fireEvent.pointerDown(screen.getByTestId("outside-action"));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("dismisses the hidden desktop request when its anchor disappears", async () => {
pointerState.isFinePointer = true;
const deferredCount = deferredSubtaskCount();
getSubtaskCountMock.mockReturnValue(deferredCount.promise);
const onOpenChange = vi.fn();
const renderHarness = (showAnchor: boolean) => (
<StateProvider>
<AnchorLifecycleHarness showAnchor={showAnchor} onOpenChange={onOpenChange} />
</StateProvider>
);
const view = render(renderHarness(true));
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
view.rerender(renderHarness(false));
await act(async () => deferredCount.resolve({ count: 2 }));
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("TaskArchiveConfirmation classification", () => {
it("does not expose an archive action while descendant classification is pending", () => {
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
@@ -79,20 +177,24 @@ describe("TaskArchiveConfirmation classification", () => {
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("shows a disabled anchored loading surface while desktop classification is pending", () => {
// @covers AC-TASKS-CONFIRMATION-SURFACE-002.4
it("waits for desktop classification before showing only the cascade dialog", async () => {
pointerState.isFinePointer = true;
getSubtaskCountMock.mockReturnValue(new Promise(() => undefined));
const onOpenChange = vi.fn();
const deferredCount = deferredSubtaskCount();
getSubtaskCountMock.mockReturnValue(deferredCount.promise);
renderConfirmation(vi.fn(), onOpenChange);
renderConfirmation();
await waitFor(() => expect(getSubtaskCountMock).toHaveBeenCalledWith("task-1"));
expect(screen.getByTestId("task-archive-confirm-popover")).toBeTruthy();
expect(screen.getByText("Loading…")).toBeTruthy();
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(true);
expect(screen.queryByTestId("task-archive-confirm-popover")).toBeNull();
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(screen.queryByTestId(CONFIRM_TEST_ID)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
await act(async () => deferredCount.resolve({ count: 2 }));
expect(await screen.findByRole("alertdialog")).toBeTruthy();
expect(screen.getByTestId("archive-cascade-checkbox")).toBeTruthy();
expect(screen.queryByTestId("task-archive-confirm-popover")).toBeNull();
});
it("uses touch-sized local actions after a resolved zero-descendant result", async () => {
@@ -1,8 +1,7 @@
"use client";
import type { RefObject } from "react";
import { useEffect, useLayoutEffect, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { IconLoader } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
@@ -157,45 +156,41 @@ function ArchiveConfirmPopover({
);
}
function ArchiveClassifyingPopover({
function PendingArchiveDismissal({
anchorRef,
focusReturnRef,
focusBoundaryRef,
onOpenChange,
confirmTestId,
}: {
anchorRef: RefObject<HTMLElement | null>;
focusReturnRef?: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
onOpenChange: (open: boolean) => void;
confirmTestId: string;
}) {
const { t } = useTranslation();
return (
<ActionConfirmPopover
open
size="wide"
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
focusBoundaryRef={focusBoundaryRef ?? anchorRef}
title={t("task:archiveTaskTitle")}
description={
<span className="flex items-center gap-2">
<IconLoader className="h-4 w-4 animate-spin" />
{t("common:loading")}
</span>
}
cancelLabel={t("common:cancel")}
confirmLabel={t(ARCHIVE_LABEL_KEY)}
confirmAriaLabel={t(ARCHIVE_LABEL_KEY)}
confirmTestId={confirmTestId}
confirmDisabled
testId="task-archive-confirm-popover"
onOpenChange={onOpenChange}
onCancel={() => onOpenChange(false)}
onConfirm={() => undefined}
/>
);
useLayoutEffect(() => {
if (anchorRef.current?.isConnected) return;
onOpenChange(false);
});
useEffect(() => {
const dismiss = () => onOpenChange(false);
const handlePointerDown = () => dismiss();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
const focusTarget = focusReturnRef?.current ?? anchorRef.current;
dismiss();
if (focusTarget?.isConnected) focusTarget.focus();
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [anchorRef, focusReturnRef, onOpenChange]);
return null;
}
type ArchiveDialogProps = Pick<
@@ -272,12 +267,10 @@ function ArchiveConfirmationContent({
}
if (!isFinePointer) return null;
return (
<ArchiveClassifyingPopover
<PendingArchiveDismissal
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
focusBoundaryRef={focusBoundaryRef}
onOpenChange={dialogProps.onOpenChange}
confirmTestId={dialogProps.confirmTestId ?? DEFAULT_CONFIRM_TEST_ID}
/>
);
}
@@ -1,3 +1,4 @@
import type { Route } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
@@ -8,6 +9,89 @@ const VISIBLE_TIMEOUT = 10_000;
// has live subtasks, default to unchecked (subtasks preserved), and
// propagate the choice to the backend.
test.describe("Kanban card archive — cascade subtasks toggle", () => {
// @covers AC-TASKS-CONFIRMATION-SURFACE-002.4
test("keeps pending classification hidden and dismissible before the cascade dialog", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const parent = await apiClient.createTask(seedData.workspaceId, "Stable Archive Parent", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.createTask(seedData.workspaceId, "Stable Archive Child", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
parent_id: parent.id,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCard(parent.id)).toBeVisible({ timeout: VISIBLE_TIMEOUT });
let releaseResponse: () => void = () => {};
const responseHeld = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
let markRequestObserved: () => void = () => {};
const requestObserved = new Promise<void>((resolve) => {
markRequestObserved = resolve;
});
let requestStarted = false;
let markHandlerSettled: () => void = () => {};
const handlerSettled = new Promise<void>((resolve) => {
markHandlerSettled = resolve;
});
const routePattern = `**/api/v1/tasks/${parent.id}/subtask-count`;
const holdSubtaskCount = async (route: Route) => {
requestStarted = true;
markRequestObserved();
await responseHeld;
try {
await route.continue();
} finally {
markHandlerSettled();
}
};
await testPage.route(routePattern, holdSubtaskCount);
try {
await kanban.openTaskActionsMenu(parent.id);
await testPage.getByRole("menuitem", { name: "Archive" }).click();
await requestObserved;
const popover = testPage.getByTestId("task-archive-confirm-popover");
const dialog = testPage.getByRole("alertdialog", { name: "Archive task" });
await expect(popover).toHaveCount(0);
await expect(dialog).toHaveCount(0);
await testPage.keyboard.press("Escape");
await expect(kanban.taskCard(parent.id).getByLabel("More options")).toBeFocused();
releaseResponse();
await handlerSettled;
await testPage.unroute(routePattern, holdSubtaskCount);
await expect(dialog).toHaveCount(0);
await expect(popover).toHaveCount(0);
await kanban.openTaskActionsMenu(parent.id);
await testPage.getByRole("menuitem", { name: "Archive" }).click();
await expect(dialog).toBeVisible();
await expect(dialog.getByTestId("archive-cascade-checkbox")).toBeVisible();
await expect(popover).toHaveCount(0);
await prCapture.screenshot("stable-archive-cascade-dialog", {
caption: "A parent task opens only the final cascade archive dialog.",
});
} finally {
releaseResponse();
if (requestStarted) await handlerSettled;
await testPage.unroute(routePattern, holdSubtaskCount);
}
});
test("renders the cascade checkbox only when the task has subtasks", async ({
testPage,
apiClient,
@@ -0,0 +1,144 @@
---
created: 2026-09-06
status: done
requirements:
- REQ-TASKS-CONFIRMATION-SURFACE-002
- REQ-UI-TASK-CLEANUP-CONFIRMATION-001
system_design:
- ../../specs/ui/system-design/confirmation-warning-hierarchy.md
legacy_specs: []
---
# Implementation Plan: Stable Archive Confirmation Surface
## Overview
Prevent a parent-task archive request from showing an anchored loading popover
and then replacing it with the cascade dialog. Gate the standard fine-pointer
surface until descendant classification settles, then mount only the existing
final popover or dialog. One sequential work order keeps the rendering change,
regression tests, and desktop/mobile compatibility evidence together.
## Scope
### In scope
- Keep the confirmation request open while descendant classification is
pending without mounting a provisional fine-pointer confirmation, while
retaining Escape and outside-pointer dismissal.
- Preserve the anchored popover for a resolved zero-descendant task and the
full cascade dialog for positive or unavailable descendant counts.
- Prove the async transition at component and desktop Kanban E2E boundaries.
- Re-run the existing phone Kanban dialog scenario as mobile-parity evidence.
### Out of scope
- Changing archive, cascade, descendant-count, settings, or navigation APIs.
- Prefetching or caching descendant counts before an archive request.
- Changing bulk, forced-dialog, explicit inline, coarse-pointer row, delete, or
confirmation-preference behavior.
- Changing confirmation copy, localization catalogs, geometry, or styling.
## Technical approach
### Fine-pointer classification gate
In `apps/web/components/task/task-archive-confirmation.tsx`, remove the
fine-pointer `ArchiveClassifyingPopover` presentation. While the shared
`useSubtaskCountState` result is `idle` or `loading`, the standard fine-pointer
branch returns no confirmation shell. The controlled `open` state and current
request remain intact, so the existing resolved-zero popover, positive-count
dialog, and error dialog mount normally once classification settles. Removing
the provisional branch also removes its loader-only import and markup.
A non-rendering pending controller retains dismissal behavior without
reintroducing a temporary confirmation. Escape closes the request and restores
focus to the configured trigger; a new pointer interaction closes it without
blocking that target. It also closes the request when live data removes the
originating anchor. Closing disables descendant classification, so a late
response cannot mount a confirmation after dismissal or anchor removal.
The existing coarse-pointer pending branch already renders no popup and is the
nearest implementation precedent. `forceDialog`, bulk, inline, preference
bypass, mutation-pending, and final confirmation branches remain unchanged.
### Component regression
Update `apps/web/components/task/task-archive-confirmation.test.tsx` so a
deferred descendant-count promise proves the full state transition. Before the
promise resolves, neither `task-archive-confirm-popover` nor an alert dialog or
archive action is present. Resolving it with a positive count renders the full
cascade dialog and never exposes the popover. Existing resolved-zero, error,
forced-dialog, callback, and cleanup-content tests continue to cover the other
branches. Separate pending-dismissal cases cover Escape with trigger focus
restoration and outside pointer intent.
### Browser regression and mobile parity
Extend `apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts` with a
fine-pointer parent-task scenario that holds the exact
`GET /api/v1/tasks/:id/subtask-count` response behind a deterministic promise
gate. After the request is observed, assert that no provisional confirmation is
visible; dismiss it with Escape, release the response, and prove no late surface
mounts. Reopen Archive, then assert that only the cascade alert dialog appears.
Release and unregister the route in `finally` so failures cannot leak fixture
state.
Phone Kanban remains on its contained forced-dialog branch, with the full
dialog as the single scroll owner and its existing stacked 44px actions,
viewport insets, focus return, and no document overflow. No mobile markup or
interaction changes; the existing
`apps/web/e2e/tests/kanban/mobile-card-archive-confirmation.spec.ts` is the
mobile-parity check.
## Tests
| Acceptance criterion | Evidence |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `AC-TASKS-CONFIRMATION-SURFACE-002.4` | Deferred-promise regressions in `task-archive-confirmation.test.tsx`: no pending shell, then only the positive-count dialog; Escape restores trigger focus and outside pointer intent dismisses. |
| `AC-TASKS-CONFIRMATION-SURFACE-002.1`, `.3`, `.4` | Desktop `cascade-subtasks-toggle.spec.ts`: delayed classification never exposes the popover and resolves to the cascade dialog; existing zero-count coverage retains the anchored popover. |
| `AC-UI-TASK-CLEANUP-CONFIRMATION-001.7` | Existing component cases retain archive callbacks, failure fallback, cascade choice, and mutation-pending behavior. |
| `AC-UI-TASK-CLEANUP-CONFIRMATION-001.8` | Existing `mobile-card-archive-confirmation.spec.ts` retains the contained phone dialog and archive outcome. |
## E2E tests
- Chromium: extend `apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts`
with the delayed parent-task classification scenario.
- Mobile Chrome: re-run
`apps/web/e2e/tests/kanban/mobile-card-archive-confirmation.spec.ts`; no new
mobile scenario is needed because the changed branch is fine-pointer-only and
the existing spec exercises the intentionally different phone composition.
## Work orders
- [x] [Task 01: Stabilize archive confirmation selection](task-01-stabilize-archive-confirmation-selection.md) — done
## Verification results
- RED component regression: failed on the provisional
`task-archive-confirm-popover` before the production change.
- RED Chromium regression: failed with the same provisional popover while the
intercepted descendant-count request remained pending.
- Review-remediation RED component cases: 3 failed because the hidden pending
request did not retain Escape, outside-pointer dismissal, or the prior
connected-anchor lifecycle guard.
- Review-remediation RED Chromium regression: failed because a late positive
result mounted the cascade dialog after Escape dismissal.
- Focused component suite: 13 passed.
- Touched-file ESLint: passed with no findings.
- Web typecheck: passed.
- Mobile Chrome confirmation E2E: 2 passed.
- Chromium cascade archive/delete E2E: 6 passed, including the delayed
classification case and a fresh desktop PR screenshot.
## Risks
- A slow descendant-count request gives no confirmation shell until the final
presentation is known. The request remains active and normally targets the
local backend; adding another loading overlay would recreate the shell-swap
defect.
- Over-broadly changing unresolved classification could regress forced mobile,
bulk, or inline callers. Keep the correction inside the standard
fine-pointer branch and retain focused coverage for the existing routes.
- The E2E regression must wait until the intercepted request is observed before
asserting absence, or it could pass before React enters the pending state.
@@ -0,0 +1,128 @@
---
id: "01-stabilize-archive-confirmation-selection"
title: "Stabilize archive confirmation selection"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-CONFIRMATION-SURFACE-002
- REQ-UI-TASK-CLEANUP-CONFIRMATION-001
acceptance_criteria:
- AC-TASKS-CONFIRMATION-SURFACE-002.1
- AC-TASKS-CONFIRMATION-SURFACE-002.3
- AC-TASKS-CONFIRMATION-SURFACE-002.4
- AC-UI-TASK-CLEANUP-CONFIRMATION-001.7
- AC-UI-TASK-CLEANUP-CONFIRMATION-001.8
system_design:
- ../../specs/ui/system-design/confirmation-warning-hierarchy.md
---
# Task 01: Stabilize Archive Confirmation Selection
## Summary
Make descendant classification choose the standard fine-pointer archive shell
before any confirmation UI mounts. Add component and desktop browser
regressions for the delayed positive-count path while preserving every existing
final confirmation route and the contained phone dialog.
## In scope
- Remove the provisional fine-pointer loading popover from
`TaskArchiveConfirmation` without changing its controlled request state.
- Preserve Escape and outside-pointer dismissal while the pending request has
no rendered confirmation shell.
- Preserve dismissal when live data removes the originating anchor while the
pending request has no rendered confirmation shell.
- Add a RED component regression that holds descendant classification pending,
then resolves it to a positive count and observes only the cascade dialog.
- Add a RED desktop Kanban E2E regression that deterministically holds the
subtask-count response and proves the provisional popover never appears.
- Run the existing zero-descendant desktop and contained phone confirmation
scenarios after the correction.
## Out of scope
- Backend, archive API, user-settings, cascade, navigation, or descendant-count
changes.
- New caching, prefetching, loading copy, animations, or confirmation surfaces.
- Changes to bulk, inline, coarse-pointer row, delete, or mobile presentation.
## Acceptance
- While a standard fine-pointer descendant classification is unresolved, no
archive confirmation shell or actionable archive control is rendered; a
positive result mounts only the existing full cascade dialog.
- Escape restores focus to the configured trigger, outside pointer intent
dismisses without being blocked, and a late result cannot mount a surface
after either dismissal.
- Removing the originating anchor dismisses the pending request, and a late
positive result cannot mount a stale dialog.
- Resolved zero counts still mount the anchored popover, classification errors
still fail safe to the full dialog, and callbacks/focus/preference behavior
remain unchanged.
- The focused component, desktop E2E, existing mobile E2E, typecheck, and
touched-file lint commands pass.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/task/task-archive-confirmation.test.tsx
cd apps/web && pnpm exec eslint components/task/task-archive-confirmation.tsx components/task/task-archive-confirmation.test.tsx e2e/tests/kanban/cascade-subtasks-toggle.spec.ts
cd apps/web && pnpm run typecheck
cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-card-archive-confirmation.spec.ts
cd apps/web && CAPTURE_PR_ASSETS=1 pnpm e2e:run --no-build tests/kanban/cascade-subtasks-toggle.spec.ts
```
The component and desktop E2E regressions must be run and observed failing for
the provisional popover before the production correction, then rerun green.
## Files likely touched
- `apps/web/components/task/task-archive-confirmation.tsx`
- `apps/web/components/task/task-archive-confirmation.test.tsx`
- `apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts`
## Dependencies
None.
## Risks
- The pending branch is shared by several presentation modes; keep the change
after the existing force-dialog/bulk decision and inside the standard
fine-pointer path.
- A browser absence assertion can race ahead of the intercepted request; the
test must wait for the route handler to observe the request before asserting.
## Parallelism
`sequential`
## Inputs
- `AC-TASKS-CONFIRMATION-SURFACE-002.4` and the classification-gated surface
selection section in the UI confirmation system design.
- `apps/web/components/task/task-archive-confirmation.tsx` and its component
test suite.
- `apps/web/e2e/tests/kanban/cascade-subtasks-toggle.spec.ts` and the existing
held-route patterns in task E2E coverage.
- The coarse-pointer unresolved branch, which already avoids mounting a
temporary popup.
## Results
- Added deferred descendant-count component and Chromium regressions. Before
the implementation, both failed because the fine-pointer loading popover was
visible while classification remained pending.
- Removed the provisional fine-pointer loading popover and its loader import.
A non-rendering controller preserves Escape, trigger-focus, and outside
pointer dismissal while the request waits, then renders the existing
resolved-zero popover or safe dialog only if it remains open.
- Review-remediation RED cases exposed the missing hidden-state dismissal at
both component and Chromium boundaries before the controller was added. A
later RED component case exposed the missing connected-anchor lifecycle guard.
- Component suite: 13 passed. Touched-file ESLint and web typecheck passed.
- Existing Mobile Chrome confirmation spec: 2 passed. Chromium cascade spec:
6 passed, with a fresh desktop capture of the final cascade dialog.
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-08-24
updated: 2026-09-05
updated: 2026-09-07
owners:
- kandev
---
@@ -76,6 +76,16 @@ pointer inline confirmation behavior.
and compact desktop/tablet viewport containment. Coarse-pointer inline
confirmation may continue to expand its row and shall retain its existing
44px action geometry and zero-overflow behavior.
- **AC-TASKS-CONFIRMATION-SURFACE-002.4:** When a fine-pointer archive action
needs descendant classification before choosing its confirmation surface, no
provisional confirmation popover or archive action shall appear while that
classification is pending. After classification, exactly one final surface
shall appear: the anchored popover for zero descendants, or the full dialog
for one or more descendants. An unavailable classification shall fail safe to
the full dialog without first showing the popover. While no surface is
visible, Escape or a new pointer interaction shall dismiss the pending
request; keyboard dismissal shall restore trigger focus, and a late
classification result shall not mount a confirmation after dismissal.
### REQ-UI-TASK-CLEANUP-CONFIRMATION-001: Scannable task cleanup confirmation
@@ -113,9 +123,9 @@ repository resources, so that I can understand the consequence before acting.
system's semantic destructive action treatment without a competing primary
treatment. Archive shall retain its non-destructive action treatment.
- **AC-UI-TASK-CLEANUP-CONFIRMATION-001.7:** Cancel, dismiss, focus, Escape,
Enter default-action behavior, loading state, cascade selection, in-flight
warning visibility, and single/bulk archive and delete callbacks shall remain
unchanged.
Enter default-action behavior, archive/delete mutation loading state, cascade
selection, in-flight warning visibility, and single/bulk archive and delete
callbacks shall remain unchanged.
- **AC-UI-TASK-CLEANUP-CONFIRMATION-001.8:** When archive is initiated from a
phone Kanban card, the card shall use the contained full task-cleanup alert
dialog even when the task has no descendants. The dialog shall be centered
@@ -154,6 +164,8 @@ repository resources, so that I can understand the consequence before acting.
dimensions.
- Replacing the centered task alerts with a drawer, sheet, or new navigation
flow.
- Changing the descendant-count API or introducing background prefetching for
archive confirmation.
- Masking sidebar row layout changes with fixed/minimum heights or negative
offsets.
- Adding animation or redesigning any confirmation surface beyond the shared
@@ -1,11 +1,11 @@
---
status: current
system: ui
updated: 2026-09-05
requirements:
- REQ-TASKS-CONFIRMATION-WARNING-001
- REQ-TASKS-CONFIRMATION-SURFACE-002
- REQ-UI-TASK-CLEANUP-CONFIRMATION-001
updated: 2026-09-07
---
# Task Confirmation Surface System Design
@@ -23,7 +23,7 @@ the task runtime contract.
| Requirement | Design section |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-TASKS-CONFIRMATION-WARNING-001` | [Components and responsibilities](#components-and-responsibilities) and [Mobile and desktop containment](#mobile-and-desktop-containment) |
| `REQ-TASKS-CONFIRMATION-SURFACE-002` | [Popover width contract](#popover-width-contract), [Fine-pointer mounting](#fine-pointer-mounting), and [Mobile and desktop containment](#mobile-and-desktop-containment) |
| `REQ-TASKS-CONFIRMATION-SURFACE-002` | [Classification-gated surface selection](#classification-gated-surface-selection), [Popover width contract](#popover-width-contract), [Fine-pointer mounting](#fine-pointer-mounting), and [Mobile and desktop containment](#mobile-and-desktop-containment) |
| `REQ-UI-TASK-CLEANUP-CONFIRMATION-001` | [Task cleanup content model](#task-cleanup-content-model), [Full-dialog composition](#full-dialog-composition), [Compact archive surfaces](#compact-archive-surfaces), [Source-specific archive routing](#source-specific-archive-routing), and [Mobile and desktop containment](#mobile-and-desktop-containment) |
## Components and responsibilities
@@ -120,6 +120,36 @@ existing row-owned surface. These callers keep their established focus,
containment, and action contracts; only the mobile Kanban card changes its
surface selection.
### Classification-gated surface selection
`TaskArchiveConfirmation` keeps descendant classification as the authoritative
input for choosing between the compact confirmation and the cascade dialog. A
standard fine-pointer caller does not mount a provisional popover while the
classification is idle or loading. A resolved zero count mounts the anchored
popover; a positive count mounts the full cascade dialog; and an error mounts
the same full dialog as the fail-safe path. No archive action is exposed before
that choice is known.
The controlled archive request remains open while classification is pending, so
the final surface can mount without another user action. This avoids moving
focus and assistive-technology context from a temporary popover into a dialog.
It also preserves the existing final surfaces and their callbacks instead of
adding cascade controls to the compact popover.
A non-rendering pending-dismissal controller retains the lifecycle previously
owned by the provisional popover. Escape closes the controlled request and
returns focus to the configured trigger; a new pointer interaction closes it
without preventing the new target's interaction. If live data removes the
originating anchor, the controller closes the request before classification can
select a final surface. Closing disables the classification hook, whose cleanup
ignores any late response, so a dismissed request cannot surface later. The
controller mounts no role, action, or visual confirmation shell.
Callers whose final presentation is already known keep their current behavior.
Forced mobile Kanban and bulk operations may mount the full dialog immediately
with its action disabled until classification settles. Explicit inline and
coarse-pointer row confirmations retain their established loading treatment.
### Popover width contract
`ActionConfirmPopover` gains a small width/size contract whose default remains
@@ -181,10 +211,11 @@ dialog computes the localized task outcome and cleanup model during render,
then passes the model to the shared task-local renderer. Archive/delete
callbacks and dialog state remain untouched. The source adapter supplies the
existing `presentation` value and, for mobile Kanban only, maps it to
`forceDialog`; pointer classification continues to select the fine-pointer
popover or the explicitly inline row surface. This changes rendering only,
not archive commands, descendant classification, preference state, or
callbacks.
`forceDialog`. For a standard fine-pointer request, descendant classification
settles before the selected popover or dialog mounts. Pointer classification
continues to select the fine-pointer popover or the explicitly inline row
surface after that gate. This changes presentation timing only, not archive
commands, descendant classification, preference state, or callbacks.
The delete dialog resets discard selection when it closes. On confirm, it sends
the cascade and discard choices through the existing callback. A typed dirty
@@ -193,13 +224,16 @@ Other deletion errors keep their existing error handling.
## Failure and recovery
If localized text is longer than the available width, the shared surface-text
contract wraps it inside the body. If content is taller than the dynamic
viewport, the body scrolls without moving the title or actions.
A failed descendant classification selects the full dialog without first
mounting a compact confirmation. If localized text is longer than the available
width, the shared surface-text contract wraps it inside the body. If content is
taller than the dynamic viewport, the body scrolls without moving the title or
actions.
If deletion returns `task_delete_dirty_worktree`, the task remains visible. The
localized message tells the user to reopen the confirmation and select discard.
Unknown executor types retain the generic running session effect.
Unknown executor types retain the generic running session effect. If no task
activity is present, no warning mounts, as before.
## Persistence
@@ -211,10 +245,19 @@ typed HTTP 409 handling are defined in
## Observability
Existing component tests continue to cover warning visibility and cleanup
content. Delete-dialog tests cover selection reset, disabled actions, single and
bulk callback values, cascade behavior, and typed conflict feedback. Rendered
desktop and phone checks cover viewport bounds, scroll ownership, action reach,
and document overflow.
content. Cleanup-summary tests cover every executor, bulk grouping, ordering,
effects, and supporting notes. Delete-dialog tests cover selection reset,
disabled actions, single and bulk callback values, cascade behavior, and typed
conflict feedback. Dialog and compact-surface tests assert equivalent structured
content, semantic action variants, unchanged callbacks, and that a deferred
positive descendant result does not expose a provisional fine-pointer popover.
Pending-dismissal component cases cover Escape, trigger focus restoration, and
outside pointer intent.
Rendered desktop and phone checks cover viewport bounds, scroll ownership,
action reach, and document overflow. The desktop cascade E2E holds the
descendant-count response, dismisses the invisible request, proves a late
result cannot mount a confirmation, then reopens Archive and observes only the
cascade dialog.
## Mobile and desktop containment
@@ -223,6 +266,9 @@ records the row `getBoundingClientRect().height` before opening Archive, while
the archive popover is visible, and after Cancel; all three values must remain
stable within subpixel precision. It also verifies the archive popover is
strictly wider than 256px and remains inside the viewport at compact widths.
For a parent task, a delayed descendant-count response leaves the confirmation
shell absent until classification settles; after release, only the full cascade
dialog is rendered and the anchored popover never becomes visible.
The phone Kanban check enters through a card overflow menu and expects the
mobile presentation to use the full alert dialog even for a zero-descendant