diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index b6845d8f6a1..2672eb23018 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -7,12 +7,13 @@ import { deriveRunStatusLabel, isNestableLiveChild, isTerminalRunStatus, + normalizeCommentBody, + settledRunChildren, transcriptToTaskChatItems, } from "@/components/task-chat/transcript-adapter"; import type { TaskChatInteractionItem, TaskChatItem, - TaskChatTurnChildItem, TaskChatTurnItem, } from "@/components/task-chat/task-chat-model"; import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; @@ -49,8 +50,10 @@ 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 muted, bubble-less interstitial - * narration (PAP-355) — live and settled alike. When the run terminates the + * Mid-run agent text follows the same grammar (PAP-356): while streaming it + * takes over the parent row's line (typewriter + line-scroll), and once + * finished it nests inside the turn as a muted expandable row — live and + * settled alike (accepted §5.4). 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 @@ -241,19 +244,17 @@ export function TaskChatThread(props: TaskChatThreadProps) { const items = useMemo(() => { // Settled turns for every terminal run whose transcript we have. The // 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(); + // comment bubble — and interstitial self-talk nests INSIDE the folded turn + // alongside the tool rows (PAP-356, accepted §5.4), in transcript order, + // instead of interleaving in the thread. 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)); + set.add(normalizeCommentBody(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; @@ -271,34 +272,10 @@ export function TaskChatThread(props: TaskChatThreadProps) { agentName: meta?.agentName, running: false, }); - 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", - ); + const children = settledRunChildren(parsed, { + commentBodies: commentBodiesByRun.get(source.id), + succeeded: source.status === "succeeded", + }); if (children.length === 0) continue; settledTurns.push({ turn: { @@ -330,14 +307,8 @@ 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 merged) { + for (const entry of orderedEntries) { out.push(entry.item); const following = turnsByAnchor.get(entry.id); if (following) out.push(...following); @@ -351,19 +322,17 @@ export function TaskChatThread(props: TaskChatThreadProps) { agentName: liveRun.agentName, 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 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); - } + // Parent-row model (PAP-354/356): the run's status line IS the live turn + // — one expandable row owning ALL activity. Finished self-talk nests + // inside alongside tool/thinking rows; the interstitial still streaming + // takes over the parent row's own line (liveStatus.selfTalk) instead of + // rendering in the thread. 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 } + ? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined } : deriveRunStatusLabel(entries); out.push({ id: `${liveRun.id}:turn`, @@ -378,6 +347,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { label: status.label, detail: status.detail, toolName: status.toolName, + 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 714e7c2628a..e18ed8c3d84 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,5 +1,8 @@ +import { useState } from "react"; +import { ChevronRight, MessageSquareReply } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; +import { flattenSelfTalk } from "./transcript-adapter"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { AgentIcon } from "@/components/AgentIconPicker"; import { @@ -32,25 +35,46 @@ function initialsForName(name: string) { * bubble with an avatar author header (the agent's assigned icon + name · mode * chip); system notices are centered and recede. */ +/** + * Finished self-talk as a nested activity row (PAP-356): one muted flattened + * line in the exact tool-row interaction grammar — click expands the full + * markdown in a left-rail inset, identical to a tool row's detail block. + * (While still streaming, the same text lives on the parent row's live line.) + */ +function InterstitialRow({ item }: { item: TaskChatMessageItem }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? ( +
+ + {item.text} + +
+ ) : null} +
+ ); +} + 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} - -
- ); + return ; } if (item.author === "system") { diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index 52d05d6d56c..291474f895c 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"; @@ -89,4 +89,42 @@ describe("TaskChatStatusPill whimsy", () => { expect(container.textContent).toContain("1.2s"); expect(container.textContent).toContain("18,240/200,000 ctx"); }); + + it("hands the line to streaming self-talk, keeping elapsed · tokens meta (PAP-356)", () => { + const item = liveStatus({ + label: "Responding", + selfTalk: "I'll extend the ipRateLimit helper with a per-account bucket.", + tokens: { used: 18240, size: 200000 }, + }); + render(item); + act(() => { + vi.advanceTimersByTime(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. + 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"); + // 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"); + expect(line?.firstElementChild?.className).toContain("tc-typewriter-line"); + }); +}); + +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 5f7baaeb30a..d8cf6c9c7aa 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 self-talk 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); +} + +/** + * Streaming self-talk on the parent row's line: the text types in (typewriter + * treatment) 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 typing 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,6 +114,40 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live); const elapsed = elapsedLabel(item.elapsedMs); + if (live && item.selfTalk) { + // Streaming self-talk 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 ↔ streaming self-talk. + 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); const ToolIcon = item.toolName diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 99cb23fe08b..cf98935fa5e 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -110,7 +110,9 @@ export function TaskChatThreadView({ // The live parent row's header changes (gerund ↔ tool-state flashes) // 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 21956842c5f..ae4bb031940 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -54,6 +54,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [ { 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-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 158554b6a9c..1f93e593c9a 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -62,13 +62,28 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { ], }; case "responding": + // Streaming self-talk takes the parent row's line (PAP-356): it types in + // where the gerund sat, wraps into the 1lh viewport line-scroll, and the + // already-finished interstitial nests below like a completed tool row. return { surface: "thread", items: [ ...exchangePrefix(), - // Mid-run agent text is interstitial self-talk (PAP-355): muted, - // bubble-less, typing itself in — matching the live adapter. - { id: "m-agent-stream", kind: "message", author: "agent", authorName: AGENT, streaming: true, interstitial: true, text: "I found an existing ipRateLimit helper, so I'll extend it with a per-account bucket and" }, + { + id: "turn-responding", + kind: "turn", + settled: false, + summary: { toolCount: 1, added: 0, removed: 0 }, + liveStatus: { + id: "st-responding", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 9300, + 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-note", kind: "message", author: "agent", authorName: AGENT, interstitial: true, text: "The login route already imports `ipRateLimit`, so I can reuse its sliding-window store instead of adding a dependency." }, + { id: "resp-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" }, + ], + }, ], }; case "tool-call": @@ -149,6 +164,8 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { 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" }, + // Settled self-talk nests with the tool rows (accepted §5.4). + { id: "m-done-note", kind: "message", author: "agent", authorName: AGENT, interstitial: true, text: "Reusing the `ipRateLimit` store — adding a per-account bucket beside it." }, { 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 864deaba152..c7e4bf53085 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -83,10 +83,7 @@ export interface TaskChatMessageItem { * 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. - */ + /** Epoch ms of the message's first streamed chunk. */ atMs?: number; } @@ -142,6 +139,13 @@ export interface TaskChatStatusItem { detail?: string; /** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */ toolName?: string; + /** + * Flattened plain text of the interstitial message currently streaming: the + * live parent row's line shows it typing in (replacing the gerund) inside a + * one-line viewport that scrolls completed lines out the top. Full markdown + * renders in the nested row once the message completes. + */ + 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 9ea04ad9f9e..d79340cfdc5 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -3,7 +3,10 @@ import type { TranscriptEntry } from "@/adapters"; import { buildTurnSummary, deriveRunStatusLabel, + flattenSelfTalk, isNestableLiveChild, + normalizeCommentBody, + settledRunChildren, toolDisplayName, transcriptToTaskChatItems, } from "./transcript-adapter"; @@ -216,26 +219,46 @@ describe("isNestableLiveChild", () => { { id: "t", kind: "tool", name: "Read", status: "completed" }, { id: "th", kind: "thinking", lines: ["hm"] }, { id: "u", kind: "usage", usage: { used: 1, size: 2 } }, - { id: "m", kind: "message", author: "agent", text: "self-talk" }, + { id: "m", kind: "message", author: "agent", text: "finished self-talk", interstitial: true }, { id: "mk", kind: "marker", variant: "session_start", label: "Session started" }, { id: "s", kind: "status", status: "running", label: "Running" }, { id: "i", kind: "interaction", interaction: {} as never }, ]; - it("nests only tool, thinking and usage rows inside the live parent row", () => { + it("nests tool, thinking, usage and finished interstitial rows inside the live parent row", () => { expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([ "tool", "thinking", "usage", + "message", ]); }); - it("keeps messages, markers, statuses and interactions in the thread", () => { - for (const kind of ["message", "marker", "status", "interaction"]) { + it("keeps markers, statuses and interactions in the thread", () => { + for (const kind of ["marker", "status", "interaction"]) { const item = items.find((it) => it.kind === kind)!; expect(isNestableLiveChild(item)).toBe(false); } }); + + it("keeps the still-streaming interstitial out — it lives on the parent row's line", () => { + expect( + isNestableLiveChild({ + id: "m2", + kind: "message", + author: "agent", + text: "typing…", + interstitial: true, + streaming: true, + }), + ).toBe(false); + }); + + it("never nests a non-interstitial message (the final reply keeps its bubble)", () => { + expect( + isNestableLiveChild({ id: "m3", kind: "message", author: "agent", text: "Final reply." }), + ).toBe(false); + }); }); describe("interstitial self-talk classification (PAP-355)", () => { @@ -253,7 +276,26 @@ describe("interstitial self-talk classification (PAP-355)", () => { for (const m of messages) { if (m.kind !== "message") continue; expect(m.interstitial).toBe(true); - expect(m.streaming).toBe(true); + } + }); + + it("marks only the message still open at the tail as streaming", () => { + const items = transcriptToTaskChatItems(stream, { runId: "run-1", running: true }); + const messages = items.filter((it) => it.kind === "message"); + if (messages[0].kind !== "message" || messages[1].kind !== "message") return; + // The first message was closed by the tool call — finished self-talk that + // nests as a settled row even while the run continues. + expect(messages[0].streaming).toBe(false); + expect(messages[1].streaming).toBe(true); + }); + + it("marks no message streaming when the tail is a tool call", () => { + const items = transcriptToTaskChatItems( + [...stream, toolCall("Bash", { command: "pnpm test" })], + { runId: "run-1", running: true }, + ); + for (const m of items) { + if (m.kind === "message") expect(m.streaming).toBe(false); } }); @@ -278,3 +320,83 @@ describe("interstitial self-talk classification (PAP-355)", () => { expect(messages[1].atMs).toBe(Date.parse("2026-07-31T12:00:05.000Z")); }); }); + +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 self-talk (PAP-356)", () => { + 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.selfTalk).toBe("I'll extend the ipRateLimit helper."); + }); + + 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]).selfTalk, + ).toBeUndefined(); + }); +}); + +describe("settledRunChildren (PAP-356, accepted §5.4)", () => { + const parsed = transcriptToTaskChatItems( + [ + { kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry, + toolCall("Read", { file_path: "a.ts" }), + { kind: "assistant", ts: TS, text: "Done — the limiter is wired in." } as TranscriptEntry, + ], + { runId: "run-1", running: false }, + ); + + it("nests self-talk between the tool rows in transcript order", () => { + const children = settledRunChildren(parsed, { succeeded: true }); + expect(children.map((c) => c.kind)).toEqual(["message", "tool", "message"]); + }); + + it("drops messages matching a posted comment body (dedup preserved)", () => { + const children = settledRunChildren(parsed, { + commentBodies: new Set([normalizeCommentBody("Done — the limiter is wired\nin.")]), + succeeded: true, + }); + expect(children.map((c) => c.kind)).toEqual(["message", "tool"]); + }); + + it("drops a succeeded run's trailing message when the run posted any comment", () => { + const children = settledRunChildren(parsed, { + commentBodies: new Set([normalizeCommentBody("A transformed version of the reply.")]), + succeeded: true, + }); + expect(children.map((c) => c.kind)).toEqual(["message", "tool"]); + }); + + it("keeps the trailing message for failed runs", () => { + const children = settledRunChildren(parsed, { + commentBodies: new Set(["unrelated"]), + succeeded: false, + }); + expect(children.map((c) => c.kind)).toEqual(["message", "tool", "message"]); + }); +}); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 438a05bc714..c7be897ae0a 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -9,6 +9,7 @@ import type { TranscriptEntry } from "@/adapters"; import type { TaskChatDiff, TaskChatItem, + TaskChatMessageItem, TaskChatToolItem, TaskChatTurnChildItem, TaskChatTurnItem, @@ -28,15 +29,37 @@ 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 (mid-run self-talk, the streaming reply), markers, statuses - * and interaction cards stay in the thread outside it. + * The live parent row's nesting rule (PAP-354, extended by PAP-356): activity + * rows — tool calls, thinking blocks, usage readouts — and FINISHED + * interstitial self-talk nest inside the expandable live turn, all sharing the + * tool-row interaction grammar. The interstitial still streaming lives on the + * parent row's own line (TaskChatStatusItem.selfTalk), not in the nested list; + * markers, statuses and interaction cards stay in the thread outside. */ export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem { + if (item.kind === "message") return item.interstitial === true && !item.streaming; return item.kind === "tool" || item.kind === "thinking" || item.kind === "usage"; } +/** + * Flatten markdown-ish self-talk to one plain line — for the live parent row + * and the collapsed nested row. Full markdown renders only in the expanded + * nested 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" { if (changeType === "add") return "add"; if (changeType === "remove") return "remove"; @@ -294,9 +317,52 @@ export function transcriptToTaskChatItems( } } + // Only the message still open at the transcript tail is streaming; earlier + // self-talk is finished and nests as a settled row even mid-run. + if (running) { + for (const [idx, it] of items.entries()) { + if (it.kind === "message" && idx !== messageIndex) it.streaming = false; + } + } + return items; } +/** Normalization used to match transcript self-talk against posted comment bodies. */ +export function normalizeCommentBody(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** + * A settled run's nested children (PAP-356 / accepted §5.4): activity rows plus + * interstitial self-talk, in transcript order, under the "✓ Worked ·" summary. + * The reply that landed as a posted comment must not render twice, so messages + * matching a run comment's body are dropped — and, since the harness may post + * the reply lightly transformed, so is the trailing message of a succeeded run + * that has comments at all. + */ +export function settledRunChildren( + parsed: readonly TaskChatItem[], + opts: { commentBodies?: ReadonlySet; succeeded: boolean }, +): TaskChatTurnChildItem[] { + const messages = parsed.filter((it): it is TaskChatMessageItem => it.kind === "message"); + let selfTalk = opts.commentBodies + ? messages.filter((m) => !opts.commentBodies!.has(normalizeCommentBody(m.text))) + : messages; + if ( + opts.commentBodies && + opts.succeeded && + selfTalk.length > 0 && + selfTalk[selfTalk.length - 1] === messages[messages.length - 1] + ) { + selfTalk = selfTalk.slice(0, -1); + } + const keep = new Set(selfTalk); + return parsed.filter((it): it is TaskChatTurnChildItem => + it.kind === "message" ? keep.has(it) : it.kind !== "turn", + ); +} + function formatDurationLabel(ms: number): string | undefined { if (!Number.isFinite(ms) || ms <= 0) return undefined; const totalSec = Math.round(ms / 1000); @@ -358,11 +424,14 @@ 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 `selfTalk` — the flattened + * text of the message streamed so far, which takes over the parent row's line. */ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): { label: string; detail?: string; toolName?: string; + selfTalk?: string; } { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; @@ -398,7 +467,29 @@ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): { }; } if (entry.kind === "tool_result") break; - if (entry.kind === "assistant") return { label: "Responding" }; + 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 7957c605b72..41251425066 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -238,6 +238,7 @@ --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-line-scroll: 240ms; /* completed self-talk 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); @@ -538,6 +539,7 @@ --motion-streaming-cursor-blink: 0ms; --motion-turn-fold: 0ms; --motion-typewriter-reveal: 0ms; + --motion-line-scroll: 0ms; --motion-scroll-pill-enter: 0ms; --motion-scroll-pill-exit: 0ms; --motion-pane-glide: 0ms; @@ -587,11 +589,16 @@ .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; } +/* Streaming self-talk on the live parent row: the text block wipes in + left-to-right in discrete steps when it takes over the line — CSS-only + typewriter, no per-char timers; appended stream text lands instantly. */ +.tc-typewriter-line { animation: tc-typewriter var(--motion-typewriter-reveal) var(--motion-typewriter-steps) both; } +/* Live-line viewport: exactly one line-height tall, clipping the wrapped + 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; } /* Interstitial narration matches the tool rows (text-xs): prose-sm sets its own font-size on .paperclip-markdown, so the wrapper's text-xs alone can't shrink it — this scoped override wins on specificity. */ @@ -636,8 +643,9 @@ .tc-scroll-pill-in, .tc-scroll-pill-out, .tc-enter-plan-entry { animation: none; } - .tc-typewriter-stream .paperclip-markdown > * { animation: none; } + .tc-typewriter-line { 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, diff --git a/ui/src/pages/TaskChatLab.tsx b/ui/src/pages/TaskChatLab.tsx index ab7e3f16b8d..df1c35d1899 100644 --- a/ui/src/pages/TaskChatLab.tsx +++ b/ui/src/pages/TaskChatLab.tsx @@ -19,13 +19,17 @@ 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 === "thinking" && 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]); @@ -52,6 +56,11 @@ function useStreamingReplay( 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 self-talk (PAP-356): 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; }); }