fix(threads): remove archived chats immediately (#3625)

This commit is contained in:
José Almeida
2026-09-12 16:54:56 +02:00
committed by GitHub
parent 3f33ce0451
commit 140ef39cc6
22 changed files with 1492 additions and 79 deletions
@@ -0,0 +1,384 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider, useAppStore, useAppStoreApi } from "@/components/state-provider";
import { createAppStore, type HydrationState } from "@/lib/state/store";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http";
import { useTaskMenuActions } from "@/hooks/use-task-menu-actions";
import { registerTasksHandlers } from "@/lib/ws/handlers/tasks";
import { DEFAULT_THREAD_VIEW } from "@/lib/state/slices/ui/thread-view-builtins";
import { ThreadsPageClient } from "./threads-page-client";
const transport = vi.hoisted(() => ({ archive: vi.fn(), remove: vi.fn(), toast: vi.fn() }));
const route = vi.hoisted(() => ({ search: "", push: vi.fn(), replace: vi.fn() }));
const setView = vi.hoisted(() => vi.fn());
const activeChats = new Set<string>();
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()),
archiveTask: transport.archive,
deleteTask: transport.remove,
}));
vi.mock("@/lib/routing/client-router", async (original) => ({
...(await original<typeof import("@/lib/routing/client-router")>()),
useRouter: () => route,
useSearchParams: () => new URLSearchParams(route.search),
}));
vi.mock("@/src/kanban-route", () => ({ useKanbanRouteBootstrap: () => {} }));
vi.mock("@/hooks/domains/kanban/use-all-workflow-snapshots", () => ({
useAllWorkflowSnapshots: () => {},
}));
vi.mock("@/hooks/use-task-listing-view", () => ({
useTaskListingView: () => ({ setView }),
}));
vi.mock("@/hooks/use-kanban-display-settings", () => ({
useKanbanDisplaySettings: () => ({
activeWorkspaceId: useAppStore((state) => state.workspaces.activeId),
workspaces: useAppStore((state) => state.workspaces.items),
workflows: useAppStore((state) => state.workflows.items),
repositories: [],
}),
}));
vi.mock("@/components/kanban/kanban-header", () => ({ KanbanHeader: () => null }));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: transport.toast }) }));
vi.mock("@/components/threads/thread-conversation", () => ({
ThreadConversation: ({ taskId }: { taskId: string }) => {
useEffect(() => {
activeChats.add(taskId);
return () => {
activeChats.delete(taskId);
};
}, [taskId]);
return null;
},
}));
let store: ReturnType<typeof useAppStoreApi>;
let actions: ReturnType<typeof useTaskMenuActions>;
function Harness() {
store = useAppStoreApi();
actions = useTaskMenuActions({ stayOnListing: true });
return <ThreadsPageClient />;
}
function task(id: string, parentTaskId?: string): KanbanState["tasks"][number] {
return {
id,
title: `Task ${id}`,
workspaceId: "workspace",
workflowId: "workflow",
workflowStepId: "step",
position: 0,
state: "IN_PROGRESS",
parentTaskId,
primarySessionId: `session-${id}`,
primarySessionState: "RUNNING",
};
}
function renderThreads(tasks = [task("a"), task("b"), task("c")]) {
const base = createAppStore().getState();
const sessions = tasks.map(
({ id }): TaskSession => ({
id: toSessionId(`session-${id}`),
task_id: toTaskId(id),
state: "RUNNING",
is_primary: true,
started_at: "2026-09-12T08:00:00Z",
updated_at: "2026-09-12T08:00:00Z",
}),
);
const initialState: HydrationState = {
workspaces: { ...base.workspaces, activeId: "workspace" },
workflows: {
activeId: null,
items: [{ id: "workflow", workspaceId: "workspace", name: "Delivery" }],
},
kanban: { ...base.kanban, workflowId: "workflow", tasks },
kanbanMulti: {
...base.kanbanMulti,
snapshots: {
workflow: { workflowId: "workflow", workflowName: "Delivery", steps: [], tasks },
},
},
taskSessions: {
...base.taskSessions,
items: Object.fromEntries(sessions.map((s) => [s.id, s])),
},
taskSessionsByTask: {
...base.taskSessionsByTask,
itemsByTaskId: Object.fromEntries(sessions.map((s) => [s.task_id, [s]])),
loadedByTaskId: Object.fromEntries(tasks.map(({ id }) => [id, true])),
},
};
return render(
<StateProvider initialState={initialState}>
<Harness />
</StateProvider>,
);
}
function deferred() {
let resolve!: () => void;
let reject!: (reason: Error) => void;
const promise = new Promise<void>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function visibleIds() {
return screen.queryAllByTestId(/^thread-column-/).map((column) => column.dataset.threadColumnId);
}
function publishArchive(id: string) {
registerTasksHandlers(store)["task.updated"]!({
id: `archived-${id}`,
type: "notification",
action: "task.updated",
payload: {
task_id: id,
workspace_id: "workspace",
workflow_id: "workflow",
workflow_step_id: "step",
title: `Task ${id}`,
state: "IN_PROGRESS",
is_ephemeral: false,
archived_at: "2026-09-12T09:00:00Z",
},
});
}
beforeEach(() => {
vi.clearAllMocks();
route.search = "";
window.history.replaceState({}, "", "/threads");
});
afterEach(() => {
cleanup();
activeChats.clear();
});
// @covers AC-TASKS-THREADS-ACTIONS-003.7, AC-TASKS-THREADS-ACTIONS-003.8
describe("Threads pending archive lifecycle", () => {
it("removes an archived column before the request settles", async () => {
const pending = deferred();
transport.archive.mockReturnValueOnce(pending.promise);
renderThreads();
expect(activeChats.has("a")).toBe(true);
let result!: Promise<boolean>;
act(() => {
result = actions.runArchive("a");
});
try {
expect(visibleIds()).toEqual(["b", "c"]);
expect(activeChats.has("a")).toBe(false);
expect(store.getState().kanbanMulti.snapshots.workflow.tasks.map(({ id }) => id)).toEqual([
"a",
"b",
"c",
]);
expect(transport.toast).not.toHaveBeenCalled();
} finally {
await act(async () => {
pending.resolve();
await result;
});
}
expect(visibleIds()).toEqual(["b", "c"]);
expect(transport.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "success" }));
});
// @covers AC-TASKS-THREADS-ACTIONS-003.4, AC-TASKS-THREADS-ACTIONS-004.6
it("readmits a rejected archive without reviving its consumed deep link", async () => {
route.search = "taskId=a&sessionId=session-a";
const pending = deferred();
transport.archive.mockReturnValueOnce(pending.promise);
renderThreads();
fireEvent.pointerDown(screen.getByTestId("thread-column-a"));
let result!: Promise<boolean>;
act(() => {
result = actions.runArchive("a");
});
try {
expect(visibleIds()).toEqual(["b", "c"]);
fireEvent.pointerDown(screen.getByTestId("thread-column-c"));
} finally {
await act(async () => {
pending.reject(new Error("archive rejected"));
await result;
});
}
expect(visibleIds()).toEqual(["b", "c", "a"]);
expect(screen.getByTestId("thread-column-a").getAttribute("data-focused")).toBeNull();
expect(route.replace).not.toHaveBeenCalled();
expect(route.push).not.toHaveBeenCalled();
expect(transport.toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
it.each(["event-first", "response-first"])(
"does not reopen successful archives with %s delivery",
async (order) => {
const pending = deferred();
transport.archive.mockReturnValueOnce(pending.promise);
renderThreads([task("a")]);
let result!: Promise<boolean>;
act(() => {
result = actions.runArchive("a");
});
try {
expect(screen.queryByTestId("threads-empty-state")).not.toBeNull();
if (order === "event-first") act(() => publishArchive("a"));
} finally {
await act(async () => {
pending.resolve();
await result;
});
}
if (order === "response-first") act(() => publishArchive("a"));
expect(visibleIds()).toEqual([]);
expect(activeChats.has("a")).toBe(false);
expect(screen.queryByTestId("threads-empty-state")).not.toBeNull();
expect(store.getState().taskRemoval.operationsByToken).toEqual({});
},
);
});
describe("Threads independent removal intent", () => {
it("releases only the failed archive when two different tasks are pending", async () => {
const first = deferred();
const second = deferred();
transport.archive.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
renderThreads();
let firstResult!: Promise<boolean>;
let secondResult!: Promise<boolean>;
act(() => {
firstResult = actions.runArchive("a");
secondResult = actions.runArchive("b");
});
try {
expect(visibleIds()).toEqual(["c"]);
await act(async () => {
first.reject(new Error("archive rejected"));
await firstResult;
});
expect(visibleIds()).toEqual(["c", "a"]);
expect(Object.values(store.getState().taskRemoval.operationsByToken)).toHaveLength(1);
} finally {
await act(async () => {
first.resolve();
second.resolve();
await Promise.all([firstResult, secondResult]);
});
}
expect(visibleIds()).toEqual(["c", "a"]);
});
it("excludes captured descendants while leaving a pending delete visible", async () => {
const archive = deferred();
const remove = deferred();
transport.archive.mockReturnValueOnce(archive.promise);
transport.remove.mockReturnValueOnce(remove.promise);
route.search = "taskId=child";
renderThreads([task("parent"), task("child", "parent"), task("b")]);
let archiveResult!: Promise<boolean>;
let deleteResult!: Promise<boolean>;
act(() => {
archiveResult = actions.runArchive("parent", { cascade: true });
deleteResult = actions.runDelete("b");
});
try {
expect(visibleIds()).toEqual(["b"]);
expect(transport.archive).toHaveBeenCalledWith("parent", { cascade: true });
} finally {
await act(async () => {
archive.resolve();
remove.resolve();
await Promise.all([archiveResult, deleteResult]);
});
}
expect(visibleIds()).toEqual([]);
});
});
describe("Threads archive rejection reconciliation", () => {
it.each(["workspace", "view"])("does not undo a newer %s choice on rejection", async (choice) => {
const pending = deferred();
transport.archive.mockReturnValueOnce(pending.promise);
renderThreads();
let result!: Promise<boolean>;
act(() => {
result = actions.runArchive("a");
});
try {
act(() =>
store.setState((state) =>
choice === "workspace"
? {
workspaces: { ...state.workspaces, activeId: "another-workspace" },
}
: {
threadViews: {
...state.threadViews,
draft: {
...DEFAULT_THREAD_VIEW,
baseViewId: DEFAULT_THREAD_VIEW.id,
taskScope: { mode: "selected", taskIds: ["b"] },
},
},
},
),
);
} finally {
await act(async () => {
pending.reject(new Error("archive rejected"));
await result;
});
}
expect(visibleIds()).toEqual(choice === "workspace" ? [] : ["b"]);
expect(route.push).not.toHaveBeenCalled();
expect(route.replace).not.toHaveBeenCalled();
});
it.each(["archive", "delete"])(
"keeps an authoritative %s removed after the request rejects",
async (event) => {
const pending = deferred();
transport.archive.mockReturnValueOnce(pending.promise);
renderThreads([task("a")]);
let result!: Promise<boolean>;
act(() => {
result = actions.runArchive("a");
});
try {
act(() => {
if (event === "archive") publishArchive("a");
else
registerTasksHandlers(store)["task.deleted"]!({
id: "deleted-a",
type: "notification",
action: "task.deleted",
payload: {
task_id: "a",
workspace_id: "workspace",
workflow_id: "workflow",
workflow_step_id: "step",
title: "Task a",
is_ephemeral: false,
},
});
});
} finally {
await act(async () => {
pending.reject(new Error("response lost"));
await result;
});
}
expect(visibleIds()).toEqual([]);
expect(screen.queryByTestId("threads-empty-state")).not.toBeNull();
},
);
});
@@ -1,5 +1,6 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTaskRemovalState } from "@/lib/state/task-removal";
const bootstrapMock = vi.hoisted(() => vi.fn());
const searchMock = vi.hoisted(() => ({ value: "" }));
@@ -29,7 +30,10 @@ vi.mock("@/hooks/use-task-listing-view", () => ({
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ kanbanMulti: { snapshots: {}, isLoading: false } }),
selector({
kanbanMulti: { snapshots: {}, isLoading: false },
taskRemoval: createTaskRemovalState(),
}),
}));
import { scopeSnapshotsToWorkspace, ThreadsPageClient } from "./threads-page-client";
+47 -30
View File
@@ -77,6 +77,49 @@ export function scopeSnapshotsToWorkspace(
);
}
function useThreadDeck(
snapshots: Record<string, WorkflowSnapshotData>,
workspaceId: string | null | undefined,
requestedTaskId: string | null,
) {
const removals = useAppStore((state) => state.taskRemoval.operationsByToken);
const storedThreadViews = useAppStore((state) => state.threadViews);
const threadViews = storedThreadViews ?? {
views: [DEFAULT_THREAD_VIEW],
activeViewId: DEFAULT_THREAD_VIEW.id,
draft: null,
syncError: null,
orderResetGeneration: 0,
};
const activeThreadView =
threadViews.views.find((view) => view.id === threadViews.activeViewId) ?? DEFAULT_THREAD_VIEW;
const query = useMemo(
() =>
queryThreadView(snapshots, activeThreadView, {
workspaceId,
requestedTaskId,
draft: threadViews.draft,
excludedTaskIds: new Set(
Object.values(removals)
.filter((operation) => operation.action === "archive")
.flatMap((operation) => operation.taskIds),
),
}),
[snapshots, workspaceId, activeThreadView, requestedTaskId, threadViews.draft, removals],
);
// Ranking decides where a column first appears; after that the slot is the
// reader's, so replying to a thread cannot slide it across the deck.
const threads = useStableThreadOrder(
query.stableCandidates,
`${query.fingerprint}:${requestedTaskId ?? ""}:${threadViews.orderResetGeneration}`,
{
resetThreads: query.admittedCandidates,
maxItems: query.effectiveView.maxColumns,
},
);
return { query, threads };
}
/**
* The Threads page: every live agent conversation in the workspace, side by
* side. It reads the workflow snapshots the board already keeps in the store,
@@ -101,14 +144,6 @@ export function ThreadsPageClient() {
const { setView } = useTaskListingView();
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const isLoading = useAppStore((state) => state.kanbanMulti.isLoading);
const storedThreadViews = useAppStore((state) => state.threadViews);
const threadViews = storedThreadViews ?? {
views: [DEFAULT_THREAD_VIEW],
activeViewId: DEFAULT_THREAD_VIEW.id,
draft: null,
syncError: null,
orderResetGeneration: 0,
};
// Keep a valid deep-link workspace during the bootstrap transition, but use
// the resolved active workspace when a stale or invalid link is supplied.
@@ -130,28 +165,9 @@ export function ThreadsPageClient() {
setView("threads");
}, [setView]);
const activeThreadView =
threadViews.views.find((view) => view.id === threadViews.activeViewId) ?? DEFAULT_THREAD_VIEW;
const requestedTaskId = searchParams.get("taskId");
const query = useMemo(
() =>
queryThreadView(scopedSnapshots, activeThreadView, {
workspaceId: scopedWorkspaceId,
requestedTaskId,
draft: threadViews.draft,
}),
[scopedSnapshots, scopedWorkspaceId, activeThreadView, requestedTaskId, threadViews.draft],
);
// Ranking decides where a column first appears; after that the slot is the
// reader's, so replying to a thread cannot slide it across the deck.
const threads = useStableThreadOrder(
query.stableCandidates,
`${query.fingerprint}:${requestedTaskId ?? ""}:${threadViews.orderResetGeneration}`,
{
resetThreads: query.admittedCandidates,
maxItems: query.effectiveView.maxColumns,
},
);
const requestedSessionId = searchParams.get("sessionId");
const { query, threads } = useThreadDeck(scopedSnapshots, scopedWorkspaceId, requestedTaskId);
const handleOpenTask = useCallback((taskId: string) => router.push(linkToTask(taskId)), [router]);
const handleInvalidRequestedSession = useCallback(
@@ -171,7 +187,7 @@ export function ThreadsPageClient() {
// requested thread may have settled between the link being offered and
// followed, and a focus id no column matches would ring nothing.
const focusedTaskId = resolveFocusedThreadId(threads, searchParams.get("taskId"));
const focusedSessionId = focusedTaskId ? searchParams.get("sessionId") : null;
const focusedSessionId = focusedTaskId ? requestedSessionId : null;
return (
<div className="flex h-full min-h-0 min-w-0 w-full flex-col bg-background">
@@ -180,6 +196,7 @@ export function ThreadsPageClient() {
isLoading={isLoading}
focusedTaskId={focusedTaskId}
focusedSessionId={focusedSessionId}
focusRequestKey={JSON.stringify([scopedWorkspaceId, requestedTaskId, requestedSessionId])}
onInvalidRequestedSession={handleInvalidRequestedSession}
onOpenTask={handleOpenTask}
renderHeader={(activeMobileTaskId) => (
+21 -2
View File
@@ -53,6 +53,10 @@ order without copying selection state. Do not derive pagination from loaded
chat or visibility-ID membership alone: both adjacent columns can stay
intersecting across a swipe midpoint. Position changes also refresh the nearest
visible detail calculation, retaining the one-phone-transcript limit.
Callback refs reconcile column additions/removals on the existing observer.
Do not rebuild it on every membership change: clearing surviving visibility
briefly unmounts readers' chats and loses editor focus. Recreate observation
only when an empty/nonempty transition replaces the board element.
`ThreadTaskActionsProvider` owns one task-action surface above removable
columns. Headers pass explicit task IDs; `useTaskManagementFlow` captures the
@@ -62,6 +66,15 @@ workspace/task identity and resolves current eligibility from shared snapshots.
cleanup with the sidebar without task-detail navigation. Do not add task API
calls or mutation policy to Threads.
The page excludes task IDs from pending `archive` operations in
`taskRemoval.operationsByToken` before `queryThreadView` applies scope, filters,
limits or temporary deep-link admission. This unmounts outgoing chats at
acceptance and keeps counts, the phone picker and pagination consistent. Do not
mutate shared snapshots optimistically or include removal intent in the query
fingerprint/stable-order reset key. Successful reconciliation prunes snapshots
before releasing intent; failure readmits only currently eligible tasks in
normal arrival order. Pending deletion retains its existing timing.
Only noninteractive desktop task-header regions handle context menus. The
visible `TaskMenuButton` is also the keyboard/touch entry; phone and coarse
pointers use one `TaskManagementDrawer` with nested pages. Keep chat, editor,
@@ -93,8 +106,14 @@ column re-rendering with new messages does not yank the deck back.
The mark retires on the first pointer or focus interaction with the deck, since
it only ever answered "where is the column I asked for". Dismissal is keyed to
the requested id, so a later deep link earns a fresh mark rather than being
swallowed by an earlier dismissal.
the raw workspace/task/session request identity, independently of its currently
resolved column. Temporary exclusion and failed archive readmission must not
revive a consumed mark; an actual new deep link still earns a fresh mark.
`useThreadFocusRequest` keeps the initial activation fallback alive after mark
dismissal until its target leaves the deck. Early interaction must not unmount
that chat before the first visibility callback, and failed readmission must
not restore a consumed fallback. URL-driven callers pass `focusRequestKey`;
the optional task-ID default deliberately retains legacy dismissal semantics.
`OpenInThreadsButton` is the other half, living in the chat status row. It has
two gates, and both matter:
@@ -259,6 +259,26 @@ describe("useThreadColumnActivation", () => {
expect(ids(PRELOAD_IDS)).toEqual([TASK_B, TASK_C, TASK_D]);
});
// @covers AC-TASKS-THREADS-ACTIONS-003.5, AC-TASKS-THREADS-ACTIONS-003.6
it.each([
{ change: "removal", nextIds: [TASK_A, TASK_B] },
{ change: "readmission", nextIds: [TASK_A, TASK_B, TASK_C, TASK_D] },
])("retains the surviving visible chat through $change", ({ nextIds }) => {
const view = render(<ActivationFixture ids={[TASK_A, TASK_B, TASK_C]} />);
observers[0].instance.emit({
target: screen.getByTestId(`column-${TASK_B}`),
isIntersecting: true,
});
expect(ids(DETAIL_IDS)).toEqual([TASK_B]);
view.rerender(<ActivationFixture ids={nextIds} />);
expect(ids(DETAIL_IDS)).toEqual([TASK_B]);
expect(observers[0].instance.observed).toEqual(
new Set(nextIds.map((id) => screen.getByTestId(`column-${id}`))),
);
});
it("gives phone only the nearest visible snap column as detail owner", () => {
responsiveMocks.useResponsiveBreakpoint.mockReturnValue(mobileBreakpoint());
render(<ActivationFixture ids={[TASK_A, TASK_B, TASK_C]} />);
@@ -75,6 +75,7 @@ afterEach(() => {
const COLUMN_A = "thread-column-a";
const FOCUSED_ATTR = "data-focused";
const COLUMN_B = "thread-column-b";
const CONVERSATION_A = "thread-conversation-session-a";
const TASK_A = "a";
const PRIMARY_SESSION_A = "session-a-primary";
const BUILDER_SESSION_A = "session-a-builder";
@@ -140,7 +141,7 @@ describe("ThreadsBoard — basic layout", () => {
it("mounts the live conversation inside each column", () => {
render(<ThreadsBoard threads={[thread({ taskId: "a" })]} onOpenTask={() => {}} />);
expect(screen.getByTestId("thread-conversation-session-a")).not.toBeNull();
expect(screen.getByTestId(CONVERSATION_A)).not.toBeNull();
});
it("keeps thirty task shells mounted without mounting thirty conversations", () => {
@@ -200,7 +201,7 @@ describe("ThreadsBoard — session list loading", () => {
expect(screen.getByTestId("thread-session-list-error")).not.toBeNull();
expect(screen.getByRole("button", { name: /retry/i })).not.toBeNull();
expect(screen.queryByTestId("thread-conversation-session-a")).toBeNull();
expect(screen.queryByTestId(CONVERSATION_A)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await act(async () => {
@@ -210,7 +211,7 @@ describe("ThreadsBoard — session list loading", () => {
await waitFor(() => {
expect(screen.queryByTestId("thread-session-list-error")).toBeNull();
expect(screen.getByTestId("thread-conversation-session-a")).not.toBeNull();
expect(screen.getByTestId(CONVERSATION_A)).not.toBeNull();
});
});
});
@@ -479,7 +480,76 @@ describe("ThreadsBoard — focusing a column from a deep link", () => {
});
});
// @covers AC-TASKS-THREADS-ACTIONS-003.4
describe("ThreadsBoard initial activation", () => {
it.each(["pointerDown", "focusIn"] as const)(
"keeps the requested conversation mounted when %s retires its mark before visibility is ready",
(interaction) => {
render(
<ThreadsBoard
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
focusRequestKey="workspace:b:session-b"
onOpenTask={() => {}}
/>,
);
const conversation = screen.getByTestId("thread-conversation-session-b");
fireEvent[interaction](screen.getByTestId(COLUMN_B));
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
expect(screen.queryByTestId("thread-conversation-session-b")).toBe(conversation);
},
);
});
describe("ThreadsBoard — retiring the deep-link mark", () => {
// @covers AC-TASKS-THREADS-ACTIONS-003.4, AC-TASKS-THREADS-ACTIONS-003.5
it("keeps a consumed URL request retired when its excluded column returns", () => {
const props = { focusRequestKey: "workspace:b:session-b", onOpenTask: () => {} };
const allThreads = [thread({ taskId: "a" }), thread({ taskId: "b" })];
const view = render(<ThreadsBoard {...props} threads={allThreads} focusedTaskId="b" />);
fireEvent.pointerDown(screen.getByTestId(COLUMN_A));
view.rerender(
<ThreadsBoard {...props} threads={[thread({ taskId: "a" })]} focusedTaskId={null} />,
);
view.rerender(<ThreadsBoard {...props} threads={allThreads} focusedTaskId="b" />);
expect(screen.getByTestId(COLUMN_B).getAttribute(FOCUSED_ATTR)).toBeNull();
expect(screen.queryByTestId(CONVERSATION_A)).not.toBeNull();
expect(screen.queryByTestId("thread-conversation-session-b")).toBeNull();
});
it("honors a new URL request after a consumed request loses its resolved column", () => {
const props = { onOpenTask: () => {} };
const view = render(
<ThreadsBoard
{...props}
threads={[thread({ taskId: "a" }), thread({ taskId: "b" })]}
focusedTaskId="b"
focusRequestKey="workspace:b:session-b"
/>,
);
fireEvent.pointerDown(screen.getByTestId(COLUMN_A));
view.rerender(
<ThreadsBoard
{...props}
threads={[thread({ taskId: "a" })]}
focusedTaskId={null}
focusRequestKey="workspace:b:session-b"
/>,
);
view.rerender(
<ThreadsBoard
{...props}
threads={[thread({ taskId: "a" })]}
focusedTaskId="a"
focusRequestKey="workspace:a:session-a"
/>,
);
expect(screen.getByTestId(COLUMN_A).getAttribute(FOCUSED_ATTR)).toBe("true");
});
it("drops the mark once the reader touches the deck", () => {
render(
<ThreadsBoard
+13 -24
View File
@@ -6,6 +6,7 @@ import type { ActiveThread } from "@/lib/threads/active-threads";
import { useTranslation } from "react-i18next";
import { ThreadColumn } from "./thread-column";
import { useThreadColumnActivation } from "./use-thread-column-activation";
import { useThreadFocusRequest } from "./use-thread-focus-request";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { MobileThreadPicker } from "./mobile-thread-picker";
import { ThreadTaskActionsProvider } from "./thread-task-actions";
@@ -16,6 +17,12 @@ type ThreadsBoardProps = {
isLoading?: boolean;
/** Column a deep link asked for; scrolled into view and ringed on arrival. */
focusedTaskId?: string | null;
/**
* URL-driven callers must pass a stable request identity across target absence.
* Omission keys dismissal to the resolved task ID for legacy callers, so a
* temporary null target resets dismissal rather than retaining URL semantics.
*/
focusRequestKey?: string | null;
/** Session a task-detail link asked the target column to select. */
focusedSessionId?: string | null;
/** Removes a target session query after the target column proves it invalid. */
@@ -59,28 +66,6 @@ function ThreadsLoadingState() {
);
}
/**
* The deep-link mark answers "where is the column I asked for", so it retires
* the moment the reader starts using the deck rather than sitting on a column
* they have since moved away from. A later deep link earns a fresh mark, which
* is why dismissal is keyed to the requested id rather than latched forever.
*
* Uses the store-previous-props pattern instead of an effect so the mark never
* paints for a frame after a new request has already been dismissed.
*/
function useRetiringFocusMark(focusedTaskId: string | null) {
const [retired, setRetired] = useState(false);
const [requested, setRequested] = useState(focusedTaskId);
if (requested !== focusedTaskId) {
setRequested(focusedTaskId);
setRetired(false);
}
return {
markedTaskId: retired ? null : focusedTaskId,
retire: () => setRetired(true),
};
}
function focusThreadPicker(event: Event, column: Element | undefined) {
const trigger = column?.querySelector<HTMLButtonElement>('[data-testid="thread-picker-trigger"]');
if (!trigger) return;
@@ -97,19 +82,23 @@ export function ThreadsBoard({
threads,
isLoading = false,
focusedTaskId = null,
focusRequestKey = focusedTaskId,
focusedSessionId = null,
onInvalidRequestedSession,
onOpenTask,
renderHeader,
}: ThreadsBoardProps) {
const { markedTaskId, retire } = useRetiringFocusMark(focusedTaskId);
const { markedTaskId, activationTaskId, retire } = useThreadFocusRequest(
focusedTaskId,
focusRequestKey,
);
const { isMobile } = useResponsiveBreakpoint();
const [pickerOpen, setPickerOpen] = useState(false);
if (!isMobile && pickerOpen) setPickerOpen(false);
const returnFocusTaskId = useRef<string | null>(null);
const orderedIds = useMemo(() => threads.map((thread) => thread.taskId), [threads]);
const { boardRef, registerColumn, preloadTaskIds, detailTaskIds, mobileTaskId } =
useThreadColumnActivation(orderedIds, focusedTaskId);
useThreadColumnActivation(orderedIds, activationTaskId);
const rememberThread = useThreadSelectionRecovery(orderedIds, boardRef, isMobile);
function taskColumn(taskId: string | null) {
@@ -92,6 +92,7 @@ export function useThreadColumnActivation(
const [visibleIds, setVisibleIds] = useState<Set<string>>(() => new Set());
const [observerReady, setObserverReady] = useState(false);
const idsKey = orderedIds.join("\u0000");
const hasColumns = orderedIds.length > 0;
const updateVisibleIds = useCallback(() => {
const next = new Set<string>();
@@ -119,13 +120,6 @@ export function useThreadColumnActivation(
useEffect(() => {
const board = boardRef.current;
const orderedSet = new Set(orderedIds);
for (const id of elementsRef.current.keys()) {
if (!orderedSet.has(id)) {
elementsRef.current.delete(id);
visibilityRef.current.delete(id);
}
}
observerRef.current?.disconnect();
observerRef.current = null;
visibilityRef.current.clear();
@@ -157,10 +151,9 @@ export function useThreadColumnActivation(
observer.disconnect();
if (observerRef.current === observer) observerRef.current = null;
};
// The keyed dependency keeps ref registration stable while still
// rebuilding observation when the shell order or membership changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey, updateVisibleIds]);
// Callback refs reconcile membership without dropping surviving visibility.
// Only an empty/nonempty transition replaces the board's DOM element.
}, [hasColumns, updateVisibleIds]);
const fallbackTaskId = useMemo(() => {
if (focusedTaskId && orderedIds.includes(focusedTaskId)) return focusedTaskId;
@@ -0,0 +1,46 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useThreadFocusRequest } from "./use-thread-focus-request";
// @covers AC-TASKS-THREADS-ACTIONS-003.4
describe("thread focus request lifetime", () => {
it("activates a requested target that resolves after hydration", () => {
const { result, rerender } = renderHook(
({ target }: { target: string | null }) => useThreadFocusRequest(target, "request-b"),
{ initialProps: { target: null as string | null } },
);
rerender({ target: "b" });
expect(result.current).toMatchObject({ markedTaskId: "b", activationTaskId: "b" });
});
it("does not activate a late target after the reader has consumed its request", () => {
const { result, rerender } = renderHook(
({ target }: { target: string | null }) => useThreadFocusRequest(target, "request-b"),
{ initialProps: { target: null as string | null } },
);
act(() => result.current.retire());
rerender({ target: "b" });
expect(result.current).toMatchObject({ markedTaskId: null, activationTaskId: null });
});
it("rearms only a new request after its consumed target departs and returns", () => {
const { result, rerender } = renderHook(
({ target, request }: { target: string | null; request: string }) =>
useThreadFocusRequest(target, request),
{ initialProps: { target: "b" as string | null, request: "request-b" } },
);
act(() => result.current.retire());
expect(result.current).toMatchObject({ markedTaskId: null, activationTaskId: "b" });
rerender({ target: null, request: "request-b" });
rerender({ target: "b", request: "request-b" });
expect(result.current).toMatchObject({ markedTaskId: null, activationTaskId: null });
rerender({ target: "b", request: "new-request-b" });
expect(result.current).toMatchObject({ markedTaskId: "b", activationTaskId: "b" });
});
});
@@ -0,0 +1,24 @@
import { useState } from "react";
/** Keeps visual dismissal separate from the initial conversation's mount lifetime. */
export function useThreadFocusRequest(
focusedTaskId: string | null,
focusRequestKey: string | null,
) {
const [retired, setRetired] = useState(false);
const [requested, setRequested] = useState(focusRequestKey);
const [activationTaskId, setActivationTaskId] = useState(focusedTaskId);
if (requested !== focusRequestKey) {
setRequested(focusRequestKey);
setRetired(false);
setActivationTaskId(focusedTaskId);
} else if ((!retired || !focusedTaskId) && activationTaskId !== focusedTaskId) {
// A consumed request loses its fallback only when its target leaves the deck.
setActivationTaskId(focusedTaskId);
}
return {
markedTaskId: retired ? null : focusedTaskId,
activationTaskId,
retire: () => setRetired(true),
};
}
@@ -12,6 +12,20 @@ import {
filteredArchiveOutcome,
lateArchiveOutcome,
} from "./threads-task-actions-edge-helpers";
import { pendingArchiveRecovery, pendingLastArchive } from "./threads-pending-archive-helpers";
for (const [name, outcome] of [
["pending archive recovers without taking focus", pendingArchiveRecovery],
["pending last archive stays empty through settlement", pendingLastArchive],
] as const) {
test(name, async ({ testPage, apiClient, seedData }, testInfo) => {
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 360, height: 780 });
await withTaskActionSettings(apiClient, () =>
outcome(testPage, apiClient, seedData, true, testInfo),
);
});
}
for (const [name, outcome] of [
["failures and retry", failedActionOutcomes],
@@ -0,0 +1,145 @@
import { expect, type Page, type Route, type TestInfo } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { seedActionThreads, ThreadActionsPage } from "./threads-task-actions-helpers";
import { holdMutationResponse } from "./removal-transition-helpers";
import { swipeDeckLeft } from "./mobile-threads-swipe-helpers";
function watchColumnDeparture(page: Page, taskId: string) {
return page.evaluateHandle((id) => {
const selector = `[data-thread-column-id="${id}"]`;
const containsColumn = (node: Node) =>
node instanceof Element && (node.matches(selector) || node.querySelector(selector));
const state = { departed: false, reappeared: false, disconnect: () => observer.disconnect() };
const observer = new MutationObserver((records) => {
for (const record of records) {
if ([...record.removedNodes].some(containsColumn)) state.departed = true;
if (state.departed && [...record.addedNodes].some(containsColumn)) state.reappeared = true;
}
});
observer.observe(document.body, { subtree: true, childList: true });
return state;
}, taskId);
}
// @covers AC-TASKS-THREADS-ACTIONS-003.4, AC-TASKS-THREADS-ACTIONS-003.7, AC-TASKS-THREADS-ACTIONS-003.8, AC-TASKS-THREADS-ACTIONS-004.6
export async function pendingArchiveRecovery(
page: Page,
api: ApiClient,
seed: SeedData,
mobile: boolean,
testInfo: TestInfo,
) {
const { a, b } = await seedActionThreads(api, seed);
const ui = new ThreadActionsPage(page, mobile);
await api.saveUserSettings({ confirm_task_archive: true });
const path = `**/api/v1/tasks/${a.id}/archive`;
let pending: Route | null = null;
const handler = (route: Route) => {
pending = route;
};
await page.route(path, handler);
try {
await page.goto(`/threads?workspace=${seed.workspaceId}&taskId=${a.id}`);
await ui.open(a.id);
await ui.pick("Archive");
await expect(page.getByTestId("archive-task-confirm")).toBeVisible();
await expect(ui.column(a.id)).toHaveCount(1);
await ui.press(page.getByRole("button", { name: "Cancel", exact: true }));
await expect(ui.column(a.id)).toHaveCount(1);
expect(pending).toBeNull();
await ui.open(a.id);
await ui.pick("Archive");
await ui.press(page.getByTestId("archive-task-confirm"));
await expect.poll(() => pending !== null).toBe(true);
await expect(ui.column(a.id)).toHaveCount(0);
await expect(ui.trigger(b.id)).toBeInViewport();
if (mobile) {
await expect(page.getByTestId("thread-swipe-cue")).toHaveCount(0);
await ui.press(ui.column(b.id).getByTestId("thread-picker-trigger"));
await expect(page.getByTestId(`thread-picker-row-${a.id}`)).toHaveCount(0);
await expect(page.getByTestId(`thread-picker-row-${b.id}`)).toHaveCount(1);
await ui.press(page.getByTestId(`thread-picker-row-${b.id}`));
}
const editor = ui.column(b.id).locator(".tiptap.ProseMirror");
await ui.press(editor);
await editor.fill("Keep reading and drafting while archive finishes");
await expect(editor).toBeFocused();
await page.screenshot({ path: testInfo.outputPath("archive-pending.png") });
await pending!.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "Archive temporarily unavailable" }),
});
pending = null;
await expect(page.getByText("Failed to archive task", { exact: true })).toBeVisible();
await expect(ui.column(a.id)).toHaveCount(1);
await expect(ui.column(a.id)).not.toHaveAttribute("data-focused", "true");
await expect(editor).toHaveText("Keep reading and drafting while archive finishes");
await expect(editor).toBeFocused();
await expect(page).toHaveURL(
new RegExp(`/threads\\?workspace=${seed.workspaceId}&taskId=${a.id}$`),
);
if (mobile) {
await expect(page.getByTestId("thread-swipe-cue")).toHaveText("1/2");
await swipeDeckLeft(page, async () => {
await expect(page.getByTestId("thread-swipe-cue")).toHaveText("2/2");
});
await expect(ui.trigger(a.id)).toBeInViewport();
}
await page.screenshot({ path: testInfo.outputPath("archive-rejected.png") });
} finally {
if (pending) await (pending as Route).abort();
await page.unroute(path, handler);
}
}
// @covers AC-TASKS-THREADS-ACTIONS-003.3, AC-TASKS-THREADS-ACTIONS-003.7
export async function pendingLastArchive(
page: Page,
api: ApiClient,
seed: SeedData,
mobile: boolean,
testInfo: TestInfo,
) {
const { a, b } = await seedActionThreads(api, seed);
expect((await api.rawRequest("POST", `/api/v1/tasks/${b.id}/archive`, {})).ok).toBe(true);
await api.saveUserSettings({ confirm_task_archive: false });
const ui = new ThreadActionsPage(page, mobile);
await page.goto(`/threads?workspace=${seed.workspaceId}&taskId=${a.id}`);
await expect(ui.column(a.id).locator(".tiptap.ProseMirror")).toBeVisible();
if (mobile) await expect(page.getByTestId("threads-mobile-topbar")).toBeInViewport();
const held = await holdMutationResponse(page, `**/api/v1/tasks/${a.id}/archive`, "POST");
const departure = await watchColumnDeparture(page, a.id);
try {
await ui.open(a.id);
await ui.pick("Archive");
const empty = page.getByTestId("threads-empty-state");
await expect(empty).toBeVisible();
await held.backendResponseReady;
expect(held.requestCount()).toBe(1);
await expect(ui.column(a.id)).toHaveCount(0);
await expect(page.getByTestId("thread-swipe-cue")).toHaveCount(0);
await expect(page.getByText("Archived 1 task.", { exact: true })).toHaveCount(0);
if (mobile) await expect(page.getByTestId("threads-mobile-topbar")).toBeInViewport();
await page.screenshot({ path: testInfo.outputPath("last-archive-pending.png") });
held.release();
await expect(page.getByText("Archived 1 task.", { exact: true })).toBeVisible();
await expect(empty).toBeVisible();
await expect(empty).toBeFocused();
await expect(ui.column(a.id)).toHaveCount(0);
expect(
await departure.evaluate(({ departed, reappeared }) => ({ departed, reappeared })),
).toEqual({ departed: true, reappeared: false });
await departure.evaluate((state) => state.disconnect());
await page.reload();
await expect(empty).toBeVisible();
} finally {
held.release();
await held.dispose();
await departure.evaluate((state) => state.disconnect()).catch(() => undefined);
await departure.dispose();
}
}
@@ -134,9 +134,11 @@ export async function lateArchiveOutcome(
await ui.pick("Archive");
await expect.poll(() => pending !== null).toBe(true);
await expect(page.getByTestId("archive-task-confirm")).toHaveCount(0);
await expect(ui.column(a.id)).toHaveCount(0);
await ui.open(b.id);
await expect(ui.choice("Delete")).toBeDisabled();
await pending!.continue();
pending = null;
await expect(ui.column(a.id)).toHaveCount(0);
await expect(ui.choice("Delete")).toBeEnabled();
await ui.nested("Priority");
@@ -145,6 +147,7 @@ export async function lateArchiveOutcome(
expect((await api.getTask(a.id)).priority).not.toBe("critical");
await expect(ui.trigger(b.id)).toBeFocused();
} finally {
if (pending) await (pending as Route).abort();
await page.unroute(`**/api/v1/tasks/${a.id}/archive`);
await api.saveUserSettings({ confirm_task_archive: true });
}
@@ -272,8 +272,7 @@ export async function allTaskActionOutcomes(
await ui.press(page.getByTestId("archive-task-confirm"));
await expect(ui.column(a.id)).toHaveCount(0);
await expect(ui.trigger(b.id)).toBeFocused();
const archived = await api.rawRequest("GET", `/api/v1/tasks/${a.id}`);
expect((await archived.json()).archived_at).toBeTruthy();
await expect.poll(async () => (await api.getTask(a.id)).archived_at).toBeTruthy();
await ui.delete(b.id);
await expect(page.getByTestId("threads-empty-state")).toBeVisible();
await expect(page.getByTestId("threads-empty-state")).toBeFocused();
@@ -11,6 +11,19 @@ import {
filteredArchiveOutcome,
lateArchiveOutcome,
} from "./threads-task-actions-edge-helpers";
import { pendingArchiveRecovery, pendingLastArchive } from "./threads-pending-archive-helpers";
for (const [name, outcome] of [
["pending archive recovers without taking focus", pendingArchiveRecovery],
["pending last archive stays empty through settlement", pendingLastArchive],
] as const) {
test(name, async ({ testPage, apiClient, seedData }, testInfo) => {
test.setTimeout(120_000);
await withTaskActionSettings(apiClient, () =>
outcome(testPage, apiClient, seedData, false, testInfo),
);
});
}
for (const [name, outcome] of [
["failures and retry", failedActionOutcomes],
@@ -443,3 +443,63 @@ describe("thread view fingerprint", () => {
expect(first.fingerprint).not.toBe(changed.fingerprint);
});
});
// @covers AC-TASKS-THREADS-ACTIONS-003.7, AC-TASKS-THREADS-ACTIONS-003.8
describe("pending archive exclusion", () => {
it("excludes pending tasks before scopes, counts and column limits", () => {
const snapshots = {
"workflow-1": snapshot([task({ id: "a" }), task({ id: "b" }), task({ id: "c" })]),
};
const activeView = view({
taskScope: { mode: "selected", taskIds: ["a", "b", "c"] },
maxColumns: 1,
});
const baseline = queryThreadView(snapshots, activeView);
const result = queryThreadView(snapshots, activeView, {
requestedTaskId: "a",
excludedTaskIds: new Set(["a"]),
});
for (const candidates of [
result.candidates,
result.matchingCandidates,
result.stableCandidates,
]) {
expect(candidates.map(({ taskId }) => taskId)).toEqual(["b", "c"]);
}
expect(result.admittedCandidates.map(({ taskId }) => taskId)).toEqual(["b"]);
expect(result).toMatchObject({ matchingCount: 2, temporaryAdmissionCount: 0, hiddenCount: 1 });
expect(result.fingerprint).toBe(baseline.fingerprint);
expect(result.effectiveView).toEqual(activeView);
expect(snapshots["workflow-1"].tasks.map(({ id }) => id)).toEqual(["a", "b", "c"]);
});
it("does not temporarily admit an excluded deep link outside the saved filter", () => {
const result = queryThreadView(
{ "workflow-1": snapshot([task({ id: "a" }), task({ id: "b" })]) },
view({ filters: [clause("titleMatch", "Task b", "matches")], maxColumns: 1 }),
{ requestedTaskId: "a", excludedTaskIds: new Set(["a"]) },
);
expect(result.admittedCandidates.map(({ taskId }) => taskId)).toEqual(["b"]);
expect(result).toMatchObject({ matchingCount: 1, temporaryAdmissionCount: 0, hiddenCount: 0 });
});
it("excludes every covered descendant and returns an empty query when none survive", () => {
const result = queryThreadView(
{
"workflow-1": snapshot([
task({ id: "parent" }),
task({ id: "child", parentTaskId: "parent" }),
]),
},
view(),
{ excludedTaskIds: new Set(["parent", "child"]) },
);
expect(result.candidates).toEqual([]);
expect(result.stableCandidates).toEqual([]);
expect(result.admittedCandidates).toEqual([]);
expect(result).toMatchObject({ matchingCount: 0, temporaryAdmissionCount: 0, hiddenCount: 0 });
});
});
+5 -1
View File
@@ -47,6 +47,8 @@ export type ThreadViewQueryOptions = {
workspaceId?: string | null;
requestedTaskId?: string | null;
draft?: ThreadViewDraft | null;
/** Transient removal intent applies before every admission path, including deep links. */
excludedTaskIds?: ReadonlySet<string>;
};
export type ThreadViewQueryResult = {
@@ -460,7 +462,9 @@ export function queryThreadView(
options: ThreadViewQueryOptions = {},
): ThreadViewQueryResult {
const effectiveView = cloneViewWithDraft(view, options.draft);
const candidates = selectThreadCandidates(snapshots, options);
const candidates = selectThreadCandidates(snapshots, options).filter(
(candidate) => !options.excludedTaskIds?.has(candidate.taskId),
);
const scoped = applyTaskScope(candidates, effectiveView.taskScope);
const matchingCandidates = scoped
.filter((candidate) => candidateMatches(candidate, effectiveView.filters))
@@ -0,0 +1,232 @@
---
created: 2026-09-11
status: done
requirements:
- REQ-TASKS-THREADS-ACTIONS-001
- REQ-TASKS-THREADS-ACTIONS-002
- REQ-TASKS-THREADS-ACTIONS-003
- REQ-TASKS-THREADS-ACTIONS-004
system_design:
- ../../specs/tasks/system-design/threads-task-actions.md
legacy_specs: []
---
# Implementation Plan: Immediate Threads Archive Removal
## Overview
Remove an archived task's conversation as soon as the user accepts Archive.
Use the existing task-removal intent to exclude its column while cleanup runs,
then let the current deck recovery select a survivor or show the empty state.
One work order owns the correction, targeted tests, and matching usage docs.
The task system owns this outcome because archive intent, mutation settlement
and visible recovery belong to the existing
[Threads task actions requirement](../../specs/tasks/requirements/threads-task-actions.md).
The [paired design](../../specs/tasks/system-design/threads-task-actions.md)
reuses the deck's ordering and viewport contracts. No new ADR is needed for
this use of the existing removal and presentation boundaries.
## Evidence and requirement conformance
Read-only trace at `20efe4855`:
1. `useTaskManagementFlow` and `useTaskMenuActions({ stayOnListing: true })`
initiate the existing shared archive operation.
2. `hooks/task-removal-coordinator.ts:coordinateTaskRemovalBatch` calls
`beginOperation` before invoking the mutation. The operation records the
task IDs, including captured cascade descendants, in `taskRemoval`.
3. `app/threads/threads-page-client.tsx:ThreadsPageClient` subscribes to
workflow snapshots and saved views, but not task-removal intent.
`queryThreadView` has no pending exclusion input. Therefore an unchanged
snapshot still yields the same column while archive is pending.
4. `ThreadColumn` continues to mount `ThreadConversation` and `TaskChatPanel`.
Successful snapshot pruning or an authoritative archive event eventually
removes it. Until then, chat can expose intermediate cleanup state.
5. `lateArchiveOutcome` already holds an archive request before forwarding it,
but asserts column absence only after `pending.continue()`. Moving the
absence assertion before that release is the smallest browser regression.
This confirms the missing presentation boundary through source, not a captured
browser replay. The user-reported blocked composer and warning are the symptom;
their precise runtime event sequence was not recorded in this design turn.
The previous contract specified eventual recovery but omitted immediate archive
departure. `AC-TASKS-THREADS-ACTIONS-003.7` and `.8` define that timing and
membership. `AC-TASKS-THREADS-ACTIONS-003.4` now permits provisional departure and requires
failed archives to restore eligible rows without claiming success or stealing
newer selection. The prior prohibition on treating failure as success remains.
This changes visible timing, not archive persistence or cleanup semantics.
The original [Threads task actions package](../threads-task-actions/plan.md)
remains a completed historical delivery. Its results are not this repair's
verification. The existing [task-removal coordinator design](../../specs/tasks/system-design/removal-navigation.md)
is reused; none of its other entry points is being redesigned.
## Scope
In scope: immediate pending archive exclusion; known consented cascade targets;
query counts and limit refill; consumed deep-link focus; surviving/empty
recovery; failed-request readmission; desktop/mobile proof and usage text.
Out of scope: delete timing, new archive APIs, cleanup scheduling, worktree
recovery, new persisted state, saved-view semantics, task-detail/Office/preview
navigation, new confirmation rules, UI geometry changes, and broad refactors.
## Technical approach
- In `ThreadsPageClient`, derive pending archive task IDs from the existing
`taskRemoval.operationsByToken` subscription. Use operation lifetime through
reconciliation; a successful response alone is not a reason to reopen chat.
- Add optional `excludedTaskIds` to `ThreadViewQueryOptions`; exclude before
all query admission paths, including explicit scopes, column limits and
temporary deep links. Keep exclusion out of `queryFingerprint` and the
stable-order reset key. Other query callers retain their defaults.
- Keep the board's action provider mounted and feed one consistent candidate
projection to columns, header counts, phone picker and pagination. Existing
`useThreadSelectionRecovery` owns successor/predecessor/empty behavior.
- Make `useThreadFocusRequest` distinguish a URL request's identity from its
resolved visible target. A temporarily excluded task returning after failure
must not reactivate an already consumed deep link. Add a narrow optional
board prop for the request identity if needed; preserve current callers.
- Reuse coordinator success/failure cleanup and localized toasts. Do not
optimistically mutate shared task snapshots or introduce API calls in Threads.
- Update the pending-exclusion note in `components/threads/AGENTS.md` and the
Threads paragraph in `docs/public/sessions-and-review.md` with implementation.
The required public documentation update is complete.
Source paths above are relative to `apps/web/` unless prefixed with `docs/`.
## ASCII UI preview
UI-01: Accepted archive of B. Opening/cancelling confirmation keeps the before
state. The current chat persistence during cleanup is established by source.
```text
Desktop before / pending today Desktop immediately after acceptance
+-------+-------+-------+ +-----------+-----------+
| A | B ... | C | | A | C ... |
| chat | chat | chat | ---> | chat | chat |
| input | input | input | | input | input |
+-------+-------+-------+ +-----------+-----------+
Archive B B column is absent
Phone before Phone immediately after acceptance
+----------------------+ +----------------------+
| Threads / view 2/3 | | Threads / view 2/2 |
| B Open ... | | C Open ... |
| Conversation B | ---> | Conversation C |
| Composer B | | Composer C |
+----------------------+ +----------------------+
Last column archived: [Threads / view] + [existing empty state]
Archive rejected: [eligible task returns] + [existing error toast]
```
Structural requirements: remove the column/chat subtree, retain survivor order,
keep one phone conversation and the existing header, and show no outgoing
composer or archive warning during pending cleanup. Widths/copy in the sketch
are illustrative. Returning failed tasks use normal arrival order and do not
take focus from the current surviving reader.
The phone entry is the existing visible overflow and inset task-actions drawer;
`mobile-thread-column-header.tsx` and `task-management-drawer.tsx` are the
closest shipped exemplars. The confirmation closes into the snapped deck,
which is the primary content surface. Existing horizontal deck scrolling,
internal transcript scrolling, dynamic viewport sizing, safe areas and touch
targets remain in place. No new scroll container or action is introduced.
## Tests
All numeric AC suffixes below expand to `AC-TASKS-THREADS-ACTIONS-*`.
| Evidence | Coverage |
| --- | --- |
| `app/threads/threads-page-archive.test.tsx`: `removes an archived column before the request settles` with the real store/deferred archive; archive/delete distinction; response/event ordering cannot remount success | `001.5`, `002.5`, `003.7` |
| `lib/threads/thread-view-query.test.ts`: pending target excluded from every candidate list/count, explicit scope/deep link, capped refill, captured descendants | `003.2`, `003.3`, `003.8` |
| Page/board tests: independent A/B operations, rejection readmission, foreign workspace/view changes, already archived/deleted rejection, consumed deep link plus newer menu/selection | `002.5`, `003.1`, `003.4`, `003.5`, `003.8`, `004.6` |
| Existing `stable-order`, `thread-selection-fallback`, board and column-activation tests: survivor position, selected sibling/draft preservation, empty focus and one phone detail | `003.1`-`003.6`, `004.6` |
| Existing `hooks/use-task-menu-actions.test.ts`: pending intent, listing route, duplicate guards and request settlement continue working | `002.5`, `003.4`, `003.7` |
Use a narrowly mocked heavy chat boundary to observe mount/unmount in a rendered
page test, while retaining the real store, query and archive coordinator. Do
not use only final-state assertions or mock the whole query being tested.
## E2E tests
Extend the existing `threads-task-actions.spec.ts` (`chromium`) and
`mobile-threads-task-actions.spec.ts` (`mobile-chrome`). Share stimulus helpers
in `threads-task-actions-edge-helpers.ts` and `threads-pending-archive-helpers.ts`;
reuse `ThreadActionsPage`, settings cleanup and the production mock-agent seed path.
Both projects cover: request held before backend dispatch; real archive
lifecycle events with HTTP response held; last-column empty state; rejection
and retry; deep-linked target; independent survivor/newer menu; confirmation
cancellation and confirmation-disabled acceptance. Unit tests cover the full
cascade/limit/workspace matrix. Assert pending absence before releasing each
barrier. Arm a scoped DOM observer before acceptance to detect outgoing chat
reappearance, and release all held routes in `finally`.
Reuse `holdMutationResponse` for the post-backend barrier. Assert the task is
archived after success and remains absent after reload. On phone, use actual
touch entry, check pagination and picker membership, and swipe the surviving
deck. Inspect desktop/phone pending screenshots during the focused run.
## Work orders
- [x] [Task 01: Remove pending archives from Threads](task-01-remove-pending-archives.md)
Sequential, one work order; no delegation or additional worktree prerequisite.
Its verification block contains the exact runnable commands.
## Verification results
Design checkpoint (2026-09-11):
- `python3 scripts/lint-spec-files.test.py`: 36 tests passed.
- `python3 scripts/lint-spec-files.py --all`: all specification files passed.
- `git diff --check`: passed for tracked changes; `git diff --no-index --check`
against `/dev/null` passed for both new plan files.
- `git status --short -- docs/specs docs/plans/threads-immediate-archive`:
exactly two modified specifications and the new plan directory.
- Read-only package validation: four documents, 22 relative links, all 15
referenced requirement/acceptance IDs, and explicit source/test paths resolve.
At the design checkpoint, production and permanent tests were unchanged,
dependencies were not installed, and product/browser tests were not run.
Implementation was subsequently authorized by "go for it" on 2026-09-12.
Initial implementation completed on 2026-09-12 in the primary session, without
delegation or additional platform tasks/sessions. The
[work order](task-01-remove-pending-archives.md#results) records behavioral
RED/GREEN evidence, corrected test assumptions, exact environment overrides,
and inspected screenshots.
- Focused frontend suite: 117 tests passed across eight files.
- Fresh managed browser runs: 8 Chromium tests and 10 mobile-chrome tests
passed with one worker and retries disabled, including native swiping.
- Typecheck, changed-file ESLint with zero warnings, Prettier and the i18n
new-code ratchet passed.
- Public-doc validator test passed; all 46 published pages validated.
Specification linter tests passed (36), all specs validated, and whitespace
checks passed.
- Public how-to documentation now describes immediate departure and failed
readmission. Scoped Threads guidance records query exclusion, focus request
identity and retention of surviving viewport observations.
PR review remediation is recorded in the
[work order](task-01-remove-pending-archives.md#pr-review-remediation): early
pointer/keyboard interaction now retires the mark without unmounting the
requested chat before visibility is ready. Local verification passed 122 tests
across nine files, the explicit Prettier command, typecheck, lint, i18n and docs
checks, and all eight desktop/ten phone browser regressions with fresh captures.
The legacy focus-key contract is documented, the stale public-doc deferral is
removed, and CI/review completion remains an external post-commit gate.
## Risks
- Filtering after column admission leaves incorrect counts or prevents refill.
- Including pending state in the reset key reranks surviving columns.
- Restoring a task can re-trigger a consumed deep link and steal focus.
- Releasing exclusion before snapshot cleanup can flash the archived chat.
- A test that checks only after releasing HTTP can pass with the defect intact.
@@ -0,0 +1,277 @@
---
id: "01-remove-pending-archives"
title: "Remove pending archives from Threads"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-THREADS-ACTIONS-001
- REQ-TASKS-THREADS-ACTIONS-002
- REQ-TASKS-THREADS-ACTIONS-003
- REQ-TASKS-THREADS-ACTIONS-004
acceptance_criteria:
- AC-TASKS-THREADS-ACTIONS-001.5
- AC-TASKS-THREADS-ACTIONS-002.5
- AC-TASKS-THREADS-ACTIONS-003.1
- AC-TASKS-THREADS-ACTIONS-003.2
- AC-TASKS-THREADS-ACTIONS-003.3
- AC-TASKS-THREADS-ACTIONS-003.4
- AC-TASKS-THREADS-ACTIONS-003.5
- AC-TASKS-THREADS-ACTIONS-003.6
- AC-TASKS-THREADS-ACTIONS-003.7
- AC-TASKS-THREADS-ACTIONS-003.8
- AC-TASKS-THREADS-ACTIONS-004.6
system_design:
- ../../specs/tasks/system-design/threads-task-actions.md
---
# Task 01: Remove Pending Archives from Threads
## Summary
Connect existing pending archive intent to Threads query admission, so the
outgoing column unmounts at acceptance. Preserve existing recovery and handle
failed readmission without reactivating a consumed deep link.
## In scope
- Render-time archive exclusion and consistent query counts/limit refill.
- Real-store regression tests for pending, failure, cascade, deep links,
independent operations, selection and event/response ordering.
- Desktop and phone E2E coverage, rendered inspection, scoped engineering
guidance and the existing public Threads usage paragraph.
## Out of scope
Backend behavior, deletion timing, shared removal coordinator redesign, new
state persistence, session management, saved-view preference changes, new copy
or geometry, and unrelated task surfaces.
## Acceptance
1. An accepted archive removes every covered admitted column before HTTP or
cleanup completes. Confirm-cancel and pending-delete controls retain current
behavior. Query scope, deep links, counts and caps honor the exclusion.
2. Success stays removed through either event/response order; rejection restores
only currently eligible tasks. Surviving columns, newer menus/selections,
consumed focus requests, workspace and saved view remain coherent.
3. Targeted unit and desktop/mobile browser checks pass, with real pending
interval assertions, phone picker/pagination/swipe evidence, and inspected
desktop/phone renders. Usage docs describe the implemented timing and failure.
## ASCII UI preview
UI-01, excerpt from the [combined preview](plan.md#ascii-ui-preview).
Coverage: `003.1`-`003.8` and `004.6` under the AC prefix in frontmatter.
```text
Accept Archive B
Desktop: [A chat] [B chat] [C chat] -> [A chat] [C chat]
Phone: [B chat, 2/3] -> [C chat, 2/2]
Last: [B chat, no other tasks] -> [existing empty state]
Failure: [surviving deck] -> [eligible B returns + error]
```
The outgoing subtree must be absent. The phone keeps one snapped conversation,
the existing fixed header, transcript scroll owner, safe-area behavior and
touch controls. Failed readmission uses normal arrival ordering and does not
steal focus. Sketch spacing and labels are illustrative.
## Verification
Run from repository root. Install once before the first frontend command in
this fresh worktree. Use `/tdd`; first reproduce with an unchanged production
query and a deferred archive request. Existing `lateArchiveOutcome` provides
the smallest browser stimulus: assert absence before `pending.continue()`.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run app/threads/threads-page-client.test.tsx app/threads/threads-page-archive.test.tsx lib/threads/thread-view-query.test.ts lib/threads/stable-order.test.ts lib/threads/thread-selection-fallback.test.ts components/threads/threads-board.test.tsx components/threads/thread-column-activation.test.tsx components/threads/use-thread-focus-request.test.ts hooks/use-task-menu-actions.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint app/threads/threads-page-client.tsx app/threads/threads-page-client.test.tsx app/threads/threads-page-archive.test.tsx lib/threads/thread-view-query.ts lib/threads/thread-view-query.test.ts components/threads/threads-board.tsx components/threads/threads-board.test.tsx components/threads/use-thread-column-activation.ts components/threads/thread-column-activation.test.tsx components/threads/use-thread-focus-request.ts components/threads/use-thread-focus-request.test.ts e2e/tests/task/threads-task-actions.spec.ts e2e/tests/task/mobile-threads-task-actions.spec.ts e2e/tests/task/threads-task-actions-helpers.ts e2e/tests/task/threads-task-actions-edge-helpers.ts e2e/tests/task/threads-pending-archive-helpers.ts --max-warnings 0)
(cd apps/web && pnpm exec prettier --check app/threads/threads-page-client.tsx app/threads/threads-page-client.test.tsx app/threads/threads-page-archive.test.tsx lib/threads/thread-view-query.ts lib/threads/thread-view-query.test.ts components/threads/threads-board.tsx components/threads/threads-board.test.tsx components/threads/use-thread-column-activation.ts components/threads/thread-column-activation.test.tsx components/threads/use-thread-focus-request.ts components/threads/use-thread-focus-request.test.ts e2e/tests/task/threads-task-actions.spec.ts e2e/tests/task/mobile-threads-task-actions.spec.ts e2e/tests/task/threads-task-actions-helpers.ts e2e/tests/task/threads-task-actions-edge-helpers.ts e2e/tests/task/threads-pending-archive-helpers.ts)
(cd apps/web && pnpm run i18n:ratchet)
(cd apps/web && pnpm e2e:run --project chromium tests/task/threads-task-actions.spec.ts -- --retries=0)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-threads-task-actions.spec.ts tests/task/mobile-threads-swipe.spec.ts -- --retries=0)
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check
```
Run browser commands sequentially; each managed invocation builds current
production assets. Use test-controlled request/response barriers and existing
causal waits, no arbitrary sleeps. Restore settings and release held routes on
failure. Record RED/GREEN separately, actual discovered/passed counts and
screenshots. If implementation adds another test or changes a helper's scope,
update the exact command list before marking the work complete.
## Files likely touched
Under `apps/web/`:
- `app/threads/threads-page-client.tsx` and `.test.tsx`.
- `app/threads/threads-page-archive.test.tsx` (real-store/coordinator integration).
- `lib/threads/thread-view-query.ts` and `.test.ts`.
- `components/threads/threads-board.tsx` and `.test.tsx` (focus request lifetime).
- `components/threads/use-thread-focus-request.ts` and `.test.ts` (visual
dismissal versus initial activation lifetime and late hydration).
- `components/threads/use-thread-column-activation.ts` and `thread-column-activation.test.tsx`
(retain surviving visibility and editor mounts across removal/readmission).
- `e2e/tests/task/threads-task-actions-edge-helpers.ts`.
- `e2e/tests/task/threads-task-actions-helpers.ts` (wait for persisted archive
independently of immediate column removal).
- `e2e/tests/task/threads-pending-archive-helpers.ts` (desktop/phone pending scenarios).
- `e2e/tests/task/threads-task-actions.spec.ts` and `mobile-threads-task-actions.spec.ts`.
- `components/threads/AGENTS.md`.
Documentation: `docs/public/sessions-and-review.md`, the paired design's status,
this work order and `plan.md` results. The owning index already links the
requirement/design pair; no duplicate capability documents are needed.
## Dependencies
None. Existing task-action and task-removal implementations are present at the
recorded source revision; preserve their behavior and historical plan results.
## Risks
Premature exclusion release, stale deep-link refocus, surviving-column rerank,
and tests that miss the interval before cleanup. See the plan's test matrix.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/tasks/requirements/threads-task-actions.md),
especially deterministic recovery and archive acceptance.
- [System design](../../specs/tasks/system-design/threads-task-actions.md),
especially pending archive exclusion and failure behavior.
- `apps/web/AGENTS.md`, `components/threads/AGENTS.md`, `/mobile-parity`, `/e2e`.
- Existing query/order tests, `use-task-menu-actions.test.ts`, `ThreadActionsPage`,
`lateArchiveOutcome`, `holdMutationResponse`, and mobile swipe helpers.
## Results
Implemented and verified on 2026-09-12 after the user's "go for it".
### Implementation
The page derives archive-only exclusions from the existing removal coordinator,
and the query applies them before every admission/count path. No optimistic
snapshot mutation, new store, API, copy, confirmation rule or delete timing
was added. Query derivation is a local hook to keep the page within the
function-length limit.
The board retains consumed URL request identity across temporary absence and
does not use a retired request as its detail fallback. The viewport observer
now follows the board element's lifetime: existing callback refs reconcile
membership without clearing surviving visibility or remounting editors.
### RED evidence
- Before production changes, the focused query/board run failed four new
assertions (59 controls passed), and all four new real-store lifecycle cases
failed because outgoing columns stayed mounted.
- A stronger consumed-link assertion exposed the old detail fallback replacing
the surviving chat. Two additional observer tests reproduced replacement of
visible B with fallback A during removal and readmission.
- A fresh browser build with the archive exclusion disabled reproduced both
held-request regressions: expected zero outgoing columns, observed one.
- The first full desktop run passed six cases and exposed two outdated test
assumptions. The success label is `Archived 1 task.`, and immediate DOM
departure no longer proves persisted archival. The existing all-actions
helper now polls backend persistence independently of column absence.
### Final checks
The initial implementation checks passed (extended fixup results appear below):
- Frozen-lockfile dependency installation succeeded.
- Vitest: 117 passed across eight files, including real-store/coordinator/WS
integration, query exclusions, focus recovery and activation budgets.
- Typecheck, changed-file ESLint (`--max-warnings 0`), Prettier and i18n ratchet:
passed.
- Chromium: 8 discovered and 8 passed, retries disabled (1.8 minutes).
- Mobile-chrome: 10 discovered and 10 passed across the task-action and swipe
specs, retries disabled (2.4 minutes). Coverage includes touch confirmation,
picker/pagination exclusion, rejection recovery, native swiping and the
existing 320/360/700/820-pixel geometry checks.
- The successful-archive browser probe observed departure without any DOM
reappearance through lifecycle events and HTTP settlement. Both projects
also verified the empty state after reload.
- Public-doc validator test passed and 46 published pages validated.
All 36 specification linter tests and the all-spec check passed.
Whitespace checks passed.
The sandbox denied the default Go cache and local socket creation. Final E2E
runs used the approved isolated runner outside that sandbox, prefixed with
`KANDEV_SERVER_HOST=127.0.0.1 GOCACHE=/tmp/threads-archive-go-cache.pom5F8`.
Both invocations rebuilt production assets and ran sequentially with one
worker. The commands additionally used
`--output /tmp/threads-archive-evidence.4vbQSF/desktop` and
`--output /tmp/threads-archive-evidence.4vbQSF/mobile`, respectively. Fixtures
owned and cleaned their temporary backends, worktrees and data; no developer
instance was mutated.
Inspected both projects' `archive-pending.png`, `archive-rejected.png` and
`last-archive-pending.png` beneath those output roots. Pending chat removal,
survivor drafts, restored-column ordering, phone pagination and empty-state
composition are correct. An additional phone header assertion passed before
and after the last archive, with its refreshed empty-state screenshot inspected
under `/tmp/threads-archive-evidence.4vbQSF/mobile-header`. That test-only check
reused the same fresh production build:
```bash
KANDEV_SERVER_HOST=127.0.0.1 GOCACHE=/tmp/threads-archive-go-cache.pom5F8 pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-threads-task-actions.spec.ts -- --grep 'pending last archive' --retries=0 --output /tmp/threads-archive-evidence.4vbQSF/mobile-header
```
One test was discovered and passed (12 seconds); the modified helper's ESLint
and Prettier checks also passed. The build retained existing bundle-size/dynamic-import
warnings and optional macOS-sidecar signing warnings on Linux.
Public docs updated: `docs/public/sessions-and-review.md` (how-to guide).
The owning design is current and the work package is complete. At the
implementation handoff, no commit, push or PR had been requested or performed.
### PR review remediation
Greptile identified a pre-observer activation race. Two board regressions
reproduced it: pointer and keyboard focus retired the visual mark and unmounted
the requested non-first conversation (28 existing cases passed). The extracted
`useThreadFocusRequest` now keeps that activation fallback until its consumed
target departs, without reviving it on failed-archive readmission. The regressions
assert the same conversation node stays mounted. Three additional hook controls
cover late hydration, interaction before hydration, and a genuinely new request.
Claude's compatibility suggestion is addressed by documenting the explicit
URL-key requirement and legacy optional default. CodeRabbit's aggregate
suggestions are addressed by removing the stale public-doc deferral and adding
the exact Prettier command above. No further public-doc change is needed:
the existing how-to already promises uninterrupted conversation use and focus
preservation; this repair enforces that contract.
Final local remediation verification on 2026-09-12:
- The updated nine-file Vitest command passed all 122 tests.
- Typecheck, changed-source ESLint with zero warnings, the explicit 16-file
Prettier check, and i18n ratchet passed.
- Fresh managed Chromium run: all eight archive/action regressions plus one
disposable PR capture passed (nine tests, 1.8 minutes).
- Mobile-chrome reused that fresh build: all ten archive/action/swipe
regressions plus one disposable PR capture passed (11 tests, 2.3 minutes).
Both runs used one worker, retries disabled, `--host`, `CAPTURE_PR_ASSETS=1`
and the previously recorded loopback/cache overrides. Capture specs were
removed afterward; screenshot publication uses a separate orphan media ref,
never the feature branch.
- All 19 harness-validator tests, all 196 harness files, all 36 spec-validator
tests, the all-spec check, the public-doc validator test and all 46 public
pages passed. Scoped guidance remains within its line budget.
Current-head CI, review-thread resolution, the minimum five-minute new-comment
watch, and current-base integration validation are tracked on the live PR/task
after the remediation commit. Local checks do not substitute for those gates.
+5
View File
@@ -113,6 +113,11 @@ changes. Canceling a confirmation leaves the task unchanged. See
[archive and deletion behavior](tasks-and-workflows.md#archive-unarchive-and-delete)
for confirmation preferences and cleanup consequences.
Once you confirm **Archive**, its conversation disappears immediately while
cleanup continues. With archive confirmation disabled, choosing **Archive** is
enough. If the request fails, the task returns when your current filters and
column limit allow, without taking focus from the thread you are using.
If an action or view filter removes your current thread, Threads selects the
next remaining thread, otherwise the previous one. If neither survives from
the previous view, it selects the first thread in the new view. The empty view
@@ -2,6 +2,7 @@
status: active
system: tasks
created: 2026-09-10
updated: 2026-09-11
owners:
- kandev
---
@@ -117,7 +118,10 @@ without losing the deck's position and session behavior.
loading, it shall retain the existing loading state. Pagination, focus, and
conversation activation shall not reference a removed column.
- **AC-TASKS-THREADS-ACTIONS-003.4:** A failure shall not permanently remove a
task from the deck or advance selection as if the operation succeeded.
task from the deck or be reported as success. A task provisionally hidden for
an archive shall return when its current task state and view still admit it,
without resetting surviving column order or overriding a newer reader
selection. An authoritative removal shall take precedence over restoration.
Server events arriving before or after the response shall produce the same
final result without repeated jumps.
- **AC-TASKS-THREADS-ACTIONS-003.5:** Recovery shall keep the Threads route,
@@ -127,6 +131,19 @@ without losing the deck's position and session behavior.
- **AC-TASKS-THREADS-ACTIONS-003.6:** Recovery and menu use shall preserve native
horizontal swiping, stable task order, conversation drafts, session switching,
and the existing viewport-based transcript and subscription budget.
- **AC-TASKS-THREADS-ACTIONS-003.7:** Once the user accepts an archive from
Threads, its task column and conversation shall disappear in the next
rendered state, before waiting for the archive response or cleanup events.
The outgoing chat shall not remain as a blocked composer or show a
workspace-archived warning during that pending operation. Merely opening or
cancelling confirmation shall not remove the column. With confirmation
disabled, choosing Archive shall be the acceptance point.
- **AC-TASKS-THREADS-ACTIONS-003.8:** Every admitted task included in an accepted
archive, including known descendants covered by cascade consent, shall be
excluded from the pending deck, thread picker, and pagination. A deep link
or saved explicit task scope shall not readmit it. Other eligible tasks
shall continue to fill available column slots under the existing limit and
recovery rules. Independent pending archives shall settle independently.
### REQ-TASKS-THREADS-ACTIONS-004: Responsive accessible interaction
@@ -181,3 +198,4 @@ without losing the deck's position and session behavior.
- [System design](../system-design/threads-task-actions.md)
- [Implementation plan](../../../plans/threads-task-actions/plan.md)
- [Immediate archive removal follow-up](../../../plans/threads-immediate-archive/plan.md)
@@ -1,6 +1,7 @@
---
status: current
system: tasks
updated: 2026-09-12
requirements:
- REQ-TASKS-THREADS-ACTIONS-001
- REQ-TASKS-THREADS-ACTIONS-002
@@ -10,6 +11,11 @@ requirements:
# Threads Task Actions System Design
The task-action surfaces and pending archive exclusion below are implemented.
The [immediate archive removal plan](../../../plans/threads-immediate-archive/plan.md)
records the extension's implementation and targeted verification separately
from the original task-action delivery.
## Ownership and dependencies
The task system owns the action target and delegates execution to the same
@@ -30,7 +36,7 @@ commit, merge, work orders and verification results.
| --- | --- |
| `REQ-TASKS-THREADS-ACTIONS-001` | Shared task operations; menu composition |
| `REQ-TASKS-THREADS-ACTIONS-002` | Target lifetime; shared task operations; failure behavior |
| `REQ-TASKS-THREADS-ACTIONS-003` | Admission and selection recovery; state delivery |
| `REQ-TASKS-THREADS-ACTIONS-003` | Pending archive exclusion; admission and selection recovery; state delivery |
| `REQ-TASKS-THREADS-ACTIONS-004` | Header entry points; phone composition; dismissal and focus |
## Existing implementation boundaries
@@ -201,12 +207,72 @@ compact. The drawer owns vertical drag/scroll only while open. No new gesture
listener, pointer capture, touch cancellation, or `touch-action: none` is added
to the native horizontal swiper.
## Pending archive exclusion
`coordinateTaskRemovalBatch` publishes `taskRemoval.operationsByToken` before
calling an archive mutation. Each operation retains its action, workspace,
target IDs and captured cascade descendants until request reconciliation
finishes. `stayOnListing` suppresses detail navigation, but retains this
operation state. Reuse that intent as the Threads presentation boundary.
`ThreadsPageClient` subscribes to the existing operation map and derives the
union of `taskIds` for operations whose action is `archive`. Pass that set to
an optional `excludedTaskIds` input on `queryThreadView`. Apply the exclusion
before task scope, filters, sorting, limits, counts and temporary deep-link
admission. All query candidate collections must agree on the exclusion.
Workspace scoping remains in the existing snapshot/candidate path. Pending
delete operations retain their current behavior in this archive-only repair.
Feed the resulting candidates to `useStableThreadOrder` and `ThreadsBoard`.
Removing membership unmounts the outgoing column and its `TaskChatPanel`,
releasing detail activity through the existing viewport owner. A CSS-hidden
chat, disabled composer, or per-column placeholder does not satisfy this
boundary. The board-level task-action owner remains mounted, even when the
last column leaves, so its captured request and error reporting can finish.
Keep the viewport observer attached across membership changes; callback refs
register and unregister shells without resetting surviving visibility. Rebuild
observation when an empty/nonempty transition replaces the board element.
This preserves surviving editor mounts without changing activation budgets.
Pending intent is transient and is not part of the saved view or query
fingerprint. Do not change snapshot task state, fabricate archive events, or
reset stable ordering to hide a column. Existing WebSocket handling continues
to reconcile all authoritative task/session data. Successful cleanup removes
task rows before the coordinator releases its operation, so release cannot
briefly readmit a successfully archived task. A rejected request releases only
its own exclusion; eligible rows return through the ordinary query and stable
order, while confirmed removals stay absent. Returning rows follow existing
arrival ordering; surviving rows keep their positions.
A previously consumed task/session deep link must not become a fresh focus
request when pending exclusion temporarily changes its resolved target to
null. Keep the focus-request identity tied to the actual URL request, separate
from its currently rendered resolution, in the page/board focus adapter.
Retain dismissal across temporary absence. New explicit URL requests still
receive normal focus behavior; do not clear unrelated URL parameters or reset
order during bookkeeping. A failed archive cannot close a newer menu or
scroll back to its restored column. `useThreadFocusRequest` tracks the visual
mark separately from the viewport owner's initial detail fallback. Interaction
retires the mark without unmounting the requested conversation before the
first visibility callback. Once a consumed request's target leaves the deck,
its fallback is cleared and readmission cannot replace the surviving chat.
An unconsumed request can still activate a target that resolves after hydration.
URL-driven board callers supply an explicit stable request key; the optional
task-ID default retains legacy dismissal semantics for non-URL callers.
Desktop uses the remaining columns and their existing scroll-offset recovery.
Phone uses the next snapped conversation, or the existing empty state, after
the archive drawer/confirmation closes. The compact topbar, visible overflow
entry, one transcript budget, picker, scroll owners and safe-area geometry
remain the shipped composition. No new controls, copy or overlays are needed.
## Admission and selection recovery
Let `queryThreadView` and `useStableThreadOrder` process confirmed shared state
as today. Changes to priority can affect existing saved-view filters; moves,
archive, delete, external updates, and manual filter changes use the same
membership reconciliation. Do not invent a parallel admitted list or sort.
with the pending archive exclusion above. Changes to priority can affect
existing saved-view filters; moves, archive, delete, external updates, and
manual filter changes use the same membership reconciliation. Do not invent a
parallel admitted list or sort.
`resolveRemainingThreadId(previousOrder, nextOrder, currentTaskId)` implements
the requirement's successor/predecessor rule. `useThreadSelectionRecovery`
@@ -256,6 +322,9 @@ Shared removal cleanup is idempotent if a task event beats the HTTP response.
Failure paths preserve confirmed values, release pending state, and display the
shared localized error. Add generic error feedback at the shared action boundary
only where the reused API currently propagates an error without visible UI.
Pending archive recovery restores query eligibility, never task persistence or
an archived workspace. An in-flight view/workspace change remains authoritative
when a failed operation releases its exclusion.
Server authorization remains final. Re-check availability for the captured
task before dispatch, and exercise rejection and provider disappearance in
@@ -264,6 +333,14 @@ operation are introduced.
## Verification design
The archive extension needs deferred-request regressions before any server
archive event, plus early lifecycle events while the HTTP response remains
held. Assert column/chat unmounting throughout that interval, query counts and
limit refill, cascade targets, last-column recovery, independent operations,
and rejection with a consumed deep link and newer reader interaction. Keep
controls for cancelled confirmation and pending deletion. The follow-up plan
owns exact commands and desktop/mobile scenario mapping.
Focused TDD covers target changes during menus/confirmations, live eligibility,
shared mutation success/failure, pending-operation guards, and deterministic
recovery. Browser tests use real isolated task fixtures and perform all six