mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
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:
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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user