feat(ui): round-9 polish — reserved interstitial slot + Worked summary attached to the reply timestamp

The interstitial row above the gerund status line is now permanently
reserved while a turn is live: text slides in/out of the always-mounted
1lh slot, so the layout above never jumps as messages appear/disappear.

On settle, a turn directly following its own agent's reply bubble
attaches to it (attachSettledTurns, after coalesceSettledTurns): the
"✓ Worked · …" summary renders appended to the bubble's timestamp line
— "2:34 PM · ✓ Worked · 38s · 2 tools" — still expandable to the tool
history. Bubble timestamps are always visible (hover-only reveal
removed). Turns without a reply bubble keep the standalone folded row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:57:06 -07:00
1 parent 4951e2da0e
commit eef40c0347
11 files changed
+265 -54

No files matched your search

+19 -14
View File
@@ -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<typeof IssueChatThread>;
* 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(
+18 -3
View File
@@ -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 (
<div className={cn("tc-enter-bubble group flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{!isHuman && item.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
@@ -119,8 +127,15 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.optimistic === "queued" ? "Queued" : "Sending…"}
</span>
) : attachedTurn ? (
// The settled turn takes over the footer line: timestamp + "✓ Worked"
// summary, always visible; expanding stretches beneath the bubble.
<div className="self-stretch" data-testid="task-chat-bubble-attached-turn">
{attachedTurn}
</div>
) : item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
// Timestamps are always visible (round 9) — no longer hover-revealed.
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
@@ -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();
@@ -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<HTMLSpanElement | null>(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<string | null>(selfTalk ?? null);
@@ -203,12 +205,13 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
) : null}
</div>
);
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 (
<div className="flex flex-col">
<div
@@ -216,8 +219,14 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
data-testid="task-chat-interstitial-row"
>
<span aria-hidden className="h-3.5 w-3.5 shrink-0" />
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<LiveSelfTalkLine text={heldSelfTalk.text} leaving={heldSelfTalk.leaving} />
{heldSelfTalk ? (
<>
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<LiveSelfTalkLine text={heldSelfTalk.text} leaving={heldSelfTalk.leaving} />
</>
) : (
<span aria-hidden className="tc-line-scroll min-w-0 flex-1" />
)}
</div>
{statusLine}
</div>
@@ -36,7 +36,20 @@ function renderItem(
) {
switch (item.kind) {
case "message":
return <TaskChatBubble item={item} />;
return (
<TaskChatBubble
item={item}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.timestamp}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined
}
/>
);
case "marker":
return <TaskChatMarker item={item} />;
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") {
@@ -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(
<TaskChatTurn item={SETTLED} timestampPrefix="2:34 PM" renderChild={(c) => <span>{c.id}</span>} />,
);
});
// "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");
});
});
+13 -1
View File
@@ -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 ? (
<>
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
@@ -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":
+13 -4
View File
@@ -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;
@@ -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<TaskChatItem, { kind: "turn" }>).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<string, SettledTurnMergeMeta>([
["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<TaskChatItem, { kind: "message" }>;
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<TaskChatItem, { kind: "message" }>).attachedTurn?.id).toBe("run-a:turn");
});
});
@@ -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<string, SettledTurnMergeMeta>,
): 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")