diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index 2263e49fb1..60619b9137 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -94,6 +94,15 @@ function makeMessage(payload: Record) { } as Parameters["task.updated"]>>[0]; } +function makeStateChangedMessage(payload: Record) { + return { + id: "msg-1", + type: "notification" as const, + action: "task.state_changed" as const, + payload, + } as Parameters["task.state_changed"]>>[0]; +} + // Shared setup for the primary-session focus-follow tests: a single task t1 // whose kanban primary, plus the active/pinned session ids, are the only knobs // that vary between cases. @@ -596,3 +605,71 @@ describe("task.updated executor preservation", () => { }); }); }); + +function makeParentTaskStore(parentTaskId: string) { + const existingTask = { + id: "t1", + workflowStepId: "step1", + title: "Old title", + position: 0, + primarySessionId: "session-1", + parentTaskId, + }; + return makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + kanbanMulti: { + isLoading: false, + snapshots: { + wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, + }, + } as unknown as AppState["kanbanMulti"], + }); +} + +describe("task parent preservation", () => { + it("preserves parentTaskId when a partial update omits parent_id", () => { + const store = makeParentTaskStore("parent-1"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ + ...makeTask("t1", "session-1"), + title: "Retitled task", + }), + ); + registerTasksHandlers(store)["task.state_changed"]!( + makeStateChangedMessage({ ...makeTask("t1", "session-1"), title: "Retitled again" }), + ); + + const state = store.getState(); + expect(state.kanban.tasks[0]).toMatchObject({ parentTaskId: "parent-1" }); + expect(state.kanbanMulti.snapshots.wf1.tasks[0]).toMatchObject({ parentTaskId: "parent-1" }); + }); + + it("clears parentTaskId when the task is explicitly detached (parent_id: null)", () => { + const store = makeParentTaskStore("parent-1"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), parent_id: null }), + ); + + const state = store.getState(); + expect(state.kanban.tasks[0]).toMatchObject({ parentTaskId: undefined }); + expect(state.kanbanMulti.snapshots.wf1.tasks[0]).toMatchObject({ parentTaskId: undefined }); + }); + + it("adopts an explicit re-parent even while the previous parent is still preserved elsewhere", () => { + const store = makeParentTaskStore("parent-old"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), parent_id: "parent-new" }), + ); + + const state = store.getState(); + expect(state.kanban.tasks[0]).toMatchObject({ parentTaskId: "parent-new" }); + expect(state.kanbanMulti.snapshots.wf1.tasks[0]).toMatchObject({ parentTaskId: "parent-new" }); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 398f1b677e..0b3756f2e0 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -63,6 +63,19 @@ function preserveOmittedStatusSummary( } } +// Task lifecycle events may omit parent_id when the parent is unchanged. Only +// an explicit `parent_id: null` means detach (see parentIDEventField in +// service_tasks.go), so an omitted link must not un-nest a cached child. +function preserveOmittedParent( + existing: KanbanTask, + merged: KanbanTask, + payload: TaskEventPayload, +): void { + if (!hasPayloadField(payload, "parent_id")) { + merged.parentTaskId = existing.parentTaskId; + } +} + function mergeTaskUpdate( existing: KanbanTask | undefined, nextTask: KanbanTask, @@ -73,6 +86,7 @@ function mergeTaskUpdate( ...nextTask, ...mergeTaskRepositoryFields(existing, nextTask), }; + preserveOmittedParent(existing, merged, payload); if (!hasPayloadField(payload, "primary_session_id") && nextTask.primarySessionId === undefined) { merged.primarySessionId = existing.primarySessionId; }