fix(web): show live Task header until nested child settles (#3075)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "śţēƥ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "步骤",
|
||||
|
||||
@@ -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": "步驟",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user