From 0a15785c982cb6ca2dd13e226163b7c2ecfbeed6 Mon Sep 17 00:00:00 2001 From: Luan Martins Date: Thu, 27 Aug 2026 19:55:34 +0000 Subject: [PATCH] fix(web): show live Task header until nested child settles (#3075) Co-authored-by: Carlos Florencio --- .../messages/tool-subagent-message.test.tsx | 364 ++++++++++++++---- .../chat/messages/tool-subagent-message.tsx | 118 +++++- .../e2e/tests/chat/mobile-subagent.spec.ts | 4 + apps/web/e2e/tests/chat/subagent.spec.ts | 4 + apps/web/lib/ws/handlers/turns.test.ts | 17 + apps/web/lib/ws/handlers/turns.ts | 3 +- apps/web/src/locales/en/task.json | 2 + apps/web/src/locales/pseudo/task.json | 2 + apps/web/src/locales/pt-pt/task.json | 2 + apps/web/src/locales/zh-cn/task.json | 2 + apps/web/src/locales/zh-hk/task.json | 2 + apps/web/src/locales/zh-tw/task.json | 2 + .../subagent-active-header-status/plan.md | 67 ++++ .../task-01-header-status-ui.md | 69 ++++ .../task-02-subagent-status-e2e.md | 60 +++ .../ui/requirements/subagent-observability.md | 30 ++ 16 files changed, 656 insertions(+), 92 deletions(-) create mode 100644 docs/plans/subagent-active-header-status/plan.md create mode 100644 docs/plans/subagent-active-header-status/task-01-header-status-ui.md create mode 100644 docs/plans/subagent-active-header-status/task-02-subagent-status-e2e.md diff --git a/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx b/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx index 4f95cfba31..db935f5556 100644 --- a/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx +++ b/apps/web/components/task/chat/messages/tool-subagent-message.test.tsx @@ -11,10 +11,16 @@ const SUBAGENT_CHEVRON = "subagent-chevron"; const IN_PROGRESS = "in_progress"; const STARTED = "started"; const WORKING = "Working..."; +const COMPLETED = "Completed"; +const FAILED = "Failed"; +const CANCELLED = "Cancelled"; const SUBAGENT_DESCRIPTION = "subagent-description"; const SUBAGENT_RESULT_TEXT = "subagent-result-text"; const CHILD_TOOL_LABEL = "Read SyncRunner.ts"; const CODE_REVIEWER = "code-reviewer"; +const FIRST_CHILD = "first child"; +const SECOND_CHILD = "second child"; +const QUEUED = "queued"; function subagentMessage({ metadataStatus = "in_progress", @@ -90,77 +96,173 @@ function renderSubagent( ); } -describe("isSubagentEffectivelyActive", () => { - it.each<{ - name: string; - metadataStatus: ToolCallMetadata["status"]; - payloadStatus: string; - isContainingTurnActive: boolean; - expected: boolean; - }>([ - { - name: "in-progress metadata is active during its turn without a started payload", - metadataStatus: IN_PROGRESS, - payloadStatus: "queued", - isContainingTurnActive: true, - expected: true, - }, - { - name: "in-progress metadata settles with its turn without a started payload", - metadataStatus: IN_PROGRESS, - payloadStatus: "queued", - isContainingTurnActive: false, - expected: false, - }, - { - name: "started payload with pending metadata is active during its turn", - metadataStatus: "pending", - payloadStatus: STARTED, - isContainingTurnActive: true, - expected: true, - }, - { - name: "started payload with pending metadata settles with its turn", - metadataStatus: "pending", - payloadStatus: STARTED, - isContainingTurnActive: false, - expected: false, - }, - { - name: "started payload without metadata status is active during its turn", - metadataStatus: undefined, - payloadStatus: STARTED, - isContainingTurnActive: true, - expected: true, - }, - { - name: "started payload without metadata status settles with its turn", - metadataStatus: undefined, - payloadStatus: STARTED, - isContainingTurnActive: false, - expected: false, - }, - { - name: "running metadata stays active without a containing-turn signal", - metadataStatus: "running", - payloadStatus: "queued", - isContainingTurnActive: false, - expected: true, - }, - { - name: "terminal metadata overrides a started payload in an active turn", - metadataStatus: COMPLETE, - payloadStatus: STARTED, - isContainingTurnActive: true, - expected: false, - }, - ])("$name", ({ metadataStatus, payloadStatus, isContainingTurnActive, expected }) => { - const message = subagentMessage({ metadataStatus, payloadStatus }); - const metadata = message.metadata as ToolCallMetadata; - if (metadataStatus === undefined) delete metadata.status; +const EFFECTIVELY_ACTIVE_CASES: Array<{ + name: string; + metadataStatus: ToolCallMetadata["status"]; + payloadStatus: string; + isContainingTurnActive: boolean; + expected: boolean; +}> = [ + { + name: "in-progress metadata is active during its turn without a started payload", + metadataStatus: IN_PROGRESS, + payloadStatus: QUEUED, + isContainingTurnActive: true, + expected: true, + }, + { + name: "in-progress metadata settles with its turn without a started payload", + metadataStatus: IN_PROGRESS, + payloadStatus: QUEUED, + isContainingTurnActive: false, + expected: false, + }, + { + name: "started payload with pending metadata is active during its turn", + metadataStatus: "pending", + payloadStatus: STARTED, + isContainingTurnActive: true, + expected: true, + }, + { + name: "started payload with pending metadata settles with its turn", + metadataStatus: "pending", + payloadStatus: STARTED, + isContainingTurnActive: false, + expected: false, + }, + { + name: "started payload without metadata status is active during its turn", + metadataStatus: undefined, + payloadStatus: STARTED, + isContainingTurnActive: true, + expected: true, + }, + { + name: "started payload without metadata status settles with its turn", + metadataStatus: undefined, + payloadStatus: STARTED, + isContainingTurnActive: false, + expected: false, + }, + { + name: "running metadata stays active without a containing-turn signal", + metadataStatus: "running", + payloadStatus: QUEUED, + isContainingTurnActive: false, + expected: true, + }, + { + name: "terminal nested payload overrides running metadata", + metadataStatus: "running", + payloadStatus: "errored", + isContainingTurnActive: true, + expected: false, + }, + { + name: "started payload stays active after spawn completes during its turn", + metadataStatus: COMPLETE, + payloadStatus: STARTED, + isContainingTurnActive: true, + expected: true, + }, + { + name: "pendingInit payload stays active after spawn completes during its turn", + metadataStatus: COMPLETE, + payloadStatus: "pendingInit", + isContainingTurnActive: true, + expected: true, + }, + { + name: "pendingInit payload settles with its turn after spawn completes", + metadataStatus: COMPLETE, + payloadStatus: "pendingInit", + isContainingTurnActive: false, + expected: false, + }, + { + name: "terminal nested payload stays settled after spawn completes", + metadataStatus: COMPLETE, + payloadStatus: COMPLETE, + isContainingTurnActive: true, + expected: false, + }, + { + name: "async_launched payload stays active after spawn completes during its turn", + metadataStatus: COMPLETE, + payloadStatus: "async_launched", + isContainingTurnActive: true, + expected: true, + }, + { + name: "async_launched payload settles with its turn after spawn completes", + metadataStatus: COMPLETE, + payloadStatus: "async_launched", + isContainingTurnActive: false, + expected: false, + }, + { + name: "ACP completed nested payload settles after spawn completes", + metadataStatus: COMPLETE, + payloadStatus: "completed", + isContainingTurnActive: true, + expected: false, + }, + { + name: "failed metadata stays settled during an active turn", + metadataStatus: "failed", + payloadStatus: STARTED, + isContainingTurnActive: true, + expected: false, + }, + { + name: "cancelled metadata stays settled during an active turn", + metadataStatus: "cancelled", + payloadStatus: STARTED, + isContainingTurnActive: true, + expected: false, + }, + { + name: "errored nested payload settles even while the turn is active", + metadataStatus: "complete", + payloadStatus: "errored", + isContainingTurnActive: true, + expected: false, + }, + { + name: "interrupted nested payload settles even while the turn is active", + metadataStatus: "complete", + payloadStatus: "interrupted", + isContainingTurnActive: true, + expected: false, + }, + { + name: "shutdown nested payload settles even while the turn is active", + metadataStatus: "complete", + payloadStatus: "shutdown", + isContainingTurnActive: true, + expected: false, + }, + { + name: "notFound nested payload settles even while the turn is active", + metadataStatus: "complete", + payloadStatus: "notFound", + isContainingTurnActive: true, + expected: false, + }, +]; - expect(isSubagentEffectivelyActive(metadata, isContainingTurnActive)).toBe(expected); - }); +describe("isSubagentEffectivelyActive", () => { + it.each(EFFECTIVELY_ACTIVE_CASES)( + "$name", + ({ metadataStatus, payloadStatus, isContainingTurnActive, expected }) => { + const message = subagentMessage({ metadataStatus, payloadStatus }); + const metadata = message.metadata as ToolCallMetadata; + if (metadataStatus === undefined) delete metadata.status; + + expect(isSubagentEffectivelyActive(metadata, isContainingTurnActive)).toBe(expected); + }, + ); }); describe("ToolSubagentMessage", () => { @@ -212,25 +314,92 @@ describe("ToolSubagentMessage", () => { expect(screen.queryByText(WORKING)).toBeNull(); expect(screen.queryByRole("button")).toBeNull(); }); + + it("shows Working after spawn completes while the nested Codex task is still pendingInit", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: "pendingInit" }), { + isContainingTurnActive: true, + }); + + expect(screen.getByRole("status", { name: "Loading" })).toBeTruthy(); + expect(screen.getByText(WORKING)).toBeTruthy(); + expect(screen.getByTitle("Working")).toBeTruthy(); + }); + + it("shows Working after spawn completes while the nested Claude task is still async_launched", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: "async_launched" }), { + isContainingTurnActive: true, + }); + + expect(screen.getByRole("status", { name: "Loading" })).toBeTruthy(); + expect(screen.getByText(WORKING)).toBeTruthy(); + expect(screen.getByTitle("Working")).toBeTruthy(); + }); + + it("does not keep Working after a nested Codex task settles as errored", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: "errored" }), { + isContainingTurnActive: true, + }); + + expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); + expect(screen.queryByText(WORKING)).toBeNull(); + expect(screen.getByLabelText(FAILED)).toBeTruthy(); + }); + + it("shows a failed nested payload when spawn metadata is complete", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: "failed" }), { + isContainingTurnActive: true, + }); + + expect(screen.getByLabelText(FAILED)).toBeTruthy(); + expect(screen.queryByLabelText(COMPLETED)).toBeNull(); + }); + + it("shows a cancelled nested payload when spawn metadata is complete", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: "cancelled" }), { + isContainingTurnActive: true, + }); + + expect(screen.getByLabelText(CANCELLED)).toBeTruthy(); + expect(screen.queryByLabelText(COMPLETED)).toBeNull(); + }); + + it("shows a completed check with a mapped hover after the turn settles", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: COMPLETE })); + + expect(screen.getByTitle(COMPLETED)).toBeTruthy(); + expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); + expect(screen.queryByText(WORKING)).toBeNull(); + }); + + it("names a failed status on hover", () => { + renderSubagent(subagentMessage({ metadataStatus: "failed", payloadStatus: COMPLETE })); + + expect(screen.getByTitle(FAILED)).toBeTruthy(); + expect(screen.queryByText(WORKING)).toBeNull(); + }); + + it("names a cancelled status on hover", () => { + renderSubagent(subagentMessage({ metadataStatus: "cancelled", payloadStatus: COMPLETE })); + + expect(screen.getByTitle(CANCELLED)).toBeTruthy(); + expect(screen.queryByText(WORKING)).toBeNull(); + }); }); describe("ToolSubagentMessage expansion", () => { it("expands nested child tools and keeps their count", () => { - const childMessages = [ - childTool("child-1", "first child"), - childTool("child-2", "second child"), - ]; + const childMessages = [childTool("child-1", FIRST_CHILD), childTool("child-2", SECOND_CHILD)]; renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: COMPLETE }), { childMessages, }); expect(screen.getByTestId("subagent-child-count").textContent).toBe("2 tool calls"); expect(screen.getByTestId(SUBAGENT_CHEVRON)).toBeTruthy(); - expect(screen.queryByText("first child")).toBeNull(); + expect(screen.queryByText(FIRST_CHILD)).toBeNull(); fireEvent.click(screen.getByRole("button")); - expect(screen.getByText("first child")).toBeTruthy(); - expect(screen.getByText("second child")).toBeTruthy(); + expect(screen.getByText(FIRST_CHILD)).toBeTruthy(); + expect(screen.getByText(SECOND_CHILD)).toBeTruthy(); }); it("keeps completed result-only subagents collapsed but expandable", () => { @@ -309,6 +478,47 @@ describe("ToolSubagentMessage expansion", () => { }); }); +describe("subagent header status", () => { + const LOADING = { name: "Loading" } as const; + + it("keeps Working and the spinner on an active card that already has children", () => { + renderSubagent(subagentMessage({ metadataStatus: "running", payloadStatus: STARTED }), { + childMessages: [childTool("child-1", FIRST_CHILD), childTool("child-2", SECOND_CHILD)], + }); + + expect(screen.getByText(WORKING)).toBeTruthy(); + expect(screen.getByRole("status", LOADING)).toBeTruthy(); + }); + + it("shows a completed check without Working or a spinner on a completed card with children", () => { + renderSubagent(subagentMessage({ metadataStatus: COMPLETE, payloadStatus: COMPLETE }), { + childMessages: [childTool("child-1", FIRST_CHILD), childTool("child-2", SECOND_CHILD)], + }); + + expect(screen.queryByText(WORKING)).toBeNull(); + expect(screen.queryByRole("status", LOADING)).toBeNull(); + expect(screen.queryByLabelText("Failed")).toBeNull(); + expect(screen.queryByLabelText("Cancelled")).toBeNull(); + expect(screen.getByLabelText(COMPLETED)).toBeTruthy(); + }); + + it("marks a failed card with a Failed label and no spinner", () => { + renderSubagent(subagentMessage({ metadataStatus: "failed", payloadStatus: COMPLETE })); + + expect(screen.getByLabelText("Failed")).toBeTruthy(); + expect(screen.queryByRole("status", LOADING)).toBeNull(); + expect(screen.queryByText(WORKING)).toBeNull(); + }); + + it("marks a cancelled card with a Cancelled label and no spinner", () => { + renderSubagent(subagentMessage({ metadataStatus: "cancelled", payloadStatus: COMPLETE })); + + expect(screen.getByLabelText("Cancelled")).toBeTruthy(); + expect(screen.queryByRole("status", LOADING)).toBeNull(); + expect(screen.queryByText(WORKING)).toBeNull(); + }); +}); + // A reviewer's verdict is the only thing anyone reads a review-wave card for. // It arrives on `toolResponse.content` and, before this, was shown only when // the subagent streamed no child tool calls — i.e. never, for Claude. diff --git a/apps/web/components/task/chat/messages/tool-subagent-message.tsx b/apps/web/components/task/chat/messages/tool-subagent-message.tsx index adc19f28e6..434e46bc14 100644 --- a/apps/web/components/task/chat/messages/tool-subagent-message.tsx +++ b/apps/web/components/task/chat/messages/tool-subagent-message.tsx @@ -1,12 +1,14 @@ "use client"; import { useState, useCallback, memo } from "react"; -import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { IconCheck, IconChevronDown, IconChevronRight, IconX } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; import { cn } from "@/lib/utils"; import type { Message } from "@/lib/types/http"; import type { SubagentTaskPayload, ToolCallMetadata } from "@/components/task/chat/types"; import { SubagentMetaRow } from "@/components/task/chat/messages/subagent-meta-row"; +import { isTerminalToolCallStatus } from "@/lib/utils/tool-call-status"; +import { normalizeToolCallStatus } from "./tool-status"; import { useTranslation } from "react-i18next"; type ToolSubagentMessageProps = { @@ -32,24 +34,74 @@ function arePropsEqual( ); } -const TERMINAL_TOOL_STATUSES = new Set([ - "complete", +const LIVE_SUBAGENT_PAYLOAD_STATUSES = new Set(["pendingInit", "started", "async_launched"]); + +// Mirrors backend subagentStatusTerminal: Codex session statuses that mean the +// nested task has already settled, even while the parent turn is still active. +const TERMINAL_SUBAGENT_PAYLOAD_STATUSES = new Set([ "completed", - "success", - "error", "failed", "cancelled", + "errored", + "interrupted", + "shutdown", + "notFound", ]); +function isSubagentPayloadTerminal(status: string | undefined): boolean { + if (!status) return false; + return TERMINAL_SUBAGENT_PAYLOAD_STATUSES.has(status) || isTerminalToolCallStatus(status); +} + +function isSubagentPayloadLive(status: string | undefined): boolean { + if (!status) return false; + if (LIVE_SUBAGENT_PAYLOAD_STATUSES.has(status)) return true; + return !isSubagentPayloadTerminal(status); +} + +type SubagentTerminalStatus = "complete" | "error" | "cancelled"; + +function normalizeSubagentTerminalStatus( + status: string | undefined, +): SubagentTerminalStatus | undefined { + if (status === "errored" || status === "notFound") return "error"; + if (status === "interrupted" || status === "shutdown") return "cancelled"; + + const normalized = normalizeToolCallStatus(status); + if (normalized === "complete" || normalized === "error" || normalized === "cancelled") { + return normalized; + } + return undefined; +} + +function resolveSubagentTerminalStatus( + metadataStatus: ToolCallMetadata["status"] | undefined, + payloadStatus: string | undefined, +): SubagentTerminalStatus { + const payloadTerminal = normalizeSubagentTerminalStatus(payloadStatus); + const metadataTerminal = normalizeSubagentTerminalStatus(metadataStatus); + if (payloadTerminal && payloadTerminal !== "complete") return payloadTerminal; + if (metadataTerminal && metadataTerminal !== "complete") return metadataTerminal; + return payloadTerminal ?? metadataTerminal ?? "complete"; +} + export function isSubagentEffectivelyActive( metadata: ToolCallMetadata | undefined, isContainingTurnActive: boolean, ): boolean { - const status = metadata?.status; - if (status && TERMINAL_TOOL_STATUSES.has(status)) return false; - if (status === "running") return true; const payloadStatus = metadata?.normalized?.subagent_task?.status; - return isContainingTurnActive && (status === "in_progress" || payloadStatus === "started"); + if (payloadStatus && isSubagentPayloadTerminal(payloadStatus)) return false; + + const status = metadata?.status; + if (status === "running") return true; + + const normalized = normalizeToolCallStatus(status); + if (normalized === "error" || normalized === "cancelled") return false; + + if (!isContainingTurnActive) return false; + + if (status === "in_progress" || isSubagentPayloadLive(payloadStatus)) return true; + return false; } // The result is what the subagent was dispatched to produce, so it is shown @@ -124,16 +176,56 @@ type SubagentHeaderProps = { subagentType: string; description: string; isActive: boolean; + status?: ToolCallMetadata["status"]; + payloadStatus?: string; childCount: number; hasExpandableContent: boolean; onToggle: () => void; }; +function SubagentStatusIcon({ + isActive, + status, + payloadStatus, +}: { + isActive: boolean; + status?: ToolCallMetadata["status"]; + payloadStatus?: string; +}) { + const { t } = useTranslation(); + if (isActive) { + const workingLabel = t("task:statusWorking"); + return ( + + {t("task:working")} + + + ); + } + const normalized = resolveSubagentTerminalStatus(status, payloadStatus); + if (normalized === "error" || normalized === "cancelled") { + const label = normalized === "cancelled" ? t("task:statusCancelled") : t("task:failed"); + return ( + + + + ); + } + const completedLabel = t("task:statusCompleted"); + return ( + + + + ); +} + function SubagentHeader({ isExpanded, subagentType, description, isActive, + status, + payloadStatus, childCount, hasExpandableContent, onToggle, @@ -141,7 +233,6 @@ function SubagentHeader({ const { t } = useTranslation(); const shownDescription = stripSubagentTypePrefix(description, subagentType); const showDescription = shownDescription !== ""; - const showInlineWorking = isActive && !hasExpandableContent; const content = ( <> {hasExpandableContent && @@ -171,10 +262,7 @@ function SubagentHeader({ {shownDescription} )} - {showInlineWorking && ( - {t("task:working")} - )} - {isActive && } + {childCount > 0 && ( { // Chat idle can arrive before the nested subagent completion event is // hydrated into the card. Wait for the metadata state boundary explicitly. await expect(card.locator('[data-testid="subagent-meta"]')).toBeVisible({ timeout: 15_000 }); + // Successful completion: identity chips stay, Working is gone, a check remains. + await expect(card.getByRole("status", { name: "Loading" })).toHaveCount(0); + await expect(card.getByText("Working...")).toHaveCount(0); + await expect(card.getByLabel("Completed")).toHaveCount(1); await expect(card.locator('[data-testid="subagent-meta-duration"]')).toContainText("2.2s"); await expect(card.locator('[data-testid="subagent-meta-tokens"]')).toContainText("9,987"); await expect(card.locator('[data-testid="subagent-meta-tools"]')).toContainText("3 tools"); diff --git a/apps/web/e2e/tests/chat/subagent.spec.ts b/apps/web/e2e/tests/chat/subagent.spec.ts index 511ab31e90..ad9a2ad23e 100644 --- a/apps/web/e2e/tests/chat/subagent.spec.ts +++ b/apps/web/e2e/tests/chat/subagent.spec.ts @@ -106,6 +106,10 @@ test.describe("Subagent card", () => { await session.waitForChatIdle({ timeout: 15_000 }); } await expect(metadata).toBeVisible(); + // Successful completion: identity chips stay, Working is gone, a check remains. + await expect(card.getByRole("status", { name: "Loading" })).toHaveCount(0); + await expect(card.getByText("Working...")).toHaveCount(0); + await expect(card.getByLabel("Completed")).toHaveCount(1); await expect(card.locator('[data-testid="subagent-meta-duration"]')).toContainText("2.2s"); await expect(card.locator('[data-testid="subagent-meta-tokens"]')).toContainText("9,987"); await expect(card.locator('[data-testid="subagent-meta-tools"]')).toContainText("3 tools"); diff --git a/apps/web/lib/ws/handlers/turns.test.ts b/apps/web/lib/ws/handlers/turns.test.ts index e1cea5b423..dd7fefdc2d 100644 --- a/apps/web/lib/ws/handlers/turns.test.ts +++ b/apps/web/lib/ws/handlers/turns.test.ts @@ -207,6 +207,23 @@ describe("session turn WebSocket handlers", () => { expect(store.getState().turns.bySession[SESSION_ID][0].completed_at).toBe(completed); }); + it("preserves cancelled tool-call status when the containing turn completes", () => { + const store = makeStore(); + store.getState().addMessage({ + id: "msg-1", + session_id: SESSION_ID, + task_id: TASK_ID, + type: "tool_call", + metadata: { status: "cancelled", tool_call_id: "tc-1" }, + } as never); + + send(store, TURN_COMPLETED, turn("turn-1", TURN_STARTED_AT, TURN_COMPLETED_AT)); + + expect(store.getState().messages.bySession[SESSION_ID][0].metadata).toEqual( + expect.objectContaining({ status: "cancelled" }), + ); + }); + it("flushes batched message updates before completing a turn", () => { const store = makeStore(); const flush = vi.fn(); diff --git a/apps/web/lib/ws/handlers/turns.ts b/apps/web/lib/ws/handlers/turns.ts index aa06eaea9b..bce4b0eb7e 100644 --- a/apps/web/lib/ws/handlers/turns.ts +++ b/apps/web/lib/ws/handlers/turns.ts @@ -1,5 +1,6 @@ import type { StoreApi } from "zustand"; import { createDebugLogger } from "@/lib/debug/log"; +import { isTerminalToolCallStatus } from "@/lib/utils/tool-call-status"; import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; import type { AppState } from "@/lib/state/store"; import type { WsHandlers } from "@/lib/ws/handlers/types"; @@ -17,7 +18,7 @@ function completePendingToolCalls(store: StoreApi, sessionId: string): for (const message of messages) { if (message.type === "permission_request") continue; const metadata = message.metadata as Record | undefined; - if (metadata?.tool_call_id && metadata.status !== "complete" && metadata.status !== "error") { + if (metadata?.tool_call_id && !isTerminalToolCallStatus(String(metadata.status ?? ""))) { store.getState().updateMessage({ ...message, metadata: { ...metadata, status: "complete" }, diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 86d1603ed8..e03fd6324b 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1443,11 +1443,13 @@ "statusBlocked": "Blocked", "statusCancelled": "Cancelled", "statusChangedFromTo": "Status changed from <1>{{from}} to <3>{{to}}", + "statusCompleted": "Completed", "statusDone": "Done", "statusInProgress": "In Progress", "statusInReview": "In Review", "statusTodo": "Todo", "statusUpdate": "Status update", + "statusWorking": "Working", "stepCount_one": "{{count}} step", "stepCount_other": "{{count}} steps", "stepFieldLabel": "step", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index d264b73f75..e2c00f5577 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1443,11 +1443,13 @@ "statusBlocked": "Ɓĺōćķēď", "statusCancelled": "Ćàńćēĺĺēď", "statusChangedFromTo": "Śţàţũś ćĥàńĝēď ƒŕōḿ <1>{{from}} ţō <3>{{to}}", + "statusCompleted": "Ćōḿƥĺēţēď", "statusDone": "Ďōńē", "statusInProgress": "Ĩń Ƥŕōĝŕēśś", "statusInReview": "Ĩń Ŕēvĩēŵ", "statusTodo": "Ţōďō", "statusUpdate": "Śţàţũś ũƥďàţē", + "statusWorking": "Ŵōŕķĩńĝ", "stepCount_one": "{{count}} śţēƥ", "stepCount_other": "{{count}} śţēƥś", "stepFieldLabel": "śţēƥ", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index b4e44a576e..af0c55f228 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1398,11 +1398,13 @@ "statusBlocked": "Bloqueada", "statusCancelled": "Cancelada", "statusChangedFromTo": "Estado alterado de <1>{{from}} para <3>{{to}}", + "statusCompleted": "Concluída", "statusDone": "Concluída", "statusInProgress": "Em curso", "statusInReview": "Em revisão", "statusTodo": "Por fazer", "statusUpdate": "Atualização de estado", + "statusWorking": "A trabalhar", "stepCount_one": "{{count}} passo", "stepCount_other": "{{count}} passos", "stepFieldLabel": "passo", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index a3bea9dc56..38c8cbd845 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1397,11 +1397,13 @@ "statusBlocked": "已阻塞", "statusCancelled": "已取消", "statusChangedFromTo": "状态从 <1>{{from}} 变更为 <3>{{to}}", + "statusCompleted": "已完成", "statusDone": "已完成", "statusInProgress": "进行中", "statusInReview": "审阅中", "statusTodo": "待办", "statusUpdate": "状态更新", + "statusWorking": "工作中", "stepCount_one": "{{count}} 个步骤", "stepCount_other": "{{count}} 个步骤", "stepFieldLabel": "步骤", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 9a2fb36bd6..4fac43bd74 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1397,11 +1397,13 @@ "statusBlocked": "已阻塞", "statusCancelled": "已取消", "statusChangedFromTo": "狀態從 <1>{{from}} 變更為 <3>{{to}}", + "statusCompleted": "已完成", "statusDone": "已完成", "statusInProgress": "進行中", "statusInReview": "審閱中", "statusTodo": "待辦", "statusUpdate": "狀態更新", + "statusWorking": "工作中", "stepCount_one": "{{count}} 個步驟", "stepCount_other": "{{count}} 個步驟", "stepFieldLabel": "步驟", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 608136b173..f458f9a3a4 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1397,11 +1397,13 @@ "statusBlocked": "已阻塞", "statusCancelled": "已取消", "statusChangedFromTo": "狀態從 <1>{{from}} 變更為 <3>{{to}}", + "statusCompleted": "已完成", "statusDone": "已完成", "statusInProgress": "進行中", "statusInReview": "審閱中", "statusTodo": "待辦", "statusUpdate": "狀態更新", + "statusWorking": "工作中", "stepCount_one": "{{count}} 個步驟", "stepCount_other": "{{count}} 個步驟", "stepFieldLabel": "步驟", diff --git a/docs/plans/subagent-active-header-status/plan.md b/docs/plans/subagent-active-header-status/plan.md new file mode 100644 index 0000000000..e9167446d8 --- /dev/null +++ b/docs/plans/subagent-active-header-status/plan.md @@ -0,0 +1,67 @@ +--- +spec: docs/specs/ui/subagent-observability.md +created: 2026-08-25 +status: done +--- + +# Implementation Plan: Subagent active header status + +## Overview + +The collapsed subagent header already has a `GridSpinner` while `isSubagentEffectivelyActive` is true, but `task:working` is gated on `isActive && !hasExpandableContent`. A live wave with nested children therefore looks like a settled type + description row. This plan keeps Working + spinner on every active header, adds a red `IconX` after failed or cancelled finishes, and shows a muted completion check after success. Identity chips stay settled-card metadata. Unit tests cover the new states; existing `/e2e:subagent` specs assert the settled header. No backend or mock-agent change. + +## Frontend + +### `apps/web/components/task/chat/messages/tool-subagent-message.tsx` + +- Delete `showInlineWorking`. Render `t("task:working")` whenever `isActive`, next to the existing `GridSpinner`, including when `hasExpandableContent` is true. +- Pass `metadata?.status` into `SubagentHeader`. When not active, if `normalizeToolCallStatus(status)` is `error` or `cancelled`, render a shrink-0 red `IconX` (`h-3.5 w-3.5 text-red-500`) with `aria-label` `task:failed` or `task:cancelled`. Pattern: `ExecuteStatusIcon` in `tool-execute-message.tsx`, but reuse `task:failed` / `task:cancelled` instead of command keys, and never render `IconCheck`. +- Keep `SubagentMetaRow` and the UUID/model/duration chip timing (`!isActive`). Preserve terminal nested payload precedence in `isSubagentEffectivelyActive`. + +### Mobile design contract + +- Desktop and mobile share the same header. No overlay, drawer, or navigation change. +- Nearest exemplar: `ExecuteStatusIcon` plus the existing subagent header (`min-h-11` expandable hit target below `sm`). +- Status glyph stays `shrink-0` so type, description, and Working truncate first. +- Parity proof: `mobile-subagent.spec.ts` asserts the settled header at phone width. + +## Tests + +- **What:** active card with children still shows Working + Loading spinner; complete card shows one Completed check; failed/cancelled show the labelled red mark. + **File:** `apps/web/components/task/chat/messages/tool-subagent-message.test.tsx`. + **How:** extend `ToolSubagentMessage` / expansion cases. Active + `childMessages` must show `task:working` and `role=status` name `Loading`. Complete + children must not. Failed/cancelled fixtures assert `getByLabelText` for Failed / Cancelled and no spinner. + +## E2E Tests + +- **Scenario:** GIVEN `/e2e:subagent` completes, WHEN the card is visible, THEN there is no Loading status, no Working copy, one Completed check, and the metadata row is visible. + **Files:** `apps/web/e2e/tests/chat/subagent.spec.ts` and `apps/web/e2e/tests/chat/mobile-subagent.spec.ts`. +- Do not add a mock-agent scenario. The existing `scenarioSubagent` window is ~150ms; active-with-children and failed/cancelled are unit-tested. + +## Verification Results + +- `pnpm --filter @kandev/web test -- components/task/chat/messages/tool-subagent-message.test.tsx` — 52 passed. +- `pnpm exec eslint` + `pnpm run typecheck` on the two unit files — pass. +- `pnpm e2e:run --host --project chromium -- tests/chat/subagent.spec.ts` — 1 passed. +- `pnpm e2e:run --host --project mobile-chrome -- tests/chat/mobile-subagent.spec.ts` — 1 passed. + +## Implementation Waves And Parallel Candidates + +Wave 1: + +- [x] [task-01-header-status-ui](task-01-header-status-ui.md) + +Wave 2: + +- [x] [task-02-subagent-status-e2e](task-02-subagent-status-e2e.md) + +Sequential. E2E asserts the settled header produced by task 01. + +## Risks + +- Fresh worktrees need `cd apps && pnpm install --frozen-lockfile` before any pnpm command. +- E2E runs the production Vite build. Rebuild after the UI change (`pnpm e2e:run` does this; a stale `apps/web/dist` will miss the header). +- Do not invent `task:subagentFailed` keys. Reuse `task:working`, `task:failed`, `task:cancelled`, and `common:loadingIndicatorLabel`. + +## Open Questions + +None. diff --git a/docs/plans/subagent-active-header-status/task-01-header-status-ui.md b/docs/plans/subagent-active-header-status/task-01-header-status-ui.md new file mode 100644 index 0000000000..90bb828342 --- /dev/null +++ b/docs/plans/subagent-active-header-status/task-01-header-status-ui.md @@ -0,0 +1,69 @@ +--- +id: "01-header-status-ui" +title: "Keep Working + spinner on every active header" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/subagent-observability.md" +--- + +# Task 01: Keep Working + spinner on every active header + +## Intent + +Make a live subagent card glanceable without expanding it: Working copy and spinner stay on the header even when the card has children, and a failed or cancelled finish gets a red mark. Successful completion gets a muted check. + +## Acceptance + +- `SubagentHeader` renders `t("task:working")` and `GridSpinner` whenever `isActive` is true, including when `hasExpandableContent` is true. The `showInlineWorking` gate is gone. +- When `isActive` is false and `normalizeToolCallStatus(metadata.status)` is `error` or `cancelled`, the header shows a red `IconX` (`h-3.5 w-3.5 text-red-500`) with `aria-label` `task:failed` or `task:cancelled`. A successful complete header shows a muted `IconCheck` with `aria-label` `task:statusCompleted`. Pending-settled headers show neither spinner, Working, nor X. +- Terminal nested payload statuses take precedence over running metadata in `isSubagentEffectivelyActive`. `SubagentMetaRow` and `!isActive` chip timing stay unchanged. No new locale keys. + +## Regression tests (write first — must fail before the production change) + +In `apps/web/components/task/chat/messages/tool-subagent-message.test.tsx`: + +- Active + children: `metadataStatus: "running"` with two `childTool` messages. Assert `getByText("Working...")` and `getByRole("status", { name: "Loading" })`. Current code hides Working when children exist, so this fails first. +- Complete + children: `metadataStatus: "complete"` with children. Assert one `getByLabelText("Completed")`, no Working, and no Loading status. +- Failed: `metadataStatus: "failed"` (or `"error"`). Assert `getByLabelText("Failed")` and no Loading status. +- Cancelled: `metadataStatus: "cancelled"`. Assert `getByLabelText("Cancelled")` and no Loading status. +- Terminal nested payloads: `payloadStatus: "errored"` overrides running metadata and `payloadStatus: "failed"` or `"cancelled"` overrides complete metadata. + +Reuse `subagentMessage` / `renderSubagent` / `childTool`. Do not invent a Done chip assertion. + +## Files likely touched + +- `apps/web/components/task/chat/messages/tool-subagent-message.tsx` +- `apps/web/components/task/chat/messages/tool-subagent-message.test.tsx` + +## Dependencies + +None. + +## Parallelism + +Sequential. Component and unit tests share the same files. + +## Inputs + +- Spec: `### Header status` and the three new header-status scenarios. +- Plan: Frontend and Tests. +- Pattern: `ExecuteStatusIcon` in `tool-execute-message.tsx` (spinner / red X / no check for this card). Import `normalizeToolCallStatus` from `./tool-status` or `@/lib/utils/tool-call-status`. + +## Verification + +```sh +cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/task/chat/messages/tool-subagent-message.test.tsx && cd web && NODE_OPTIONS="--max-old-space-size=6144" pnpm run typecheck && pnpm exec eslint components/task/chat/messages/tool-subagent-message.tsx components/task/chat/messages/tool-subagent-message.test.tsx && pnpm exec prettier --check components/task/chat/messages/tool-subagent-message.tsx components/task/chat/messages/tool-subagent-message.test.tsx +``` + +## Output contract + +Summary, files changed, tests run, blockers, risks, and this task plus `plan.md` status/results updated in the same conversation. + +## Results + +Done. Removed `showInlineWorking`. Active headers always show `task:working` + `GridSpinner`. Terminal nested failures and cancellations now override stale metadata. Failed/cancelled headers show a labelled red `IconX`. Complete headers show a labelled muted check. + +- Unit tests: 52 passed (`tool-subagent-message.test.tsx`). The regression run failed 4 cases before the status precedence fix. +- Typecheck / eslint / prettier: pass. diff --git a/docs/plans/subagent-active-header-status/task-02-subagent-status-e2e.md b/docs/plans/subagent-active-header-status/task-02-subagent-status-e2e.md new file mode 100644 index 0000000000..ba7b9975da --- /dev/null +++ b/docs/plans/subagent-active-header-status/task-02-subagent-status-e2e.md @@ -0,0 +1,60 @@ +--- +id: "02-subagent-status-e2e" +title: "Assert settled subagent header in desktop and mobile E2E" +status: done +wave: 2 +depends_on: ["01-header-status-ui"] +plan: "plan.md" +spec: "../../specs/ui/subagent-observability.md" +--- + +# Task 02: Assert settled subagent header in desktop and mobile E2E + +## Intent + +Lock the successful-completion header in the existing `/e2e:subagent` flow on desktop and phone. Do not add a mock-agent scenario: the live window is ~150ms, and active/failed/cancelled are covered by task 01 unit tests. + +## Acceptance + +- After chat idle on `/e2e:subagent`, the single `subagent-card` has no `role=status` named Loading, no `Working...` text, and one `Completed` label. Existing type, description, and metadata-chip assertions stay. +- The same settled-header assertions run on `mobile-chrome` via `mobile-subagent.spec.ts`. +- No new mock-agent scenario, no new i18n keys, no new Playwright file. + +## Files likely touched + +- `apps/web/e2e/tests/chat/subagent.spec.ts` +- `apps/web/e2e/tests/chat/mobile-subagent.spec.ts` + +## Dependencies + +Task 01. The settled header (no Working, no spinner) is the post-change contract; these specs already wait for chat idle and the metadata row. + +## Parallelism + +Sequential after task 01. Both files assert the same settled card. + +## Inputs + +- Spec: successful-completion header-status scenario. +- Plan: E2E Tests. +- Existing `/e2e:subagent` fixture and `SessionPage` helpers in both specs. +- After idle, `scenarioSubagent` has already called `completeSubagentTool`; do not race the brief start window. + +## Verification + +```sh +cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --host --project chromium -- tests/chat/subagent.spec.ts && pnpm e2e:run --host --project mobile-chrome -- tests/chat/mobile-subagent.spec.ts +``` + +`pnpm e2e:run` rebuilds the production Vite bundle. Do not run against a stale `apps/web/dist`. + +## Output contract + +Summary, files changed, tests run, blockers, risks, and this task plus `plan.md` status/results updated in the same conversation. + +## Results + +Done. After metadata is visible, both specs assert the card has no Loading status, no `Working...`, and one `Completed` label. + +- Desktop: `pnpm e2e:run --host --project chromium -- tests/chat/subagent.spec.ts` — 1 passed (19.7s). +- Mobile: `pnpm e2e:run --host --project mobile-chrome -- tests/chat/mobile-subagent.spec.ts` — 1 passed (14.4s). diff --git a/docs/specs/ui/requirements/subagent-observability.md b/docs/specs/ui/requirements/subagent-observability.md index 6a69a6a0f0..eb4d7850a2 100644 --- a/docs/specs/ui/requirements/subagent-observability.md +++ b/docs/specs/ui/requirements/subagent-observability.md @@ -104,6 +104,23 @@ over. equals the rendered child-message count, since the header already states it. A divergence between the two keeps both. +### Header status + +- While a card is effectively active (`isSubagentEffectivelyActive`), its + header always shows `task:working` and the existing `GridSpinner`, even when + the card has expandable children. The previous + `isActive && !hasExpandableContent` gate on the Working label is gone: a live + wave with nested tool calls must not look like a settled type + description + row. +- After a failed or cancelled finish, the header shows a red `IconX` with + `aria-label` `task:failed` or `task:cancelled`. Successful completion shows a + muted check (`aria-label` `task:statusCompleted`); the spinner and Working + copy disappear, and the existing identity and metrics chips appear. No Done + chip is added. +- UUID, model, duration, and token chips stay identity and metrics. They still + render only when the card is not active. No Running / Failed / Done chip is + added to the metadata row. + ### Turn-group label - A collapsed turn group's label states the counts it actually describes. The @@ -156,6 +173,15 @@ over. - **GIVEN** a task whose `active_subagent_count` is two, **WHEN** its kanban card renders, **THEN** a chip shows the count; **WHEN** the count drops to zero, **THEN** the chip disappears without a reload. +- **GIVEN** an active subagent with nested child tool calls, **WHEN** its card + renders collapsed or expanded, **THEN** the header shows `Working...` and a + spinner, and the UUID/model chips are absent. +- **GIVEN** a successfully completed subagent, **WHEN** its card renders, + **THEN** the header has no spinner, no Working copy, and a muted check + labelled Completed, and the existing identity and metrics chips are visible. +- **GIVEN** a failed or cancelled subagent, **WHEN** its card renders, **THEN** + the header shows a red error mark labelled Failed or Cancelled, with no + spinner and no success check. ## Out of scope @@ -169,6 +195,10 @@ over. path. OpenCode, Cursor, and Amp continue to report only what they report. - A dedicated subagent list, drill-down panel, or per-subagent filtering on the board. The board gets a count chip only. +- A Running / Failed / Done chip on the subagent metadata row. Status stays a + header glyph (Working copy while live, then a muted check or a red X). +- Promoting identity chips (UUID, model) into the active header. They remain + settled-card metadata. - Any change to `active_subagent_count` derivation, liveness accounting, or the busy-signal contract, all owned by [background-work-liveness](../../platform/requirements/background-work-liveness.md).