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,