diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index df8ca8c647b..14be27056e1 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -51,12 +51,12 @@ export type TaskChatThreadProps = ComponentProps; * Run activity is grouped into TaskChatTurnItem: the in-flight run renders as * 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 (interstitial self-talk) is ambient signal only - * (PAP-357): it never renders as text in this view — while it streams the - * parent row's status word glints (tc-shimmer-narrate) and the pulse dot - * swaps to the Responding icon; the words themselves stay in the run log and - * the classic transcript. When the run terminates 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 @@ -246,9 +246,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { const items = useMemo(() => { // Settled turns for every terminal run whose transcript we have. Messages - // are excluded entirely (PAP-357): the final reply already landed as the - // run's comment bubble, and interstitial self-talk is ambient — it stays - // in the run log / classic transcript, never in this thread. + // and thinking are excluded entirely (PAP-361): the final reply already + // landed as the run's comment bubble, interstitial updates are ephemeral + // (live-line only), and thinking stays in the run log / classic + // transcript — "Worked · N tools" expands to exactly the tool rows. const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = []; for (const source of runs) { if (!isTerminalRunStatus(source.status)) continue; @@ -315,15 +316,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { running: true, }); // 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 self-talk is suppressed (PAP-357) and signals - // through the header's narrating glint instead. The parent row sits - // last — the slot its settled summary takes over in place. + // 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. const children = parsed.filter(isNestableLiveChild); const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; const queued = liveRun.status === "queued"; const status = queued - ? { label: "Queued", detail: "Waiting to start", toolName: undefined, narrating: undefined } + ? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined } : deriveRunStatusLabel(entries); out.push({ id: `${liveRun.id}:turn`, @@ -338,7 +339,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { label: status.label, detail: status.detail, toolName: status.toolName, - narrating: status.narrating, + selfTalk: status.selfTalk, startedAtMs: startedAt ?? undefined, }, }); diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 6dcddc21a3a..387731cce81 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -34,9 +34,10 @@ function initialsForName(name: string) { */ export function TaskChatBubble({ item }: TaskChatBubbleProps) { if (item.interstitial) { - // Mid-run self-talk is ambient signal, not content (PAP-357): the parent - // row's status word glints while it streams, and the text stays in the - // run log / classic transcript. Never rendered in this view. + // Interstitial updates are ephemeral (PAP-361): while streaming the text + // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and + // once finished it renders nowhere — the run log / classic transcript + // remain the archive. Never rendered as a bubble. return null; } diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index 71f4ddce394..aa80092f1b0 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TaskChatStatusPill } from "./TaskChatStatusPill"; +import { lineScrollOffset, TaskChatStatusPill } from "./TaskChatStatusPill"; import { whimsyWord, WHIMSY_ROTATE_MS } from "./status-whimsy"; import type { TaskChatStatusItem } from "./task-chat-model"; @@ -90,36 +90,52 @@ describe("TaskChatStatusPill whimsy", () => { expect(container.textContent).toContain("18,240/200,000 ctx"); }); - it("keeps the gerund and glints while self-talk streams (PAP-357)", () => { + it("hands the line to streaming interstitial text, keeping elapsed · tokens meta (PAP-361)", () => { const item = liveStatus({ label: "Responding", - narrating: true, + selfTalk: "I'll extend the ipRateLimit helper with a per-account bucket.", tokens: { used: 18240, size: 200000 }, }); render(item); act(() => { vi.advanceTimersByTime(1200); }); - // Nothing new to read: the whimsy gerund holds the line (no streamed text, - // no "Responding…" copy) and the right-side meta stays put. - expect(container.textContent).toContain(`${whimsyWord(item.id, 1200)}…`); + const line = container.querySelector('[data-testid="task-chat-live-self-talk"]'); + expect(line).not.toBeNull(); + expect(line?.textContent).toBe("I'll extend the ipRateLimit helper with a per-account bucket."); + // The gerund/whimsy label yields the line; the right-side meta stays put, + // and the pulse dot renders as ever. expect(container.textContent).not.toContain("Responding…"); + expect(container.textContent).not.toContain(`${whimsyWord(item.id, 1200)}…`); expect(container.textContent).toContain("1.2s"); expect(container.textContent).toContain("18,240/200,000 ctx"); - // The status word glints via the narration shimmer class… - const word = container.querySelector(".tc-shimmer-narrate"); - expect(word).not.toBeNull(); - expect(word?.textContent).toBe(`${whimsyWord(item.id, 1200)}…`); - // …and the pulse dot stays put — the glint is the sole narration signal - // (PAP-359: no icon swap). - expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).toBeNull(); expect(container.querySelector(".animate-pulse")).not.toBeNull(); + // Viewport + transition classes: 1lh clip outside, transform-only inner. + expect(line?.className).toContain("tc-line-scroll"); + expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner"); }); - it("carries no glint when not narrating, dot always present", () => { - render(liveStatus({ tokens: { used: 18240, size: 200000 } })); - expect(container.querySelector(".tc-shimmer-narrate")).toBeNull(); - expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).toBeNull(); + it("returns the line to the gerund once the interstitial completes", () => { + const item = liveStatus({ tokens: { used: 18240, size: 200000 } }); + render(liveStatus({ label: "Responding", selfTalk: "Almost there." })); + render(item); + // selfTalk gone → no live line, whimsy gerund back, dot still present. + 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(); }); }); + +describe("lineScrollOffset", () => { + it("translates up one line-height per wrapped line", () => { + expect(lineScrollOffset(16, 16)).toBe(0); // one line — no shift + expect(lineScrollOffset(32, 16)).toBe(1); + expect(lineScrollOffset(48.5, 16)).toBe(2); // sub-pixel scrollHeight rounds + }); + + it("never goes negative and guards bad measurements", () => { + expect(lineScrollOffset(0, 16)).toBe(0); + expect(lineScrollOffset(32, 0)).toBe(0); + expect(lineScrollOffset(Number.NaN, 16)).toBe(0); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index b49f64569e6..38f569bede1 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react"; import type { TaskChatStatusItem } from "./task-chat-model"; @@ -31,6 +31,55 @@ function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): numbe return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs); } +/** + * How many line-heights the streaming interstitial block is shifted up so only + * its LAST line shows in the 1lh viewport: lines − 1 (never negative). + */ +export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): number { + if (!Number.isFinite(scrollHeight) || !Number.isFinite(lineHeightPx) || lineHeightPx <= 0) { + return 0; + } + return Math.max(0, Math.round(scrollHeight / lineHeightPx) - 1); +} + +/** + * A streaming interstitial update on the parent row's line: the text streams + * inside a one-line-height clipped viewport; when it wraps, the inner block + * translates up by whole line-heights (--motion-line-scroll, transform-only, + * snaps under reduced motion) so the completed line slides out the top while + * the stream continues on the fresh line — the same motion grammar as tool + * rows animating in. Line count is measured from scrollHeight / line-height, + * re-checked on resize. + */ +function LiveSelfTalkLine({ text }: { text: string }) { + const innerRef = useRef(null); + const [offset, setOffset] = useState(0); + useLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + const measure = () => { + const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight); + setOffset(lineScrollOffset(el.scrollHeight, lineHeight)); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [text]); + return ( + + 0 ? { transform: `translateY(calc(${-offset} * 1lh))` } : undefined} + > + {text} + + + ); +} + const CONFIG = { running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" }, working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" }, @@ -65,21 +114,51 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live); const elapsed = elapsedLabel(item.elapsedMs); + if (live && item.selfTalk) { + // A streaming interstitial update takes the line (replacing the gerund) in + // the muted tool-row style; right-side meta (elapsed · tokens) stays put. + // The row alternates: whimsy gerund ↔ in-flight tool ↔ interstitial text. + const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs); + const SelfTalkIcon = statusLabelIcon("Responding"); + return ( +
+ + + + {SelfTalkIcon ? : null} + + {liveElapsed ? ( + + {liveElapsed} + + ) : null} + {item.tokens ? ( + + {item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx + + ) : null} + {chevronOpen !== undefined ? ( + + ) : null} +
+ ); + } + if (live) { const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs); - // Ambient self-talk (PAP-357/PAP-359): while an interstitial streams, - // nothing new appears to read — the gerund holds the line and the word - // glints (tc-shimmer-narrate); the pulse dot stays put throughout. - const narrating = item.narrating === true; const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : statusLabelIcon(item.label); // Whimsy fills gaps, never replaces signal: only the generic - // "Running"/"Working" labels — and the narrating state, whose "Responding" - // is signalled by the glint instead of copy — swap for a deterministic - // whimsical gerund (seeded by the run-scoped item id, rotating ~10s via - // the live tick). - const label = narrating || isGenericStatusLabel(item.label) + // "Running"/"Working" labels swap for a deterministic whimsical gerund + // (seeded by the run-scoped item id, rotating ~10s via the live tick). + const label = isGenericStatusLabel(item.label) ? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0) : item.label; return ( @@ -92,18 +171,11 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)" /> - {!narrating && ToolIcon ? ( - - ) : null} + {ToolIcon ? : null} {/* Text cells share a baseline row so the smaller mono readouts don't float above the label's baseline. */} - + {label}… {liveElapsed ? ( diff --git a/ui/src/components/task-chat/TaskChatThinkingBlock.test.tsx b/ui/src/components/task-chat/TaskChatThinkingBlock.test.tsx deleted file mode 100644 index 62fdeed16ce..00000000000 --- a/ui/src/components/task-chat/TaskChatThinkingBlock.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// @vitest-environment jsdom - -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock"; -import type { TaskChatThinkingItem } from "./task-chat-model"; - -function thinking(overrides: Partial = {}): TaskChatThinkingItem { - return { - id: "run-42:thought-1", - kind: "thinking", - lines: ["First, look at the failing test.", "The mock is stale."], - ...overrides, - }; -} - -describe("TaskChatThinkingBlock (PAP-359 tool-row grammar)", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - function render(item: TaskChatThinkingItem) { - act(() => { - root.render(); - }); - } - - it("leads the header with the Brain icon in the tool-icon slot, no rail on the header", () => { - render(thinking({ collapsed: true, summaryLabel: "Thought for 4s" })); - const header = container.querySelector("button"); - expect(header).not.toBeNull(); - // Brain icon fills the same h-3.5/w-3.5 lead slot as tool-row icons. - const icon = header?.querySelector("svg.lucide-brain"); - expect(icon).not.toBeNull(); - expect(icon?.getAttribute("class")).toContain("h-3.5"); - // The old left rail is gone from the header level. - expect(header?.className).not.toContain("border-l-2"); - expect(container.firstElementChild?.className).not.toContain("border-l-2"); - expect(container.textContent).toContain("Thought for 4s"); - }); - - it("streams open with the shimmering Thinking… label and rails only the body", () => { - render(thinking({ streaming: true })); - const shimmer = container.querySelector(".shimmer-text"); - expect(shimmer?.textContent).toBe("Thinking…"); - // Body is expanded and keeps the recessed inset rail. - expect(container.textContent).toContain("First, look at the failing test."); - const body = container.querySelector("div.border-l-2"); - expect(body).not.toBeNull(); - expect(body?.contains(container.querySelector("button"))).toBe(false); - }); - - it("settled history arrives collapsed and toggles on click with aria-expanded", () => { - render(thinking({ collapsed: true, summaryLabel: "Thought for 4s" })); - const header = container.querySelector("button")!; - expect(header.getAttribute("aria-expanded")).toBe("false"); - expect(container.textContent).not.toContain("The mock is stale."); - act(() => { - header.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(header.getAttribute("aria-expanded")).toBe("true"); - expect(container.textContent).toContain("The mock is stale."); - act(() => { - header.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(header.getAttribute("aria-expanded")).toBe("false"); - expect(container.textContent).not.toContain("The mock is stale."); - }); -}); diff --git a/ui/src/components/task-chat/TaskChatThinkingBlock.tsx b/ui/src/components/task-chat/TaskChatThinkingBlock.tsx deleted file mode 100644 index f0a157e2322..00000000000 --- a/ui/src/components/task-chat/TaskChatThinkingBlock.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from "react"; -import { cn } from "@/lib/utils"; -import { ChevronRight } from "lucide-react"; -import type { TaskChatThinkingItem } from "./task-chat-model"; -import { statusLabelIcon } from "./tool-taxonomy"; - -/** - * Chain-of-thought (ACP agent_thought_chunk) in nested tool-row grammar - * (PAP-359): a Brain-led header at the same level as tool rows — no rail or - * extra indent on the header itself — whose label shimmers while streaming - * ("Thinking…") and settles to "Thought for Ns". Clicking toggles the recessed - * pre-wrap thought body (left-rail inset, like tool detail). Streams open; - * settled history arrives collapsed and re-expands on click. No card chrome — - * thinking is activity metadata, subordinate to real content. - */ -export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) { - const [open, setOpen] = useState(!item.collapsed); - const settledLabel = item.summaryLabel ?? "Thought process"; - const BrainIcon = statusLabelIcon("Thinking"); - - return ( -
- - {open ? ( -
- {item.lines.map((line, i) => ( -

- {line} -

- ))} - {item.streaming ? ( - - ) : null} -
- ) : null} -
- ); -} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 0799bdc71cc..aa625adc329 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -6,7 +6,6 @@ import { TaskChatBubble } from "./TaskChatBubble"; import { TaskChatMarker } from "./TaskChatMarker"; import { TaskChatStatusPill } from "./TaskChatStatusPill"; import { TaskChatToolCard } from "./TaskChatToolCard"; -import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock"; import { TaskChatUsageReadout } from "./TaskChatUsageReadout"; import { TaskMessageScroller } from "./TaskMessageScroller"; @@ -41,7 +40,11 @@ function renderItem( case "marker": return ; case "thinking": - return ; + // Thinking never renders as a row (PAP-361): its live signal is the + // pill's "Thinking…" state, and the text stays in the run log / classic + // transcript. The kind survives in the model because the transcript + // parser still emits it (both nesting rules filter it out). + return null; case "tool": return ; case "status": @@ -111,10 +114,13 @@ function signatureOf(it: TaskChatItem): number { if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0); if (it.kind === "turn") { if (it.settled) return 1; - // The live parent row's header changes (gerund ↔ tool-state flashes) - // count too, so the collapsed single-line turn still advances the key. + // The live parent row's header changes (gerund ↔ tool-state flashes, + // streaming interstitial text growing) count too, so the collapsed + // single-line turn still advances the key. const headerSig = it.liveStatus - ? it.liveStatus.label.length + (it.liveStatus.detail?.length ?? 0) + ? it.liveStatus.label.length + + (it.liveStatus.detail?.length ?? 0) + + (it.liveStatus.selfTalk?.length ?? 0) : 0; return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig); } diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index edfba651fed..b562f007023 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -52,7 +52,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-shimmer", group: "States", kind: "time", min: 0, max: 5000, step: 50 }, + { name: "--motion-line-scroll", 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-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index 2fc35d1ce44..fea5f943f45 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -54,17 +54,30 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { ], }; case "thinking": + // The surviving thinking signal (PAP-361): the live line's "Thinking…" + // state — Brain icon + shimmer on the pill. Thinking rows no longer + // render in the thread or nest under turns. return { surface: "thread", items: [ ...exchangePrefix(), - { id: "th-1", kind: "thinking", streaming: true, lines: ["The login route is in server/src/routes/auth.ts.", "There's already an ipRateLimit helper I can reuse.", "I'll add a per-account bucket keyed on the email."] }, + { + id: "turn-thinking", + kind: "turn", + settled: false, + summary: { toolCount: 1, added: 0, removed: 0 }, + liveStatus: { id: "st-thinking", kind: "status", status: "running", label: "Thinking", startedAtMs: Date.now() - 6100, tokens: { used: 18240, size: 200000 } }, + items: [ + { id: "th-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" }, + ], + }, ], }; case "responding": - // Ambient self-talk (PAP-357): while narration streams, the parent row - // keeps its gerund — the status word glints (--motion-shimmer) and the - // pulse dot swaps to the Responding icon. Nothing new to read. + // A streaming interstitial update takes the parent row's line (PAP-361): + // it streams where the gerund sat and wraps into the 1lh viewport + // line-scroll. Ephemeral — when it finishes, the line returns to the + // gerund/tool rotation and the text renders nowhere. return { surface: "thread", items: [ @@ -74,7 +87,11 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { kind: "turn", settled: false, summary: { toolCount: 1, added: 0, removed: 0 }, - liveStatus: { id: "st-responding", kind: "status", status: "running", label: "Responding", narrating: true, startedAtMs: Date.now() - 9300, tokens: { used: 18240, size: 200000 } }, + liveStatus: { + id: "st-responding", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 9300, tokens: { used: 18240, size: 200000 }, + selfTalk: + "I found an existing ipRateLimit helper, so I'll extend it with a per-account token bucket keyed on the email address instead of adding a second limiter. The bucket refills at six requests a minute, matching the lockout policy the auth spec documents, and failed attempts drain it twice as fast so brute-force runs hit the ceiling quickly while a fat-fingered password barely registers.", + }, items: [ { id: "resp-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" }, ], @@ -140,7 +157,6 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { summary: { toolCount: 1, added: 0, removed: 0 }, liveStatus: { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } }, items: [ - { id: "r-think", kind: "thinking", lines: ["The login route is in server/src/routes/auth.ts.", "Check for an existing limiter before writing one."] }, { id: "r-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" }, ], }, @@ -155,10 +171,10 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { id: "turn-done", kind: "turn", settled: true, - // toolCount matches the nested tool rows exactly (PAP-357 parity). + // "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: "th-done", kind: "thinking", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] }, { 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 } }, ], diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 8fe91861a8d..e2c71d624f5 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -77,11 +77,11 @@ export interface TaskChatMessageItem { /** 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). Suppressed in the redesigned view (PAP-357) — it only - * indicates activity via the parent row's narrating glint, and the text - * stays in the run log / classic transcript. Tagged for live and settled - * transcripts alike. + * Agent text streamed inside a run turn (interstitial updates between tool + * calls). Ephemeral in the redesigned view (PAP-361): while streaming it + * takes the live parent row's line (TaskChatStatusItem.selfTalk); once + * finished it renders nowhere — the run log / classic transcript remain the + * archive. Tagged for live and settled transcripts alike. */ interstitial?: boolean; /** Epoch ms of the message's first streamed chunk. */ @@ -141,12 +141,13 @@ export interface TaskChatStatusItem { /** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */ toolName?: string; /** - * True while interstitial self-talk streams at the run's tail (PAP-357). - * Ambient signal only: the status word keeps its gerund but glints - * (--motion-shimmer) and the pulse dot swaps to the Responding icon. The - * streamed text is never shown on the line. + * Flattened plain text of the interstitial update currently streaming + * (PAP-361): it takes over the live parent row's line (replacing the gerund) + * inside a one-line viewport that scrolls completed lines out the top. + * Ephemeral — when the message finishes the line returns to the gerund/tool + * rotation, and nothing persists in the thread. */ - narrating?: boolean; + selfTalk?: string; elapsedMs?: number; /** Run start epoch ms; live states tick their own elapsed from this. */ startedAtMs?: number; diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 775034451a7..6733e37a902 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -3,6 +3,7 @@ import type { TranscriptEntry } from "@/adapters"; import { buildTurnSummary, deriveRunStatusLabel, + flattenSelfTalk, isNestableLiveChild, settledRunChildren, toolDisplayName, @@ -223,18 +224,19 @@ describe("isNestableLiveChild", () => { { id: "i", kind: "interaction", interaction: {} as never }, ]; - it("nests only tool, thinking and usage rows inside the live parent row", () => { + it("nests only tool and usage rows inside the live parent row (PAP-361)", () => { expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([ "tool", - "thinking", "usage", ]); }); - it("keeps messages, markers, statuses and interactions out (PAP-357)", () => { - // Interstitial self-talk — finished or streaming — never nests: it is - // ambient signal suppressed in this view. The final reply keeps its bubble. - for (const kind of ["message", "marker", "status", "interaction"]) { + it("keeps messages, thinking, markers, statuses and interactions out", () => { + // Interstitial updates — finished or streaming — never nest: they are + // ephemeral, living on the live line only while streaming. Thinking never + // nests either (its live signal is the pill's "Thinking…" state), and the + // final reply keeps its bubble. + for (const kind of ["message", "thinking", "marker", "status", "interaction"]) { const item = items.find((it) => it.kind === kind)!; expect(isNestableLiveChild(item)).toBe(false); } @@ -313,27 +315,49 @@ describe("interstitial self-talk classification (PAP-355)", () => { }); }); -describe("deriveRunStatusLabel narrating flag (PAP-357)", () => { - it("flags a tail assistant message as narrating — no streamed text surfaced", () => { +describe("flattenSelfTalk", () => { + it("strips markdown markers to one plain line", () => { + expect( + flattenSelfTalk("## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it"), + ).toBe("Plan I'll extend the ipRateLimit helper: read it patch it"); + }); + + it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => { + expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe("see the docs for bol"); + expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;"); + }); +}); + +describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => { + it("returns the trailing message's flattened text with the Responding label", () => { const status = deriveRunStatusLabel([ { kind: "assistant", ts: TS, text: "I'll **extend** " } as TranscriptEntry, { kind: "assistant", ts: TS, text: "the `ipRateLimit` helper." } as TranscriptEntry, ]); expect(status.label).toBe("Responding"); - expect(status.narrating).toBe(true); + expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper."); expect(status.detail).toBeUndefined(); }); - it("carries no narrating flag for tool or thinking tails", () => { - expect(deriveRunStatusLabel([toolCall("Read")]).narrating).toBeUndefined(); + it("only accumulates the trailing message, not one closed by a tool call", () => { + const status = deriveRunStatusLabel([ + { kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry, + toolCall("Read", { file_path: "a.ts" }), + { kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry, + ]); + expect(status.selfTalk).toBe("Fresh line."); + }); + + it("carries no selfTalk for tool or thinking tails", () => { + expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined(); expect( - deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).narrating, + deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).selfTalk, ).toBeUndefined(); - expect(deriveRunStatusLabel([]).narrating).toBeUndefined(); + expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined(); }); }); -describe("settledRunChildren (PAP-357)", () => { +describe("settledRunChildren (PAP-361)", () => { const transcript: TranscriptEntry[] = [ { kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry, toolCall("Read", { file_path: "a.ts" }), @@ -343,9 +367,9 @@ describe("settledRunChildren (PAP-357)", () => { ]; const parsed = transcriptToTaskChatItems(transcript, { runId: "run-1", running: false }); - it("keeps only activity rows — every message is excluded", () => { + it("keeps exactly the tool rows — messages AND thinking are excluded", () => { const children = settledRunChildren(parsed); - expect(children.map((c) => c.kind)).toEqual(["tool", "thinking", "tool"]); + expect(children.map((c) => c.kind)).toEqual(["tool", "tool"]); }); it("matches the folded summary's tool count exactly (row-count parity)", () => { diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 3358a3b1e60..3ce6393dd98 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -28,15 +28,35 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean } /** - * The live parent row's nesting rule (PAP-354): only activity rows — tool - * calls, thinking blocks, usage readouts — nest inside the expandable live - * turn. Messages never nest (PAP-357): interstitial self-talk is ambient - * signal suppressed in this view (it stays in the run log and the classic - * transcript), and the run's final reply lands as its posted comment bubble. - * Markers, statuses and interaction cards stay in the thread outside. + * The live parent row's nesting rule (PAP-354, narrowed by PAP-361): only tool + * calls and usage readouts nest inside the expandable live turn. Messages + * never nest — an interstitial update streams on the parent row's own line + * (TaskChatStatusItem.selfTalk) and vanishes when it completes, and the run's + * final reply lands as its posted comment bubble. Thinking never nests either: + * its live signal is the pill's "Thinking…" state, and the text stays in the + * run log / classic transcript. Markers, statuses and interaction cards stay + * in the thread outside. */ export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem { - return item.kind === "tool" || item.kind === "thinking" || item.kind === "usage"; + return item.kind === "tool" || item.kind === "usage"; +} + +/** + * Flatten markdown-ish interstitial text to one plain line for the live parent + * row. Stream-safe: markers are stripped without requiring pairs. + */ +export function flattenSelfTalk(text: string): string { + return text + .replace(/```[a-z]*\n?/gi, " ") + .replace(/`/g, "") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*>\s?/gm, "") + .replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1") + .replace(/\*+/g, "") + .replace(/__/g, "") + .replace(/\s+/g, " ") + .trim(); } function diffKind(changeType: string): "add" | "remove" | "context" { @@ -308,15 +328,17 @@ export function transcriptToTaskChatItems( } /** - * A settled run's nested children (PAP-357): the activity rows — tools, - * thinking, usage — in transcript order, under the "✓ Worked ·" summary. - * Messages are excluded entirely: the final reply already landed as the run's - * posted comment bubble, and interstitial self-talk is ambient signal that - * stays in the run log / classic transcript rather than rendering here. + * A settled run's nested children (PAP-361): exactly the tool rows (plus usage + * readouts) in transcript order, under the "✓ Worked ·" summary — parity with + * the summary's tool count. Messages are excluded: the final reply already + * landed as the run's posted comment bubble, and interstitial updates are + * ephemeral (they take the live line while streaming, then vanish). Thinking + * is excluded too — the run log / classic transcript remain its archive. */ export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] { return parsed.filter( - (it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message", + (it): it is TaskChatTurnChildItem => + it.kind !== "turn" && it.kind !== "message" && it.kind !== "thinking", ); } @@ -381,15 +403,15 @@ export function buildTurnSummary( * 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") * with tool + target as detail; `toolName` lets the pill show the family icon. - * A tail assistant message yields "Responding" plus `narrating` — the ambient - * flag the pill turns into a glint on the status word (PAP-357); the streamed - * text itself is never surfaced on the line. + * A tail assistant message yields "Responding" plus `selfTalk` — the flattened + * text of the interstitial update streamed so far, which takes over the parent + * row's line while it streams (PAP-361). */ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): { label: string; detail?: string; toolName?: string; - narrating?: boolean; + selfTalk?: string; } { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; @@ -425,7 +447,29 @@ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): { }; } if (entry.kind === "tool_result") break; - if (entry.kind === "assistant") return { label: "Responding", narrating: true }; + if (entry.kind === "assistant") { + // Accumulate the trailing message's deltas (the same coalescing run the + // parser groups into one item: broken by tool/thinking/diff, not by + // status-only entries like stdout). + const parts: string[] = []; + for (let j = i; j >= 0; j--) { + const prev = entries[j]; + if (prev.kind === "assistant") { + if (prev.text) parts.unshift(prev.text); + continue; + } + if ( + prev.kind === "tool_call" || + prev.kind === "tool_result" || + prev.kind === "thinking" || + prev.kind === "diff" + ) { + break; + } + } + const selfTalk = flattenSelfTalk(parts.join("")); + return { label: "Responding", selfTalk: selfTalk || undefined }; + } if (entry.kind === "thinking") return { label: "Thinking" }; } return { label: "Running" }; diff --git a/ui/src/index.css b/ui/src/index.css index 20c3cccc417..a4f41579201 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -236,7 +236,7 @@ --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-shimmer: 2000ms; /* narration glint sweeping the status word while self-talk streams */ + --motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */ --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); @@ -536,7 +536,7 @@ --motion-plan-entry-stagger: 0ms; --motion-streaming-cursor-blink: 0ms; --motion-turn-fold: 0ms; - --motion-shimmer: 0ms; + --motion-line-scroll: 0ms; --motion-scroll-pill-enter: 0ms; --motion-scroll-pill-exit: 0ms; --motion-pane-glide: 0ms; @@ -581,6 +581,13 @@ .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; } +/* Live-line viewport (PAP-361): exactly one line-height tall, clipping the + wrapped interstitial block. On wrap, the inner block translates up whole + line-heights (set inline as translateY(-(n−1)·1lh)) so the completed line + slides out the top — transform-only, same motion grammar as tool rows + animating in. */ +.tc-line-scroll { display: block; height: 1lh; overflow: hidden; } +.tc-line-scroll-inner { transition: transform var(--motion-line-scroll) var(--motion-ease-standard); will-change: transform; } /* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized). The pill is centered with translateX(-50%), so the keyframes carry the X @@ -622,6 +629,7 @@ .tc-scroll-pill-out, .tc-enter-plan-entry { animation: none; } .tc-turn-fold, + .tc-line-scroll-inner, .tc-pane-glide { transition: none; } /* The pill keyframes carry the X-centering; with animation:none restore it. */ .tc-scroll-pill-in, @@ -738,17 +746,6 @@ --shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 30%, transparent); } -/* Narration glint (PAP-357): while interstitial self-talk streams, the status - word's sweep brightens and quickens (--motion-shimmer vs the ambient 2.5s) - — an activity signal with nothing new to read. Applied on top of - .shimmer-text/.shimmer-text-muted; declared after them so the overrides win - at equal specificity, and the .shimmer-text reduced-motion rule below - already renders it static. */ -.tc-shimmer-narrate { - --shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 10%, transparent); - animation-duration: var(--motion-shimmer); -} - /* Utilities expected by shadcn base components (ui/attachment.tsx). Declared with @utility so variant forms (e.g. group-data-[state=uploading]:shimmer) compile under Tailwind v4. */ diff --git a/ui/src/pages/TaskChatLab.tsx b/ui/src/pages/TaskChatLab.tsx index ab7e3f16b8d..f6a216335a1 100644 --- a/ui/src/pages/TaskChatLab.tsx +++ b/ui/src/pages/TaskChatLab.tsx @@ -11,7 +11,7 @@ import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView"; import { TweakPanel } from "@/components/task-chat/TweakPanel"; import type { TaskChatItem } from "@/components/task-chat/task-chat-model"; -/** Progressively reveal any streaming message/thinking text at `speed`. */ +/** Progressively reveal any streaming message / live-line interstitial text at `speed`. */ function useStreamingReplay( baseItems: TaskChatItem[], speed: number, @@ -19,13 +19,15 @@ function useStreamingReplay( ): TaskChatItem[] { const [chars, setChars] = useState(Number.MAX_SAFE_INTEGER); const streamingIndex = baseItems.findIndex( - (i) => (i.kind === "message" && i.streaming) || (i.kind === "thinking" && i.streaming), + (i) => + (i.kind === "message" && i.streaming) || + (i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null), ); const fullText = useMemo(() => { const it = baseItems[streamingIndex]; if (!it) return ""; if (it.kind === "message") return it.text; - if (it.kind === "thinking") return it.lines.join("\n"); + if (it.kind === "turn") return it.liveStatus?.selfTalk ?? ""; return ""; }, [baseItems, streamingIndex]); @@ -51,7 +53,11 @@ function useStreamingReplay( const shown = fullText.slice(0, chars); const done = chars >= fullText.length; if (it.kind === "message") return { ...it, text: shown, streaming: !done }; - if (it.kind === "thinking") return { ...it, lines: shown.split("\n"), streaming: !done }; + // Parent-row interstitial (PAP-361): reveal the live line's text; the y + // line-scroll kicks in as the revealed text wraps past one line. + if (it.kind === "turn" && it.liveStatus) { + return { ...it, liveStatus: { ...it.liveStatus, selfTalk: shown } }; + } return it; }); }