fix(web): stop unrelated task.updated events from un-nesting a subtask (#2207)

This commit is contained in:
Clément Fiere
2026-08-05 23:37:37 +02:00
committed by GitHub
parent d15d5da8fa
commit 6faa883069
2 changed files with 91 additions and 0 deletions
+77
View File
@@ -94,6 +94,15 @@ function makeMessage(payload: Record<string, unknown>) {
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.updated"]>>[0];
}
function makeStateChangedMessage(payload: Record<string, unknown>) {
return {
id: "msg-1",
type: "notification" as const,
action: "task.state_changed" as const,
payload,
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["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" });
});
});
+14
View File
@@ -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;
}