diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index f5892597065..dc675668bee 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -3,6 +3,7 @@ import { IssueChatThread } from "@/components/IssueChatThread"; import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts"; import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; import { + attachSettledTurns, buildTurnSummary, coalesceSettledTurns, deriveRunStatusLabel, @@ -55,16 +56,16 @@ export type TaskChatThreadProps = ComponentProps; * tool state + elapsed + tokens) is the turn's single visible line, with the * chronological tool activity nested behind an expand (PAP-354). Mid-run * agent text (interstitial updates between tool calls) is ephemeral - * (PAP-361): while one streams it takes the parent row's line inside a - * one-line line-scroll viewport, and when it finishes the line returns to the - * gerund/tool rotation — nothing persists; the run log and the classic - * transcript remain the archive. When the run terminates the - * same turn id flips settled, so the header morphs in place into the one-line - * "✓ Worked · …" summary (expand state preserved; runs already terminal at - * mount collapse instantly). Settled turns interleave after the run's last - * comment (comment.runId linkage) — the agent's reply bubble above, the folded - * activity summary below, in the slot the live parent row occupied. flag-OFF - * remains byte-for-byte IssueChatThread. + * (PAP-361, round 9): while one streams it occupies a dedicated one-line row + * PERMANENTLY RESERVED above the status line (so the layout above never + * jumps), and when it finishes the text slides out and the slot sits empty — + * nothing persists; the run log and the classic transcript remain the + * archive. When the run terminates, its settled turn anchors after the run's + * last comment (comment.runId linkage) and — when it directly follows that + * reply bubble — attaches to it: the "✓ Worked · …" summary renders appended + * to the bubble's always-visible timestamp line (round 9), still expandable + * to the tool history. Turns without a reply bubble keep the standalone + * folded row. flag-OFF remains byte-for-byte IssueChatThread. */ export function TaskChatThread(props: TaskChatThreadProps) { const { @@ -325,9 +326,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { }); // Parent-row model (PAP-354): the run's status line IS the live turn — // one expandable row owning the activity. Only tool/usage rows nest - // inside it; a streaming interstitial update takes the header's own line - // (liveStatus.selfTalk, PAP-361) and vanishes when it completes. The - // parent row sits last — the slot its settled summary takes over in place. + // inside it; a streaming interstitial update renders in the reserved row + // above the status line (liveStatus.selfTalk, PAP-361/round 9) and + // vanishes when it completes. The parent row sits last; on settle its + // summary attaches to the reply bubble's timestamp line. const children = parsed.filter(isNestableLiveChild); const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; const queued = liveRun.status === "queued"; @@ -355,7 +357,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { // PAP-362: two runs replying back-to-back (same agent, nothing but the // agent's own bubbles between) fold into ONE "Worked" row below the last // reply; a user message, interaction, or the live turn keeps them apart. - return coalesceSettledTurns(out, turnMergeMetaById); + // Round 9: a settled turn directly following its own agent's reply bubble + // then attaches to that bubble — the "Worked · …" summary renders on the + // bubble's always-visible timestamp line instead of as a standalone row. + return attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById); }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]); const renderInteraction = useCallback( diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 387731cce81..e164abf8cc0 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -16,6 +17,13 @@ import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { item: TaskChatMessageItem; + /** + * The settled run turn rendered on this bubble's footer line (round 9): + * replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools" + * (the timestamp leads the summary), expandable to the nested tool history. + * Supplied by TaskChatThreadView when `item.attachedTurn` is set. + */ + attachedTurn?: ReactNode; } function initialsForName(name: string) { @@ -32,7 +40,7 @@ function initialsForName(name: string) { * bubble with an avatar author header (the agent's assigned icon + name · mode * chip); system notices are centered and recede. */ -export function TaskChatBubble({ item }: TaskChatBubbleProps) { +export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) { if (item.interstitial) { // Interstitial updates are ephemeral (PAP-361): while streaming the text // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and @@ -54,7 +62,7 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) { // attachment chips under the bubble; link-only lines leave the body text. const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text); return ( -
+
{!isHuman && item.authorName ? ( @@ -119,8 +127,15 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) { {item.optimistic === "queued" ? "Queued" : "Sending…"} + ) : attachedTurn ? ( + // The settled turn takes over the footer line: timestamp + "✓ Worked" + // summary, always visible; expanding stretches beneath the bubble. +
+ {attachedTurn} +
) : item.timestamp ? ( - + // Timestamps are always visible (round 9) — no longer hover-revealed. + {item.timestamp} ) : null} diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index ae332f089bb..3eefff58523 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -90,6 +90,19 @@ describe("TaskChatStatusPill whimsy", () => { expect(container.textContent).toContain("18,240/200,000 ctx"); }); + it("permanently reserves the interstitial row while live so layout never jumps (round 9)", () => { + const item = liveStatus({ tokens: { used: 18240, size: 200000 } }); + render(item); + // No message streaming: the row is still mounted as an empty one-line + // slot (same height as when text occupies it) — nothing above shifts when + // a message later appears. + const row = container.querySelector('[data-testid="task-chat-interstitial-row"]'); + expect(row).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull(); + // The empty slot keeps the 1lh viewport height. + expect(row?.querySelector(".tc-line-scroll")).not.toBeNull(); + }); + it("mounts a streaming interstitial as its own row above the status line (PAP-361 amended)", () => { const item = liveStatus({ label: "Responding", @@ -118,20 +131,21 @@ describe("TaskChatStatusPill whimsy", () => { expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner"); }); - it("slides the row out and unmounts it once the interstitial completes", () => { + it("slides the text out but keeps the reserved row once the interstitial completes", () => { const item = liveStatus({ tokens: { used: 18240, size: 200000 } }); render(liveStatus({ label: "Responding", selfTalk: "Almost there." })); - expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).not.toBeNull(); render(item); - // selfTalk gone → the row is briefly held for its slide-out… + // selfTalk gone → the text is briefly held for its slide-out… const held = container.querySelector('[data-testid="task-chat-interstitial-row"]'); expect(held?.textContent).toContain("Almost there."); - // …then unmounts after the motion-token hold (0ms in jsdom — no computed - // token value); gerund + dot carry on. + // …then the TEXT unmounts after the motion-token hold (0ms in jsdom — no + // computed token value) while the reserved row stays in layout (round 9: + // no jump); gerund + dot carry on below. act(() => { vi.advanceTimersByTime(50); }); - expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).toBeNull(); + expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).not.toBeNull(); expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull(); expect(container.textContent).toContain(`${whimsyWord(item.id, 0)}…`); expect(container.querySelector(".animate-pulse")).not.toBeNull(); diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index f0fb3e7a54a..cc85395dde5 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -49,8 +49,9 @@ export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): nu * below the 1lh clip; when it wraps, the inner block translates up by whole * line-heights so the completed line slides out the top while the stream * continues on the fresh line; and when the message ends (`leaving`) it shifts - * one further line so the last line slides out before the row unmounts. Line - * count is measured from scrollHeight / line-height, re-checked on resize. + * one further line so the last line slides out before the text unmounts (the + * reserved row itself never leaves layout while the turn is live). Line count + * is measured from scrollHeight / line-height, re-checked on resize. */ function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean }) { const innerRef = useRef(null); @@ -88,9 +89,10 @@ function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean } } /** - * Holds the streaming interstitial text briefly after it clears so the row can - * slide out before unmounting. The hold duration is read from the same motion - * token the slide transition uses (0 under reduced motion → instant unmount). + * Holds the streaming interstitial text briefly after it clears so it can + * slide out of the reserved row before unmounting. The hold duration is read + * from the same motion token the slide transition uses (0 under reduced + * motion → instant unmount). */ function useHeldSelfTalk(selfTalk: string | undefined): { text: string; leaving: boolean } | null { const [held, setHeld] = useState(selfTalk ?? null); @@ -203,12 +205,13 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta ) : null}
); - if (!heldSelfTalk) return statusLine; - // A streaming interstitial gets its own bubble-less single-line row - // DIRECTLY ABOVE the status line — the two never share a line. The row - // slides into / out of the 1lh viewport (LiveSelfTalkLine) when a message - // starts/ends and unmounts after the slide-out; the empty lead slot keeps - // its text column-aligned with the status label below. + // The interstitial row is PERMANENTLY RESERVED while the turn is live + // (round 9): the layout above never jumps to make room. A streaming + // interstitial gets this bubble-less single-line row DIRECTLY ABOVE the + // status line — the two never share a line. Text slides into / out of the + // 1lh viewport (LiveSelfTalkLine) when a message starts/ends; between + // messages the row stays as an empty one-line slot of identical height. + // The empty lead slot keeps the text column-aligned with the status label. return (
- {SelfTalkIcon ? : null} - + {heldSelfTalk ? ( + <> + {SelfTalkIcon ? : null} + + + ) : ( + + )}
{statusLine}
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index aa625adc329..2fa4bb81761 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -36,7 +36,20 @@ function renderItem( ) { switch (item.kind) { case "message": - return ; + return ( + renderItem(child, onApprovalDecision)} + /> + ) : undefined + } + /> + ); case "marker": return ; case "thinking": @@ -109,7 +122,7 @@ export function TaskChatThreadView({ // item count changing) still advances the auto-follow key. Shared by the // desktop scroller above and the mobile window-scroll follow (TaskChatThread). function signatureOf(it: TaskChatItem): number { - if (it.kind === "message") return it.text.length; + if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0); if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0); if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0); if (it.kind === "turn") { diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index cc7bd899e29..b5da2346494 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -203,4 +203,17 @@ describe("TaskChatTurn", () => { expect(summaryBtn()?.getAttribute("aria-expanded")).toBe("true"); expect(fold()?.getAttribute("data-folded")).toBe("false"); }); + + it("leads the settled summary with the bubble timestamp when attached (round 9)", () => { + flushSync(() => { + root.render( + {c.id}} />, + ); + }); + // "2:34 PM · ✓ Worked · …" — timestamp first, always visible, and the + // expand affordance still works from the same line. + expect(summaryBtn()?.textContent).toMatch(/^2:34 PM·Worked/); + flushSync(() => summaryBtn()!.click()); + expect(fold()?.getAttribute("data-folded")).toBe("false"); + }); }); diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 05be0179c10..038c18410a6 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -7,6 +7,12 @@ import { TaskChatStatusPill } from "./TaskChatStatusPill"; interface TaskChatTurnProps { item: TaskChatTurnItem; renderChild: (child: TaskChatTurnChildItem) => ReactNode; + /** + * When the settled turn is attached to its reply bubble (round 9), the + * bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" — + * always visible, in the slot the hover-only timestamp used to occupy. + */ + timestampPrefix?: string; } /** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */ @@ -44,7 +50,7 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string { * `liveStatus` (harness fixtures) renders its children expanded with no header * and folds when it settles. */ -export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) { +export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTurnProps) { const parentRow = !item.settled && item.liveStatus != null; // Parent-row live turns and settled turns start as their one-line header; // only the headerless legacy live turn starts expanded. @@ -77,6 +83,12 @@ export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) { className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground" data-testid="task-chat-turn-summary" > + {timestampPrefix ? ( + <> + {timestampPrefix} + · + + ) : null} {item.summary.failed ? "Stopped" : "Worked"} {turnSummaryMetrics(item.summary) ? ( diff --git a/ui/src/components/task-chat/task-chat-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index 5aebb690588..84cf1663a1b 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -168,19 +168,31 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { surface: "thread", items: [ ...exchangePrefix(), + // Round 9: the settled turn attaches to the final reply bubble — the + // "✓ Worked · …" summary renders on the bubble's always-visible + // timestamp line ("2:34 PM · ✓ Worked · 38s · 2 tools"). { - id: "turn-done", - kind: "turn", - settled: true, - // "Worked · N tools" expands to exactly the tool rows (PAP-361): - // toolCount matches the nested rows, no thinking row. - summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" }, - items: [ - { id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" }, - { id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } }, - ], + id: "m-done", + kind: "message", + author: "agent", + authorName: AGENT, + agentIcon: "bot", + modeLabel: "Agent mode", + text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", + timestamp: "2:34 PM", + attachedTurn: { + id: "turn-done", + kind: "turn", + settled: true, + // "Worked · N tools" expands to exactly the tool rows (PAP-361): + // toolCount matches the nested rows, no thinking row. + summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" }, + items: [ + { id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" }, + { id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } }, + ], + }, }, - { id: "m-done", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", modeLabel: "Agent mode", text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", timestamp: "2:34 PM" }, ], }; case "awaiting-approval": diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 179948e09cb..720a21fab86 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -86,6 +86,14 @@ export interface TaskChatMessageItem { interstitial?: boolean; /** Epoch ms of the message's first streamed chunk. */ atMs?: number; + /** + * The settled run turn "attached" to this bubble (round 9): when the run's + * final reply lands, the live parent row transforms into the "Worked · …" + * summary rendered on this bubble's always-visible timestamp line — + * "2:34 PM · ✓ Worked · 38s · 3 tools" — instead of a standalone row. + * Expanding still nests the tool history beneath the bubble. + */ + attachedTurn?: TaskChatTurnItem; } /** Collapsed chain-of-thought (ACP agent_thought_chunk). */ @@ -142,10 +150,11 @@ export interface TaskChatStatusItem { toolName?: string; /** * Flattened plain text of the interstitial update currently streaming - * (PAP-361, amended round 8): it renders as a dedicated single-line row - * directly above the status line, inside a one-line viewport that scrolls - * completed lines out the top; the gerund rotation below runs uninterrupted. - * Ephemeral — when the message finishes the row slides out and unmounts, + * (PAP-361, round 9): it renders in a dedicated single-line row directly + * above the status line — a slot PERMANENTLY RESERVED while the turn is + * live, so the layout above never jumps — inside a one-line viewport that + * scrolls completed lines out the top; the gerund rotation below runs + * uninterrupted. Ephemeral — when the message finishes the text slides out * and nothing persists in the thread. */ selfTalk?: string; diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 916a745d3ea..85935c21e5f 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { TranscriptEntry } from "@/adapters"; import { + attachSettledTurns, buildMergedTurnSummary, buildTurnSummary, coalesceSettledTurns, @@ -512,3 +513,73 @@ describe("coalesceSettledTurns (PAP-362)", () => { expect((merged[0] as Extract).animateFold).toBe(true); }); }); + +describe("attachSettledTurns (round 9)", () => { + const runA: TranscriptEntry[] = [ + { kind: "tool_call", ts: TS, name: "Read", toolUseId: "a-1" } as TranscriptEntry, + ]; + + function agentBubble(id: string, authorName = "CEO"): TaskChatItem { + return { id, kind: "message", author: "agent", authorName, text: "Done." }; + } + function settledTurn(id: string): TaskChatItem { + return { + id, + kind: "turn", + settled: true, + items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }], + summary: buildTurnSummary(runA, { durationMs: 30_000 }), + }; + } + const metaById = new Map([ + ["run-a:turn", { agentKey: "agent-1", agentName: "CEO", parts: [{ entries: runA, durationMs: 30_000 }] }], + ]); + + it("folds a settled turn into the agent reply bubble directly above it", () => { + const out = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-a:turn")], metaById); + expect(out).toHaveLength(1); + const bubble = out[0] as Extract; + expect(bubble.id).toBe("msg-a"); + expect(bubble.attachedTurn?.id).toBe("run-a:turn"); + expect(bubble.attachedTurn?.summary.durationLabel).toBe("30s"); + }); + + it("keeps the standalone row when the preceding item is not that agent's bubble", () => { + // Human message directly above. + const afterHuman = attachSettledTurns( + [{ id: "msg-u", kind: "message", author: "human", text: "hi" }, settledTurn("run-a:turn")], + metaById, + ); + expect(afterHuman.map((i) => i.kind)).toEqual(["message", "turn"]); + // Another agent's bubble above. + const otherAgent = attachSettledTurns([agentBubble("msg-q", "QA"), settledTurn("run-a:turn")], metaById); + expect(otherAgent.map((i) => i.kind)).toEqual(["message", "turn"]); + // No meta (unknown identity) → conservative standalone fallback. + const noMeta = attachSettledTurns([agentBubble("msg-a"), settledTurn("run-x:turn")], metaById); + expect(noMeta.map((i) => i.kind)).toEqual(["message", "turn"]); + // Nothing above at all. + const first = attachSettledTurns([settledTurn("run-a:turn")], metaById); + expect(first.map((i) => i.kind)).toEqual(["turn"]); + }); + + it("never attaches the live (unsettled) turn and attaches at most one turn per bubble", () => { + const live: TaskChatItem = { + id: "run-live:turn", + kind: "turn", + settled: false, + items: [], + summary: buildTurnSummary([]), + }; + const out = attachSettledTurns([agentBubble("msg-a"), live], metaById); + expect(out.map((i) => i.kind)).toEqual(["message", "turn"]); + + // A second settled turn behind an already-attached bubble stays standalone + // (coalesceSettledTurns runs first, so same-agent turns arrive pre-merged). + const twice = attachSettledTurns( + [agentBubble("msg-a"), settledTurn("run-a:turn"), settledTurn("run-a:turn")], + metaById, + ); + expect(twice.map((i) => i.kind)).toEqual(["message", "turn"]); + expect((twice[0] as Extract).attachedTurn?.id).toBe("run-a:turn"); + }); +}); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index cedd2788124..a9810842161 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -501,6 +501,44 @@ export function coalesceSettledTurns( return out; } +/** + * Final assembly pass (round 9, after coalesceSettledTurns): a settled turn + * that directly follows its own agent's reply bubble folds INTO that bubble as + * `attachedTurn` — the "Worked · …" summary renders appended to the bubble's + * always-visible timestamp line instead of as a standalone row. The turn must + * belong to the same agent as the bubble (metaById identity); turns without + * meta, or preceded by anything other than that agent's non-interstitial + * bubble, keep the standalone-row fallback. + */ +export function attachSettledTurns( + items: readonly TaskChatItem[], + metaById: ReadonlyMap, +): TaskChatItem[] { + const out: TaskChatItem[] = []; + for (const item of items) { + const prev = out[out.length - 1]; + if ( + item.kind === "turn" && + item.settled && + prev?.kind === "message" && + prev.author === "agent" && + !prev.interstitial && + !prev.streaming && + prev.attachedTurn == null + ) { + const meta = metaById.get(item.id); + const sameAgent = + meta != null && (meta.agentName == null || prev.authorName == null || meta.agentName === prev.authorName); + if (sameAgent) { + out[out.length - 1] = { ...prev, attachedTurn: item }; + continue; + } + } + out.push(item); + } + return out; +} + /** * Human-readable label for the live status pill from the tail of a transcript. * A tail tool_call yields the taxonomy verb ("Searching", "Running a command")