From 96037fec6e2099f69bff1e621a592d15904abe1f Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 2 Aug 2026 12:09:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20interstitial=20held=20line=20?= =?UTF-8?q?=E2=80=94=20hold-until-superseded=20+=20~4s=20min=20dwell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useHeldSelfTalk reworked: a finished update stays visible, static and truncate-ellipsized, until the next update supersedes it or the turn ends (never fades). Swaps are paced by the new --motion-interstitial-dwell token (4000ms; deliberately NOT zeroed under reduced motion — it is pacing, not movement); rapid bursts collapse latest-wins via a queue of one. The .tc-line-scroll slide now plays ONLY at swap moments (keyed remount); while streaming, the tail-line offset applies with the transition disabled inline, killing the per-wrap glide. Lab: responding-burst state + segmented replay (blank-line-separated updates with gaps) to exercise dwell/swap. Co-Authored-By: Claude Fable 5 --- .../task-chat/TaskChatStatusPill.test.tsx | 109 ++++++++++- .../task-chat/TaskChatStatusPill.tsx | 173 ++++++++++++++---- ui/src/components/task-chat/motion-tokens.ts | 1 + .../task-chat/task-chat-fixtures.ts | 30 +++ .../components/task-chat/task-chat-model.ts | 9 +- .../components/task-chat/task-chat-states.ts | 8 + ui/src/index.css | 4 + ui/src/pages/TaskChatLab.tsx | 55 +++++- 8 files changed, 332 insertions(+), 57 deletions(-) diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index 3eefff58523..871660d4671 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -32,6 +32,7 @@ describe("TaskChatStatusPill whimsy", () => { afterEach(() => { act(() => root.unmount()); container.remove(); + document.documentElement.style.removeProperty("--motion-interstitial-dwell"); vi.useRealTimers(); }); @@ -131,24 +132,112 @@ describe("TaskChatStatusPill whimsy", () => { expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner"); }); - it("slides the text out but keeps the reserved row once the interstitial completes", () => { + it("holds the finished update — static, ellipsized, never fading — until superseded (PAP-368)", () => { const item = liveStatus({ tokens: { used: 18240, size: 200000 } }); render(liveStatus({ label: "Responding", selfTalk: "Almost there." })); expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).not.toBeNull(); render(item); - // selfTalk gone → the text is briefly held for its slide-out… - const held = container.querySelector('[data-testid="task-chat-interstitial-row"]'); - expect(held?.textContent).toContain("Almost there."); - // …then the TEXT unmounts after the motion-token hold (0ms in jsdom — no - // computed token value) while the reserved row stays in layout (round 9: - // no jump); gerund + dot carry on below. + // selfTalk gone → the text STAYS as a held single-line readout: no + // slide-out, no fade, even a minute later; the reserved row keeps its + // place and the gerund + dot carry on below. + act(() => { + vi.advanceTimersByTime(60_000); + }); + const line = container.querySelector('[data-testid="task-chat-live-self-talk"]'); + expect(line?.textContent).toBe("Almost there."); + expect(line?.getAttribute("data-streaming")).toBeNull(); + expect(line?.firstElementChild?.className).toContain("truncate"); + expect(container.textContent).toContain(`${whimsyWord(item.id, 60_000)}…`); + expect(container.querySelector(".animate-pulse")).not.toBeNull(); + }); + + it("streams the tail line without a transition once the enter slide settles", () => { + render(liveStatus({ label: "Responding", selfTalk: "Streaming along nicely." })); act(() => { vi.advanceTimersByTime(50); }); - expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).not.toBeNull(); + const line = container.querySelector('[data-testid="task-chat-live-self-talk"]'); + expect(line?.getAttribute("data-streaming")).toBe("true"); + // Post-enter (--motion-line-scroll reads 0ms in jsdom) the inline + // transition is disabled: mid-stream wraps snap instead of gliding. + const inner = line?.firstElementChild as HTMLElement; + expect(inner.style.transition).toBe("none"); + expect(inner.className).not.toContain("truncate"); + }); + + it("respects the minimum dwell between swaps and collapses bursts latest-wins", () => { + document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms"); + // Update A streams and finishes → held. + render(liveStatus({ label: "Responding", selfTalk: "Update A." })); + render(liveStatus({})); + const innerBefore = container.querySelector(".tc-line-scroll-inner"); + act(() => { + vi.advanceTimersByTime(1000); + }); + // Update B arrives 1s after A swapped in: inside the dwell → queued. + render(liveStatus({ label: "Responding", selfTalk: "Update B." })); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(container.textContent).toContain("Update A."); + // Update C supersedes B while still queued (latest-wins queue of one). + render(liveStatus({ label: "Responding", selfTalk: "Update C." })); + act(() => { + vi.advanceTimersByTime(900); + }); + // 3.9s since A swapped in — still inside the dwell. + expect(container.textContent).toContain("Update A."); + expect(container.textContent).not.toContain("Update B."); + act(() => { + vi.advanceTimersByTime(200); + }); + // Dwell expired → only the NEWEST pending update swaps in; B never shows. + const line = container.querySelector('[data-testid="task-chat-live-self-talk"]'); + expect(line?.textContent).toBe("Update C."); + expect(container.textContent).not.toContain("Update A."); + expect(container.textContent).not.toContain("Update B."); + // The swap remounts the line (new key) so the slide plays exactly then. + expect(container.querySelector(".tc-line-scroll-inner")).not.toBe(innerBefore); + // C was still streaming at swap time → it keeps tracking the tail. + expect(line?.getAttribute("data-streaming")).toBe("true"); + }); + + it("swaps a queued update in as held when its stream already ended", () => { + document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms"); + render(liveStatus({ label: "Responding", selfTalk: "Update A." })); + render(liveStatus({})); + act(() => { + vi.advanceTimersByTime(1000); + }); + // B streams and ENDS while A's dwell is still running. + render(liveStatus({ label: "Responding", selfTalk: "Update B, done already." })); + render(liveStatus({})); + act(() => { + vi.advanceTimersByTime(3100); + }); + const line = container.querySelector('[data-testid="task-chat-live-self-talk"]'); + expect(line?.textContent).toBe("Update B, done already."); + expect(line?.getAttribute("data-streaming")).toBeNull(); + expect(line?.firstElementChild?.className).toContain("truncate"); + }); + + it("clears the held line when the turn ends", () => { + render(liveStatus({ label: "Responding", selfTalk: "Held across the turn?" })); + render(liveStatus({})); + expect(container.textContent).toContain("Held across the turn?"); + // Turn settles into a non-live status: the row (and held text) is gone; + // a later live turn must not resurrect the old update. + render(liveStatus({ status: "interrupted", label: "Interrupted" })); + act(() => { + vi.advanceTimersByTime(50); + }); + expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).toBeNull(); + render(liveStatus({})); + act(() => { + vi.advanceTimersByTime(50); + }); + expect(container.textContent).not.toContain("Held across the turn?"); 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 cc85395dde5..d166516f534 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -4,6 +4,7 @@ import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from " import type { TaskChatStatusItem } from "./task-chat-model"; import { statusLabelIcon, toolTaxonomy } from "./tool-taxonomy"; import { isGenericStatusLabel, whimsyWord } from "./status-whimsy"; +import { parseCssTimeMs } from "./motion-tokens"; function elapsedLabel(ms?: number): string | null { if (ms == null) return null; @@ -42,21 +43,32 @@ export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): nu return Math.max(0, Math.round(scrollHeight / lineHeightPx) - 1); } +/** Read a `--motion-*` time token off :root in ms (0 when unset, e.g. jsdom). */ +function motionTokenMs(name: string): number { + const raw = getComputedStyle(document.documentElement).getPropertyValue(name); + const ms = parseCssTimeMs(raw); + return Number.isFinite(ms) ? ms : 0; +} + /** - * 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 text unmounts (the - * reserved row itself never leaves layout while the turn is live). Line count - * is measured from scrollHeight / line-height, re-checked on resize. + * The interstitial update inside a one-line-height clipped viewport. + * + * The `.tc-line-scroll` slide plays ONLY at swap moments (PAP-368): on mount — + * i.e. when a new update replaces the previous one, the parent keys this + * component by swap — the text slides up from below the 1lh clip, and once + * that enter window (--motion-line-scroll) elapses the transition is disabled + * inline. While `streaming`, the block is shifted up by whole line-heights so + * only the tail line shows, but with the transition off the shift SNAPS — no + * per-wrap glide keeping the row in constant motion. Once the update finishes + * (`streaming` false) the held text renders single-line ellipsized, static. + * Line count is measured from scrollHeight / line-height, re-checked on + * resize. */ -function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean }) { +function LiveSelfTalkLine({ text, streaming }: { text: string; streaming: boolean }) { const innerRef = useRef(null); const [offset, setOffset] = useState(0); const [entered, setEntered] = useState(false); + const [settled, setSettled] = useState(false); useLayoutEffect(() => { const el = innerRef.current; if (!el) return; @@ -69,18 +81,29 @@ function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean } const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); - }, [text]); + }, [text, streaming]); useEffect(() => { const id = requestAnimationFrame(() => setEntered(true)); - return () => cancelAnimationFrame(id); + const timer = window.setTimeout(() => setSettled(true), motionTokenMs("--motion-line-scroll")); + return () => { + cancelAnimationFrame(id); + window.clearTimeout(timer); + }; }, []); - const shift = !entered ? 1 : -offset - (leaving ? 1 : 0); + const shift = !entered ? 1 : streaming ? -offset : 0; return ( - + {text} @@ -88,27 +111,94 @@ function LiveSelfTalkLine({ text, leaving }: { text: string; leaving?: boolean } ); } +interface HeldSelfTalk { + text: string; + /** The displayed update is the currently-streaming tail message. */ + streaming: boolean; + /** Increments at each swap — keys LiveSelfTalkLine so the slide replays. */ + swapKey: number; +} + /** - * Holds the streaming interstitial text briefly after it clears so it can - * slide out of the reserved row before unmounting. The hold duration is read - * from the same motion token the slide transition uses (0 under reduced - * motion → instant unmount). + * Hold-until-superseded interstitial state (PAP-368): a finished update STAYS + * visible, static, until the next update replaces it or the turn ends — never + * fades. Swaps are paced by --motion-interstitial-dwell (~4s minimum between + * swaps) so visibility tracks reading time, not streaming speed. Updates that + * arrive inside the dwell window collapse latest-wins (a queue of one: only + * the newest pending update swaps in when the dwell expires). + * + * Message boundaries are the `selfTalk` gaps: the transcript tail clears it + * between assistant messages (tool calls / thinking in between), so a + * defined→undefined→defined sequence marks a new update. */ -function useHeldSelfTalk(selfTalk: string | undefined): { text: string; leaving: boolean } | null { - const [held, setHeld] = useState(selfTalk ?? null); +function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfTalk | null { + const [line, setLine] = useState(null); + const lineRef = useRef(line); + const pendingRef = useRef(null); + const tailRef = useRef(selfTalk); + const lastSwapAtRef = useRef(0); + const timerRef = useRef(null); + tailRef.current = selfTalk; + useEffect(() => { - if (selfTalk) { - setHeld(selfTalk); + const show = (next: HeldSelfTalk | null) => { + lineRef.current = next; + setLine(next); + }; + const swapIn = (text: string, streaming: boolean) => { + lastSwapAtRef.current = Date.now(); + show({ text, streaming, swapKey: (lineRef.current?.swapKey ?? 0) + 1 }); + }; + if (!live) { + // Turn end clears the row (the reserved slot itself unmounts with it). + if (timerRef.current != null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + pendingRef.current = null; + if (lineRef.current) show(null); 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 cur = lineRef.current; + if (selfTalk == null) { + // Stream ended: the displayed update becomes held — it does NOT leave. + if (cur?.streaming) show({ ...cur, streaming: false }); + return; + } + if (cur == null) { + swapIn(selfTalk, true); + return; + } + if (cur.streaming) { + // Same message growing — update in place, no swap. + if (cur.text !== selfTalk) show({ ...cur, text: selfTalk }); + return; + } + // A new update while one is held: queue it latest-wins and swap when the + // dwell since the last swap has elapsed. + pendingRef.current = selfTalk; + if (timerRef.current == null) { + const dwellMs = motionTokenMs("--motion-interstitial-dwell"); + const delay = Math.max(0, lastSwapAtRef.current + dwellMs - Date.now()); + timerRef.current = window.setTimeout(() => { + timerRef.current = null; + const text = pendingRef.current; + pendingRef.current = null; + // Still streaming iff the tail message is live at swap time; if so the + // next effect pass keeps the text tracking the tail. + if (text != null) swapIn(text, tailRef.current != null); + }, delay); + } + }, [selfTalk, live]); + + useEffect( + () => () => { + if (timerRef.current != null) window.clearTimeout(timerRef.current); + }, + [], + ); + + return line; } const CONFIG = { @@ -143,7 +233,7 @@ 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 heldSelfTalk = useHeldSelfTalk(live ? item.selfTalk : undefined, live); const elapsed = elapsedLabel(item.elapsedMs); if (live) { @@ -208,10 +298,13 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta // The interstitial row is PERMANENTLY RESERVED while the turn is live // (round 9): the layout above never jumps to make room. A streaming // interstitial gets this bubble-less single-line row DIRECTLY ABOVE the - // status line — the two never share a line. Text slides into / out of the - // 1lh viewport (LiveSelfTalkLine) when a message starts/ends; between - // messages the row stays as an empty one-line slot of identical height. - // The empty lead slot keeps the text column-aligned with the status label. + // status line — the two never share a line. The latest update is held + // until superseded (PAP-368): it stays as a static one-line readout after + // its stream ends, and the .tc-line-scroll slide plays only when a new + // update swaps in (keyed remount), paced by --motion-interstitial-dwell. + // Before the first update the row is an empty one-line slot of identical + // height; the empty lead slot keeps the text column-aligned with the + // status label. return (
{SelfTalkIcon ? : null} - + ) : ( diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index b562f007023..d670a46637c 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -53,6 +53,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-line-scroll", group: "States", kind: "time", min: 0, max: 1500, step: 10 }, + { name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 }, { 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 84cf1663a1b..fd332908f7f 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -99,6 +99,36 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario { }, ], }; + case "responding-burst": + // A run emitting several interstitial updates in quick succession + // (PAP-368): the lab replay streams each blank-line-separated segment as + // its own update with a short gap between. The pill HOLDS each finished + // update until the next swaps in, paced by --motion-interstitial-dwell + // (latest-wins when updates outpace the dwell). + return { + surface: "thread", + items: [ + ...exchangePrefix(), + { + id: "turn-responding-burst", + kind: "turn", + settled: false, + summary: { toolCount: 2, added: 0, removed: 0 }, + liveStatus: { + id: "st-responding-burst", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 21400, tokens: { used: 18240, size: 200000 }, + selfTalk: + "Found the existing ipRateLimit helper — extending it beats adding a second limiter.\n\n" + + "Wiring a per-account token bucket keyed on the email address, refilling at six requests a minute per the auth spec.\n\n" + + "Failed attempts drain the bucket twice as fast, so brute-force runs hit the ceiling while a fat-fingered password barely registers.\n\n" + + "Now updating the login route to consume from the bucket before the password check and adding tests for the lockout path.", + }, + items: [ + { id: "burst-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" }, + { id: "burst-grep", kind: "tool", name: "Grep", target: "ipRateLimit", toolKind: "search", status: "completed" }, + ], + }, + ], + }; case "tool-call": return { surface: "thread", diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 720a21fab86..286ba1c10b6 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -152,10 +152,11 @@ export interface TaskChatStatusItem { * Flattened plain text of the interstitial update currently streaming * (PAP-361, round 9): it renders in a dedicated single-line row directly * above the status line — a slot PERMANENTLY RESERVED while the turn is - * live, so the layout above never jumps — inside a one-line viewport that - * scrolls completed lines out the top; the gerund rotation below runs - * uninterrupted. Ephemeral — when the message finishes the text slides out - * and nothing persists in the thread. + * live, so the layout above never jumps — inside a one-line viewport + * showing the streaming tail line; the gerund rotation below runs + * uninterrupted. When the message finishes the pill HOLDS the text as a + * static ellipsized line until the next update supersedes it or the turn + * ends (PAP-368) — nothing persists in the settled thread. */ selfTalk?: string; elapsedMs?: number; diff --git a/ui/src/components/task-chat/task-chat-states.ts b/ui/src/components/task-chat/task-chat-states.ts index fbdcd588693..01fafc2d452 100644 --- a/ui/src/components/task-chat/task-chat-states.ts +++ b/ui/src/components/task-chat/task-chat-states.ts @@ -18,6 +18,7 @@ export const TASK_CHAT_STATES = [ "agent-message", "thinking", "responding", + "responding-burst", "tool-call", "diff", "working", @@ -81,6 +82,13 @@ export const TASK_CHAT_STATE_META: Record = surface: "thread", protocol: "text_delta stream:output, streaming", }, + "responding-burst": { + id: "responding-burst", + label: "Responding (update burst)", + tier: "live", + surface: "thread", + protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)", + }, "tool-call": { id: "tool-call", label: "Tool call", diff --git a/ui/src/index.css b/ui/src/index.css index a4f41579201..6217203adf4 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -237,6 +237,7 @@ --motion-streaming-cursor-blink: 1.1s; --motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */ --motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */ + --motion-interstitial-dwell: 4000ms; /* minimum hold on an interstitial update before the next one may swap in */ --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); @@ -537,6 +538,9 @@ --motion-streaming-cursor-blink: 0ms; --motion-turn-fold: 0ms; --motion-line-scroll: 0ms; + /* --motion-interstitial-dwell is deliberately NOT zeroed: it is a pacing + hold, not a movement — zeroing it would make the interstitial line churn + faster under reduced motion. Swaps themselves snap (line-scroll: 0). */ --motion-scroll-pill-enter: 0ms; --motion-scroll-pill-exit: 0ms; --motion-pane-glide: 0ms; diff --git a/ui/src/pages/TaskChatLab.tsx b/ui/src/pages/TaskChatLab.tsx index f6a216335a1..ca79e6cb523 100644 --- a/ui/src/pages/TaskChatLab.tsx +++ b/ui/src/pages/TaskChatLab.tsx @@ -11,6 +11,37 @@ import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView"; import { TweakPanel } from "@/components/task-chat/TweakPanel"; import type { TaskChatItem } from "@/components/task-chat/task-chat-model"; +/** Replay ticks of selfTalk gap between scripted interstitial updates (~0.7s at 1×). */ +const SELF_TALK_GAP_TICKS = 30; + +/** A fixture's scripted interstitial updates: blank-line-separated segments. */ +function selfTalkSegments(script: string): string[] { + return script + .split(/\n{2,}/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * The selfTalk visible at replay tick `tick`: each segment streams one char + * per tick as its own update, with a gap (selfTalk undefined — the real + * adapter clears it between assistant messages) before the next; after the + * last segment ends, selfTalk stays undefined so the pill's hold-until- + * superseded behavior (PAP-368) is visible in the lab. + */ +function selfTalkAtTick(segments: string[], tick: number): string | undefined { + let t = tick; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (t <= seg.length) return seg.slice(0, t) || undefined; + t -= seg.length; + if (i === segments.length - 1) return undefined; + if (t <= SELF_TALK_GAP_TICKS) return undefined; + t -= SELF_TALK_GAP_TICKS; + } + return undefined; +} + /** Progressively reveal any streaming message / live-line interstitial text at `speed`. */ function useStreamingReplay( baseItems: TaskChatItem[], @@ -23,6 +54,8 @@ function useStreamingReplay( (i.kind === "message" && i.streaming) || (i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null), ); + const streamingItem = baseItems[streamingIndex]; + const isInterstitial = streamingItem?.kind === "turn"; const fullText = useMemo(() => { const it = baseItems[streamingIndex]; if (!it) return ""; @@ -30,6 +63,10 @@ function useStreamingReplay( if (it.kind === "turn") return it.liveStatus?.selfTalk ?? ""; return ""; }, [baseItems, streamingIndex]); + const segments = useMemo( + () => (isInterstitial ? selfTalkSegments(fullText) : []), + [isInterstitial, fullText], + ); useEffect(() => { if (streamingIndex < 0) { @@ -38,14 +75,21 @@ function useStreamingReplay( } setChars(0); let n = 0; + // One extra tick past the last segment lands on the trailing undefined + // (message ended) state. + const stopAt = isInterstitial + ? segments.reduce((a, s) => a + s.length, 0) + + SELF_TALK_GAP_TICKS * Math.max(0, segments.length - 1) + + 1 + : fullText.length; const baseIntervalMs = 24; const interval = window.setInterval(() => { n += 1; setChars(n); - if (n >= fullText.length) window.clearInterval(interval); + if (n >= stopAt) window.clearInterval(interval); }, Math.max(4, baseIntervalMs / speed)); return () => window.clearInterval(interval); - }, [streamingIndex, fullText, speed, playToken]); + }, [streamingIndex, fullText, isInterstitial, segments, speed, playToken]); if (streamingIndex < 0) return baseItems; return baseItems.map((it, idx) => { @@ -53,10 +97,11 @@ function useStreamingReplay( const shown = fullText.slice(0, chars); const done = chars >= fullText.length; if (it.kind === "message") return { ...it, text: shown, 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. + // Parent-row interstitial (PAP-361/368): reveal the scripted updates + // segment by segment; the pill holds each finished one until the next + // swaps in. if (it.kind === "turn" && it.liveStatus) { - return { ...it, liveStatus: { ...it.liveStatus, selfTalk: shown } }; + return { ...it, liveStatus: { ...it.liveStatus, selfTalk: selfTalkAtTick(segments, chars) } }; } return it; });