fix: sync archived task websocket removals (#1556)

* fix: sync archived task websocket removals

* fix: address archived task review cleanup

* fix: handle archived office task routes

* fix: share task route pathname normalization

* fix: refresh office task removal views

---------

Co-authored-by: root <root@kandev.ts.net>
This commit is contained in:
Carlos Florêncio
2026-06-30 16:40:52 +01:00
committed by GitHub
parent 1abb1c914e
commit 393b750a4e
5 changed files with 387 additions and 89 deletions
+15 -1
View File
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { isTaskDetailPath, linkToTask, linkToTasks, replaceTaskUrl } from "./links";
import {
isTaskDetailPath,
linkToTask,
linkToTasks,
normalizePathname,
replaceTaskUrl,
} from "./links";
describe("task links", () => {
afterEach(() => {
@@ -47,3 +53,11 @@ describe("isTaskDetailPath", () => {
expect(isTaskDetailPath("/t/task-123/extra", "task-123")).toBe(false);
});
});
describe("normalizePathname", () => {
it("removes one trailing slash except from the root path", () => {
expect(normalizePathname("/")).toBe("/");
expect(normalizePathname("/office/tasks/task-123/")).toBe("/office/tasks/task-123");
expect(normalizePathname("/office/tasks/task-123")).toBe("/office/tasks/task-123");
});
});
+5 -2
View File
@@ -6,13 +6,16 @@ export function linkToTask(taskId: string, layout?: string): string {
/** Task-detail route prefixes the SPA serves: canonical and compatibility. */
const TASK_DETAIL_PREFIXES = ["/t/", "/tasks/"];
export function normalizePathname(pathname: string): string {
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}
/**
* True when `pathname` is a task-detail route for `taskId`. Matches both the
* canonical `/t/:id` and the compatibility `/tasks/:id` paths.
*/
export function isTaskDetailPath(pathname: string, taskId: string): boolean {
const normalized =
pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
const normalized = normalizePathname(pathname);
return TASK_DETAIL_PREFIXES.some((prefix) => normalized === `${prefix}${taskId}`);
}
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import { removeRecentTask } from "@/lib/recent-tasks";
import type { AppState } from "@/lib/state/store";
import { registerTasksHandlers } from "./tasks";
vi.mock("@/lib/recent-tasks", () => ({
removeRecentTask: vi.fn(),
}));
type Listener = (state: AppState) => void;
type KanbanTask = { id: string; title: string; workflowId: string; workflowStepId: string };
const TASK_ID = "t1";
const SESSION_ID = "sess-old";
const ARCHIVED_AT = "2026-06-30T12:00:00Z";
function makeStore(initial: Partial<AppState> = {}) {
let state = {
kanban: { workflowId: "wf1", steps: [], tasks: [] },
kanbanMulti: { snapshots: {}, isLoading: false },
tasks: {
activeTaskId: null,
activeSessionId: null,
pinnedSessionId: null,
lastSessionByTaskId: {},
},
taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} },
environmentIdBySessionId: {},
setActiveSessionAuto: vi.fn(),
removeTaskFromSidebarPrefs: vi.fn(),
setTaskDeletedNotification: vi.fn(),
setOfficeRefetchTrigger: vi.fn(),
...initial,
} as unknown as AppState;
const listeners = new Set<Listener>();
return {
getState: () => state,
setState: (updater: AppState | ((s: AppState) => AppState)) => {
const next =
typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater;
state = { ...state, ...next };
for (const listener of listeners) listener(state);
},
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy: vi.fn(),
getInitialState: vi.fn(),
} as unknown as StoreApi<AppState> & { getState: () => AppState };
}
function makeUpdatedMessage(payload: Record<string, unknown>) {
return {
id: "msg-1",
type: "notification" as const,
action: "task.updated" as const,
payload,
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.updated"]>>[0];
}
function makeDeletedMessage(payload: Record<string, unknown>) {
return {
id: "msg-1",
type: "notification" as const,
action: "task.deleted" as const,
payload,
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.deleted"]>>[0];
}
function taskPayload(id: string, workflowId = "wf1") {
return {
task_id: id,
workflow_id: workflowId,
workflow_step_id: "step1",
title: "Test",
state: "IN_PROGRESS",
is_ephemeral: false,
};
}
function archiveTask(store: StoreApi<AppState>, taskId = TASK_ID) {
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(
makeUpdatedMessage({
...taskPayload(taskId),
archived_at: ARCHIVED_AT,
}),
);
}
function makeStoreWithTask(initial: Partial<AppState> = {}) {
return makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: TASK_ID, primarySessionId: SESSION_ID, workflowId: "wf1" }],
} as unknown as AppState["kanban"],
...initial,
});
}
describe("task.updated archive cleanup", () => {
beforeEach(() => {
vi.mocked(removeRecentTask).mockClear();
window.history.replaceState({}, "", "/");
});
it("removes archived tasks from the active kanban cache even when workflow focus changed", () => {
const staleTask: KanbanTask = {
id: "t1",
title: "Test",
workflowId: "wf1",
workflowStepId: "step1",
};
const store = makeStore({
kanban: {
workflowId: "wf-active",
steps: [],
tasks: [staleTask],
} as unknown as AppState["kanban"],
kanbanMulti: {
isLoading: false,
snapshots: {
wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [staleTask] },
},
} as unknown as AppState["kanbanMulti"],
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(
makeUpdatedMessage({
...taskPayload(TASK_ID, "wf1"),
archived_at: ARCHIVED_AT,
}),
);
const state = store.getState();
expect(state.kanban.tasks).toEqual([]);
expect(state.kanbanMulti.snapshots.wf1.tasks).toEqual([]);
});
it("clears active task state, pin, recent history, and sidebar prefs for archived task events", () => {
const store = makeStoreWithTask({
tasks: {
activeTaskId: TASK_ID,
activeSessionId: SESSION_ID,
pinnedSessionId: SESSION_ID,
lastSessionByTaskId: { [TASK_ID]: SESSION_ID, t2: "sess-other" },
},
environmentIdBySessionId: {},
} as unknown as Partial<AppState>);
archiveTask(store);
const state = store.getState();
expect(state.tasks.activeTaskId).toBeNull();
expect(state.tasks.activeSessionId).toBeNull();
expect(state.tasks.pinnedSessionId).toBeNull();
expect(state.tasks.lastSessionByTaskId).not.toHaveProperty(TASK_ID);
expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", "sess-other");
expect(removeRecentTask).toHaveBeenCalledWith(TASK_ID);
expect(state.removeTaskFromSidebarPrefs).toHaveBeenCalledWith(TASK_ID);
expect(state.setOfficeRefetchTrigger).toHaveBeenCalledWith("tasks");
});
it.each(["/t/t1", "/tasks/t1"])("redirects away when archived on %s", (path) => {
window.history.replaceState({}, "", path);
const store = makeStoreWithTask({
tasks: {
activeTaskId: TASK_ID,
activeSessionId: SESSION_ID,
pinnedSessionId: null,
lastSessionByTaskId: {},
},
environmentIdBySessionId: {},
} as unknown as Partial<AppState>);
archiveTask(store);
expect(window.location.pathname).toBe("/");
});
it("does not redirect when a different task is archived", () => {
window.history.replaceState({}, "", "/t/other");
const store = makeStoreWithTask();
archiveTask(store);
expect(window.location.pathname).toBe("/t/other");
});
});
describe("office task removal routes", () => {
beforeEach(() => {
vi.mocked(removeRecentTask).mockClear();
window.history.replaceState({}, "", "/");
});
it.each(["/office/tasks/t1", "/office/tasks/t1/"])(
"redirects office task detail route %s to the office task list when archived",
(path) => {
window.history.replaceState({}, "", path);
const store = makeStoreWithTask();
archiveTask(store);
expect(window.location.pathname).toBe("/office/tasks");
},
);
it("redirects office task detail routes to the office task list when auto-deleted", () => {
window.history.replaceState({}, "", "/office/tasks/t1");
const store = makeStoreWithTask();
const handlers = registerTasksHandlers(store);
handlers["task.deleted"]!(
makeDeletedMessage({
task_id: TASK_ID,
workflow_id: "wf1",
title: "Deleted task",
reason: "pr_approved_by_user",
}),
);
expect(window.location.pathname).toBe("/office/tasks");
expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({
taskId: TASK_ID,
title: "Deleted task",
reason: "pr_approved_by_user",
});
});
});
+11 -6
View File
@@ -536,6 +536,10 @@ describe("task.updated repository clearing", () => {
});
describe("task.deleted cleanup", () => {
beforeEach(() => {
vi.mocked(removeRecentTask).mockClear();
});
it("removes the deleted task from recent task history", () => {
const store = makeStore({
kanban: {
@@ -558,18 +562,18 @@ describe("task.deleted cleanup", () => {
expect(removeRecentTask).toHaveBeenCalledWith("t1");
});
it("removes the deleted task from lastSessionByTaskId", () => {
it("clears deleted task session state", () => {
const store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }],
tasks: [{ id: "t1", primarySessionId: SESS_PINNED, workflowId: "wf1" }],
} as unknown as AppState["kanban"],
tasks: {
activeTaskId: null,
activeSessionId: null,
pinnedSessionId: null,
lastSessionByTaskId: { t1: "sess-old", t2: SESS_OTHER },
activeTaskId: "t1",
activeSessionId: SESS_PINNED,
pinnedSessionId: SESS_PINNED,
lastSessionByTaskId: { t1: SESS_PINNED, t2: SESS_OTHER },
},
environmentIdBySessionId: {},
});
@@ -578,6 +582,7 @@ describe("task.deleted cleanup", () => {
handlers["task.deleted"]!(makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" }));
const state = store.getState();
expect(state.tasks.pinnedSessionId).toBeNull();
expect(state.tasks.lastSessionByTaskId).not.toHaveProperty("t1");
expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", SESS_OTHER);
});
+122 -80
View File
@@ -9,7 +9,7 @@ import { toKanbanTask, type TaskLike } from "@/lib/kanban/map-task";
import { sessionId as toSessionId } from "@/lib/types/http";
import { mergeTaskRepositoryFields } from "@/lib/ws/handlers/task-repositories";
import { softNavigate } from "@/lib/routing/client-router";
import { isTaskDetailPath } from "@/lib/links";
import { isTaskDetailPath, normalizePathname } from "@/lib/links";
import {
clearPinnedSessionIfOverridden,
shouldPreservePinnedSessionForTask,
@@ -95,40 +95,137 @@ function findTaskInState(state: AppState, taskId: string): KanbanTask | undefine
}
/** Remove a task from both single-kanban and multi-kanban snapshots. */
function removeTaskFromBothKanbans(state: AppState, wfId: string, taskId: string): AppState {
function removeTaskFromBothKanbans(state: AppState, taskId: string): AppState {
let next = state;
if (state.kanban.workflowId === wfId) {
if (state.kanban.tasks.some((t) => t.id === taskId)) {
next = {
...next,
kanban: { ...next.kanban, tasks: next.kanban.tasks.filter((t) => t.id !== taskId) },
};
}
const snapshot = state.kanbanMulti.snapshots[wfId];
if (snapshot) {
const snapshots = Object.entries(next.kanbanMulti.snapshots);
const changedSnapshots = snapshots.filter(([, snapshot]) =>
snapshot.tasks.some((t) => t.id === taskId),
);
if (changedSnapshots.length > 0) {
const nextSnapshots = { ...next.kanbanMulti.snapshots };
for (const [workflowId, snapshot] of changedSnapshots) {
nextSnapshots[workflowId] = {
...snapshot,
tasks: snapshot.tasks.filter((t) => t.id !== taskId),
};
}
next = {
...next,
kanbanMulti: {
...next.kanbanMulti,
snapshots: {
...next.kanbanMulti.snapshots,
[wfId]: { ...snapshot, tasks: snapshot.tasks.filter((t) => t.id !== taskId) },
},
snapshots: nextSnapshots,
},
};
}
return next;
}
function clearRemovedTaskSelection(state: AppState, taskId: string): AppState {
let next = state;
if (next.tasks.activeTaskId === taskId) {
next = {
...next,
tasks: {
...next.tasks,
activeTaskId: null,
activeSessionId: null,
pinnedSessionId: null,
},
};
}
if (next.tasks.lastSessionByTaskId[taskId]) {
const { [taskId]: _, ...rest } = next.tasks.lastSessionByTaskId;
next = { ...next, tasks: { ...next.tasks, lastSessionByTaskId: rest } };
}
return next;
}
function removedTaskRedirectHref(pathname: string, taskId: string): string | null {
if (isTaskDetailPath(pathname, taskId)) return "/";
const normalized = normalizePathname(pathname);
return normalized === `/office/tasks/${taskId}` ? "/office/tasks" : null;
}
/**
* Soft-redirect away from a deleted task's page. Only fires when the user is
* currently parked on that task's route (`/t/<id>` or the compatibility
* `/tasks/<id>`), so a background deletion of some other task never yanks the
* user elsewhere.
* Soft-redirect away from a removed task's page. Only fires when the user is
* currently parked on that task's route, so a background removal of some other
* task never yanks the user elsewhere.
*/
function redirectAwayFromDeletedTask(deletedId: string): void {
function redirectAwayFromRemovedTask(taskId: string): void {
if (typeof window === "undefined") return;
if (!isTaskDetailPath(window.location.pathname, deletedId)) return;
softNavigate("/", "replace");
const href = removedTaskRedirectHref(window.location.pathname, taskId);
if (!href) return;
softNavigate(href, "replace");
}
type TaskUpdatedMessage = Parameters<NonNullable<WsHandlers["task.updated"]>>[0];
function handleTaskUpdated(store: StoreApi<AppState>, message: TaskUpdatedMessage): void {
// Skip ephemeral tasks (e.g., quick chat) - they shouldn't appear on the Kanban board
if (message.payload.is_ephemeral) return;
// Capture the previous primary session id BEFORE the upsert so we can
// detect a primary-session swap (e.g. workflow profile switch reusing a
// different session) and follow focus to the new primary.
const beforeState = store.getState();
const taskId = message.payload.task_id;
const previousPrimary = findTaskInState(beforeState, taskId)?.primarySessionId ?? null;
const archivedAt = message.payload.archived_at;
if (archivedAt) {
removeRecentTask(taskId);
const state = store.getState();
state.removeTaskFromSidebarPrefs(taskId);
state.setOfficeRefetchTrigger("tasks");
}
store.setState((state) => {
const wfId = message.payload.workflow_id;
const oldWfId = message.payload.old_workflow_id;
let next = state;
if (archivedAt || (oldWfId && oldWfId !== wfId)) {
next = removeTaskFromBothKanbans(next, taskId);
}
if (archivedAt) {
return clearRemovedTaskSelection(next, taskId);
}
return upsertTaskInBothKanbans(next, wfId, message.payload);
});
if (archivedAt) {
redirectAwayFromRemovedTask(taskId);
return;
}
// Follow focus to the new primary when:
// - the user is currently viewing this task,
// - the user was sitting on the previous primary,
// - they do NOT have a non-terminal pinned session for this task, and
// - the primary actually changed.
// This makes workflow profile switches transparent for unpinned users
// without yanking users off a live session they deliberately selected.
const afterState = store.getState();
const newPrimary = findTaskInState(afterState, taskId)?.primarySessionId ?? null;
if (
newPrimary &&
newPrimary !== previousPrimary &&
afterState.tasks.activeTaskId === taskId &&
afterState.tasks.activeSessionId === previousPrimary &&
!shouldPreservePinnedSessionForTask(afterState, taskId)
) {
clearPinnedSessionIfOverridden(store, newPrimary);
afterState.setActiveSessionAuto(taskId, newPrimary);
}
}
export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
@@ -140,56 +237,9 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
upsertTaskInBothKanbans(state, message.payload.workflow_id, message.payload),
);
},
"task.updated": (message) => {
// Skip ephemeral tasks (e.g., quick chat) - they shouldn't appear on the Kanban board
if (message.payload.is_ephemeral) return;
// Capture the previous primary session id BEFORE the upsert so we can
// detect a primary-session swap (e.g. workflow profile switch reusing a
// different session) and follow focus to the new primary.
const beforeState = store.getState();
const taskId = message.payload.task_id;
const previousPrimary = findTaskInState(beforeState, taskId)?.primarySessionId ?? null;
store.setState((state) => {
const wfId = message.payload.workflow_id;
const oldWfId = message.payload.old_workflow_id;
let next = state;
if (oldWfId && oldWfId !== wfId) {
next = removeTaskFromBothKanbans(next, oldWfId, taskId);
}
if (message.payload.archived_at) {
return removeTaskFromBothKanbans(next, wfId, taskId);
}
return upsertTaskInBothKanbans(next, wfId, message.payload);
});
// Follow focus to the new primary when:
// - the user is currently viewing this task,
// - the user was sitting on the previous primary,
// - they do NOT have a non-terminal pinned session for this task, and
// - the primary actually changed.
// This makes workflow profile switches transparent for unpinned users
// without yanking users off a live session they deliberately selected.
const afterState = store.getState();
const newPrimary = findTaskInState(afterState, taskId)?.primarySessionId ?? null;
if (
newPrimary &&
newPrimary !== previousPrimary &&
afterState.tasks.activeTaskId === taskId &&
afterState.tasks.activeSessionId === previousPrimary &&
!shouldPreservePinnedSessionForTask(afterState, taskId)
) {
clearPinnedSessionIfOverridden(store, newPrimary);
afterState.setActiveSessionAuto(taskId, newPrimary);
}
},
"task.updated": (message) => handleTaskUpdated(store, message),
"task.deleted": (message) => {
const deletedId = message.payload.task_id;
const wfId = message.payload.workflow_id;
removeRecentTask(deletedId);
const currentState = store.getState();
@@ -221,25 +271,17 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
const wasActive = currentState.tasks.activeTaskId === deletedId;
store.setState((state) => {
const isActive = state.tasks.activeTaskId === deletedId;
let next = removeTaskFromBothKanbans(state, wfId, deletedId);
if (isActive) {
next = { ...next, tasks: { ...next.tasks, activeTaskId: null, activeSessionId: null } };
}
if (next.tasks.lastSessionByTaskId[deletedId]) {
const { [deletedId]: _, ...rest } = next.tasks.lastSessionByTaskId;
next = { ...next, tasks: { ...next.tasks, lastSessionByTaskId: rest } };
}
return next;
});
store.setState((state) =>
clearRemovedTaskSelection(removeTaskFromBothKanbans(state, deletedId), deletedId),
);
// Capture the route match before any redirect mutates the pathname. This
// covers a fresh load where the browser is parked on the task's route
// (`/t/<id>` or `/tasks/<id>`) but TaskPageContent hasn't hydrated
// `activeTaskId` yet, so `wasActive` is still false.
// but TaskPageContent hasn't hydrated `activeTaskId` yet, so `wasActive`
// is still false.
const onDeletedRoute =
typeof window !== "undefined" && isTaskDetailPath(window.location.pathname, deletedId);
typeof window !== "undefined" &&
removedTaskRedirectHref(window.location.pathname, deletedId) !== null;
// Only react to genuine auto-deletions, which the backend tags with a
// reason (e.g. a review task whose PR was approved). User-initiated deletes
@@ -248,7 +290,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
// preempt it and strand the user on the home route. For auto-deletions we
// move off the now-dead route (helper is route-guarded) and explain why.
if (message.payload.reason && (wasActive || onDeletedRoute)) {
redirectAwayFromDeletedTask(deletedId);
redirectAwayFromRemovedTask(deletedId);
store.getState().setTaskDeletedNotification({
taskId: deletedId,
title: message.payload.title,