diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index aa42947156..db038cc335 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -56,6 +56,7 @@ export function useSheetData(workspaceId: string | null) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const { allTasks, + pendingArchiveTaskIds, allSteps, stepsByWorkflowId, wipQueueByTaskId, @@ -100,6 +101,7 @@ export function useSheetData(workspaceId: string | null) { repositoriesById, stepColorById: new Map(allSteps.map((step) => [step.id, step.color])), automaticColorSettings, + pendingArchiveTaskIds, }; const items: TaskSwitcherItem[] = workspaceContextAccessDenied ? [] @@ -122,6 +124,7 @@ export function useSheetData(workspaceId: string | null) { repositoriesById: ctx.repositoriesById, stepColorById: ctx.stepColorById, automaticColorSettings: ctx.automaticColorSettings, + pendingArchiveTaskIds: ctx.pendingArchiveTaskIds, }; items.unshift(buildArchivedSidebarItem(archivedState, archivedContext)); } @@ -136,6 +139,7 @@ export function useSheetData(workspaceId: string | null) { dismissedAgentErrors, wipQueueByTaskId, automaticColorSettings, + pendingArchiveTaskIds, archivedState, workspaceContextAccessDenied, ]); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts index 5f99aa594d..e9e3b90e8a 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts @@ -54,6 +54,24 @@ describe("toSheetItem repository projection", () => { expect(item.priority).toBe("critical"); }); + + it("marks active rows covered by a pending archive", () => { + const item = toSheetItem(task(), { + ...emptyCtx(), + pendingArchiveTaskIds: new Set(["t1"]), + }); + + expect(item.isPendingArchive).toBe(true); + }); + + it("does not mark confirmed archived rows as pending", () => { + const item = toSheetItem(task({ isArchived: true }), { + ...emptyCtx(), + pendingArchiveTaskIds: new Set(["t1"]), + }); + + expect(item.isPendingArchive).toBe(false); + }); }); describe("toSheetItem remote executor projection", () => { diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts index 936aa8a642..9d645d0103 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts @@ -25,6 +25,7 @@ export type SheetItemCtx = { repositoriesById?: ReadonlyMap; stepColorById?: ReadonlyMap; automaticColorSettings?: SidebarTaskColorAutomation; + pendingArchiveTaskIds?: ReadonlySet; }; const EMPTY_REPOSITORIES_BY_ID = new Map(); @@ -114,6 +115,7 @@ export function toSheetItem( primaryExecutorProfileId: task.primaryExecutorProfileId ?? undefined, workflowStepColor: facts.workflowStepColor, isArchived: task.isArchived === true, + isPendingArchive: !task.isArchived && ctx.pendingArchiveTaskIds?.has(task.id) === true, isFromOffice: task.isFromOffice, isRemoteExecutor: task.isRemoteExecutor, remoteExecutorId: task.primaryExecutorId ?? undefined, diff --git a/apps/web/components/task/task-item-archive-pending.test.tsx b/apps/web/components/task/task-item-archive-pending.test.tsx new file mode 100644 index 0000000000..8de1061a1b --- /dev/null +++ b/apps/web/components/task/task-item-archive-pending.test.tsx @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { StateProvider } from "@/components/state-provider"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { TaskItem } from "./task-item"; + +afterEach(() => cleanup()); + +describe("TaskItem pending archive state", () => { + it("dims the row and shows a spinner while archive is pending", () => { + render( + + + + + , + ); + + const row = screen.getByTestId("sidebar-task-item"); + expect(row.getAttribute("aria-busy")).toBe("true"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + expect(row.className).toContain("opacity-60"); + expect(screen.getByTestId("task-state-archive-pending").className).toContain("animate-spin"); + expect(screen.queryByTestId("task-state-turn-finished")).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 8406eb143f..74ad0f52c2 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -54,6 +54,7 @@ type TaskItemProps = { */ parkedOnBackgroundWork?: boolean; isArchived?: boolean; + isPendingArchive?: boolean; isSelected?: boolean; /** Whether this row is part of an active multi-selection (distinct from the active-task highlight). */ isMultiSelected?: boolean; @@ -179,6 +180,15 @@ function taskItemRowClick( return (e) => (onSelect ? onSelect(e) : onClick?.()); } +function pendingArchiveRowProps(isPendingArchive?: boolean) { + if (!isPendingArchive) return {}; + return { + "aria-busy": true as const, + "aria-disabled": true as const, + className: "cursor-wait opacity-60", + }; +} + function TaskItemTitle({ title }: { title: string }) { return {title}; } @@ -334,6 +344,7 @@ export const TaskItem = memo(function TaskItem({ foregroundActivity, parkedOnBackgroundWork, isArchived, + isPendingArchive, isSelected = false, isMultiSelected = false, onClick, @@ -374,6 +385,7 @@ export const TaskItem = memo(function TaskItem({ taskRowPresentation, }: TaskItemProps) { const effectiveMenuOpen = menuOpen || isDeleting === true; + const pendingProps = pendingArchiveRowProps(isPendingArchive); const resolvedTaskRow = resolveTaskRowPresentation(taskRowPresentation, { showRepository }); const relativeTime = showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt; const taskColor = useTaskColor(taskId); @@ -386,6 +398,7 @@ export const TaskItem = memo(function TaskItem({ tabIndex={0} data-testid="sidebar-task-item" data-task-row-id={taskId} + {...pendingProps} {...taskItemStateAttrs(isSelected, isMultiSelected)} onClick={taskItemRowClick(onSelect, onClick)} onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, onClick)} @@ -397,6 +410,7 @@ export const TaskItem = memo(function TaskItem({ indent.depth === 0, resolvedTaskRow.detailsEnabled, ), + pendingProps.className, archiveConfirmation && "flex-wrap", )} > @@ -412,6 +426,7 @@ export const TaskItem = memo(function TaskItem({ parkedOnBackgroundWork={parkedOnBackgroundWork} hasPendingClarification={hasPendingClarification} hasPendingPermission={hasPendingPermission} + isPendingArchive={isPendingArchive} interrupted={interrupted} isOnLastWorkflowStep={isOnLastWorkflowStep} showBackgroundTooltip diff --git a/apps/web/components/task/task-session-sidebar-item-pending-archive.test.ts b/apps/web/components/task/task-session-sidebar-item-pending-archive.test.ts new file mode 100644 index 0000000000..b46923d2df --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-item-pending-archive.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildSidebarItem } from "./task-session-sidebar-item"; + +type SidebarTask = Parameters[0]; +type SidebarContext = Parameters[1]; + +function emptyContext(): SidebarContext { + return { + repositorySlugById: new Map(), + titleById: new Map(), + workflowNameById: new Map(), + stepTitleById: new Map(), + }; +} + +function task(overrides: Partial = {}): SidebarTask { + return { + id: "t1", + _workflowId: "wf1", + title: "Task", + workflowStepId: "step-1", + ...overrides, + } as SidebarTask; +} + +describe("buildSidebarItem pending archive projection", () => { + it("marks active rows covered by a pending archive", () => { + const item = buildSidebarItem(task(), { + ...emptyContext(), + pendingArchiveTaskIds: new Set(["t1"]), + }); + + expect(item.isPendingArchive).toBe(true); + }); + + it("does not mark confirmed archived rows as pending", () => { + const item = buildSidebarItem(task({ isArchived: true }), { + ...emptyContext(), + pendingArchiveTaskIds: new Set(["t1"]), + }); + + expect(item.isPendingArchive).toBe(false); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar-item.ts b/apps/web/components/task/task-session-sidebar-item.ts index 52dd0ea00c..d8ad54d928 100644 --- a/apps/web/components/task/task-session-sidebar-item.ts +++ b/apps/web/components/task/task-session-sidebar-item.ts @@ -22,6 +22,7 @@ export type SidebarItemContext = { repositoriesById?: ReadonlyMap; stepColorById?: ReadonlyMap; automaticColorSettings?: SidebarTaskColorAutomation; + pendingArchiveTaskIds?: ReadonlySet; }; const EMPTY_REPOSITORIES_BY_ID = new Map(); @@ -120,6 +121,14 @@ function sidebarStatus( }; } +function isPendingArchive( + task: KanbanState["tasks"][number], + context: SidebarItemContext, +): boolean { + if (task.isArchived === true) return false; + return context.pendingArchiveTaskIds?.has(task.id) === true; +} + /** Map a task-level status projection to a sidebar item without session streams. */ export function buildSidebarItem( task: KanbanState["tasks"][number] & { _workflowId: string }, @@ -159,6 +168,7 @@ export function buildSidebarItem( remoteExecutorName: task.primaryExecutorName ?? undefined, createdAt: task.createdAt, isArchived: task.isArchived === true, + isPendingArchive: isPendingArchive(task, context), isFromOffice: task.isFromOffice, parentTaskTitle: task.parentTaskId ? context.titleById.get(task.parentTaskId) : undefined, parentTaskId: task.parentTaskId ?? undefined, diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 1fa8a27926..10611aa580 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -76,6 +76,7 @@ function useSidebarData(workspaceId: string | null) { const { allTasks, + pendingArchiveTaskIds, allSteps, stepsByWorkflowId, wipQueueByTaskId, @@ -115,6 +116,7 @@ function useSidebarData(workspaceId: string | null) { repositoriesById, stepColorById, automaticColorSettings, + pendingArchiveTaskIds, }; const items: TaskSwitcherItem[] = allTasks.map((task) => buildSidebarItem(task, mapCtx)); if ( @@ -136,6 +138,7 @@ function useSidebarData(workspaceId: string | null) { acknowledgedAgentErrors, dismissedAgentErrors, automaticColorSettings, + pendingArchiveTaskIds, ]); return { diff --git a/apps/web/components/task/task-state-icon.tsx b/apps/web/components/task/task-state-icon.tsx index 4357fb1064..8070bb5c24 100644 --- a/apps/web/components/task/task-state-icon.tsx +++ b/apps/web/components/task/task-state-icon.tsx @@ -34,6 +34,8 @@ export type TaskStateIconProps = { * foregroundActivity (AC-34). */ parkedOnBackgroundWork?: boolean; + /** True while an accepted archive request is still in flight. */ + isPendingArchive?: boolean; accessibleLabel?: string; showBackgroundTooltip?: boolean; }; @@ -133,7 +135,24 @@ function TaskReviewIcon({ ); } -export function TaskStateIcon({ +function PendingArchiveTaskIcon() { + return ( + + ); +} + +export function TaskStateIcon(props: TaskStateIconProps) { + if (props.isPendingArchive) return ; + return ; +} + +function TaskStateIconContent({ sessionState, state, foregroundActivity, diff --git a/apps/web/components/task/task-switcher-row.tsx b/apps/web/components/task/task-switcher-row.tsx index 4ad84f70c2..2a8aaa4d12 100644 --- a/apps/web/components/task/task-switcher-row.tsx +++ b/apps/web/components/task/task-switcher-row.tsx @@ -172,6 +172,7 @@ function TaskRowItem({ interrupted={task.interrupted} parkedOnBackgroundWork={task.parkedOnBackgroundWork} isArchived={task.isArchived} + isPendingArchive={task.isPendingArchive} isSelected={isSelected} diffStats={task.diffStats} comparisonUnavailable={task.comparisonUnavailable} diff --git a/apps/web/components/task/task-switcher-types.ts b/apps/web/components/task/task-switcher-types.ts index 466f42aaa8..457d0f75d3 100644 --- a/apps/web/components/task/task-switcher-types.ts +++ b/apps/web/components/task/task-switcher-types.ts @@ -62,6 +62,8 @@ export type TaskSwitcherItem = { lastActivityAt?: string; createdAt?: string; isArchived?: boolean; + /** True while an accepted archive request is still in flight. */ + isPendingArchive?: boolean; isFromOffice?: boolean; primarySessionId?: string | null; hasPendingClarification?: boolean; diff --git a/apps/web/e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts new file mode 100644 index 0000000000..362ed77ab6 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts @@ -0,0 +1,16 @@ +import { test } from "../../fixtures/test-base"; +import { checkImmediateArchive } from "./sidebar-immediate-archive-helpers"; + +test("phone picker shows archive progress and restores failed archive", async ({ + testPage, + apiClient, + seedData, +}, testInfo) => { + await checkImmediateArchive({ + page: testPage, + api: apiClient, + seed: seedData, + mobile: true, + screenshotPath: testInfo.outputPath("pending-archive.png"), + }); +}); diff --git a/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts b/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts new file mode 100644 index 0000000000..d9ec0575cb --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts @@ -0,0 +1,104 @@ +import { expect, type Page, type Route } from "@playwright/test"; +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +// @covers AC-TASKS-REMOVAL-NAVIGATION-003.1, AC-TASKS-REMOVAL-NAVIGATION-003.2, AC-TASKS-REMOVAL-NAVIGATION-003.3 +export async function checkImmediateArchive(options: { + page: Page; + api: ApiClient; + seed: SeedData; + mobile: boolean; + screenshotPath: string; +}) { + const { page, api, seed, mobile } = options; + const { settings } = await api.getUserSettings(); + const baseline = + typeof settings.confirm_task_archive === "boolean" ? settings.confirm_task_archive : undefined; + await api.saveUserSettings({ confirm_task_archive: true }); + const taskOptions = { workflow_id: seed.workflowId, workflow_step_id: seed.startStepId }; + const nav = await api.seedTask(seed.workspaceId, "Keep selected", taskOptions); + const target = await api.seedTask(seed.workspaceId, "Archive immediately", taskOptions); + const path = `**/api/v1/tasks/${target.task_id}/archive`; + let pending: Route | null = null; + const handler = (route: Route) => { + pending = route; + }; + await page.route(path, handler); + const session = new SessionPage(page); + const sheet = page.getByRole("dialog", { name: "Tasks", exact: true }); + const rows = () => (mobile ? sheet : session.sidebar).getByTestId("sidebar-task-item"); + const targetRow = () => rows().filter({ hasText: "Archive immediately" }); + const press = async (locator: ReturnType) => { + if (mobile) await locator.tap(); + else await locator.click(); + }; + const openPicker = async () => { + if (mobile && !(await sheet.isVisible())) await page.getByTestId("mobile-session-menu").tap(); + }; + const openArchive = async () => { + await openPicker(); + if (mobile) { + await targetRow().getByRole("button", { name: "Task actions" }).tap(); + await page.getByRole("menuitem", { name: "Archive", exact: true }).tap(); + await expect(sheet).toBeHidden(); + } else { + await session.openSidebarMenuAndClick("Archive immediately", "Archive"); + } + }; + try { + await page.goto(`/t/${nav.task_id}`); + await session.waitForLoad(); + await openArchive(); + await press(page.getByRole("button", { name: "Cancel", exact: true })); + await openPicker(); + await expect(targetRow()).toBeVisible(); + expect(pending).toBeNull(); + + await openArchive(); + await press(page.getByTestId("archive-task-confirm")); + await expect.poll(() => pending !== null).toBe(true); + if (mobile) await page.getByTestId("mobile-session-menu").tap(); + await expect(targetRow()).toBeVisible(); + await expect(targetRow()).toHaveAttribute("aria-busy", "true"); + await expect(targetRow()).toHaveClass(/opacity-60/); + await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible(); + await expect(rows().filter({ hasText: "Keep selected" })).toBeInViewport(); + await expect(page).toHaveURL(new RegExp(`/t/${nav.task_id}$`)); + await (mobile ? sheet : session.sidebar).screenshot({ path: options.screenshotPath }); + await expect(rows().filter({ hasText: "Keep selected" })).toBeInViewport(); + await pending!.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "Archive unavailable" }), + }); + pending = null; + if (mobile) { + await openPicker(); + } else { + await expect(page.getByText("Failed to archive task", { exact: true })).toBeVisible(); + } + await expect(targetRow()).toBeVisible(); + await expect(targetRow()).not.toHaveAttribute("aria-busy"); + await expect(targetRow().getByTestId("task-state-archive-pending")).toHaveCount(0); + await expect(page).toHaveURL(new RegExp(`/t/${nav.task_id}$`)); + + await openArchive(); + await press(page.getByTestId("archive-task-confirm")); + await expect.poll(() => pending !== null).toBe(true); + if (mobile) await page.getByTestId("mobile-session-menu").tap(); + await expect(targetRow()).toBeVisible(); + await expect(targetRow()).toHaveAttribute("aria-busy", "true"); + await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible(); + await pending!.continue(); + pending = null; + await expect( + page.getByTestId("toast-message").filter({ hasText: "Archived 1 task." }), + ).toBeVisible(); + await expect(targetRow()).toHaveCount(0); + } finally { + if (pending) await (pending as Route).abort(); + await page.unroute(path, handler); + await api.saveUserSettings({ confirm_task_archive: baseline }); + } +} diff --git a/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts b/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts new file mode 100644 index 0000000000..2bc7974cba --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts @@ -0,0 +1,16 @@ +import { test } from "../../fixtures/test-base"; +import { checkImmediateArchive } from "./sidebar-immediate-archive-helpers"; + +test("sidebar shows archive progress before removal and recovers on failure", async ({ + testPage, + apiClient, + seedData, +}, testInfo) => { + await checkImmediateArchive({ + page: testPage, + api: apiClient, + seed: seedData, + mobile: false, + screenshotPath: testInfo.outputPath("pending-archive.png"), + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.archive.test.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.archive.test.ts new file mode 100644 index 0000000000..d5bc73e023 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.archive.test.ts @@ -0,0 +1,202 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useStore } from "zustand"; +import { createAppStore, type AppState } from "@/lib/state/store"; +import { useTaskRemoval } from "@/hooks/use-task-removal"; +import { useWorkspaceSidebarTasks } from "./use-workspace-sidebar-tasks"; + +let store: ReturnType; +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: AppState) => unknown) => useStore(store, selector), + useAppStoreApi: () => store, +})); +vi.mock("./use-all-workflow-snapshots", () => ({ useAllWorkflowSnapshots: () => {} })); +vi.mock("@/lib/api", () => ({ fetchTask: vi.fn(), listTaskSessions: vi.fn() })); +vi.mock("@/lib/routing/client-router", () => ({ softNavigate: vi.fn() })); +vi.mock("@/lib/state/dockview-store", () => ({ performLayoutSwitch: vi.fn() })); + +function task(id: string, parentTaskId?: string) { + return { + id, + parentTaskId, + workspaceId: "ws", + workflowId: "wf", + workflowStepId: "step", + title: id, + position: 0, + }; +} +function setTasks(tasks = [task("a"), task("b"), task("child", "a")]) { + store.setState((state) => ({ + kanban: { ...state.kanban, workflowId: "wf", tasks }, + kanbanMulti: { + ...state.kanbanMulti, + snapshots: { wf: { workflowId: "wf", workflowName: "Workflow", steps: [], tasks } }, + }, + })); +} +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function setup() { + return renderHook(() => ({ + sidebar: useWorkspaceSidebarTasks("ws"), + removal: useTaskRemoval({ store, stayOnListing: true }), + })); +} + +beforeEach(() => { + store = createAppStore(); + store.setState((state) => ({ + workflows: { ...state.workflows, items: [{ id: "wf", workspaceId: "ws", name: "Workflow" }] }, + workspaces: { ...state.workspaces, activeId: "ws" }, + })); + setTasks(); +}); + +describe("sidebar pending archive projection", () => { + // @covers AC-TASKS-REMOVAL-NAVIGATION-003.1, AC-TASKS-REMOVAL-NAVIGATION-003.2, AC-TASKS-REMOVAL-NAVIGATION-003.3 + it("marks before mutation settles and restores latest data after rejection", async () => { + const view = setup(); + const pending = deferred(); + const sibling = view.result.current.sidebar.allTasks[1]; + let operation!: Promise; + act(() => { + operation = view.result.current.removal.runTaskRemovalBatch("archive", [ + { taskId: "a", mutate: () => pending.promise }, + ]); + }); + expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"])); + expect(view.result.current.sidebar.allTasks[1]).toBe(sibling); + expect(store.getState().kanban.tasks).toHaveLength(3); + act(() => setTasks([{ ...task("a"), title: "New title" }, task("b"), task("child", "a")])); + expect(view.result.current.sidebar.allTasks.map((row) => row.title)).toEqual([ + "New title", + "b", + "child", + ]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"])); + await act(async () => { + pending.reject(new Error("refused")); + await operation; + }); + expect(view.result.current.sidebar.allTasks.map((row) => row.title)).toEqual([ + "New title", + "b", + "child", + ]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set()); + }); + + it("marks a bulk cascade and only retains failed targets", async () => { + const view = setup(); + const first = deferred(); + const second = deferred(); + let operation!: Promise; + act(() => { + operation = view.result.current.removal.runTaskRemovalBatch( + "archive", + [ + { taskId: "a", mutate: () => first.promise }, + { taskId: "b", mutate: () => second.promise }, + ], + { cascade: true }, + ); + }); + expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a", "b", "child"])); + await act(async () => { + first.resolve(); + second.reject(new Error("refused")); + await operation; + }); + expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["b"]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set()); + }); +}); + +describe("sidebar archive compatibility", () => { + it("keeps confirmed archived rows visible and respects later navigation", () => { + const view = setup(); + act(() => { + store.getState().beginTaskRemoval({ + action: "archive", + workspaceId: "ws", + taskIds: ["a"], + requestIds: ["a"], + departure: null, + }); + store.getState().setActiveTask("b"); + }); + expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"])); + act(() => { + const state = store.getState(); + const tasks = state.kanban.tasks.map((row) => + row.id === "a" ? { ...row, isArchived: true } : row, + ); + store.setState({ + kanban: { ...state.kanban, tasks }, + kanbanMulti: { + ...state.kanbanMulti, + snapshots: { wf: { ...state.kanbanMulti.snapshots.wf, tasks } }, + }, + }); + }); + expect(view.result.current.sidebar.allTasks.find((row) => row.id === "a")?.isArchived).toBe( + true, + ); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set()); + expect(store.getState().tasks.activeTaskId).toBe("b"); + }); + + it("removes the last row when success releases pending intent", async () => { + setTasks([task("a")]); + const view = setup(); + const pending = deferred(); + let operation!: Promise; + act(() => { + operation = view.result.current.removal.runTaskRemovalBatch("archive", [ + { taskId: "a", mutate: () => pending.promise }, + ]); + }); + expect(view.result.current.sidebar.allTasks).toHaveLength(1); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set(["a"])); + await act(async () => { + pending.resolve(); + await operation; + }); + expect(view.result.current.sidebar.allTasks).toHaveLength(0); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set()); + expect(store.getState().taskRemoval.operationsByToken).toEqual({}); + }); + + it("does not hide delete targets or another workspace's operation", () => { + const view = setup(); + act(() => { + store.getState().beginTaskRemoval({ + action: "delete", + workspaceId: "ws", + taskIds: ["a"], + requestIds: ["a"], + departure: null, + }); + store.getState().beginTaskRemoval({ + action: "archive", + workspaceId: "other", + taskIds: ["b"], + requestIds: ["b"], + departure: null, + }); + }); + expect(view.result.current.sidebar.allTasks.map((row) => row.id)).toEqual(["a", "b", "child"]); + expect(view.result.current.sidebar.pendingArchiveTaskIds).toEqual(new Set()); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts index 0015e60ea1..3cffb58a15 100644 --- a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts @@ -22,6 +22,10 @@ type Snapshot = { }; type MockState = { + taskRemoval: { + pendingTokenByTaskId: Record; + operationsByToken: Record; + }; kanbanMulti: { snapshots: Record; isLoading: boolean; @@ -48,6 +52,7 @@ type MockState = { }; let mockState: MockState = { + taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} }, kanbanMulti: { snapshots: {}, isLoading: false }, workflows: { items: [] }, kanban: { workflowId: null, tasks: [], steps: [] }, @@ -71,6 +76,7 @@ import { mergeSidebarArchivedTasks, useWorkspaceSidebarTasks } from "./use-works function setMockState(patch: Partial) { mockState = { + taskRemoval: { ...mockState.taskRemoval, ...(patch.taskRemoval ?? {}) }, kanbanMulti: { ...mockState.kanbanMulti, ...(patch.kanbanMulti ?? {}) }, workflows: { ...mockState.workflows, ...(patch.workflows ?? {}) }, kanban: { ...mockState.kanban, ...(patch.kanban ?? {}) }, @@ -99,6 +105,7 @@ describe("useWorkspaceSidebarTasks", () => { beforeEach(() => { vi.clearAllMocks(); mockState = { + taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} }, kanbanMulti: { snapshots: {}, isLoading: false }, workflows: { items: [] }, kanban: { workflowId: null, tasks: [], steps: [] }, @@ -227,6 +234,7 @@ describe("useWorkspaceSidebarTasks reference stability", () => { beforeEach(() => { vi.clearAllMocks(); mockState = { + taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} }, kanbanMulti: { snapshots: {}, isLoading: false }, workflows: { items: [] }, kanban: { workflowId: null, tasks: [], steps: [] }, @@ -287,6 +295,7 @@ describe("useWorkspaceSidebarTasks reference stability", () => { describe("useWorkspaceSidebarTasks WIP queue", () => { beforeEach(() => { mockState = { + taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} }, kanbanMulti: { snapshots: {}, isLoading: false }, workflows: { items: [] }, kanban: { workflowId: null, tasks: [], steps: [] }, @@ -406,6 +415,7 @@ describe("useWorkspaceSidebarTasks — loading", () => { beforeEach(() => { vi.clearAllMocks(); mockState = { + taskRemoval: { pendingTokenByTaskId: {}, operationsByToken: {} }, kanbanMulti: { snapshots: {}, isLoading: false }, workflows: { items: [] }, kanban: { workflowId: null, tasks: [], steps: [] }, diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts index f4a48f7ecc..b0eea2239a 100644 --- a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts @@ -12,9 +12,11 @@ import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu" import type { KanbanState } from "@/lib/state/slices/kanban/types"; import type { WorkspaceContextReadError } from "@/lib/state/slices/kanban/types"; import type { AppState } from "@/lib/state/store"; +import type { TaskRemovalState } from "@/lib/state/task-removal"; import { getDestinationQueue, type WipQueueStatus } from "@/lib/kanban/wip-queue"; export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & { + pendingArchiveTaskIds: ReadonlySet; workflows: TaskMoveWorkflow[]; wipQueueByTaskId: Map; isLoading: boolean; @@ -29,6 +31,10 @@ export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & { const NOOP_REFRESH = () => {}; type SidebarTask = AggregatedSidebarTasks["allTasks"][number]; +type SidebarTaskRemovalProjection = Pick< + TaskRemovalState, + "pendingTokenByTaskId" | "operationsByToken" +>; function shallowTaskEqual(previous: SidebarTask, next: SidebarTask): boolean { const previousKeys = Object.keys(previous) as Array; @@ -181,6 +187,45 @@ export function mergeSidebarArchivedTasks( return archived.length > 0 ? [...activeTasks, ...archived] : activeTasks; } +function useSidebarTaskProjection( + activeTasks: SidebarTask[], + archivedTasks: KanbanState["tasks"], + workspaceId: string | null, + needsArchivedTasks: boolean, + taskRemoval: SidebarTaskRemovalProjection, +) { + const { operationsByToken, pendingTokenByTaskId } = taskRemoval; + const previousTasksRef = useRef([]); + const merged = useMemo( + () => mergeSidebarArchivedTasks(activeTasks, archivedTasks, workspaceId, needsArchivedTasks), + [activeTasks, archivedTasks, needsArchivedTasks, workspaceId], + ); + const allTasks = useMemo(() => { + const shared = reuseUnchangedTasks(previousTasksRef.current, merged); + previousTasksRef.current = shared; + return shared; + }, [merged]); + const pendingArchiveTaskIds = useMemo(() => { + const pending = new Set(); + if (!workspaceId) return pending; + const activeTaskIds = new Set( + allTasks.filter((task) => task.isArchived !== true).map((task) => task.id), + ); + for (const [taskId, token] of Object.entries(pendingTokenByTaskId)) { + const operation = operationsByToken[token]; + if ( + activeTaskIds.has(taskId) && + operation?.action === "archive" && + operation.workspaceId === workspaceId + ) { + pending.add(taskId); + } + } + return pending; + }, [allTasks, taskRemoval, workspaceId]); + return { allTasks, pendingArchiveTaskIds }; +} + /** * Shared data source for the desktop sidebar and the mobile task-switcher sheet. * @@ -212,6 +257,12 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS const needsArchivedTasks = viewRequiresArchivedTasks(effectiveView); const archived = useSidebarArchivedTasks(workspaceId, needsArchivedTasks); + const pendingTokenByTaskId = useAppStore((state) => state.taskRemoval.pendingTokenByTaskId); + const operationsByToken = useAppStore((state) => state.taskRemoval.operationsByToken); + const taskRemoval = useMemo( + () => ({ operationsByToken, pendingTokenByTaskId }), + [operationsByToken, pendingTokenByTaskId], + ); const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading); const workflows = useAppStore((state) => state.workflows.items); @@ -262,18 +313,13 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS const { allSteps, stepsByWorkflowId } = useSharedStepMetadata(aggregated); - const previousTasksRef = useRef([]); - const allTasks = useMemo(() => { - const merged = mergeSidebarArchivedTasks( - aggregated.allTasks, - archived.tasks, - workspaceId, - needsArchivedTasks, - ); - const shared = reuseUnchangedTasks(previousTasksRef.current, merged); - previousTasksRef.current = shared; - return shared; - }, [aggregated.allTasks, archived.tasks, needsArchivedTasks, workspaceId]); + const { allTasks, pendingArchiveTaskIds } = useSidebarTaskProjection( + aggregated.allTasks, + archived.tasks, + workspaceId, + needsArchivedTasks, + taskRemoval, + ); const wipQueueByTaskId = useMemo( () => buildWipQueueByTaskId(allTasks, allSteps, stepsByWorkflowId), @@ -299,6 +345,7 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS return { ...aggregated, allTasks, + pendingArchiveTaskIds, allSteps, stepsByWorkflowId, wipQueueByTaskId, diff --git a/docs/plans/immediate-sidebar-archive/plan.md b/docs/plans/immediate-sidebar-archive/plan.md new file mode 100644 index 0000000000..3c24f10e54 --- /dev/null +++ b/docs/plans/immediate-sidebar-archive/plan.md @@ -0,0 +1,109 @@ +--- +created: 2026-09-15 +status: implemented +requirements: + - REQ-TASKS-REMOVAL-NAVIGATION-003 +system_design: + - ../../specs/tasks/system-design/removal-navigation.md +legacy_specs: [] +--- + +# Implementation plan: Immediate sidebar archive + +## Overview + +Project accepted archive targets through the existing shared sidebar while the +request is pending. Tasks owns this extension because pending archive intent +and recovery are task lifecycle state. One sequential work order delivers the +complete change. + +## Scope + +Include selected/unselected archive, bulk and known cascade targets, failure, +refresh races, and desktop/phone parity. Exclude delete semantics, cleanup/API +changes, new navigation surfaces, and navigation redesign. + +## Technical approach + +The existing coordinator publishes pending state immediately but retains caches +via `switchOnly`; the sidebar reads those caches without observing pending state. +Extend `useWorkspaceSidebarTasks` with archive-only pending IDs backed by +`taskRemoval`, before downstream tree/group projections. Pass the marker through +the shared task row, dim it, and show a spinner while preserving canonical rows +for recovery and authoritative archived-inclusive saved-view behavior. See the +linked design's Immediate sidebar archive projection section. + +## ASCII UI preview + +UI-01: Active task navigation, archive A accepted (AC-003.1 through AC-003.3). + +```text +Desktop sidebar Phone task picker (reopened) +Before Pending/Success Before Pending/Success +[A ...] [A ...] (dim/spin) [A ...] [A ...] (dim/spin) +[B ...] [B ...] [B ...] [B ...] + +Failure, A still active: [A ...] [B ...] +Last row archived: existing empty task list +``` + +The pending row keeps its place; success removes it and collapses the gap. +Spacing is illustrative. +Phone keeps its existing sheet, fixed header, safe-area padding, and single +scrolling list. Its visible menu is the archive entry point; acceptance retains +existing dismissal. No new mobile surface is needed for this frequent navigation +action. Existing `session-task-switcher-sheet.tsx` is the shipped exemplar. + +## Tests + +Add `use-workspace-sidebar-tasks.archive.test.ts` with a real removal-state transition +and deferred mutation: assert the pending marker before settlement, retention +across cache refresh, restoration after rejection, success removal after release, +unselected archive, non-cascade surviving children, cascade/bulk membership, and +archived views. Add a task-row test for dimming and the spinner. Use +`use-task-removal-coordinator.test.ts` to cover operation release and mixed bulk +success/failure where needed. These map to AC-003.1 through AC-003.3. + +## E2E tests + +Add `task/sidebar-immediate-archive.spec.ts` (chromium) and +`task/mobile-sidebar-immediate-archive.spec.ts` (mobile-chrome). Hold the archive +request before server processing, accept archive, and assert a dimmed spinner +row before release; withholding only the response is insufficient because WS may +win. Reopen the phone picker during the hold. Include cancellation, rejection +and successful completion cases. The real-store last-row test proves the final +empty projection; existing archive redirect specs cover the empty destination. +Use isolated fixtures and explicit deferred request release, never fixed sleeps. +All three new acceptance criteria need coverage. + +## Work orders + +- [x] [Task 01: Present pending archive rows](task-01-hide-pending-archive-rows.md) + +## Verification results + +Design validation passed on 2026-09-15: catalog validation (272 decisions, +934 specifications), full specification lint, specification-linter tests +(36 passed), and `git diff --check`. Implementation complete: the sidebar +projection, shared desktop/phone rows, and deferred regressions cover the +pending dimmed-spinner state, success removal, and failure recovery. Targeted +unit tests, typecheck, ESLint, formatting, and both desktop/phone browser tests +passed. Production backend and web builds passed. Both pending-state screenshots +were inspected against UI-01. The browser runs required local socket permission +outside the sandbox and reused fresh build assets with `--no-build`. See the work +order for commands and results. + +## Risks + +- Marking only the selected task misses unselected/bulk actions. +- Releasing the pending marker too early causes a success-time row flash. +- Filtering archived rows indiscriminately breaks saved archive views. +- A test holding only HTTP responses can miss an early live-event transition. + +## Related package and docs + +The completed [removal navigation package](../task-removal-navigation/plan.md) +provides the coordinator and remains historical evidence; this adds a new scope. +The archive section in `docs/public/tasks-and-workflows.md` explains pending +archive presentation and recovery. No new ADR is needed: +this reuses the existing local-operation/state ownership boundary. diff --git a/docs/plans/immediate-sidebar-archive/task-01-hide-pending-archive-rows.md b/docs/plans/immediate-sidebar-archive/task-01-hide-pending-archive-rows.md new file mode 100644 index 0000000000..2ffa072cff --- /dev/null +++ b/docs/plans/immediate-sidebar-archive/task-01-hide-pending-archive-rows.md @@ -0,0 +1,142 @@ +--- +id: "01-hide-pending-archive-rows" +title: "Present pending archive rows" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-REMOVAL-NAVIGATION-003 +acceptance_criteria: + - AC-TASKS-REMOVAL-NAVIGATION-003.1 + - AC-TASKS-REMOVAL-NAVIGATION-003.2 + - AC-TASKS-REMOVAL-NAVIGATION-003.3 +system_design: + - ../../specs/tasks/system-design/removal-navigation.md +--- + +# Task 01: Present pending archive rows + +## Summary + +Subscribe the shared sidebar data projection to accepted archive operations. +Dim pending active rows with a spinner, then remove them on success while +preserving existing recovery semantics. + +## In scope + +Archive-only pending markers, real-store deferred regressions, desktop and phone proof. + +## Out of scope + +Delete changes, API changes, persisted state, new controls, and navigation redesign. + +## Acceptance + +- Prove the dimmed spinner presentation before network/destination settlement, + including unselected, bulk, cascade and last-row cases. +- Remove successful targets after release while preserving new cache data, + failure recovery, concurrent navigation and saved archived-inclusive views. +- Desktop and phone rendered regressions pass with requests held before processing. + +## ASCII UI preview + +UI-01: Active task navigation, archive A accepted (AC-003.1 through AC-003.3). + +```text +Desktop sidebar Phone task picker (reopened) +Before Pending/Success Before Pending/Success +[A ...] [A ...] (dim/spin) [A ...] [A ...] (dim/spin) +[B ...] [B ...] [B ...] [B ...] + +Failure, A still active: [A ...] [B ...] +Last row archived: existing empty task list +``` + +The pending row keeps its place; success removes it and collapses the gap. +Spacing is illustrative. +Phone keeps its existing sheet, fixed header, safe-area padding, and single +scrolling list. Its visible menu is the archive entry point; acceptance retains +existing dismissal. No new mobile surface is needed for this frequent navigation +action. Existing `session-task-switcher-sheet.tsx` is the shipped exemplar. + +Full preview and scenario mapping: [plan](plan.md#ascii-ui-preview). + +## Verification + +Run from the repository root. Install dependencies once for a fresh worktree. +Write the deferred regression first and record its expected failure, then fix. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.archive.test.ts hooks/use-task-removal-coordinator.test.ts lib/state/task-removal.test.ts lib/ws/handlers/tasks-archive.test.ts components/task/task-item.test.tsx components/task/task-item-archive-pending.test.tsx components/task/task-session-sidebar-item.test.ts components/task/task-session-sidebar-item-pending-archive.test.ts components/task/mobile/session-task-switcher-sheet-item.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm exec eslint hooks/domains/kanban/use-workspace-sidebar-tasks.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.archive.test.ts hooks/use-task-removal-coordinator.test.ts e2e/tests/task/sidebar-immediate-archive-helpers.ts e2e/tests/task/sidebar-immediate-archive.spec.ts e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts) +(cd apps/web && GOCACHE=/tmp/kandev-sidebar-go-cache pnpm e2e:run --project=chromium e2e/tests/task/sidebar-immediate-archive.spec.ts) +(cd apps/web && GOCACHE=/tmp/kandev-sidebar-go-cache pnpm e2e:run --no-build --project=mobile-chrome e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Managed E2E rebuilds production assets. If a helper or additional suite changes, +include its targeted lint/test command in Results. Compare rendered rows with +UI-01. Read `/tdd`, `/e2e` and scoped web guidance before implementation. + +## Files likely touched + +- `apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts` +- `apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.archive.test.ts` (new real-store regressions) +- `apps/web/components/task/task-item.tsx` and shared task-state row components +- `apps/web/components/task/task-item-archive-pending.test.tsx` and projection tests +- `apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts` (shared browser scenario) +- `apps/web/e2e/tests/task/sidebar-immediate-archive.spec.ts` (new) +- `apps/web/e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts` (new) + +## Dependencies + +None. Existing task-removal coordinator is present. + +## Risks + +Use operation membership, not departure ownership. Do not mutate caches to +implement temporary presentation. Preserve archived views and existing empty states. + +## Parallelism + +sequential + +## Inputs + +- [Requirements](../../specs/tasks/requirements/removal-navigation.md), REQ-003. +- [Design](../../specs/tasks/system-design/removal-navigation.md), Immediate sidebar archive projection. +- Existing coordinator tests and `task/removal-transition-helpers.ts` E2E pattern. + +## Results + +- Dependencies installed with the frozen lockfile. +- RED: two real-store tests failed because pending archive rows were not yet + projected. +- Final unit verification: all 115 targeted tests passed across the listed + suites, including the pending-row dimming and spinner assertion. +- Web typecheck passed. Targeted ESLint passed without warnings; formatting passed. +- Full backend and production web build passed using the writable Go cache. +- Initial browser attempts could not bind a socket inside the sandbox. Browser + runs use an isolated backend with permitted local sockets and `--no-build` + against those fresh assets; no developer instance or data was used. +- Phone regression passed after explicitly reopening the picker through its + button rather than treating a closing animation as an open sheet. Its pending + screenshot was inspected: the target remains dimmed with a spinner, and its + settled screenshot shows one surviving row with no archived row or retained + gap. +- Final browser commands passed (1 test each): + `GOCACHE=/tmp/kandev-sidebar-go-cache pnpm e2e:run --host --no-build --project chromium e2e/tests/task/sidebar-immediate-archive.spec.ts` + and + `GOCACHE=/tmp/kandev-sidebar-go-cache pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/task/mobile-sidebar-immediate-archive.spec.ts`. +- Desktop pending screenshot inspected against UI-01: the target remains in its + original place, dimmed with a spinner, and the neighboring row does not jump. +- Catalog validation, full specification lint, and diff checks passed. + +The phone's existing archive-error path logs the failure rather than displaying +Desktop's toast. The shared test asserts pending-row recovery on both and the +existing desktop error notification; no notification behavior was changed. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index bd462b4911..cbeead3015 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -795,6 +795,8 @@ On a phone, archive uses a focused confirmation step in the open Tasks sheet, or a compact bottom sheet from a page. [Phone confirmation controls](mobile-remote-access.md#confirm-an-action-on-a-phone) explain how to review the action and return to your list without losing your place. +After you confirm archive, the task stays in the active sidebar and phone task picker in a dimmed, busy state with a spinner while the request is pending. It disappears after a successful archive. If the request fails, the task returns to its normal state. Saved views that include archived tasks still show confirmed archives. + Archive records the task as archived and removes it from active views immediately. Runtime stopping and physical cleanup then run in the background with a 60-second timeout. Cleanup is best-effort: a stop or deletion failure is logged and does not undo the archive, and Kandev preserves a runtime or environment when a nonterminal session cannot be stopped. Shared inherited environments and borrowed worktrees are also preserved while another active task still uses them. | Executor | Archive cleanup | diff --git a/docs/specs/tasks/requirements/removal-navigation.md b/docs/specs/tasks/requirements/removal-navigation.md index 1f38e26e59..eeec15b35d 100644 --- a/docs/specs/tasks/requirements/removal-navigation.md +++ b/docs/specs/tasks/requirements/removal-navigation.md @@ -1,5 +1,5 @@ --- -status: draft +status: active system: tasks created: 2026-09-10 owners: @@ -79,6 +79,30 @@ removal, replacement eligibility, and recovery depend on the task lifecycle. restart a session because removal empties its session list. After confirmed failure, ordinary task behavior shall resume only for an available task. +### REQ-TASKS-REMOVAL-NAVIGATION-003: Immediate archive visibility + +**Intent:** Show accepted archive targets as busy until they are removed from +active task navigation after the archive operation completes. + +#### Acceptance criteria + +- **AC-TASKS-REMOVAL-NAVIGATION-003.1:** After archive acceptance, every visible + active task in the removal set shall remain in the desktop sidebar and phone + task picker in a dimmed, busy state with a spinner on the next render, before + the archive request, destination lookup, or live event completes. The row + shall keep its place and expose no stale interactive state. Opening or + cancelling confirmation shall leave rows unchanged. +- **AC-TASKS-REMOVAL-NAVIGATION-003.2:** Refreshes and live updates during the + operation shall preserve the pending presentation. Successful targets shall + be removed after completion and failed targets that remain active shall return + to their normal presentation without overwriting newer task data or user + navigation. Bulk partial failure shall restore only failed targets. +- **AC-TASKS-REMOVAL-NAVIGATION-003.3:** Archiving an unselected task shall leave + the selected task and route unchanged. Non-cascade archive shall not hide + surviving subtasks. Existing archived-inclusive saved views shall continue to + show confirmed archived tasks according to their filters; pending archive + intent shall not manufacture a confirmed archived task. + ## Compatibility and exclusions Existing archive confirmation preferences and cascade choices remain governed @@ -88,7 +112,7 @@ This adds local user-action presentation guarantees; it does not change task API permissions, session-only deletion, Quick Chat expiration/close, or server cleanup. Remote/API/MCP removal retains existing lifecycle reconciliation and redirects. Cold unavailable task routes retain [their current contract](missing-task-route-recovery.md). -Undo, animations, new settings, and a new mobile navigation composition are excluded. +Undo, new settings, and a new mobile navigation composition are excluded. ## System design @@ -97,3 +121,5 @@ Undo, animations, new settings, and a new mobile navigation composition are excl ## Implementation plans - [Task removal navigation](../../../plans/task-removal-navigation/plan.md) + +- [Immediate sidebar archive](../../../plans/immediate-sidebar-archive/plan.md) diff --git a/docs/specs/tasks/system-design/removal-navigation.md b/docs/specs/tasks/system-design/removal-navigation.md index c81c870b9e..b4d58b1dad 100644 --- a/docs/specs/tasks/system-design/removal-navigation.md +++ b/docs/specs/tasks/system-design/removal-navigation.md @@ -1,10 +1,11 @@ --- -status: draft +status: current system: tasks created: 2026-09-10 requirements: - REQ-TASKS-REMOVAL-NAVIGATION-001 - REQ-TASKS-REMOVAL-NAVIGATION-002 + - REQ-TASKS-REMOVAL-NAVIGATION-003 owners: - kandev --- @@ -17,7 +18,7 @@ The task system coordinates local removal intent, task presentation, and fallback selection. HTTP and WebSocket task state remain authoritative. No backend, persistence, permission, or cleanup protocol changes are required. -Source inspection found these gaps: +The original investigation, before coordinator implementation, found these gaps: - Desktop and phone delete handlers await `deleteTaskById` before `removeTaskFromBoard`; `useTaskCRUD` also waits before removing board rows. @@ -34,10 +35,59 @@ controlled delayed-response regressions before production changes. ## Requirement mapping -| Requirement | Design sections | -| --- | --- | -| REQ-TASKS-REMOVAL-NAVIGATION-001 | Presentation boundary, navigation, entry points, mobile | +| Requirement | Design sections | +| -------------------------------- | -------------------------------------------------------- | +| REQ-TASKS-REMOVAL-NAVIGATION-001 | Presentation boundary, navigation, entry points, mobile | | REQ-TASKS-REMOVAL-NAVIGATION-002 | Operation state, reconciliation, failure recovery, tests | +| REQ-TASKS-REMOVAL-NAVIGATION-003 | Pending archive presentation in shared task navigation | + +## Immediate sidebar archive projection + +`REQ-TASKS-REMOVAL-NAVIGATION-003` extends the existing operation state to the +sidebar's visible projection. `coordinateTaskRemovalBatch` already publishes +`beginTaskRemoval` before destination lookup and mutation. Its optimistic +`switchOnly` path deliberately retains task caches; successful reconciliation +prunes them later. `useWorkspaceSidebarTasks` currently aggregates those caches +without subscribing to removal intent. This accounts for the retained row under +latency; it is source-trace evidence, not a measured browser reproduction. + +Subscribe the shared desktop/phone `useWorkspaceSidebarTasks` projection to +`taskRemoval`. Derive archive-pending task IDs from `taskIds`, +`pendingTokenByTaskId`, and `operationsByToken`, scoped by task identity and +operation workspace. Return the rows with their existing order and references, +plus a pending marker for active rows. Do not reuse a navigation-ownership +predicate: unselected tasks and operations whose user has navigated still need +the pending presentation. Delete behavior is outside this extension. + +Pass the marker through the sidebar and phone item projections to the shared +task row. A pending row keeps its normal geometry, becomes visually dimmed, +sets busy/disabled accessibility state, and replaces its task-state icon with a +muted spinner. Keep the row in the list so neighboring rows do not jump while +the request is in flight; prevent stale actions from presenting it as settled. + +Retain this pending presentation through operation reconciliation and release it +through the existing coordinator. Cache refreshes cannot bypass the marker. +Success must prune active caches before release, as the coordinator currently +does; the row then disappears and its gap collapses. On failure, releasing the +marker reveals the latest eligible cache row; do not restore an old snapshot or +write synthetic archive metadata. Continue applying archive events and +archived-cache updates so archived-inclusive saved views preserve their existing +semantics. A confirmed archived row can remain in such a view; it is no longer +marked pending. + +The presentation marker does not alter selection, recent-task preferences, +persistence, or server cleanup. Existing navigation recovery remains +authoritative. Unknown outcomes use existing authoritative refresh/recovery; +never reconstruct a row removed by a server event. Cascade membership uses the +operation's captured known descendants; later authoritative cascade events +handle uncached children. + +Desktop uses the existing sidebar; phone uses the existing task-switcher sheet +and visible overflow actions. Their shared data hook owns the pending marker. +Keep the existing sheet dismissal, scroll owner, safe areas, touch targets, and +task navigation. Reopening the phone picker while a request is pending must +still show the dimmed spinner row. No new copy, geometry, or navigation +composition is required. ## Operation state @@ -200,3 +250,5 @@ production builds. Exact scenario mapping and commands belong to the plan. This local orchestration change needs no new ADR: its constraints and rationale are fully captured by this design and its requirements. + +- [Immediate sidebar archive plan](../../../plans/immediate-sidebar-archive/plan.md)