mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): round-8 amendment — interstitial update gets its own row above the status line (never shares it); gerund rotation uninterrupted; row slides in/out of the 1lh viewport via the same line-scroll transition and unmounts after slide-out
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
354bb822e0
commit
4951e2da0e
4 files changed
+101
-65
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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<HTMLSpanElement | null>(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 (
|
||||
<span className="tc-line-scroll min-w-0 flex-1" data-testid="task-chat-live-self-talk">
|
||||
<span
|
||||
ref={innerRef}
|
||||
className="tc-line-scroll-inner block"
|
||||
style={offset > 0 ? { transform: `translateY(calc(${-offset} * 1lh))` } : undefined}
|
||||
style={shift !== 0 ? { transform: `translateY(calc(${shift} * 1lh))` } : undefined}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
</span>
|
||||
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<LiveSelfTalkLine text={item.selfTalk} />
|
||||
{liveElapsed ? (
|
||||
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
|
||||
{liveElapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{item.tokens ? (
|
||||
<span className="shrink-0 font-mono text-(length:--text-micro)">
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
{chevronOpen !== undefined ? (
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
{/* 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}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground"
|
||||
data-testid="task-chat-interstitial-row"
|
||||
>
|
||||
<span aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<LiveSelfTalkLine text={heldSelfTalk.text} leaving={heldSelfTalk.leaving} />
|
||||
</div>
|
||||
{statusLine}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user