fix(web): show live Task header until nested child settles (#3075)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Luan Martins
2026-08-27 19:55:34 +00:00
committed by GitHub
parent bdfe965ef5
commit 0a15785c98
16 changed files with 656 additions and 92 deletions
@@ -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.
@@ -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 (
<span className="inline-flex items-center gap-1.5 shrink-0" title={workingLabel}>
<span className="text-xs text-muted-foreground italic">{t("task:working")}</span>
<GridSpinner className="text-muted-foreground shrink-0" />
</span>
);
}
const normalized = resolveSubagentTerminalStatus(status, payloadStatus);
if (normalized === "error" || normalized === "cancelled") {
const label = normalized === "cancelled" ? t("task:statusCancelled") : t("task:failed");
return (
<span className="shrink-0" title={label} aria-label={label}>
<IconX aria-hidden className="h-3.5 w-3.5 text-red-500" />
</span>
);
}
const completedLabel = t("task:statusCompleted");
return (
<span className="shrink-0" title={completedLabel} aria-label={completedLabel}>
<IconCheck aria-hidden className="h-3.5 w-3.5 text-muted-foreground" />
</span>
);
}
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}
</span>
)}
{showInlineWorking && (
<span className="text-xs text-muted-foreground italic">{t("task:working")}</span>
)}
{isActive && <GridSpinner className="text-muted-foreground shrink-0" />}
<SubagentStatusIcon isActive={isActive} status={status} payloadStatus={payloadStatus} />
{childCount > 0 && (
<span
data-testid="subagent-child-count"
@@ -311,6 +399,8 @@ export const ToolSubagentMessage = memo(function ToolSubagentMessage({
subagentType={subagentType}
description={description}
isActive={isActive}
status={metadata?.status}
payloadStatus={subagentTask?.status}
childCount={childCount}
hasExpandableContent={hasExpandableContent}
onToggle={handleToggle}
@@ -46,6 +46,10 @@ test.describe("Mobile subagent card", () => {
// 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");
+4
View File
@@ -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");
+17
View File
@@ -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();
+2 -1
View File
@@ -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<AppState>, sessionId: string):
for (const message of messages) {
if (message.type === "permission_request") continue;
const metadata = message.metadata as Record<string, unknown> | 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" },
+2
View File
@@ -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",
+2
View File
@@ -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": "śţēƥ",
+2
View File
@@ -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",
+2
View File
@@ -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": "步骤",
+2
View File
@@ -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": "步驟",
+2
View File
@@ -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": "步驟",
@@ -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.
@@ -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.
@@ -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).
@@ -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).