From 4951e2da0e8abc37261b569689e59c8adf51587f Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 2 Aug 2026 03:00:13 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20round-8=20amendment=20=E2=80=94=20i?= =?UTF-8?q?nterstitial=20update=20gets=20its=20own=20row=20above=20the=20s?= =?UTF-8?q?tatus=20line=20(never=20shares=20it);=20gerund=20rotation=20uni?= =?UTF-8?q?nterrupted;=20row=20slides=20in/out=20of=20the=201lh=20viewport?= =?UTF-8?q?=20via=20the=20same=20line-scroll=20transition=20and=20unmounts?= =?UTF-8?q?=20after=20slide-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../task-chat/TaskChatStatusPill.test.tsx | 26 +++- .../task-chat/TaskChatStatusPill.tsx | 122 +++++++++++------- .../task-chat/task-chat-fixtures.ts | 9 +- .../components/task-chat/task-chat-model.ts | 9 +- 4 files changed, 101 insertions(+), 65 deletions(-) diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index aa80092f1b0..ae332f089bb 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -90,7 +90,7 @@ describe("TaskChatStatusPill whimsy", () => { expect(container.textContent).toContain("18,240/200,000 ctx"); }); - it("hands the line to streaming interstitial text, keeping elapsed · tokens meta (PAP-361)", () => { + it("mounts a streaming interstitial as its own row above the status line (PAP-361 amended)", () => { const item = liveStatus({ label: "Responding", selfTalk: "I'll extend the ipRateLimit helper with a per-account bucket.", @@ -100,13 +100,16 @@ describe("TaskChatStatusPill whimsy", () => { act(() => { vi.advanceTimersByTime(1200); }); + const row = container.querySelector('[data-testid="task-chat-interstitial-row"]'); const line = container.querySelector('[data-testid="task-chat-live-self-talk"]'); - expect(line).not.toBeNull(); + expect(row).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. + // The row sits DIRECTLY ABOVE the status line — never sharing it: the + // gerund rotation runs uninterrupted below (no "Responding…" copy), with + // meta and pulse dot in place. + expect(row?.nextElementSibling?.textContent).toContain(`${whimsyWord(item.id, 1200)}…`); + expect(row?.contains(line)).toBe(true); 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"); expect(container.querySelector(".animate-pulse")).not.toBeNull(); @@ -115,11 +118,20 @@ describe("TaskChatStatusPill whimsy", () => { expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner"); }); - it("returns the line to the gerund once the interstitial completes", () => { + it("slides the row out and unmounts it once the interstitial completes", () => { const item = liveStatus({ tokens: { used: 18240, size: 200000 } }); render(liveStatus({ label: "Responding", selfTalk: "Almost there." })); + expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).not.toBeNull(); render(item); - // selfTalk gone → no live line, whimsy gerund back, dot still present. + // selfTalk gone → the row is briefly held for its slide-out… + const held = container.querySelector('[data-testid="task-chat-interstitial-row"]'); + expect(held?.textContent).toContain("Almost there."); + // …then unmounts after the motion-token hold (0ms in jsdom — no computed + // token value); gerund + dot carry on. + act(() => { + vi.advanceTimersByTime(50); + }); + expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).toBeNull(); expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull(); expect(container.textContent).toContain(`${whimsyWord(item.id, 0)}…`); expect(container.querySelector(".animate-pulse")).not.toBeNull(); diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index 38f569bede1..f0fb3e7a54a 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -43,17 +43,19 @@ export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): nu } /** - * 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. + * A streaming interstitial update inside a one-line-height clipped viewport. + * One transform transition (--motion-line-scroll, transform-only, snaps under + * reduced motion) does all the y work: the text enters by sliding up from + * below the 1lh clip; when it wraps, the inner block translates up by whole + * line-heights so the completed line slides out the top while the stream + * continues on the fresh line; and when the message ends (`leaving`) it shifts + * one further line so the last line slides out before the row unmounts. Line + * count is measured from scrollHeight / line-height, re-checked on resize. */ -function LiveSelfTalkLine({ text }: { text: string }) { +function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean }) { const innerRef = useRef(null); const [offset, setOffset] = useState(0); + const [entered, setEntered] = useState(false); useLayoutEffect(() => { const el = innerRef.current; if (!el) return; @@ -67,12 +69,17 @@ function LiveSelfTalkLine({ text }: { text: string }) { observer.observe(el); return () => observer.disconnect(); }, [text]); + useEffect(() => { + const id = requestAnimationFrame(() => setEntered(true)); + return () => cancelAnimationFrame(id); + }, []); + const shift = !entered ? 1 : -offset - (leaving ? 1 : 0); return ( 0 ? { transform: `translateY(calc(${-offset} * 1lh))` } : undefined} + style={shift !== 0 ? { transform: `translateY(calc(${shift} * 1lh))` } : undefined} > {text} @@ -80,6 +87,28 @@ function LiveSelfTalkLine({ text }: { text: string }) { ); } +/** + * Holds the streaming interstitial text briefly after it clears so the row can + * slide out before unmounting. The hold duration is read from the same motion + * token the slide transition uses (0 under reduced motion → instant unmount). + */ +function useHeldSelfTalk(selfTalk: string | undefined): { text: string; leaving: boolean } | null { + const [held, setHeld] = useState(selfTalk ?? null); + useEffect(() => { + if (selfTalk) { + setHeld(selfTalk); + return; + } + if (held == null) return; + const raw = getComputedStyle(document.documentElement).getPropertyValue("--motion-line-scroll"); + const ms = Number.parseFloat(raw); + const id = window.setTimeout(() => setHeld(null), Number.isFinite(ms) ? ms : 0); + return () => window.clearTimeout(id); + }, [selfTalk, held]); + if (selfTalk) return { text: selfTalk, leaving: false }; + return held != null ? { text: held, leaving: true } : null; +} + const CONFIG = { running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" }, working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" }, @@ -112,56 +141,30 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta const awaiting = item.status === "awaiting_approval"; const live = item.status === "running" || item.status === "working"; const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live); + const heldSelfTalk = useHeldSelfTalk(live ? item.selfTalk : undefined); 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); + // While an interstitial streams, its text lives on its OWN row above; the + // status line below keeps the uninterrupted gerund rotation ("Responding" + // never takes the line, and no Responding icon appears here). + const streamingInterstitial = item.selfTalk != null; const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon - : statusLabelIcon(item.label); + : streamingInterstitial + ? null + : statusLabelIcon(item.label); // Whimsy fills gaps, never replaces signal: only the generic - // "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) + // "Running"/"Working" labels — and the interstitial-streaming state, whose + // text renders on the row above — swap for a deterministic whimsical + // gerund (seeded by the run-scoped item id, rotating ~10s via the live + // tick). + const label = streamingInterstitial || isGenericStatusLabel(item.label) ? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0) : item.label; - return ( + const SelfTalkIcon = statusLabelIcon("Responding"); + const statusLine = (
{/* Fixed-size lead slot keeps the label from moving as tool icons come and go; the pulse dot renders unconditionally. */} @@ -200,6 +203,25 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta ) : null}
); + if (!heldSelfTalk) return statusLine; + // A streaming interstitial gets its own bubble-less single-line row + // DIRECTLY ABOVE the status line — the two never share a line. The row + // slides into / out of the 1lh viewport (LiveSelfTalkLine) when a message + // starts/ends and unmounts after the slide-out; the empty lead slot keeps + // its text column-aligned with the status label below. + return ( +
+
+ + {SelfTalkIcon ? : null} + +
+ {statusLine} +
+ ); } return ( diff --git a/ui/src/components/task-chat/task-chat-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index fea5f943f45..5aebb690588 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -74,10 +74,11 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { ], }; case "responding": - // 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. + // A streaming interstitial update gets its own row directly above the + // status line (PAP-361, amended): it wraps into the 1lh viewport + // line-scroll while the gerund rotation below runs uninterrupted. + // Ephemeral — when it finishes, the row slides out and the text renders + // nowhere. return { surface: "thread", items: [ diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index e2c71d624f5..179948e09cb 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -142,10 +142,11 @@ export interface TaskChatStatusItem { toolName?: string; /** * 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. + * (PAP-361, amended round 8): it renders as a dedicated single-line row + * directly above the status line, inside a one-line viewport that scrolls + * completed lines out the top; the gerund rotation below runs uninterrupted. + * Ephemeral — when the message finishes the row slides out and unmounts, + * and nothing persists in the thread. */ selfTalk?: string; elapsedMs?: number;