fix(ui): show archive progress before removal (#3730)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user