feat(ui): interstitial held line — hold-until-superseded + ~4s min dwell

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 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:57:06 -07:00
1 parent 04397ac526
commit 96037fec6e
8 files changed
+332 -57

No files matched your search

@@ -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();
});
});
@@ -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<HTMLSpanElement | null>(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 (
<span className="tc-line-scroll min-w-0 flex-1" data-testid="task-chat-live-self-talk">
<span
className="tc-line-scroll min-w-0 flex-1"
data-testid="task-chat-live-self-talk"
data-streaming={streaming || undefined}
>
<span
ref={innerRef}
className="tc-line-scroll-inner block"
style={shift !== 0 ? { transform: `translateY(calc(${shift} * 1lh))` } : undefined}
className={cn("tc-line-scroll-inner block", !streaming && "truncate")}
style={{
transform: shift !== 0 ? `translateY(calc(${shift} * 1lh))` : undefined,
transition: settled ? "none" : undefined,
}}
>
{text}
</span>
@@ -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<string | null>(selfTalk ?? null);
function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfTalk | null {
const [line, setLine] = useState<HeldSelfTalk | null>(null);
const lineRef = useRef<HeldSelfTalk | null>(line);
const pendingRef = useRef<string | null>(null);
const tailRef = useRef<string | undefined>(selfTalk);
const lastSwapAtRef = useRef(0);
const timerRef = useRef<number | null>(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 (
<div className="flex flex-col">
<div
@@ -222,7 +315,11 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
{heldSelfTalk ? (
<>
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<LiveSelfTalkLine text={heldSelfTalk.text} leaving={heldSelfTalk.leaving} />
<LiveSelfTalkLine
key={heldSelfTalk.swapKey}
text={heldSelfTalk.text}
streaming={heldSelfTalk.streaming}
/>
</>
) : (
<span aria-hidden className="tc-line-scroll min-w-0 flex-1" />
@@ -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 },
@@ -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",
@@ -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;
@@ -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<TaskChatStateId, TaskChatStateMeta> =
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",
+4
View File
@@ -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;
+50 -5
View File
@@ -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;
});