fix(ui): show archive progress before removal (#3730)

This commit is contained in:
José Almeida
2026-09-17 13:16:25 +02:00
committed by GitHub
parent f32eb37378
commit 79894dc506
22 changed files with 890 additions and 20 deletions
@@ -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,
]);
@@ -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", () => {
@@ -25,6 +25,7 @@ export type SheetItemCtx = {
repositoriesById?: ReadonlyMap<string, Repository>;
stepColorById?: ReadonlyMap<string, string>;
automaticColorSettings?: SidebarTaskColorAutomation;
pendingArchiveTaskIds?: ReadonlySet<string>;
};
const EMPTY_REPOSITORIES_BY_ID = new Map<string, Repository>();
@@ -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,
@@ -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(
<StateProvider>
<TooltipProvider>
<TaskItem title="Needs answer" state="REVIEW" isPendingArchive />
</TooltipProvider>
</StateProvider>,
);
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();
});
});
+15
View File
@@ -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 <ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow>;
}
@@ -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
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { buildSidebarItem } from "./task-session-sidebar-item";
type SidebarTask = Parameters<typeof buildSidebarItem>[0];
type SidebarContext = Parameters<typeof buildSidebarItem>[1];
function emptyContext(): SidebarContext {
return {
repositorySlugById: new Map(),
titleById: new Map(),
workflowNameById: new Map(),
stepTitleById: new Map(),
};
}
function task(overrides: Partial<SidebarTask> = {}): 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);
});
});
@@ -22,6 +22,7 @@ export type SidebarItemContext = {
repositoriesById?: ReadonlyMap<string, Repository>;
stepColorById?: ReadonlyMap<string, string>;
automaticColorSettings?: SidebarTaskColorAutomation;
pendingArchiveTaskIds?: ReadonlySet<string>;
};
const EMPTY_REPOSITORIES_BY_ID = new Map<string, Repository>();
@@ -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,
@@ -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 {
+20 -1
View File
@@ -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 (
<CompositorSpin
aria-hidden="true"
data-testid="task-state-archive-pending"
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
>
<IconCircleDashed className="size-full" />
</CompositorSpin>
);
}
export function TaskStateIcon(props: TaskStateIconProps) {
if (props.isPendingArchive) return <PendingArchiveTaskIcon />;
return <TaskStateIconContent {...props} />;
}
function TaskStateIconContent({
sessionState,
state,
foregroundActivity,
@@ -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}
@@ -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;
@@ -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"),
});
});
@@ -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<Page["getByTestId"]>) => {
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 });
}
}
@@ -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"),
});
});
@@ -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<typeof createAppStore>;
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<void>((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<unknown>;
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<unknown>;
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<unknown>;
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());
});
});
@@ -22,6 +22,10 @@ type Snapshot = {
};
type MockState = {
taskRemoval: {
pendingTokenByTaskId: Record<string, string>;
operationsByToken: Record<string, unknown>;
};
kanbanMulti: {
snapshots: Record<string, Snapshot>;
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>) {
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: [] },
@@ -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<string>;
workflows: TaskMoveWorkflow[];
wipQueueByTaskId: Map<string, WipQueueStatus>;
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<keyof SidebarTask>;
@@ -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<SidebarTask[]>([]);
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<string>();
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<SidebarTask[]>([]);
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,
@@ -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.
@@ -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.
+2
View File
@@ -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 |
@@ -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)
@@ -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)