From 03fda2829d35d471822f2326c14af96c38b20216 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 03:21:18 +0000 Subject: [PATCH 1/7] feat(web): show a turn's subagents as one collapsible card --- .../chat/MessagesTimeline.logic.test.ts | 100 +++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 95 +++++++++++++---- .../components/chat/MessagesTimeline.test.tsx | 9 +- .../src/components/chat/MessagesTimeline.tsx | 100 +++++++++++++----- .../src/components/chat/V2LifecycleRow.tsx | 93 +++++++++++++--- .../pullRequest/pullRequestPresentation.tsx | 4 +- 6 files changed, 333 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e00b01b16e17..dd4f956003ed 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,101 @@ describe("linked timeline resources", () => { expect(rows[1]).toMatchObject({ subagents: [{ item: { id: "a" } }, { item: { id: "b" } }] }); }); + it.each([ + { status: "completed", envelope: "direct" }, + { status: "completed", envelope: "structured" }, + { status: "completed", envelope: "text" }, + { status: "running", envelope: "direct" }, + ] as const)( + "groups subagents across their $status delegation calls with $envelope output", + ({ status, envelope }) => { + const child = (id: string) => { + const entry = event(id, "subagent"); + return { + ...entry, + projectedItem: { + item: { + ...entry.projectedItem.item, + origin: "app_owned", + subagentId: id, + prompt: 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 }, + ...(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"), + child("b"), + delegation("delegate-b", "b"), + delegation("unmatched", "other-child"), + child("c"), + delegation("failed", "c", true), + child("d"), + ], + expandedRunIds: new Set([runId]), + }); + expect(rows.find((row) => row.id === "a")).toMatchObject({ + subagents: [{ item: { id: "a" } }, { item: { id: "b" } }], + }); + expect(rows.some((row) => row.id === "b")).toBe(false); + 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).not.toContain("delegate-a"); + expect(visibleTools).not.toContain("delegate-b"); + }, + ); + 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..740fbae18a97 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,57 @@ function attachTrailingToolGroupsToAssistant( return result; } +// Delegation already has a durable child card. Match its returned child identity +// (or its task while running) before removing the duplicate tool row. +function withoutSubagentDelegationRows(entries: ReadonlyArray) { + const tasksByRun = new Map>(); + 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 tasks = tasksByRun.get(item.runId) ?? new Set(); + tasks.add(item.prompt); + tasksByRun.set(item.runId, tasks); + const children = childrenByRun.get(item.runId) ?? new Set(); + children.add(item.subagentId); + childrenByRun.set(item.runId, children); + } + const parse = (value: unknown): unknown => { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return null; + } + }; + 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); + } + const input = parse(item.input); + return !( + item.status === "running" && + typeof input === "object" && + input !== null && + "task" in input && + typeof input.task === "string" && + tasksByRun.get(item.runId)?.has(input.task) + ); + }); +} + export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestRun?: TimelineLatestRun | null; @@ -1006,6 +1059,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 +1068,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 +1119,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 +1129,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 +1218,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 +1315,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 +1457,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 +1572,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 +1589,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 +1605,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..552bdbf4ba44 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -253,10 +253,17 @@ 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, + V2LifecycleRow, + type HandoffTimelineRun, +} from "./V2LifecycleRow"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { SkillInlineText } from "./SkillInlineText"; +import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { buildReviewCommentRenderablePatch, @@ -2879,38 +2886,79 @@ function V2EventTimelineRow({ row }: { row: Extract }) { +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 members = (row.subagents ?? [row.projectedItem]).filter( + ({ item }) => item.type === "subagent", + ); const summary = subagentGroupSummary(members.map(({ item }) => item)); - const toggleExpanded = () => { + const status = deriveAgentSpawnSummary({ + agents: members.map(({ item }) => ({ kind: "subagent", status: item.status })), + agentCount: members.length, + }); + 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 ( - -
- + + + {members + .slice(0, 3) + .map(({ item }) => + item.type === "subagent" ? ( + provider.instanceId === item.providerInstanceId, + )} + /> + ) : null, + )} + + + + {members.length} {members.length === 1 ? "subagent" : "subagents"} + + + {status.status} + + + + + {expanded ? ( - +
{members.map((projected) => ( onOpenThread={ctx.onOpenThread} /> ))} - +
) : 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..897c89601954 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 { PullRequestActorAvatar } from "../pullRequest/pullRequestPresentation"; +import { cn } from "../../lib/utils"; +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, @@ -225,6 +228,34 @@ export function V2LifecycleRow(props: { return null; } +export function SubagentAvatar({ + driver, + provider, +}: { + driver?: ProviderDriverKind | undefined; + provider?: ServerProvider | undefined; +}) { + return ( + + ) : ( + + ) + } + /> + ); +} + function SubagentTimelineLink(props: { readonly parentRef: ScopedThreadRef; readonly subagentId: NodeId; @@ -242,32 +273,62 @@ 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 = status.replaceAll("_", " "); + const result = (agent?.result ?? props.result)?.trim(); + const progress = (agent?.progress ?? props.progress)?.trim(); + const settled = ["completed", "failed", "cancelled", "interrupted"].includes(status); + const detail = settled ? result || progress : progress || result; + const content = ( + <> + + + {props.title} + {detail ? ( + + {detail} + + ) : null} + + + {statusLabel} + + {threadId !== null ? ( + + ) : null} + ); - const label = {props.title}; + const className = + "flex w-full min-w-0 items-center gap-3 rounded-lg border border-border/60 p-3 text-left"; return ( +
+ {content} +
) : ( - props.onOpenThread(threadId)} - icon={icon} - label={label} - /> + className={cn( + className, + "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + )} + > + {content} + ) } /> diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index d46cad36622c..a08076e21b2b 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -421,9 +421,11 @@ export function PullRequestReviewOutcomeBadge({ export function PullRequestActorAvatar({ actor, className, + fallback, }: { actor: PullRequestActor | null; className?: string; + fallback?: ReactNode; }) { const login = actor?.login ?? "ghost"; const avatarUrl = actor?.avatarUrl ?? null; @@ -437,7 +439,7 @@ export function PullRequestActorAvatar({ className, )} > - {login.slice(0, 1).toUpperCase()} + {fallback ?? login.slice(0, 1).toUpperCase()} ) : ( Date: Mon, 21 Sep 2026 03:30:45 +0000 Subject: [PATCH 2/7] fix(web): keep subagent status live and match delegation identities --- .../chat/MessagesTimeline.logic.test.ts | 38 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 26 ++----------- .../src/components/chat/MessagesTimeline.tsx | 21 ++++++++-- .../src/components/chat/V2LifecycleRow.tsx | 6 +-- 4 files changed, 47 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dd4f956003ed..2d41eb02c8f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3747,13 +3747,15 @@ describe("linked timeline resources", () => { }); it.each([ - { status: "completed", envelope: "direct" }, - { status: "completed", envelope: "structured" }, - { status: "completed", envelope: "text" }, - { status: "running", envelope: "direct" }, + { 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)( - "groups subagents across their $status delegation calls with $envelope output", - ({ status, envelope }) => { + "matches $status $role delegation calls by child identity with $envelope output", + ({ status, envelope, role }) => { const child = (id: string) => { const entry = event(id, "subagent"); return { @@ -3763,7 +3765,8 @@ describe("linked timeline resources", () => { ...entry.projectedItem.item, origin: "app_owned", subagentId: id, - prompt: id, + prompt: + role === "general" ? id : `Act as the ${role} sub-agent for this task.\n\n${id}`, childThreadId: null, }, } as OrchestrationV2ProjectedTurnItem, @@ -3792,7 +3795,7 @@ describe("linked timeline resources", () => { type: "dynamic_tool", status: failed ? "failed" : status, toolName: "t3-code.delegate_task", - input: { task: taskId }, + input: { task: taskId === "b" ? "a" : taskId, role }, ...(status === "completed" ? { output: @@ -3814,7 +3817,7 @@ describe("linked timeline resources", () => { timelineEntries: [ child("a"), delegation("delegate-a", "a"), - child("b"), + ...(status === "completed" ? [child("b")] : []), delegation("delegate-b", "b"), delegation("unmatched", "other-child"), child("c"), @@ -3823,10 +3826,15 @@ describe("linked timeline resources", () => { ], expandedRunIds: new Set([runId]), }); - expect(rows.find((row) => row.id === "a")).toMatchObject({ - subagents: [{ item: { id: "a" } }, { item: { id: "b" } }], - }); - expect(rows.some((row) => row.id === "b")).toBe(false); + 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) => @@ -3836,8 +3844,8 @@ describe("linked timeline resources", () => { ); expect(visibleTools).toContain("unmatched"); expect(visibleTools).toContain("failed"); - expect(visibleTools).not.toContain("delegate-a"); - expect(visibleTools).not.toContain("delegate-b"); + expect(visibleTools.includes("delegate-a")).toBe(status === "running"); + expect(visibleTools.includes("delegate-b")).toBe(status === "running"); }, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 740fbae18a97..32e71e585d18 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -992,30 +992,18 @@ function attachTrailingToolGroupsToAssistant( return result; } -// Delegation already has a durable child card. Match its returned child identity -// (or its task while running) before removing the duplicate tool row. +// 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 tasksByRun = new Map>(); 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 tasks = tasksByRun.get(item.runId) ?? new Set(); - tasks.add(item.prompt); - tasksByRun.set(item.runId, tasks); const children = childrenByRun.get(item.runId) ?? new Set(); children.add(item.subagentId); childrenByRun.set(item.runId, children); } - const parse = (value: unknown): unknown => { - if (typeof value !== "string") return value; - try { - return JSON.parse(value); - } catch { - return null; - } - }; return entries.filter((entry) => { if (entry.kind !== "work" || workEntryDisplayIndicatesToolFailure(entry.entry)) return true; const item = entry.entry.projectedItem?.item ?? entry.entry.structuredPayload; @@ -1031,15 +1019,7 @@ function withoutSubagentDelegationRows(entries: ReadonlyArray) { if (output?.taskId !== undefined) { return !childrenByRun.get(item.runId)?.has(output.taskId); } - const input = parse(item.input); - return !( - item.status === "running" && - typeof input === "object" && - input !== null && - "task" in input && - typeof input.task === "string" && - tasksByRun.get(item.runId)?.has(input.task) - ); + return true; }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 552bdbf4ba44..5ab0be4656d5 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"; @@ -2899,9 +2901,22 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ const members = (row.subagents ?? [row.projectedItem]).filter( ({ item }) => item.type === "subagent", ); - const summary = subagentGroupSummary(members.map(({ item }) => item)); + const liveAgents = useAtomValue( + environmentThreadDetails.threadAtom( + scopeThreadRef(ctx.activeThreadEnvironmentId, row.projectedItem.item.threadId), + ), + (thread) => thread?.projection.subagents, + ); + const agents = members.map(({ item }) => ({ + kind: "subagent" as const, + status: + (item.type === "subagent" + ? liveAgents?.find((agent) => agent.id === item.subagentId)?.status + : undefined) ?? item.status, + })); + const summary = subagentGroupSummary(agents); const status = deriveAgentSpawnSummary({ - agents: members.map(({ item }) => ({ kind: "subagent", status: item.status })), + agents, agentCount: members.length, }); const toggleExpanded = (open: boolean) => { diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 897c89601954..c2f7c4787df3 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -316,8 +316,8 @@ function SubagentTimelineLink(props: { {content} ) : ( - + ) } /> From 9c292e8f5487c0f31d6789412bef74f38ee26b9b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:14:12 +0000 Subject: [PATCH 3/7] feat(web): polish the subagent card round provider avatars with the agents panel's status dots, an overlapped stack in the collapsed header with status counts and elapsed time, one clean row per subagent inside a single bordered card, and the agents panel's status words instead of the raw child-task sentence. Co-Authored-By: Claude Fable 5.1 --- .../src/components/chat/MessagesTimeline.tsx | 189 +++++++++++------- .../src/components/chat/V2LifecycleRow.tsx | 176 ++++++++++++---- .../pullRequest/pullRequestPresentation.tsx | 4 +- .../src/state/subagentDisplay.ts | 21 ++ 4 files changed, 273 insertions(+), 117 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5ab0be4656d5..b66c62779638 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -41,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) => {}; @@ -265,7 +268,8 @@ import { import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { SkillInlineText } from "./SkillInlineText"; -import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; +import { AgentElapsed } from "../AgentsPanel"; +import * as DateTime from "effect/DateTime"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { buildReviewCommentRenderablePatch, @@ -2888,6 +2892,36 @@ function V2EventTimelineRow({ row }: { row: Extract, +) { + let startMs: number | null = null; + let endMs: number | null = null; + 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); + } + } + 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 || endMs === null ? null : new Date(endMs).toISOString(), + }; +} + const V2SubagentGroup = memo(function V2SubagentGroup({ row, }: { @@ -2898,8 +2932,8 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ const [expanded, setExpanded] = useState(() => ctx.workGroupViewState.expandedEntries.has(groupId), ); - const members = (row.subagents ?? [row.projectedItem]).filter( - ({ item }) => item.type === "subagent", + const members = (row.subagents ?? [row.projectedItem]).flatMap(({ item }) => + item.type === "subagent" ? [item] : [], ); const liveAgents = useAtomValue( environmentThreadDetails.threadAtom( @@ -2907,18 +2941,17 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ ), (thread) => thread?.projection.subagents, ); - const agents = members.map(({ item }) => ({ - kind: "subagent" as const, - status: - (item.type === "subagent" - ? liveAgents?.find((agent) => agent.id === item.subagentId)?.status - : undefined) ?? item.status, - })); - const summary = subagentGroupSummary(agents); - const status = deriveAgentSpawnSummary({ - agents, - agentCount: members.length, + 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 toggleExpanded = (open: boolean) => { ctx.onToggleWorkEntry(row.id, expanded); if (open) ctx.workGroupViewState.expandedEntries.add(groupId); @@ -2926,70 +2959,76 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ setExpanded(open); }; return ( - - - - {members - .slice(0, 3) - .map(({ item }) => - item.type === "subagent" ? ( - provider.instanceId === item.providerInstanceId, - )} - /> - ) : null, - )} - - - - {members.length} {members.length === 1 ? "subagent" : "subagents"} - - - {status.status} - - - + + - - - {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} + + {summarizeSubagentStatuses(agents.map(({ status }) => status))} + + + + + + +
+ + {expanded ? ( +
+ {members.map((item) => ( + + ))} +
+ ) : null} +
+
+ ); }); diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index c2f7c4787df3..a708a1755724 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -7,7 +7,8 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { environmentThreadDetails } from "../../state/threads"; -import { PullRequestActorAvatar } from "../pullRequest/pullRequestPresentation"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import * as DateTime from "effect/DateTime"; import { cn } from "../../lib/utils"; import { WorkLogRow } from "./WorkLog"; import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; @@ -220,6 +221,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} /> @@ -228,31 +231,96 @@ 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(/(^|\s)[-*]\s+/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +function isoOrNull(value: DateTime.Utc | null | undefined): string | null { + return value ? DateTime.formatIso(value) : null; +} + +/** 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} + ); } @@ -265,6 +333,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; }) { @@ -274,38 +344,66 @@ function SubagentTimelineLink(props: { ); const threadId = props.threadId; const status = agent?.status ?? props.status; - const statusLabel = status.replaceAll("_", " "); + const statusLabel = subagentStatusVisual(status).label; const result = (agent?.result ?? props.result)?.trim(); const progress = (agent?.progress ?? props.progress)?.trim(); - const settled = ["completed", "failed", "cancelled", "interrupted"].includes(status); - const detail = settled ? result || progress : progress || result; + 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 ? ( - - {detail} + + + {props.title} - ) : null} + {detail !== null && status !== "completed" ? ( + + {statusLabel} + + ) : null} + + + {detail === null ? ( + statusLabel + ) : detail.includes("/") && !detail.includes(" ") ? ( + + ) : ( + {detail} + )} + - - {statusLabel} + + {threadId !== null ? ( - + ) : null} ); const className = - "flex w-full min-w-0 items-center gap-3 rounded-lg border border-border/60 p-3 text-left"; + "group/subagent flex w-full min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5 text-left"; return ( props.onOpenThread(threadId)} className={cn( className, - "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", )} > {content} @@ -337,7 +435,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/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index a08076e21b2b..d46cad36622c 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -421,11 +421,9 @@ export function PullRequestReviewOutcomeBadge({ export function PullRequestActorAvatar({ actor, className, - fallback, }: { actor: PullRequestActor | null; className?: string; - fallback?: ReactNode; }) { const login = actor?.login ?? "ghost"; const avatarUrl = actor?.avatarUrl ?? null; @@ -439,7 +437,7 @@ export function PullRequestActorAvatar({ className, )} > - {fallback ?? login.slice(0, 1).toUpperCase()} + {login.slice(0, 1).toUpperCase()} ) : ( , +): 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, ""); From 35f26a3b0825a6bac417a04eae338f42f7a39d49 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:17:48 +0000 Subject: [PATCH 4/7] fix(web): render the subagent row as a plain button like work log rows --- apps/web/src/components/chat/V2LifecycleRow.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index a708a1755724..d243bc0d15ee 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -414,19 +414,19 @@ function SubagentTimelineLink(props: { {content} ) : ( - + ) } /> From 7ec6d1e3e5482f6abceb9d4ae0a165541d0fbeff Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 04:24:30 +0000 Subject: [PATCH 5/7] fix(web): describe subagent card status to assistive tech and hide unknown durations --- apps/web/src/components/chat/MessagesTimeline.tsx | 8 +++++--- apps/web/src/components/chat/V2LifecycleRow.tsx | 12 +++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b66c62779638..b35c7ec0e39e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -262,13 +262,13 @@ import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collaps import { isV2LifecycleItem, SubagentAvatar, + SubagentElapsed, V2LifecycleRow, type HandoffTimelineRun, } from "./V2LifecycleRow"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { SkillInlineText } from "./SkillInlineText"; -import { AgentElapsed } from "../AgentsPanel"; import * as DateTime from "effect/DateTime"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { @@ -2952,6 +2952,7 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ }); 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 (open) ctx.workGroupViewState.expandedEntries.add(groupId); @@ -2963,6 +2964,7 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ - {summarizeSubagentStatuses(agents.map(({ status }) => status))} + {statusSummary} - + [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, @@ -392,7 +402,7 @@ function SubagentTimelineLink(props: { - + {threadId !== null ? ( Date: Mon, 21 Sep 2026 04:29:28 +0000 Subject: [PATCH 6/7] fix(web): keep inline asterisks in subagent results and withhold unknown group durations --- apps/web/src/components/chat/MessagesTimeline.tsx | 11 +++++++++-- apps/web/src/components/chat/V2LifecycleRow.tsx | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b35c7ec0e39e..e1e130c095c0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2892,7 +2892,11 @@ function V2EventTimelineRow({ row }: { row: Extract Date: Mon, 21 Sep 2026 13:25:09 -0700 Subject: [PATCH 7/7] fix(web): rebase subagent card onto v2 hover cards AgentsPanel was removed on v2, so the status visuals and elapsed timer the card reused now live in V2LifecycleRow and AgentElapsed; the hover card from #12842 is kept inside the new SubagentAvatar rows. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/chat/AgentElapsed.tsx | 6 +++++- apps/web/src/components/chat/V2LifecycleRow.tsx | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) 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/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 46001901afc6..42ba3bed3021 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -9,7 +9,6 @@ import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environ import { environmentThreadDetails } from "../../state/threads"; import { MiddleTruncate } from "../ui/middle-truncate"; import * as DateTime from "effect/DateTime"; -import { cn } from "../../lib/utils"; import { WorkLogRow } from "./WorkLog"; import { resolveHandoffEndpoints, type HandoffTimelineRun } from "@t3tools/client-runtime/handoff"; import { Fragment } from "react";