From f650a157e3ef388dbd763836cbab59ac9cf6dbeb Mon Sep 17 00:00:00 2001 From: Nir Levanon Date: Wed, 9 Sep 2026 23:46:28 +0300 Subject: [PATCH] fix: don't collapse a genuine clarification/permission wait to REVIEW (#3549) Co-authored-by: Carlos Florencio --- apps/web/app/tasks/[id]/kanban-task-shell.tsx | 18 ++++++---- apps/web/app/tasks/rich-task-list-row.tsx | 1 + apps/web/components/kanban-card-content.tsx | 1 + .../components/kanban/graph2-step-node.tsx | 1 + .../kanban/swimlane-graph-content.tsx | 2 ++ .../web/hooks/use-task-pending-input.test.tsx | 16 +++++++++ apps/web/hooks/use-task-pending-input.ts | 13 ++++++-- apps/web/lib/utils/needs-action.test.ts | 33 +++++++++++++++++++ apps/web/lib/utils/needs-action.ts | 7 +++- 9 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 apps/web/lib/utils/needs-action.test.ts diff --git a/apps/web/app/tasks/[id]/kanban-task-shell.tsx b/apps/web/app/tasks/[id]/kanban-task-shell.tsx index fa29029f0a..a5b6582cf5 100644 --- a/apps/web/app/tasks/[id]/kanban-task-shell.tsx +++ b/apps/web/app/tasks/[id]/kanban-task-shell.tsx @@ -103,15 +103,21 @@ export function KanbanTaskShell({ // background-running task reads distinctly and never as done, // and carry the sidebar's rich "needs me" reading — pending clarification / // permission — so the header distinguishes waiting-for-input. +function simpleTaskPendingFallback(task: Task | null) { + if (!task) return {}; + return { + taskId: task.id, + taskPendingAction: task.task_pending_action, + statusSummary: task.status_summary, + primarySessionState: task.primary_session_state, + primarySessionPendingAction: task.primary_session_pending_action, + }; +} + function simpleTaskHeaderData(task: Task | null) { return { primarySessionId: task?.primary_session_id, - pendingFallback: { - taskId: task?.id, - taskPendingAction: task?.task_pending_action, - primarySessionState: task?.primary_session_state, - primarySessionPendingAction: task?.primary_session_pending_action, - }, + pendingFallback: simpleTaskPendingFallback(task), identifier: task?.id?.slice(0, 8), title: task?.title ?? t("tasks:loading"), state: task?.state ?? null, diff --git a/apps/web/app/tasks/rich-task-list-row.tsx b/apps/web/app/tasks/rich-task-list-row.tsx index cd33e92e26..8f0fbcb42a 100644 --- a/apps/web/app/tasks/rich-task-list-row.tsx +++ b/apps/web/app/tasks/rich-task-list-row.tsx @@ -171,6 +171,7 @@ export function TaskListRowPrimaryContent({ const pendingInput = useTaskPendingInput(task.primary_session_id, { taskId: task.id, taskPendingAction: task.task_pending_action, + statusSummary: task.status_summary, primarySessionState: task.primary_session_state, primarySessionPendingAction: task.primary_session_pending_action, }); diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index e15f310d23..2346648c1d 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -368,6 +368,7 @@ function KanbanCardActions({ const pendingInput = useTaskPendingInput(task.primarySessionId, { taskId: task.id, taskPendingAction: task.taskPendingAction, + statusSummary: task.statusSummary, primarySessionState: task.primarySessionState, primarySessionPendingAction: task.primarySessionPendingAction, }); diff --git a/apps/web/components/kanban/graph2-step-node.tsx b/apps/web/components/kanban/graph2-step-node.tsx index 487a01b362..718c3b9991 100644 --- a/apps/web/components/kanban/graph2-step-node.tsx +++ b/apps/web/components/kanban/graph2-step-node.tsx @@ -126,6 +126,7 @@ export function Graph2StepNode({ const pendingInput = useTaskPendingInput(task.primarySessionId, { taskId: task.id, taskPendingAction: task.taskPendingAction, + statusSummary: task.statusSummary, primarySessionState: task.primarySessionState, primarySessionPendingAction: task.primarySessionPendingAction, }); diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx index ff3eab315e..12586f0566 100644 --- a/apps/web/components/kanban/swimlane-graph-content.tsx +++ b/apps/web/components/kanban/swimlane-graph-content.tsx @@ -80,6 +80,7 @@ function DraggableTaskChip({ const pendingInput = useTaskPendingInput(task.primarySessionId, { taskId: task.id, taskPendingAction: task.taskPendingAction, + statusSummary: task.statusSummary, primarySessionState: task.primarySessionState, primarySessionPendingAction: task.primarySessionPendingAction, }); @@ -117,6 +118,7 @@ function TaskChipPreview({ task }: { task: Task }) { const pendingInput = useTaskPendingInput(task.primarySessionId, { taskId: task.id, taskPendingAction: task.taskPendingAction, + statusSummary: task.statusSummary, primarySessionState: task.primarySessionState, primarySessionPendingAction: task.primarySessionPendingAction, }); diff --git a/apps/web/hooks/use-task-pending-input.test.tsx b/apps/web/hooks/use-task-pending-input.test.tsx index 75e729a049..e4b63cae3a 100644 --- a/apps/web/hooks/use-task-pending-input.test.tsx +++ b/apps/web/hooks/use-task-pending-input.test.tsx @@ -193,6 +193,22 @@ describe("useTaskPendingInput", () => { }); }); +describe("useTaskPendingInput task status summary authority", () => { + it("prefers the task status summary over a stale legacy task snapshot", () => { + const { result } = renderHook( + () => + useTaskPendingInput("session-1", { + taskId: "task-1", + taskPendingAction: "clarification", + statusSummary: { pending_action: "permission" }, + } as Parameters[1]), + { wrapper: wrapper() }, + ); + + expect(result.current).toEqual({ clarification: false, permission: true }); + }); +}); + describe("useTaskPendingInput pending clarification turn authority", () => { it("preserves pending clarification while session state authority is still loading", () => { const olderTurn = turn("turn-older", PRIMARY_SESSION_ID, BASE_TIMESTAMP); diff --git a/apps/web/hooks/use-task-pending-input.ts b/apps/web/hooks/use-task-pending-input.ts index 561117be41..802c307105 100644 --- a/apps/web/hooks/use-task-pending-input.ts +++ b/apps/web/hooks/use-task-pending-input.ts @@ -15,6 +15,7 @@ const NONE: PendingInput = { clarification: false, permission: false }; export type PendingInputFallback = { taskId?: string | null; taskPendingAction?: TaskPendingAction | null; + statusSummary?: { pending_action?: TaskPendingAction | null } | null; primarySessionState?: string | null; primarySessionPendingAction?: TaskPendingAction | null; }; @@ -39,6 +40,14 @@ function actionFlags(action: TaskPendingAction | null | undefined): PendingInput return { clarification: action === "clarification", permission: action === "permission" }; } +function fallbackTaskPendingAction( + fallback: PendingInputFallback | undefined, +): TaskPendingAction | null | undefined { + return fallback?.statusSummary != null + ? fallback.statusSummary.pending_action + : fallback?.taskPendingAction; +} + function loadedSessionFlags( messagesBySession: Record, turnsBySession: Record, @@ -122,7 +131,7 @@ function selectFlagsFromLoadedTaskSessions( session.pending_action, ); }, - fallback?.taskPendingAction, + fallbackTaskPendingAction(fallback), ); if (!result.hasUnloadedMessages) { return { clarification: result.clarification, permission: result.permission }; @@ -139,7 +148,7 @@ function selectFlagsFromPrimarySession( primarySession: PrimarySessionProjection, fallback: PendingInputFallback | undefined, ): PendingInput { - const taskSnapshot = actionFlags(fallback?.taskPendingAction); + const taskSnapshot = actionFlags(fallbackTaskPendingAction(fallback)); if (taskSnapshot.clarification || taskSnapshot.permission) return taskSnapshot; if (!primarySession.id) return NONE; if (messagesBySession[primarySession.id] !== undefined) { diff --git a/apps/web/lib/utils/needs-action.test.ts b/apps/web/lib/utils/needs-action.test.ts new file mode 100644 index 0000000000..59e58c9245 --- /dev/null +++ b/apps/web/lib/utils/needs-action.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { needsAction } from "./needs-action"; + +describe("needsAction", () => { + it("uses the task status summary when legacy pending data is stale", () => { + expect( + needsAction({ + state: "REVIEW", + taskPendingAction: "clarification", + statusSummary: { pending_action: "permission" }, + }), + ).toBe(true); + }); + + it("recognizes a pending action when the task remains in REVIEW", () => { + expect( + needsAction({ + state: "REVIEW", + statusSummary: { pending_action: "clarification" }, + }), + ).toBe(true); + }); + + it("does not revive a legacy action after the status summary clears it", () => { + expect( + needsAction({ + state: "REVIEW", + taskPendingAction: "clarification", + statusSummary: {}, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/lib/utils/needs-action.ts b/apps/web/lib/utils/needs-action.ts index a80a6b4028..8cc8ef1884 100644 --- a/apps/web/lib/utils/needs-action.ts +++ b/apps/web/lib/utils/needs-action.ts @@ -1,4 +1,4 @@ -import type { TaskState } from "@/lib/types/http"; +import type { TaskPendingAction, TaskState } from "@/lib/types/http"; /** * Determines if a task needs user attention/action. @@ -6,11 +6,16 @@ import type { TaskState } from "@/lib/types/http"; */ export function needsAction(task: { state?: TaskState; + taskPendingAction?: TaskPendingAction | null; + statusSummary?: { pending_action?: TaskPendingAction | null } | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; }): boolean { + const pendingAction = + task.statusSummary != null ? task.statusSummary.pending_action : task.taskPendingAction; return ( (task.reviewStatus === "pending" && task.state !== "IN_PROGRESS") || task.reviewStatus === "changes_requested" || + pendingAction != null || task.state === "WAITING_FOR_INPUT" ); }