diff --git a/apps/web/src/components/chat/AgentElapsed.tsx b/apps/web/src/components/chat/AgentElapsed.tsx index 312b0a96fd6d..ca3a8af3993b 100644 --- a/apps/web/src/components/chat/AgentElapsed.tsx +++ b/apps/web/src/components/chat/AgentElapsed.tsx @@ -27,7 +27,11 @@ function elapsedBetween(startedAt: string, endIso: string | null): string { * Elapsed time for the current activation. Live agents self-tick via DOM * writes (zero React commits per tick); settled agents freeze at completedAt. */ -export function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { +export function AgentElapsed({ + agent, +}: { + agent: Pick; +}) { const textRef = useRef(null); const live = agent.status === "running" || agent.status === "waiting"; const startedAt = agent.startedAt; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e00b01b16e17..2d41eb02c8f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -12,6 +12,7 @@ import { deriveTimelineEntriesFromVisibleTurnItems, deriveTimelineEntriesFromVisibleTurnItemsWithState, workEntryDisplayIndicatesToolFailure, + type TimelineEntry, } from "../../session-logic"; import { makeStreamingTimelineFixture } from "../../test-fixtures"; import type { TurnDiffSummary } from "../../types"; @@ -3679,7 +3680,7 @@ describe("linked timeline resources", () => { }); const common = { isWorking: false, turnDiffSummaries: [], supportsConversationRollback: false }; - it("previews a thought and joins adjacent worklogs without removing message boundaries", () => { + it("previews a thought and separates subagent cards from worklogs", () => { const rows = deriveMessagesTimelineRows({ ...common, timelineEntries: [ @@ -3717,8 +3718,8 @@ describe("linked timeline resources", () => { }); expect(rows.find((row) => row.kind === "work")).toMatchObject({ displayLabel: "First paragraph. Second paragraph.", - continuesWorkLog: true, }); + expect(rows.find((row) => row.kind === "work")?.continuesWorkLog).toBeUndefined(); expect(rows.find((row) => row.id === "child")?.continuesWorkLog).toBeUndefined(); }); @@ -3745,6 +3746,109 @@ describe("linked timeline resources", () => { expect(rows[1]).toMatchObject({ subagents: [{ item: { id: "a" } }, { item: { id: "b" } }] }); }); + it.each([ + { status: "completed", envelope: "direct", role: "general" }, + { status: "completed", envelope: "structured", role: "general" }, + { status: "completed", envelope: "text", role: "general" }, + { status: "completed", envelope: "structured", role: "research" }, + { status: "running", envelope: "direct", role: "general" }, + { status: "running", envelope: "direct", role: "research" }, + ] as const)( + "matches $status $role delegation calls by child identity with $envelope output", + ({ status, envelope, role }) => { + const child = (id: string) => { + const entry = event(id, "subagent"); + return { + ...entry, + projectedItem: { + item: { + ...entry.projectedItem.item, + origin: "app_owned", + subagentId: id, + prompt: + role === "general" ? id : `Act as the ${role} sub-agent for this task.\n\n${id}`, + childThreadId: null, + }, + } as OrchestrationV2ProjectedTurnItem, + }; + }; + const delegation = (id: string, taskId: string, failed = false): TimelineEntry => ({ + id, + kind: "work", + createdAt: "2026-09-08T10:00:02Z", + entry: { + id, + runId, + createdAt: "2026-09-08T10:00:02Z", + label: "Delegated a child task", + tone: failed ? "error" : "tool", + itemType: "dynamic_tool", + toolLifecycleStatus: failed + ? "failed" + : status === "running" + ? "inProgress" + : "completed", + projectedItem: { + item: { + id, + runId, + type: "dynamic_tool", + status: failed ? "failed" : status, + toolName: "t3-code.delegate_task", + input: { task: taskId === "b" ? "a" : taskId, role }, + ...(status === "completed" + ? { + output: + envelope === "structured" + ? { content: JSON.stringify({ taskId }), structuredContent: { taskId } } + : envelope === "text" + ? { content: [{ type: "text", text: JSON.stringify({ taskId }) }] } + : { taskId }, + } + : {}), + }, + } as OrchestrationV2ProjectedTurnItem, + }, + }); + const rows = deriveMessagesTimelineRows({ + ...common, + isWorking: status === "running", + runningRunId: status === "running" ? runId : null, + timelineEntries: [ + child("a"), + delegation("delegate-a", "a"), + ...(status === "completed" ? [child("b")] : []), + delegation("delegate-b", "b"), + delegation("unmatched", "other-child"), + child("c"), + delegation("failed", "c", true), + child("d"), + ], + expandedRunIds: new Set([runId]), + }); + if (status === "completed") { + expect(rows.find((row) => row.id === "a")).toMatchObject({ + subagents: [{ item: { id: "a" } }, { item: { id: "b" } }], + }); + expect(rows.some((row) => row.id === "b")).toBe(false); + } else { + expect(rows.find((row) => row.id === "a")).toBeDefined(); + expect(rows.find((row) => row.id === "b")).toBeUndefined(); + } + expect(rows.find((row) => row.id === "c")).toBeDefined(); + expect(rows.find((row) => row.id === "d")).toBeDefined(); + const visibleTools = rows.flatMap((row) => + row.kind === "work" || row.kind === "work-live" + ? row.groupedEntries.map((entry) => entry.id) + : [], + ); + expect(visibleTools).toContain("unmatched"); + expect(visibleTools).toContain("failed"); + expect(visibleTools.includes("delegate-a")).toBe(status === "running"); + expect(visibleTools.includes("delegate-b")).toBe(status === "running"); + }, + ); + it("keeps created-chat summaries after the final answer and folds only their timeline rows", () => { const timelineEntries = [ { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 80f1465693c7..32e71e585d18 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -43,9 +43,11 @@ import { } from "@t3tools/contracts"; import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; import { + resolveT3McpToolDefinition, resolveT3McpToolPresentation, type T3McpToolPresentation, } from "@t3tools/shared/t3McpToolPresentation"; +import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; function timelineEntryRunId(entry: TimelineEntry): RunId | null { @@ -990,6 +992,37 @@ function attachTrailingToolGroupsToAssistant( return result; } +// Delegation already has a durable child card. Remove its tool row only after +// the returned task ID identifies that child; pending calls can share a prompt. +function withoutSubagentDelegationRows(entries: ReadonlyArray) { + const childrenByRun = new Map>(); + for (const entry of entries) { + if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") continue; + const item = entry.projectedItem.item; + if (item.origin !== "app_owned" || item.runId === null) continue; + const children = childrenByRun.get(item.runId) ?? new Set(); + children.add(item.subagentId); + childrenByRun.set(item.runId, children); + } + return entries.filter((entry) => { + if (entry.kind !== "work" || workEntryDisplayIndicatesToolFailure(entry.entry)) return true; + const item = entry.entry.projectedItem?.item ?? entry.entry.structuredPayload; + if ( + item?.type !== "dynamic_tool" || + item.runId === null || + (item.status !== "running" && item.status !== "completed") || + resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "delegate" + ) + return true; + const output = compactDynamicToolOutput(item.output); + if (output?.isError) return true; + if (output?.taskId !== undefined) { + return !childrenByRun.get(item.runId)?.has(output.taskId); + } + return true; + }); +} + export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestRun?: TimelineLatestRun | null; @@ -1006,6 +1039,7 @@ export function deriveMessagesTimelineRows(input: { /** Live bootstrap progress. Renders a stage card under the first user message. */ worktreeSetup?: WorktreeSetupSnapshot | null; }): MessagesTimelineRow[] { + const timelineEntries = withoutSubagentDelegationRows(input.timelineEntries); const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { if (summary.assistantMessageId) { @@ -1014,28 +1048,28 @@ export function deriveMessagesTimelineRows(input: { } const revertTurnCountByUserMessageId = input.supportsConversationRollback ? deriveRevertTurnCountByUserMessageId({ - timelineEntries: input.timelineEntries, + timelineEntries: timelineEntries, checkpoints: input.turnDiffSummaries, }) : new Map(); const nextRows: MessagesTimelineRow[] = []; const durationStartByMessageId = computeMessageDurationStart( - input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); - const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); + const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); - const failedRunIds = failedTimelineRunIds(input.timelineEntries, input.latestRun ?? null); + const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( - input.timelineEntries, + timelineEntries, failedRunIds, ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ - timelineEntries: input.timelineEntries, + timelineEntries: timelineEntries, unsettledRunId, isWorking: input.isWorking, }); const foldsByAnchorEntryId = deriveTurnFolds({ - timelineEntries: input.timelineEntries, + timelineEntries: timelineEntries, terminalAssistantMessageIds, latestRun: input.latestRun ?? null, unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), @@ -1065,8 +1099,8 @@ export function deriveMessagesTimelineRows(input: { // A steer continues the current turn. Keep its elapsed-time header below // the initiating prompt (or automatic wake), rather than moving it down. const activeTurnHeaderIndex = input.isWorking - ? lastResponseBoundaryIndex(input.timelineEntries) + 1 - : input.timelineEntries.length; + ? lastResponseBoundaryIndex(timelineEntries) + 1 + : timelineEntries.length; // Contiguous trailing work entries of the active run collapse into one live // row that survives between actions: while a tool runs it shows that tool, @@ -1075,8 +1109,8 @@ export function deriveMessagesTimelineRows(input: { const activeToolEntries: Array> = []; if (input.isWorking && unsettledRunId !== null) { let tailAttemptId: string | null | undefined; - for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { - const entry = input.timelineEntries[index]!; + for (let index = timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { + const entry = timelineEntries[index]!; if ( entry.kind !== "work" || entry.entry.tone === "error" || @@ -1164,8 +1198,8 @@ export function deriveMessagesTimelineRows(input: { ); }; - for (let index = 0; index < input.timelineEntries.length; index += 1) { - const timelineEntry = input.timelineEntries[index]; + for (let index = 0; index < timelineEntries.length; index += 1) { + const timelineEntry = timelineEntries[index]; if (!timelineEntry) { continue; } @@ -1261,8 +1295,8 @@ export function deriveMessagesTimelineRows(input: { } const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; - while (cursor < input.timelineEntries.length) { - const nextEntry = input.timelineEntries[cursor]; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; if ( !nextEntry || nextEntry.kind !== "work" || @@ -1403,7 +1437,9 @@ export function deriveMessagesTimelineRows(input: { timelineEntry.projectedItem.item.type === "subagent" && previous?.kind === "event" && previous.projectedItem.item.type === "subagent" && - previous.projectedItem.item.runId === timelineEntry.projectedItem.item.runId + previous.projectedItem.item.runId === timelineEntry.projectedItem.item.runId && + previous.projectedItem.item.providerTurnId === + timelineEntry.projectedItem.item.providerTurnId ) { nextRows[nextRows.length - 1] = { ...previous, @@ -1516,7 +1552,7 @@ export function deriveMessagesTimelineRows(input: { // A running setup owns the working slot above its card and shows no // activity row of its own; every other state gets the usual tail. const hasWorkingRow = nextRows.some((row) => row.kind === "working"); - if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === input.timelineEntries.length) { + if (input.isWorking && !hasWorkingRow && activeTurnHeaderIndex === timelineEntries.length) { appendWorkingRow(); } if ( @@ -1533,7 +1569,7 @@ export function deriveMessagesTimelineRows(input: { } const result = attachTrailingToolGroupsToAssistant( - attachCreatedThreadSummaries(nextRows, input.timelineEntries), + attachCreatedThreadSummaries(nextRows, timelineEntries), ); return result.map((row, index) => timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1]) @@ -1549,8 +1585,7 @@ function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean { (row.kind === "work" || row.kind === "work-toggle" || row.kind === "work-live" || - row.kind === "thinking" || - (row.kind === "event" && row.projectedItem.item.type === "subagent")) + row.kind === "thinking") ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e4f4bdb74229..e3d0c16a71f7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1850,6 +1850,8 @@ describe("MessagesTimeline", () => { async ({ status, progress, result, preview }) => { activityTestState.expandedRuns = true; activityTestState.subagentTooltips = true; + vi.stubGlobal("HTMLElement", ElementStub); + window.HTMLElement = ElementStub as typeof HTMLElement; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0); vi.stubGlobal("cancelAnimationFrame", () => {}); @@ -1902,7 +1904,7 @@ describe("MessagesTimeline", () => { />, ); }); - const groupLabel = status === "running" ? "Kicked off 1 subagent" : "Ran 1 subagent"; + const groupLabel = "1 subagent"; const group = () => renderer!.root.findAll( (node) => node.type === "button" && node.props["aria-label"] === groupLabel, @@ -1912,7 +1914,7 @@ describe("MessagesTimeline", () => { (node) => node.type === "button" && node.props["aria-label"] === "Open Package audit", ); expect(child()).toHaveLength(0); - await act(() => group().props.onClick()); + await act(() => group().props.onClick({ nativeEvent: new Event("click") })); expect(child()).toHaveLength(1); const content = renderer!.root .findAll((node) => typeof node.type === "string") @@ -1924,10 +1926,11 @@ describe("MessagesTimeline", () => { if (progress && progress !== preview) expect(content).not.toContain(progress); await act(() => child()[0]!.props.onClick()); expect(onOpenThread).toHaveBeenCalledWith("thread-subagent-1"); - await act(() => group().props.onClick()); + await act(() => group().props.onClick({ nativeEvent: new Event("click") })); expect(child()).toHaveLength(0); } finally { await act(() => renderer?.unmount()); + vi.stubGlobal("HTMLElement", undefined); } }, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a56364e123b9..e1e130c095c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -27,7 +27,9 @@ import { type ThreadId, type ToolActivityIcon, } from "@t3tools/contracts"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useAtomValue } from "@effect/atom-react"; +import { environmentThreadDetails } from "../../state/threads"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { Link } from "@tanstack/react-router"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; @@ -39,7 +41,10 @@ import { } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { subagentGroupSummary } from "@t3tools/client-runtime/state/subagent-display"; +import { + subagentGroupSummary, + summarizeSubagentStatuses, +} from "@t3tools/client-runtime/state/subagent-display"; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; @@ -253,10 +258,18 @@ import { } from "../../timestampFormat"; import { V2ItemInspector } from "./V2ItemInspector"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; -import { isV2LifecycleItem, V2LifecycleRow, type HandoffTimelineRun } from "./V2LifecycleRow"; +import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible"; +import { + isV2LifecycleItem, + SubagentAvatar, + SubagentElapsed, + V2LifecycleRow, + type HandoffTimelineRun, +} from "./V2LifecycleRow"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { SkillInlineText } from "./SkillInlineText"; +import * as DateTime from "effect/DateTime"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { buildReviewCommentRenderablePatch, @@ -2879,56 +2892,154 @@ function V2EventTimelineRow({ row }: { row: Extract }) { +/** + * One elapsed span for the whole group: first launch to last settle, ticking + * while any member works. A settled member without a completion time leaves + * the end unknown, so the span is withheld rather than cut short. + */ +function subagentGroupTiming( + agents: ReadonlyArray<{ + status: OrchestrationV2TurnItem["status"]; + startedAt: DateTime.Utc | null; + completedAt: DateTime.Utc | null; + }>, +) { + let startMs: number | null = null; + let endMs: number | null = null; + let endUnknown = false; + for (const agent of agents) { + if (agent.startedAt) { + const ms = DateTime.toEpochMillis(agent.startedAt); + startMs = startMs === null ? ms : Math.min(startMs, ms); + } + if (agent.completedAt) { + const ms = DateTime.toEpochMillis(agent.completedAt); + endMs = endMs === null ? ms : Math.max(endMs, ms); + } else { + endUnknown = true; + } + } + const live = agents.some( + ({ status }) => status === "pending" || status === "running" || status === "waiting", + ); + return { + status: live ? ("running" as const) : ("completed" as const), + startedAt: startMs === null ? null : new Date(startMs).toISOString(), + completedAt: live || endUnknown || endMs === null ? null : new Date(endMs).toISOString(), + }; +} + +const V2SubagentGroup = memo(function V2SubagentGroup({ + row, +}: { + row: Extract; +}) { const ctx = use(TimelineRowCtx); const groupId = `subagent-group:${row.id}`; const [expanded, setExpanded] = useState(() => ctx.workGroupViewState.expandedEntries.has(groupId), ); - const members = row.subagents ?? [row.projectedItem]; - const summary = subagentGroupSummary(members.map(({ item }) => item)); - const toggleExpanded = () => { + const members = (row.subagents ?? [row.projectedItem]).flatMap(({ item }) => + item.type === "subagent" ? [item] : [], + ); + const liveAgents = useAtomValue( + environmentThreadDetails.threadAtom( + scopeThreadRef(ctx.activeThreadEnvironmentId, row.projectedItem.item.threadId), + ), + (thread) => thread?.projection.subagents, + ); + const agents = members.map((item) => { + const live = liveAgents?.find((agent) => agent.id === item.subagentId); + return { + item, + status: live?.status ?? item.status, + startedAt: live?.startedAt ?? item.startedAt, + completedAt: live?.completedAt ?? item.completedAt, + }; + }); + const summary = subagentGroupSummary(agents); + const label = `${members.length} ${members.length === 1 ? "subagent" : "subagents"}`; + const statusSummary = summarizeSubagentStatuses(agents.map(({ status }) => status)); + const toggleExpanded = (open: boolean) => { ctx.onToggleWorkEntry(row.id, expanded); - if (expanded) ctx.workGroupViewState.expandedEntries.delete(groupId); - else ctx.workGroupViewState.expandedEntries.add(groupId); - setExpanded(!expanded); + if (open) ctx.workGroupViewState.expandedEntries.add(groupId); + else ctx.workGroupViewState.expandedEntries.delete(groupId); + setExpanded(open); }; return ( - -
- - {expanded ? ( - - {members.map((projected) => ( - + + + + {agents.slice(0, 3).map(({ item, status }) => ( + provider.instanceId === item.providerInstanceId, + )} + status={agents.length === 1 ? status : undefined} /> ))} - - ) : null} -
+ {agents.length > 3 ? ( + + +{agents.length - 3} + + ) : null} + + + {label} + + {statusSummary} + + + + + + + + + {expanded ? ( +
+ {members.map((item) => ( + + ))} +
+ ) : null} +
+
); -} +}); // --------------------------------------------------------------------------- // Extracted row sections — own their state / store subscriptions so changes diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index afc076075195..42ba3bed3021 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -7,8 +7,9 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { environmentThreadDetails } from "../../state/threads"; -import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; -import { WorkLogButton, WorkLogRow } from "./WorkLog"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import * as DateTime from "effect/DateTime"; +import { WorkLogRow } from "./WorkLog"; import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; import { Fragment } from "react"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; @@ -24,6 +25,8 @@ import { } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { + BotIcon, + ChevronRightIcon, ArrowRightLeftIcon, ArrowRightIcon, GitForkIcon, @@ -217,6 +220,8 @@ export function V2LifecycleRow(props: { title={formatSubagentDisplayTitle(item.title ?? "Subagent")} result={item.result} progress={item.progress} + startedAt={item.startedAt} + completedAt={item.completedAt} threadId={item.childThreadId} onOpenThread={props.onOpenThread} /> @@ -225,6 +230,109 @@ export function V2LifecycleRow(props: { return null; } +/** + * In-flight states all present as Working, the way the agents fleet view did: + * detail belongs in the activity line, and a waiting or queued subagent is + * still the fleet doing its job. Only settled states differentiate. Idle reads + * as settled rather than in progress, since a resting child looks done unless + * resumed. + */ +const STATUS_VISUALS: Record< + OrchestrationV2TurnItem["status"], + { dotClass: string; label: string } +> = { + pending: { dotClass: "bg-info", label: "Working" }, + running: { dotClass: "bg-info", label: "Working" }, + waiting: { dotClass: "bg-info", label: "Working" }, + idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" }, + completed: { dotClass: "bg-success", label: "Completed" }, + failed: { dotClass: "bg-destructive", label: "Failed" }, + cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, + interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, +}; + +function subagentStatusVisual(status: OrchestrationV2TurnItem["status"]) { + return STATUS_VISUALS[status]; +} + +const SETTLED_SUBAGENT_STATUSES = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", +]); + +/** The server's placeholder when a child ends without output; the status dot already says it. */ +const GENERIC_CHILD_END = /^Child task ended with status\b/i; + +/** One line of a markdown result: drop list bullets, code ticks, and link targets. */ +function plainDetail(text: string): string { + return text + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/`/g, "") + .replace(/^[ \t]*[-*][ \t]+/gm, "") + .replace(/\s+/g, " ") + .trim(); +} + +function isoOrNull(value: DateTime.Utc | null | undefined): string | null { + return value ? DateTime.formatIso(value) : null; +} + +/** + * The agents panel timer counts to now when a settled agent has no completion + * time, which would show a card's age as work duration. Show nothing instead. + */ +export function SubagentElapsed({ agent }: { agent: Parameters[0]["agent"] }) { + const live = agent.status === "running" || agent.status === "waiting"; + if (!live && agent.completedAt === null) return null; + return ; +} + +/** Round provider tile with the agents panel's status dot; rings let a header stack overlap. */ +export function SubagentAvatar({ + driver, + provider, + status, + className, +}: { + driver?: ProviderDriverKind | undefined; + provider?: ServerProvider | undefined; + /** Omitted inside an overlapped stack, where a covered dot would only add noise. */ + status?: OrchestrationV2TurnItem["status"] | undefined; + className?: string; +}) { + return ( + + {driver ? ( + + ) : ( + + )} + {status ? ( + + ) : null} + + ); +} + function SubagentTimelineLink(props: { readonly parentRef: ScopedThreadRef; readonly subagentId: NodeId; @@ -234,6 +342,8 @@ function SubagentTimelineLink(props: { readonly result: string | null; readonly progress: string | undefined; readonly status: OrchestrationV2TurnItem["status"]; + readonly startedAt: DateTime.Utc | null; + readonly completedAt: DateTime.Utc | null; readonly threadId: ThreadId | null; readonly onOpenThread: (threadId: ThreadId) => void; }) { @@ -242,32 +352,90 @@ function SubagentTimelineLink(props: { (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null, ); const threadId = props.threadId; - const statusLabel = props.status.replaceAll("_", " "); - const icon = ( - + const status = agent?.status ?? props.status; + const statusLabel = subagentStatusVisual(status).label; + const result = (agent?.result ?? props.result)?.trim(); + const progress = (agent?.progress ?? props.progress)?.trim(); + const settled = SETTLED_SUBAGENT_STATUSES.has(status); + const rawDetail = settled ? result || progress : progress || result; + const detail = + rawDetail && !GENERIC_CHILD_END.test(rawDetail) ? plainDetail(rawDetail) || null : null; + const failed = status === "failed"; + const timing = { + status, + startedAt: isoOrNull(agent?.startedAt ?? props.startedAt), + completedAt: isoOrNull(agent?.completedAt ?? props.completedAt), + }; + const content = ( + <> + + + + + {props.title} + + {detail !== null && status !== "completed" ? ( + + {statusLabel} + + ) : null} + + + {detail === null ? ( + statusLabel + ) : detail.includes("/") && !detail.includes(" ") ? ( + + ) : ( + {detail} + )} + + + + + + {threadId !== null ? ( + + ) : null} + ); - const label = {props.title}; + const className = + "group/subagent flex w-full min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5 text-left"; return ( +
+ {content} +
) : ( - props.onOpenThread(threadId)} - icon={icon} - label={label} - /> + className={cn( + className, + "cursor-pointer transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", + )} + > + {content} + ) } /> @@ -276,7 +444,7 @@ function SubagentTimelineLink(props: { {...props} elapsed={agent ? : null} model={agent?.model ?? null} - status={agent?.status ?? props.status} + status={status} result={agent?.result ?? props.result} progress={agent?.progress ?? props.progress} /> diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 1fe95098f5fe..4d1d2c5cd15d 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -14,6 +14,27 @@ export function subagentGroupSummary( }; } +/** + * Counts a group's states in the order a reader scans them: what is still + * running first, then outcomes, using the agents panel's words. + */ +export function summarizeSubagentStatuses( + statuses: ReadonlyArray, +): string { + const counts = { working: 0, done: 0, failed: 0, stopped: 0, idle: 0 }; + for (const status of statuses) { + if (status === "pending" || status === "running" || status === "waiting") counts.working += 1; + else if (status === "completed") counts.done += 1; + else if (status === "failed") counts.failed += 1; + else if (status === "idle") counts.idle += 1; + else counts.stopped += 1; + } + return (Object.keys(counts) as Array) + .filter((key) => counts[key] > 0) + .map((key) => `${counts[key]} ${key}`) + .join(" · "); +} + /** Formats Codex task paths for display while leaving provider identity untouched. */ export function formatSubagentDisplayTitle(title: string): string { const displayTitle = title.replace(/^Subagent:\s*/i, "");