fix(web): prevent cascade archive navigation to descendants (#2557)

This commit is contained in:
Carlos Florêncio
2026-08-12 14:22:35 +01:00
committed by GitHub
parent 88bf44dfca
commit 9c3e7a2d31
11 changed files with 1013 additions and 23 deletions
+5 -1
View File
@@ -643,8 +643,12 @@ export class SessionPage {
* Hovers to reveal the menu trigger, opens it, clicks "Archive",
* and confirms the archive dialog.
*/
async archiveTaskInSidebar(title: string): Promise<void> {
async archiveTaskInSidebar(title: string, options: { cascade?: boolean } = {}): Promise<void> {
await this.openSidebarMenuAndClick(title, "Archive");
if (options.cascade) {
const cascadeCheckbox = this.page.getByTestId("archive-cascade-checkbox");
await cascadeCheckbox.click();
}
// Confirm the archive dialog
const confirmButton = this.page
.getByRole("alertdialog")
@@ -95,4 +95,99 @@ test.describe("Archive task redirect", () => {
// Should redirect to the home page
await expect(testPage).not.toHaveURL(/\/t\//, { timeout: 15_000 });
});
test("skips cascaded descendants when choosing the archive destination", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const parentTitle = "Cascade archive parent";
const childTitle = "Cascade archive recent child";
const secondChildTitle = "Cascade archive second child";
const unrelatedTitle = "Cascade archive unrelated task";
const createOptions = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
};
const parent = await apiClient.createTaskWithAgent(
seedData.workspaceId,
parentTitle,
seedData.agentProfileId,
{ ...createOptions, description: 'e2e:message("cascade parent response")' },
);
const child = await apiClient.createTaskWithAgent(
seedData.workspaceId,
childTitle,
seedData.agentProfileId,
{
...createOptions,
parent_id: parent.id,
description: 'e2e:message("cascade child response")',
},
);
const secondChild = await apiClient.createTaskWithAgent(
seedData.workspaceId,
secondChildTitle,
seedData.agentProfileId,
{
...createOptions,
parent_id: parent.id,
description: 'e2e:message("cascade second child response")',
},
);
const unrelated = await apiClient.createTaskWithAgent(
seedData.workspaceId,
unrelatedTitle,
seedData.agentProfileId,
{ ...createOptions, description: 'e2e:message("cascade unrelated response")' },
);
for (const task of [parent, child, secondChild, unrelated]) {
if (!task.session_id) throw new Error(`missing session for ${task.title}`);
}
// Visit the child before the parent. Once the parent is removed, the old
// recent-task order therefore tries the child first.
await testPage.goto(`/t/${child.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("cascade child response").last()).toBeVisible({
timeout: 30_000,
});
await testPage.goto(`/t/${parent.id}`);
await session.waitForLoad();
await expect(session.chat.getByText("cascade parent response").last()).toBeVisible({
timeout: 30_000,
});
await expect(session.taskInSidebar(parentTitle)).toBeVisible({ timeout: 15_000 });
await expect(session.taskInSidebar(childTitle)).toBeVisible({ timeout: 15_000 });
await expect(session.taskInSidebar(secondChildTitle)).toBeVisible({ timeout: 15_000 });
await expect(session.taskInSidebar(unrelatedTitle)).toBeVisible({ timeout: 15_000 });
const documentRequests: string[] = [];
testPage.on("request", (request) => {
if (
request.isNavigationRequest() &&
request.frame() === testPage.mainFrame() &&
request.resourceType() === "document"
) {
documentRequests.push(request.url());
}
});
const documentRequestsBeforeArchive = documentRequests.length;
await session.archiveTaskInSidebar(parentTitle, { cascade: true });
await expect(testPage).toHaveURL(new RegExp(`/t/${unrelated.id}$`), { timeout: 20_000 });
await expect(session.chat.getByText("cascade unrelated response").last()).toBeVisible({
timeout: 30_000,
});
await expect(session.activeSidebarTaskItem(unrelatedTitle)).toBeVisible({ timeout: 15_000 });
await expect(session.sidebarTaskItem(parentTitle)).toHaveCount(0, { timeout: 15_000 });
await expect(session.sidebarTaskItem(childTitle)).toHaveCount(0, { timeout: 15_000 });
await expect(session.sidebarTaskItem(secondChildTitle)).toHaveCount(0, { timeout: 15_000 });
expect(documentRequests).toHaveLength(documentRequestsBeforeArchive);
});
});
@@ -0,0 +1,132 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
test.describe("Mobile archive task redirect", () => {
test("keeps navigation responsive after skipping cascaded descendants", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const parentTitle = "Mobile cascade archive parent";
const childTitle = "Mobile cascade archive recent child";
const secondChildTitle = "Mobile cascade archive second child";
const unrelatedTitle = "Mobile cascade archive unrelated task";
const laterTitle = "Mobile cascade archive later task";
const createOptions = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
};
const parent = await apiClient.createTaskWithAgent(
seedData.workspaceId,
parentTitle,
seedData.agentProfileId,
{ ...createOptions, description: 'e2e:message("mobile cascade parent response")' },
);
const child = await apiClient.createTaskWithAgent(
seedData.workspaceId,
childTitle,
seedData.agentProfileId,
{
...createOptions,
parent_id: parent.id,
description: 'e2e:message("mobile cascade child response")',
},
);
const secondChild = await apiClient.createTaskWithAgent(
seedData.workspaceId,
secondChildTitle,
seedData.agentProfileId,
{
...createOptions,
parent_id: parent.id,
description: 'e2e:message("mobile cascade second child response")',
},
);
const unrelated = await apiClient.createTaskWithAgent(
seedData.workspaceId,
unrelatedTitle,
seedData.agentProfileId,
{ ...createOptions, description: 'e2e:message("mobile cascade unrelated response")' },
);
const later = await apiClient.createTaskWithAgent(
seedData.workspaceId,
laterTitle,
seedData.agentProfileId,
{ ...createOptions, description: 'e2e:message("mobile cascade later response")' },
);
for (const task of [parent, child, secondChild, unrelated, later]) {
if (!task.session_id) throw new Error(`missing session for ${task.title}`);
}
// Establish the same recent-child ordering as desktop, then open the
// parent through a normal SPA document navigation before measuring reloads.
await testPage.goto(`/t/${child.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
await expect(session.chat.getByText("mobile cascade child response").last()).toBeVisible({
timeout: 30_000,
});
await testPage.goto(`/t/${parent.id}`);
await session.waitForLoad();
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
await expect(session.chat.getByText("mobile cascade parent response").last()).toBeVisible({
timeout: 30_000,
});
const documentRequests: string[] = [];
testPage.on("request", (request) => {
if (
request.isNavigationRequest() &&
request.frame() === testPage.mainFrame() &&
request.resourceType() === "document"
) {
documentRequests.push(request.url());
}
});
const documentRequestsBeforeArchive = documentRequests.length;
await testPage.getByTestId("mobile-session-menu").tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
const parentRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: parentTitle });
await expect(parentRow).toBeVisible({ timeout: 15_000 });
await parentRow.getByRole("button", { name: "Task actions" }).tap();
await testPage.getByRole("menuitem", { name: "Archive", exact: true }).tap();
const archiveDialog = testPage.getByRole("alertdialog");
await expect(archiveDialog).toBeVisible();
await archiveDialog.getByTestId("archive-cascade-checkbox").tap();
await archiveDialog.getByRole("button", { name: "Archive", exact: true }).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${unrelated.id}$`), { timeout: 20_000 });
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
await expect(session.chat.getByText("mobile cascade unrelated response").last()).toBeVisible({
timeout: 30_000,
});
expect(documentRequests).toHaveLength(documentRequestsBeforeArchive);
await testPage.getByTestId("mobile-session-menu").tap();
const postArchiveDrawer = testPage.getByRole("dialog", { name: "Tasks" });
await expect(
postArchiveDrawer.getByTestId("sidebar-task-item").filter({ hasText: parentTitle }),
).toHaveCount(0, { timeout: 15_000 });
await expect(
postArchiveDrawer.getByTestId("sidebar-task-item").filter({ hasText: childTitle }),
).toHaveCount(0, { timeout: 15_000 });
await expect(
postArchiveDrawer.getByTestId("sidebar-task-item").filter({ hasText: secondChildTitle }),
).toHaveCount(0, { timeout: 15_000 });
await postArchiveDrawer.getByTestId("sidebar-task-item").filter({ hasText: laterTitle }).tap();
await expect(postArchiveDrawer).toBeHidden();
await expect(testPage).toHaveURL(new RegExp(`/t/${later.id}$`), { timeout: 20_000 });
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
await expect(session.chat.getByText("mobile cascade later response").last()).toBeVisible({
timeout: 30_000,
});
expect(documentRequests).toHaveLength(documentRequestsBeforeArchive);
});
});
+39
View File
@@ -105,4 +105,43 @@ describe("useArchiveAndSwitchTask", () => {
expect(replaceTaskUrlMock).toHaveBeenCalledWith("task-A");
expect(archiveTaskMock).toHaveBeenCalledWith("task-A", undefined);
});
it("excludes the cascade tree before and after the archive request", async () => {
archiveTaskMock.mockResolvedValueOnce(undefined);
const excludedTaskIds = new Set(["task-A", "task-child"]);
removeTaskFromBoardMock
.mockResolvedValueOnce({ switchedTaskId: "task-B", excludedTaskIds })
.mockResolvedValueOnce({ switchedTaskId: "task-B" });
const { result } = renderHook(() => useArchiveAndSwitchTask());
await result.current("task-A", { cascade: true });
expect(removeTaskFromBoardMock).toHaveBeenNthCalledWith(1, "task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
switchOnly: true,
excludeTaskTree: true,
});
expect(removeTaskFromBoardMock).toHaveBeenNthCalledWith(2, "task-A", {
wasActiveTaskId: "task-A",
wasActiveSessionId: "sess-A",
excludeTaskTree: true,
excludedTaskIds,
});
expect(archiveTaskMock).toHaveBeenCalledWith("task-A", { cascade: true });
});
it("does not restore a task when a cascade archive fails before any switch", async () => {
const error = new Error("archive failed");
archiveTaskMock.mockRejectedValueOnce(error);
removeTaskFromBoardMock.mockResolvedValueOnce({ switchedTaskId: null });
const { result } = renderHook(() => useArchiveAndSwitchTask());
await expect(result.current("task-A", { cascade: true })).rejects.toThrow("archive failed");
expect(removeTaskFromBoardMock).toHaveBeenCalledTimes(1);
expect(setActiveSessionMock).not.toHaveBeenCalled();
expect(setActiveTaskMock).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -42,16 +42,25 @@ export function useArchiveAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
async (taskId: string, opts?: { cascade?: boolean }) => {
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
store.getState().tasks;
const removalOptions = opts?.cascade ? { excludeTaskTree: true } : {};
const initialSwitch = await removeTaskFromBoard(taskId, {
wasActiveTaskId,
wasActiveSessionId,
switchOnly: true,
...removalOptions,
});
try {
await archiveTaskById(taskId, opts);
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
await removeTaskFromBoard(taskId, {
wasActiveTaskId,
wasActiveSessionId,
...removalOptions,
...(initialSwitch.excludedTaskIds
? { excludedTaskIds: initialSwitch.excludedTaskIds }
: {}),
});
} catch (error) {
if (
wasActiveTaskId &&
+266 -15
View File
@@ -6,6 +6,7 @@ const replaceTaskUrlMock = vi.fn();
const performLayoutSwitchMock = vi.fn();
const listTaskSessionsMock = vi.fn();
const fetchTaskMock = vi.fn();
const OVERVIEW_URL = "/?home=overview";
vi.mock("@/lib/links", () => ({
linkToTaskOverview: () => "/?home=overview",
@@ -24,7 +25,25 @@ vi.mock("@/lib/api", () => ({
import { useTaskRemoval, selectNextTaskAfterRemoval } from "./use-task-removal";
import { setRecentTasks } from "@/lib/recent-tasks";
type TaskRow = { id: string; primarySessionId: string | null };
type TaskRow = { id: string; primarySessionId: string | null; parentTaskId?: string | null };
const CASCADE_PARENT: TaskRow = { id: "task-parent", primarySessionId: "sess-parent" };
const CASCADE_CHILD: TaskRow = {
id: "task-child",
primarySessionId: "sess-child",
parentTaskId: CASCADE_PARENT.id,
};
const CASCADE_DEEP_CHILD: TaskRow = {
id: "task-deep-child",
primarySessionId: "sess-deep-child",
parentTaskId: CASCADE_CHILD.id,
};
const RECENT_TASK_VISITED_AT = "2026-06-07T10:00:00Z";
const REMOVED_TASK_VISITED_AT = "2026-06-07T09:00:00Z";
const OLD_TASK_VISITED_AT = "2026-06-06T10:00:00Z";
const CASCADE_CHILD_VISITED_AT = "2026-06-07T12:00:00Z";
const CASCADE_DEEP_CHILD_VISITED_AT = "2026-06-07T11:00:00Z";
type FakeState = {
tasks: { activeTaskId: string | null; activeSessionId: string | null };
@@ -91,6 +110,30 @@ function makeStore(init: {
};
}
function mockLocation() {
const hrefSetter = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: {
get href() {
return "";
},
set href(value: string) {
hrefSetter(value);
},
},
});
return {
hrefSetter,
restore: () =>
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
}),
};
}
const nextTask: TaskRow = { id: "task-next", primarySessionId: "sess-next" };
const recentTaskId = "task-recent";
const recentSessionId = "sess-recent";
@@ -122,17 +165,17 @@ function setRecentTaskFirst() {
{
taskId: recentTaskId,
title: "Recent task",
visitedAt: "2026-06-07T10:00:00Z",
visitedAt: RECENT_TASK_VISITED_AT,
},
{
taskId: "task-A",
title: "Removed task",
visitedAt: "2026-06-07T09:00:00Z",
visitedAt: REMOVED_TASK_VISITED_AT,
},
{
taskId: "task-old",
title: "Old task",
visitedAt: "2026-06-06T10:00:00Z",
visitedAt: OLD_TASK_VISITED_AT,
},
]);
}
@@ -142,17 +185,17 @@ function setRemovedTaskFirst() {
{
taskId: "task-A",
title: "Removed task",
visitedAt: "2026-06-07T10:00:00Z",
visitedAt: RECENT_TASK_VISITED_AT,
},
{
taskId: recentTaskId,
title: "Recent task",
visitedAt: "2026-06-07T09:00:00Z",
visitedAt: REMOVED_TASK_VISITED_AT,
},
{
taskId: "task-old",
title: "Old task",
visitedAt: "2026-06-06T10:00:00Z",
visitedAt: OLD_TASK_VISITED_AT,
},
]);
}
@@ -284,7 +327,7 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => {
wasActiveSessionId: "sess-A",
});
expect(removeResult.switchedTaskId).toBeNull();
expect(hrefSetter).toHaveBeenCalledWith("/?home=overview");
expect(hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
Object.defineProperty(window, "location", {
configurable: true,
@@ -331,7 +374,7 @@ describe("useTaskRemoval — next task selection", () => {
expect(removal.switchedTaskId).toBeNull();
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(replaceTaskUrlMock).not.toHaveBeenCalled();
expect(hrefSetter).toHaveBeenCalledWith("/?home=overview");
expect(hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
Object.defineProperty(window, "location", {
configurable: true,
@@ -403,26 +446,234 @@ describe("useTaskRemoval — next task selection", () => {
});
});
describe("useTaskRemoval — cascade tree exclusion", () => {
it("skips a cascade tree collected from workflow and canonical projections", async () => {
const unrelated: TaskRow = { id: "task-unrelated", primarySessionId: "sess-unrelated" };
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD, CASCADE_DEEP_CHILD, unrelated],
canonicalTasks: [
CASCADE_DEEP_CHILD,
{ ...CASCADE_CHILD, parentTaskId: CASCADE_DEEP_CHILD.id },
],
});
setRecentTasks([
{ taskId: CASCADE_CHILD.id, title: "Child", visitedAt: CASCADE_CHILD_VISITED_AT },
{
taskId: CASCADE_DEEP_CHILD.id,
title: "Deep child",
visitedAt: CASCADE_DEEP_CHILD_VISITED_AT,
},
{ taskId: unrelated.id, title: "Unrelated", visitedAt: RECENT_TASK_VISITED_AT },
]);
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
unrelated.id,
unrelated.primarySessionId,
);
});
it("prefers the workflow snapshot when canonical ancestry is stale", async () => {
const detachedChild = { ...CASCADE_CHILD, parentTaskId: null };
const unrelated: TaskRow = { id: "task-unrelated", primarySessionId: "sess-unrelated" };
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, detachedChild, unrelated],
canonicalTasks: [CASCADE_CHILD],
});
setRecentTasks([
{ taskId: detachedChild.id, title: "Detached child", visitedAt: CASCADE_CHILD_VISITED_AT },
{ taskId: unrelated.id, title: "Unrelated", visitedAt: RECENT_TASK_VISITED_AT },
]);
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
detachedChild.id,
detachedChild.primarySessionId,
);
});
it("can still switch to an active child when cascade exclusion is not requested", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
setRecentTasks([
{ taskId: CASCADE_CHILD.id, title: "Child", visitedAt: CASCADE_CHILD_VISITED_AT },
]);
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
});
expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith(
CASCADE_CHILD.id,
CASCADE_CHILD.primarySessionId,
);
});
});
describe("useTaskRemoval — cascade no-candidate transition", () => {
it("does not navigate home when switch-only mode has no safe candidate", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
const location = mockLocation();
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const removal = await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(removal.switchedTaskId).toBeNull();
expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled();
expect(location.hrefSetter).not.toHaveBeenCalled();
} finally {
location.restore();
}
});
it("opens the overview after final cascade cleanup when no safe task remains", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
const location = mockLocation();
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const removal = await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
excludeTaskTree: true,
} as never);
expect(removal.switchedTaskId).toBeNull();
expect(location.hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
location.restore();
}
});
it("retains the original cascade tree for cleanup after cache pruning", async () => {
const store = makeStore({
activeTaskId: CASCADE_PARENT.id,
activeSessionId: CASCADE_PARENT.primarySessionId,
remainingTasks: [CASCADE_PARENT, CASCADE_CHILD],
});
const location = mockLocation();
try {
const { result } = renderHook(() =>
useTaskRemoval({ store: store as unknown as StoreApi<never> }),
);
const initialRemoval = await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
switchOnly: true,
excludeTaskTree: true,
} as never);
expect(initialRemoval.excludedTaskIds).toEqual(
new Set([CASCADE_PARENT.id, CASCADE_CHILD.id]),
);
store.getRecorded().kanbanMulti.snapshots["wf-1"].tasks = [
{ ...CASCADE_CHILD, parentTaskId: null },
];
store.getRecorded().kanban.tasks = [];
await result.current.removeTaskFromBoard(CASCADE_PARENT.id, {
wasActiveTaskId: CASCADE_PARENT.id,
wasActiveSessionId: CASCADE_PARENT.primarySessionId,
excludeTaskTree: true,
excludedTaskIds: initialRemoval.excludedTaskIds,
} as never);
expect(location.hrefSetter).toHaveBeenCalledWith(OVERVIEW_URL);
} finally {
location.restore();
}
});
});
describe("selectNextTaskAfterRemoval — stale-candidate rejection", () => {
// The exported helper takes the full kanban task shape; the tests use the
// minimal TaskRow, so cast at the boundary exactly as the store-based tests do.
const select = selectNextTaskAfterRemoval as unknown as (
remaining: TaskRow[],
removedTaskId: string,
isLive: (taskId: string) => Promise<boolean>,
excludedTaskIds?: ReadonlySet<string>,
) => Promise<TaskRow | null>;
it("skips every excluded descendant while preserving recent-task order", async () => {
const child: TaskRow = { ...CASCADE_CHILD, parentTaskId: null };
const deepChild: TaskRow = { ...CASCADE_DEEP_CHILD, parentTaskId: null };
const unrelated: TaskRow = { id: "task-unrelated", primarySessionId: "sess-unrelated" };
setRecentTasks([
{ taskId: child.id, title: "Child", visitedAt: CASCADE_CHILD_VISITED_AT },
{ taskId: deepChild.id, title: "Deep child", visitedAt: CASCADE_DEEP_CHILD_VISITED_AT },
{ taskId: unrelated.id, title: "Unrelated", visitedAt: RECENT_TASK_VISITED_AT },
]);
await expect(
select(
[child, deepChild, unrelated],
CASCADE_PARENT.id,
async () => true,
new Set([CASCADE_PARENT.id, child.id, deepChild.id]),
),
).resolves.toBe(unrelated);
});
it("returns null (redirect home) when the only remaining candidate is not a live board member", async () => {
// A task deleted moments earlier can linger in a snapshot that races the
// local delete. It is not the removed task, but it is no longer a live
// board member, so it must not be chosen — the caller then redirects home.
const stale: TaskRow = { id: "task-A-stale", primarySessionId: "sess-A" };
await expect(select([stale], "task-B", async () => false)).resolves.toBeNull();
});
it("returns the candidate when it is a live board member", async () => {
// Guards against over-rejection: a genuinely present task is still selected.
const live: TaskRow = { id: "task-A-live", primarySessionId: "sess-A" };
await expect(select([live], "task-B", async () => true)).resolves.toBe(live);
+74 -6
View File
@@ -28,10 +28,15 @@ type RemoveFromBoardOptions = {
wasActiveSessionId?: string | null;
/** Switch away from the task without removing it from board state yet. */
switchOnly?: boolean;
/** Exclude the removed task and every cached descendant from candidates. */
excludeTaskTree?: boolean;
/** Reuse the tree captured before an archive can prune cached descendants. */
excludedTaskIds?: ReadonlySet<string>;
};
type RemoveFromBoardResult = {
switchedTaskId: string | null;
excludedTaskIds?: ReadonlySet<string>;
};
function cachedSessionsHaveEnvIds(sessions: TaskSession[]): boolean {
@@ -89,6 +94,8 @@ function removeTaskFromSnapshots(store: StoreApi<AppState>, taskId: string): voi
}
function collectRemainingTasks(store: StoreApi<AppState>): KanbanState["tasks"] {
// Keep candidate ordering snapshot-first; task-tree exclusion follows the
// same precedence and uses kanban.tasks only to fill missing rows.
const allRemainingTasks: KanbanState["tasks"] = [];
for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) {
allRemainingTasks.push(...snapshot.tasks);
@@ -99,6 +106,52 @@ function collectRemainingTasks(store: StoreApi<AppState>): KanbanState["tasks"]
return allRemainingTasks;
}
function collectTaskTreeIds(
rootTaskId: string,
taskLists: Array<KanbanState["tasks"]>,
): ReadonlySet<string> {
const tasksById = new Map<string, KanbanState["tasks"][number]>();
for (const tasks of taskLists) {
for (const task of tasks) {
if (!tasksById.has(task.id)) tasksById.set(task.id, task);
}
}
const childrenByParentId = new Map<string, string[]>();
for (const task of tasksById.values()) {
if (!task.parentTaskId) continue;
const children = childrenByParentId.get(task.parentTaskId) ?? [];
children.push(task.id);
childrenByParentId.set(task.parentTaskId, children);
}
const excludedTaskIds = new Set<string>([rootTaskId]);
const pendingParentIds = [rootTaskId];
while (pendingParentIds.length > 0) {
const parentId = pendingParentIds.pop();
if (!parentId) continue;
for (const childId of childrenByParentId.get(parentId) ?? []) {
if (excludedTaskIds.has(childId)) continue;
excludedTaskIds.add(childId);
pendingParentIds.push(childId);
}
}
return excludedTaskIds;
}
function collectTaskTreeIdsFromStore(
store: StoreApi<AppState>,
rootTaskId: string,
): ReadonlySet<string> {
const state = store.getState();
return collectTaskTreeIds(rootTaskId, [
...Object.values(state.kanbanMulti.snapshots).map((snapshot) => snapshot.tasks),
// Snapshots are the optimistic source used by the task switchers. The
// canonical board fills gaps without overriding a duplicate snapshot row.
state.kanban.tasks,
]);
}
/**
* Orders next-task candidates by recent use, then board order, without trusting
* either list as proof that a task still exists.
@@ -106,8 +159,11 @@ function collectRemainingTasks(store: StoreApi<AppState>): KanbanState["tasks"]
function orderedTaskCandidates(
remainingTasks: KanbanState["tasks"],
removedTaskId: string,
excludedTaskIds?: ReadonlySet<string>,
): KanbanState["tasks"] {
const candidates = remainingTasks.filter((task) => task.id !== removedTaskId);
const candidates = remainingTasks.filter(
(task) => task.id !== removedTaskId && !excludedTaskIds?.has(task.id),
);
const remainingById = new Map(candidates.map((task) => [task.id, task]));
const ordered: KanbanState["tasks"] = [];
for (const recent of getRecentTasks()) {
@@ -139,8 +195,9 @@ export async function selectNextTaskAfterRemoval(
remainingTasks: KanbanState["tasks"],
removedTaskId: string,
isLive: (taskId: string) => Promise<boolean> = taskIsLive,
excludedTaskIds?: ReadonlySet<string>,
): Promise<KanbanState["tasks"][number] | null> {
for (const task of orderedTaskCandidates(remainingTasks, removedTaskId)) {
for (const task of orderedTaskCandidates(remainingTasks, removedTaskId, excludedTaskIds)) {
if (await isLive(task.id)) return task;
}
return null;
@@ -254,14 +311,22 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp
const removeTaskFromBoard = useCallback(
async (taskId: string, opts?: RemoveFromBoardOptions): Promise<RemoveFromBoardResult> => {
if (!opts?.switchOnly) removeTaskFromSnapshots(store, taskId);
const excludedTaskIds = opts?.excludeTaskTree
? (opts.excludedTaskIds ?? collectTaskTreeIdsFromStore(store, taskId))
: undefined;
const allRemainingTasks = collectRemainingTasks(store);
if (!shouldSwitchAfterRemoval(store, taskId, opts)) {
return { switchedTaskId: null };
return { switchedTaskId: null, excludedTaskIds };
}
const oldEnvId = resolveOldEnvId(store, opts);
const nextTask = await selectNextTaskAfterRemoval(allRemainingTasks, taskId);
const nextTask = await selectNextTaskAfterRemoval(
allRemainingTasks,
taskId,
taskIsLive,
excludedTaskIds,
);
if (nextTask) {
await switchToNextTask({
store,
@@ -270,11 +335,14 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp
useLayoutSwitch,
loadTaskSessionsForTask,
});
return { switchedTaskId: nextTask.id };
return { switchedTaskId: nextTask.id, excludedTaskIds };
}
// When switchOnly=true and no safe candidate exists, defer Home until
// the post-archive cleanup confirms that the request succeeded.
if (opts?.switchOnly) return { switchedTaskId: null, excludedTaskIds };
window.location.href = linkToTaskOverview();
return { switchedTaskId: null };
return { switchedTaskId: null, excludedTaskIds };
},
[store, useLayoutSwitch, loadTaskSessionsForTask],
);
@@ -0,0 +1,158 @@
---
spec: docs/specs/tasks/archive-confirmation.md
created: 2026-08-12
status: complete
---
# Implementation Plan: Cascade Archive Navigation
## Overview
Make the shared archive switch logic aware of the full task tree selected by a
cascade archive. The client will select only a live task outside that tree. If
none exists, it will wait for archive success before it opens Home. Focused unit
tests and desktop and phone E2E tests will protect URL, rendered state, failure
recovery, and later navigation.
## Root cause
`useArchiveAndSwitchTask` calls `removeTaskFromBoard` with `switchOnly: true`
before it sends the archive request. `selectNextTaskAfterRemoval` excludes only
the parent task ID. It can therefore select a recent child even when
`cascade: true` will archive that child in the same request.
The diagnostic bundle records this sequence on 2026-08-12:
1. The client selected child task `ef8...` while parent `a077...` was active.
2. The archive request then archived the parent and two children.
3. The WebSocket archive event redirected the now-active child to Home.
The pre-switch uses `history.replaceState`, while the WebSocket handler uses
the application navigation path. These competing transitions can leave the
address bar and rendered route out of sync. The archive API completed without
an error, which explains why a hard refresh repaired the page.
## Frontend
### Cascade-aware candidate selection
- Extend the options accepted by `removeTaskFromBoard` so the caller can mark
the removed task tree as excluded from candidate selection.
- Build the excluded task IDs from the parent links in the cached workflow and
Kanban projections. Use a transitive walk so the logic matches the backend's
recursive cascade contract even if deeper trees appear in cached data.
- Keep non-cascade behavior unchanged. An active child remains a valid next task
when only its parent is archived.
- Continue to order safe candidates by recent use and board order. Continue to
validate each candidate with the task API before switching.
### Deterministic archive transition
- Pass the cascade exclusion option to both the pre-request switch and the
post-request cleanup in `useArchiveAndSwitchTask`.
- When `switchOnly` finds no safe task, return without changing the route. The
successful cleanup or WebSocket event can then open Home after the archive
succeeds.
- Keep the current rollback rule. If the archive request fails after a safe
pre-switch, restore the original task, session, and URL. If no safe pre-switch
occurred, leave the original task rendered.
- Do not change `replaceTaskUrl`, the archive endpoint, or WebSocket redirect
ownership. The repair removes the doomed navigation target that creates the
race.
## Mobile design contract
- **Shared behavior:** desktop sidebar and mobile task switcher actions already
call `useArchiveAndSwitchTask`. The repair stays in this shared hook and its
removal helper.
- **Nearest shipped exemplar:** keep the current task action and
`TaskArchiveConfirmDialog` flow in
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx`.
- **Composition:** no surface, drawer, dialog, control, scroll owner, or
responsive layout changes.
- **Phone outcome:** after a cascade archive, the phone renders a safe remaining
task whose ID matches the URL. The task drawer can then open another task
without a page refresh.
- **Input and accessibility:** existing task action and cascade checkbox
semantics remain unchanged. The E2E test uses the shipped touch path.
## Tests
- In `apps/web/hooks/use-task-removal.test.ts`, prove that cascade selection
skips all descendants and selects an unrelated live task.
- Prove that non-cascade selection can still select a child.
- Prove that a switch-only pass with no safe candidate does not open Home, while
the final successful removal does.
- In `apps/web/hooks/use-task-actions.test.ts`, prove that cascade exclusion is
used before and after the API request. Prove that failure rollback still
restores the original task.
## E2E tests
- Extend `apps/web/e2e/tests/task/archive-task-redirect.spec.ts`. Create a
parent, two children, and one unrelated task. Visit a child before the parent
so the old recent-task policy selects that child. Archive the parent with the
cascade checkbox and assert that the unrelated task ID and content render.
- Add `apps/web/e2e/tests/task/mobile-archive-task-redirect.spec.ts` for the same
recent-child setup through the phone task drawer. Assert the safe task route
and rendered mobile layout. Then create another live task and open it from the
drawer without reloading.
- Count main-frame document requests around each archive transition. The count
must not increase, which proves that the fix does not depend on a hard refresh.
- Extend `SessionPage.archiveTaskInSidebar` with an optional cascade input so
desktop archive tests use one stable dialog interaction.
## Verification
Run the workspace install once first if `apps/node_modules` is absent:
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps && pnpm --filter @kandev/web exec vitest run hooks/use-task-removal.test.ts hooks/use-task-actions.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm e2e:run tests/task/archive-task-redirect.spec.ts -- --grep 'skips cascaded descendants')
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-archive-task-redirect.spec.ts -- --grep 'keeps navigation responsive')
```
Confirm that Playwright discovers and runs the intended test for each focused
E2E command. `No tests found` is not passing evidence.
## Implementation waves and parallel candidates
Wave 1:
- [x] [Task 01: Make archive switching cascade-aware](task-01-cascade-aware-switch.md)
Wave 2, after Task 01:
- [x] [Task 02: Add archive navigation E2E coverage](task-02-archive-navigation-e2e.md)
The tasks are sequential. The E2E regression requires the shared selection fix,
and both E2E files use the same managed production build. No subagent execution
is authorized by this plan.
## Risks and out of scope
- Cached projections can contain duplicate rows. Descendant collection must
deduplicate IDs and stop on malformed parent cycles.
- WebSocket events can arrive before the archive response. The existing
`wasActiveTaskId` guard must keep user navigation authoritative.
- The change must not filter children from non-cascade archives.
- Backend archive behavior, persistence, archive confirmation settings, delete
behavior, and route infrastructure remain out of scope.
## Verification results
All implementation waves are complete. Focused unit tests pass (23 tests
across the removal and action hook suites), the web typecheck passes, and the
desktop and phone archive navigation scenarios pass without main-frame
document requests during the SPA transitions.
Verification:
- `(cd apps && pnpm --filter @kandev/web exec vitest run hooks/use-task-removal.test.ts hooks/use-task-actions.test.ts)` passed with 23 tests after review remediation.
- `cd apps/web && pnpm run typecheck` passed.
- `cd apps/web && pnpm e2e:run tests/task/archive-task-redirect.spec.ts -- --grep 'skips cascaded descendants'` passed with 1 test.
- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-archive-task-redirect.spec.ts -- --grep 'keeps navigation responsive'` passed with 1 test.
- `cd apps/web && pnpm e2e:run tests/task/archive-task-redirect.spec.ts` passed with 2 tests.
- Managed E2E runners cleaned up their isolated runtime and database after each run.
@@ -0,0 +1,102 @@
---
id: "01-cascade-aware-switch"
title: "Make archive switching cascade-aware"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/archive-confirmation.md"
---
# Task 01: Make Archive Switching Cascade-Aware
Exclude every task selected by a cascade archive from the shared next-task
search. Defer Home navigation until archive success when no safe task exists.
## Acceptance
- A cascade archive never selects its parent or any descendant as the temporary
or final task.
- The descendant walk is transitive, deduplicates cached rows, and terminates if
malformed data contains a parent cycle.
- A non-cascade parent archive can still select an active child.
- A pre-request switch with no safe candidate leaves the active task and URL in
place.
- A successful final removal with no safe candidate opens the task overview.
- A failed archive restores the original task after a safe pre-switch. It leaves
the original task in place when no pre-switch occurred.
- Recent-task ordering, live-task validation, layout switching, and manual user
navigation guards retain their current behavior.
## Files likely touched
- `apps/web/hooks/use-task-removal.ts`
- `apps/web/hooks/use-task-removal.test.ts`
- `apps/web/hooks/use-task-actions.ts`
- `apps/web/hooks/use-task-actions.test.ts`
## Dependencies
None.
## Parallelism
`sequential`. Task 02 depends on this behavior for its rendered regression.
## Inputs
- Spec sections **What**, **Failure modes**, and **Scenarios**.
- Plan sections **Cascade-aware candidate selection** and **Deterministic archive
transition**.
- `apps/web/lib/ws/handlers/tasks.ts` as the existing successful archive
redirect owner.
- `apps/web/lib/links.ts` for the existing URL replacement contract. Do not
change it as part of this task.
## TDD sequence
1. Add failing selection tests for a recent child inside an excluded task tree,
a deeper descendant, a malformed cycle, and a safe unrelated task.
2. Add a control test that permits a child when cascade is false.
3. Add a failing hook test that proves switch-only mode does not open Home when
no safe candidate exists. Preserve the final-removal Home test.
4. Add failing action tests for the cascade option before and after the API
request and for both archive failure paths.
5. Implement the smallest shared option and task-tree walk that pass the tests.
6. Refactor only if needed to keep candidate ordering and transition ownership
explicit.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web exec vitest run hooks/use-task-removal.test.ts hooks/use-task-actions.test.ts)
(cd apps/web && pnpm run typecheck)
```
## Risks
- Collecting descendants from only one projection can miss a cached row.
Combine the available workflow and canonical Kanban projections before the
walk.
- A broad filter would orphan active subtasks after a non-cascade archive. Apply
tree exclusion only when the archive request has `cascade: true`.
- Do not replace the WebSocket route handler or add a second final navigation
owner.
## Output contract
Report RED and GREEN evidence, exact files changed, cascade outcomes, and
non-cascade outcomes. Include failure behavior, typecheck, remaining risks, and
synchronized task and plan status. Record each command and outcome in
`## Results`.
## Results
- RED: `cd apps && pnpm --filter @kandev/web exec vitest run hooks/use-task-removal.test.ts hooks/use-task-actions.test.ts` failed with 4 expected regressions: cascade descendants were selected, switch-only mode navigated or selected a doomed child, and cascade exclusion was not forwarded.
- GREEN: the same focused Vitest command passed with 21 tests across both files.
- `cd apps/web && pnpm run typecheck` passed.
- The shared removal hook now walks all cached workflow and canonical Kanban task projections transitively with duplicate and cycle protection. Cascade callers exclude that tree before and after the archive request; non-cascade callers keep child selection; switch-only cleanup leaves the current route in place when no safe candidate exists.
- Changed files: `apps/web/hooks/use-task-removal.ts`, `apps/web/hooks/use-task-removal.test.ts`, `apps/web/hooks/use-task-actions.ts`, and `apps/web/hooks/use-task-actions.test.ts`.
- Archive failure coverage passes for both paths: a safe pre-switch restores the original task/session/URL, while a cascade with no pre-switch leaves the original task in place.
- Review remediation adds snapshot-first duplicate-row reconciliation and carries the pre-request exclusion set into post-archive cleanup so pruned descendants cannot become candidates. The focused suites now pass with 23 tests.
- Remaining risks are limited to the existing WebSocket/manual-navigation race guards and projection freshness; the backend archive contract and route ownership remain unchanged. Task 01 is done and the parent plan is complete.
@@ -0,0 +1,118 @@
---
id: "02-archive-navigation-e2e"
title: "Add archive navigation E2E coverage"
status: done
wave: 2
depends_on: ["01-cascade-aware-switch"]
plan: "plan.md"
spec: "../../specs/tasks/archive-confirmation.md"
---
# Task 02: Add Archive Navigation E2E Coverage
Reproduce the recent-child cascade sequence on desktop and phone. Prove that the
safe task, URL, rendered content, and later task navigation remain synchronized
without a hard refresh.
## Acceptance
- The desktop test makes a child the most recent alternative, archives its
active parent with cascade enabled, and opens the only unrelated live task.
- The phone test performs the same archive through the task drawer and renders
the unrelated task in the mobile task layout.
- The phone test can open a later live task from the same SPA instance after the
cascade archive.
- The parent and both children disappear from active task projections.
- Main-frame document request counts do not increase during the archive switch
or later phone task switch.
- Existing active-task and last-task archive redirect coverage remains green.
## Files likely touched
- `apps/web/e2e/pages/session-page.ts`
- `apps/web/e2e/tests/task/archive-task-redirect.spec.ts`
- `apps/web/e2e/tests/task/mobile-archive-task-redirect.spec.ts`
## Dependencies
- Task 01 must be complete.
## Parallelism
`sequential`. Both scenarios consume Task 01 and share the managed production
build.
## Inputs
- Plan sections **E2E tests** and **Mobile design contract**.
- `apps/web/e2e/tests/task/archive-task-redirect.spec.ts` for desktop archive
and session-content assertions.
- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` for phone drawer
and task action interactions.
- `apps/web/e2e/pages/mobile-kanban-page.ts` for mobile task-card assertions if
the scenario reaches Home during failure diagnosis.
## TDD sequence
1. Extend `SessionPage.archiveTaskInSidebar` with an optional cascade input.
2. Add the desktop recent-child regression and run it against the current code
to capture the wrong child or Home destination.
3. Add the phone regression with the shipped drawer and dialog controls. Run it
against the current code to capture the route and rendered-state mismatch.
4. Run both scenarios after Task 01 is green.
5. Run the existing tests in the desktop redirect file to protect normal and
last-task behavior.
## Verification
Run the workspace install once first if `apps/node_modules` is absent:
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm e2e:run tests/task/archive-task-redirect.spec.ts)
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-archive-task-redirect.spec.ts -- --grep 'keeps navigation responsive')
```
Confirm that Playwright discovers the intended mobile test. `No tests found` is
not passing evidence.
## Risks
- The regression must establish recent-task order by visiting a child and then
its parent. Creation order alone is not deterministic evidence.
- Seeded tasks need primary sessions so the test can distinguish a real task
render from a URL-only change.
- Count only main-frame document navigation requests. API and asset requests are
expected and do not represent a hard refresh.
- Wait for the archive result and task WebSocket updates through user-visible
assertions. Do not add fixed sleeps.
## Output contract
Report the original RED symptom, GREEN desktop and phone outcomes, and
main-frame document request evidence. Include exact commands, managed runner
cleanup, remaining risks, and synchronized task and plan status. Record each
command and outcome in `## Results`.
## Results
- The first desktop fixture attempt exposed that the E2E task API rejects a
depth-two task tree. The regression fixtures use two direct children, while
the unit suite covers transitive descendants and malformed cycles.
- `cd apps/web && pnpm e2e:run tests/task/archive-task-redirect.spec.ts -- --grep 'skips cascaded descendants'` passed with 1 test in the managed production runner.
- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-archive-task-redirect.spec.ts -- --grep 'keeps navigation responsive'` passed with 1 test in the managed production runner.
- `cd apps/web && pnpm e2e:run tests/task/archive-task-redirect.spec.ts` passed with 2 tests, retaining normal and last-task archive redirect coverage.
- Desktop and phone assertions kept the URL and rendered task synchronized,
removed the parent and both children from active projections, and opened a
later phone task from the drawer without increasing the main-frame document
request count. The desktop archive count was 0 to 0, and the phone archive
and later drawer navigation counts were each 0 to 0.
- The original RED symptom was the recent child becoming the temporary target
of a parent cascade archive, after which the WebSocket redirect could leave
the URL and rendered route out of sync. The GREEN tests now choose the
unrelated task and preserve later SPA navigation.
- Remaining risks are limited to asynchronous WebSocket update ordering and
cached projection freshness; user navigation remains protected by the
existing active-task guard. Task 02 is done and the parent plan is complete.
- The managed E2E runners cleaned up their isolated runtime and database after
each run.
+14
View File
@@ -1,6 +1,7 @@
---
status: shipped
created: 2026-07-15
updated: 2026-08-12
owner: kandev
---
@@ -17,6 +18,11 @@ Frequent task archiving makes a mandatory confirmation dialog costly for users w
- When enabled, archive actions continue to show the existing cleanup summary and optional subtask cascade control before archiving.
- When disabled, archive actions from every UI surface archive immediately without rendering the confirmation dialog.
- Confirmation-free archive actions do not cascade to subtasks. Users who need to archive subtasks together can temporarily enable confirmation and use the existing cascade control.
- When an active task is archived, the rendered task and the task ID in the URL change together.
- If another live task remains, Kandev opens a task that is not part of the archive operation.
- If no task remains outside the archive operation, Kandev opens the task overview after the archive succeeds.
- A cascade archive never uses the parent or one of its descendants as the temporary or final navigation target.
- Desktop and mobile task switchers use the same post-archive navigation behavior.
- Delete confirmations and programmatic archive operations are unaffected.
## Data model
@@ -38,6 +44,8 @@ No archive endpoint contract changes.
- If saving the preference fails, the settings control returns to its previous value and archive behavior remains unchanged.
- If user settings have not loaded or omit the field, the client requires confirmation.
- Archive API failures continue to use each archive surface's existing error handling.
- If an active task was temporarily replaced before an archive request fails, the client restores that task and its URL.
- If no safe replacement task exists, the client stays on the active task until the archive request succeeds.
## Persistence guarantees
@@ -49,6 +57,10 @@ The preference survives backend and client restarts as part of the existing user
- **GIVEN** confirmation is enabled, **WHEN** the user cancels the archive dialog, **THEN** the task remains active.
- **GIVEN** confirmation is disabled, **WHEN** the user requests an archive from the sidebar, task banner, task card, list, pipeline, mobile task switcher, or bulk action, **THEN** the archive starts immediately and no archive confirmation dialog appears.
- **GIVEN** confirmation is disabled and a task has active subtasks, **WHEN** the user archives the task, **THEN** the parent is archived with cascade disabled and the subtasks remain active.
- **GIVEN** an active parent has subtasks plus an unrelated live task, **WHEN** the user enables cascade archive, **THEN** Kandev opens the unrelated task, not a doomed descendant.
- **GIVEN** the active parent tree contains all live tasks, **WHEN** the user enables cascade archive, **THEN** Kandev waits for success and then opens the task overview.
- **GIVEN** an archive completes on desktop or mobile, **WHEN** the user opens another task, **THEN** the URL and rendered task update without a hard refresh.
- **GIVEN** an archive request fails after Kandev opened a replacement task, **WHEN** failure handling completes, **THEN** Kandev restores the original active task and URL.
- **GIVEN** saving the preference fails, **WHEN** the request completes, **THEN** the control and archive behavior revert to the previously persisted value.
## Out of scope
@@ -56,7 +68,9 @@ The preference survives backend and client restarts as part of the existing user
- Disabling confirmation for task deletion or other destructive actions.
- Adding a per-archive cascade default when confirmation is disabled.
- Changing API, CLI, MCP, automation, or agent-driven archive behavior.
- Changing the task switcher layout, archive dialog, or mobile composition.
## Implementation plan
- [Archive Confirmation Preference](../../plans/archive-confirmation-preference/plan.md)
- [Cascade Archive Navigation](../../plans/cascade-archive-navigation/plan.md)