From 2714832c3d4134fd4feb224f072d63421fe63fac Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 2 Aug 2026 00:06:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20task-chat=20de-emphasized=20self-ta?= =?UTF-8?q?lk=20=E2=80=94=20bubble-less=20muted=20narration=20with=20CSS?= =?UTF-8?q?=20typewriter=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-run agent messages are tagged interstitial by the transcript adapter and render bubble-less/avatar-less in muted text; the run's posted reply keeps its bubble. Settled history keeps the self-talk interleaved at its streamed timestamp (deduped against posted comment bodies). New --motion-typewriter-* tokens drive a CSS-only steps() clip reveal per streamed block, zeroed under prefers-reduced-motion. Co-Authored-By: Claude Fable 5 --- ui/src/components/TaskChatThread.tsx | 66 ++++++++++++++++--- .../components/task-chat/TaskChatBubble.tsx | 20 ++++++ ui/src/components/task-chat/motion-tokens.ts | 2 + .../components/task-chat/task-chat-model.ts | 12 ++++ .../task-chat/transcript-adapter.test.ts | 41 ++++++++++++ .../task-chat/transcript-adapter.ts | 5 ++ ui/src/index.css | 13 ++++ 7 files changed, 149 insertions(+), 10 deletions(-) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index d4ae983e400..b6845d8f6a1 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -49,7 +49,8 @@ export type TaskChatThreadProps = ComponentProps; * ONE expandable parent row — its status line (whimsy gerund or in-flight * tool state + elapsed + tokens) is the turn's single visible line, with the * chronological tool/thinking activity nested behind an expand (PAP-354). - * Mid-run agent text stays outside as bubbles. When the run terminates the + * Mid-run agent text stays outside as muted, bubble-less interstitial + * narration (PAP-355) — live and settled alike. 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 @@ -239,9 +240,20 @@ export function TaskChatThread(props: TaskChatThreadProps) { const items = useMemo(() => { // Settled turns for every terminal run whose transcript we have. The - // transcript's assistant text is excluded — it already landed as the run's - // comment bubble; the turn holds the activity (thinking/tools/diffs). + // transcript's FINAL reply is excluded — it already landed as the run's + // comment bubble — but interstitial self-talk stays in the thread, muted + // (PAP-355): it interleaves into the backbone at its streamed timestamp so + // live and history agree, and it never nests inside the folded turn. + const normalizeBody = (s: string) => s.replace(/\s+/g, " ").trim(); + const commentBodiesByRun = new Map>(); + for (const comment of comments) { + if (comment.deletedAt || !comment.runId) continue; + const set = commentBodiesByRun.get(comment.runId) ?? new Set(); + set.add(normalizeBody(comment.body)); + commentBodiesByRun.set(comment.runId, set); + } const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = []; + const interstitialEntries: { ms: number; order: number; id: string; item: TaskChatItem }[] = []; for (const source of runs) { if (!isTerminalRunStatus(source.status)) continue; if (liveRun && source.id === liveRun.id) continue; @@ -254,11 +266,39 @@ export function TaskChatThread(props: TaskChatThreadProps) { Number.isFinite(started) && Number.isFinite(finished) ? Math.max(0, finished - started) : undefined; - const children = transcriptToTaskChatItems(entries, { + const parsed = transcriptToTaskChatItems(entries, { runId: source.id, agentName: meta?.agentName, running: false, - }).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message"); + }); + const messages = parsed.filter((it) => it.kind === "message"); + // The reply that landed as a posted comment must not render twice: drop + // messages matching a run comment's body, and — since the harness may + // post the reply lightly transformed — a succeeded run's trailing + // message when the run has comments at all. + const bodies = commentBodiesByRun.get(source.id); + let selfTalk = bodies + ? messages.filter((m) => !bodies.has(normalizeBody(m.text))) + : messages; + if ( + bodies && + source.status === "succeeded" && + selfTalk.length > 0 && + selfTalk[selfTalk.length - 1] === messages[messages.length - 1] + ) { + selfTalk = selfTalk.slice(0, -1); + } + for (const m of selfTalk) { + interstitialEntries.push({ + ms: m.atMs ?? (Number.isFinite(started) ? started : 0), + order: 1, + id: m.id, + item: m, + }); + } + const children = parsed.filter( + (it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message", + ); if (children.length === 0) continue; settledTurns.push({ turn: { @@ -290,8 +330,14 @@ export function TaskChatThread(props: TaskChatThreadProps) { } } + const merged = interstitialEntries.length + ? [...orderedEntries, ...interstitialEntries].sort( + (a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id), + ) + : orderedEntries; + const out: TaskChatItem[] = []; - for (const entry of orderedEntries) { + for (const entry of merged) { out.push(entry.item); const following = turnsByAnchor.get(entry.id); if (following) out.push(...following); @@ -307,9 +353,9 @@ 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/thinking/usage rows - // nest inside it; mid-run agent text stays in the thread as bubbles - // above, so the parent row sits last — the slot its settled summary - // takes over in place. + // nest inside it; mid-run agent text stays in the thread above as muted + // interstitial narration (PAP-355), so the parent row sits last — the + // slot its settled summary takes over in place. for (const it of parsed) { if (it.kind === "message") out.push(it); } @@ -337,7 +383,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }); } return out; - }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]); + }, [comments, orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]); const renderInteraction = useCallback( (item: TaskChatInteractionItem) => ( diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index bf27073f46f..82478bcf0a0 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -33,6 +33,26 @@ function initialsForName(name: string) { * chip); system notices are centered and recede. */ export function TaskChatBubble({ item }: TaskChatBubbleProps) { + if (item.interstitial) { + // Mid-run self-talk: no bubble, no avatar header — muted narration that + // stays in the thread but never competes with the final reply. While + // streaming, block-level chunks type themselves in (tc-typewriter-stream); + // history renders instantly. + return ( +
+ + {item.text} + +
+ ); + } + if (item.author === "system") { return (
diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index de723a482b0..21956842c5f 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -29,6 +29,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-ease-out", group: "Easing", kind: "easing" }, { name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" }, { name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" }, + { name: "--motion-typewriter-steps", group: "Easing", kind: "easing" }, // Duration primitives { name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 }, @@ -52,6 +53,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 }, { name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-typewriter-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, { name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 1428f5465bc..864deaba152 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -76,6 +76,18 @@ export interface TaskChatMessageItem { modeLabel?: string; /** Assigned agent icon name (AgentIconName) for the avatar header. */ agentIcon?: string | null; + /** + * Agent self-talk streamed inside a run turn (interstitial narration between + * tool calls). Renders bubble-less and muted so it never competes with the + * run's final reply — which arrives as a posted comment and is never + * interstitial. Set for live and settled transcripts alike. + */ + interstitial?: boolean; + /** + * Epoch ms of the message's first streamed chunk — the sort key used to + * interleave settled-transcript self-talk into the comment backbone. + */ + atMs?: number; } /** Collapsed chain-of-thought (ACP agent_thought_chunk). */ diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 985653ce415..9ea04ad9f9e 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -237,3 +237,44 @@ describe("isNestableLiveChild", () => { } }); }); + +describe("interstitial self-talk classification (PAP-355)", () => { + const stream: TranscriptEntry[] = [ + { kind: "assistant", ts: TS, text: "Let me check the adapter." } as TranscriptEntry, + toolCall("Read", { file_path: "a.ts" }), + { kind: "assistant", ts: "2026-07-31T12:00:05.000Z", text: "Found it — fixing now." } as TranscriptEntry, + ]; + + it("tags agent messages streamed inside a live run turn as interstitial", () => { + const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }).filter( + (it) => it.kind === "message", + ); + expect(messages).toHaveLength(2); + for (const m of messages) { + if (m.kind !== "message") continue; + expect(m.interstitial).toBe(true); + expect(m.streaming).toBe(true); + } + }); + + it("tags settled-history messages interstitial too, so live and history agree", () => { + const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter( + (it) => it.kind === "message", + ); + expect(messages).toHaveLength(2); + for (const m of messages) { + if (m.kind !== "message") continue; + expect(m.interstitial).toBe(true); + expect(m.streaming).toBe(false); + } + }); + + it("stamps atMs from the first streamed chunk for history interleaving", () => { + const messages = transcriptToTaskChatItems(stream, { runId: "run-1", running: false }).filter( + (it) => it.kind === "message", + ); + if (messages[0].kind !== "message" || messages[1].kind !== "message") return; + expect(messages[0].atMs).toBe(Date.parse(TS)); + expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z")); + }); +}); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index ad0d7ee7afd..438a05bc714 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -188,6 +188,7 @@ export function transcriptToTaskChatItems( const it = items[messageIndex]; if (it.kind === "message") it.text += entry.text; } else { + const atMs = Date.parse(entry.ts); items.push({ id: `${runId}:msg:${i}`, kind: "message", @@ -195,6 +196,10 @@ export function transcriptToTaskChatItems( authorName: agentName, text: entry.text, streaming: running, + // Everything the agent says inside a run turn is self-talk until it + // lands as the posted comment — live and history tag it alike. + interstitial: true, + atMs: Number.isFinite(atMs) ? atMs : undefined, }); messageIndex = items.length - 1; thinkingIndex = -1; diff --git a/ui/src/index.css b/ui/src/index.css index 345f2d4c9f4..b2efaa013b1 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -236,6 +236,8 @@ --motion-count-tween: var(--motion-duration-slow); --motion-streaming-cursor-blink: 1.1s; --motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */ + --motion-typewriter-reveal: 450ms; /* interstitial self-talk chunk typing itself in */ + --motion-typewriter-steps: steps(24, end); /* discrete reveal — the "typed" feel */ --motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */ --motion-scroll-pill-exit: 400ms; /* scroll-to-latest pill exit */ --motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1); @@ -535,6 +537,7 @@ --motion-plan-entry-stagger: 0ms; --motion-streaming-cursor-blink: 0ms; --motion-turn-fold: 0ms; + --motion-typewriter-reveal: 0ms; --motion-scroll-pill-enter: 0ms; --motion-scroll-pill-exit: 0ms; --motion-pane-glide: 0ms; @@ -570,6 +573,10 @@ 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } } +@keyframes tc-typewriter { + from { clip-path: inset(0 100% 0 0); } + to { clip-path: inset(0 0 0 0); } +} .tc-enter-bubble { animation: tc-fade-rise var(--motion-bubble-enter) var(--motion-ease-out-expo) both; } .tc-enter-marker { animation: tc-fade-in var(--motion-marker-enter) var(--motion-ease-out) both; } @@ -580,6 +587,11 @@ .tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; } .tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; } .tc-enter-plan-entry { animation: tc-fade-rise var(--motion-plan-check) var(--motion-ease-out-expo) both; } +/* Interstitial self-talk: each block-level chunk (a new streamed paragraph / + list / code block) wipes in left-to-right in discrete steps as it arrives — + CSS-only typewriter, no per-char timers. Text appended to an existing block + lands instantly; history omits the class entirely. */ +.tc-typewriter-stream .paperclip-markdown > * { animation: tc-typewriter var(--motion-typewriter-reveal) var(--motion-typewriter-steps) both; } /* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized). The pill is centered with translateX(-50%), so the keyframes carry the X @@ -620,6 +632,7 @@ .tc-scroll-pill-in, .tc-scroll-pill-out, .tc-enter-plan-entry { animation: none; } + .tc-typewriter-stream .paperclip-markdown > * { animation: none; } .tc-turn-fold, .tc-pane-glide { transition: none; } /* The pill keyframes carry the X-centering; with animation:none restore it. */