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}}1> to <3>{{to}}3>",
+ "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}}1> ţō <3>{{to}}3>",
+ "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}}1> para <3>{{to}}3>",
+ "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}}1> 变更为 <3>{{to}}3>",
+ "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}}1> 變更為 <3>{{to}}3>",
+ "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}}1> 變更為 <3>{{to}}3>",
+ "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).