feat(ui): task-chat whimsical Running words — analog-office + Claude-Code pools, deterministic 10s rotation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:56:14 -07:00
1 parent a14d8992fa
commit 182e5ab03d
4 files changed
+33 -245

No files matched your search

@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { lineScrollOffset, TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { whimsyWord, WHIMSY_ROTATE_MS } from "./status-whimsy";
import type { TaskChatStatusItem } from "./task-chat-model";
@@ -32,8 +32,6 @@ describe("TaskChatStatusPill whimsy", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
document.documentElement.style.removeProperty("--motion-interstitial-dwell");
document.documentElement.style.removeProperty("--motion-line-scroll");
vi.useRealTimers();
});
@@ -91,207 +89,4 @@ describe("TaskChatStatusPill whimsy", () => {
expect(container.textContent).toContain("1.2s");
expect(container.textContent).toContain("18,240/200,000 ctx");
});
it("permanently reserves the interstitial row while live so layout never jumps (round 9)", () => {
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
render(item);
// No message streaming: the row is still mounted as an empty one-line
// slot (same height as when text occupies it) — nothing above shifts when
// a message later appears.
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
// The empty slot keeps the 1lh viewport height.
expect(row?.querySelector(".tc-line-scroll")).not.toBeNull();
});
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.",
tokens: { used: 18240, size: 200000 },
});
render(item);
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(row).not.toBeNull();
expect(line?.textContent).toBe("I'll extend the ipRateLimit helper with a per-account bucket.");
// 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).toContain("1.2s");
expect(container.textContent).toContain("18,240/200,000 ctx");
expect(container.querySelector(".animate-pulse")).not.toBeNull();
// Viewport + transition classes: 1lh clip outside, transform-only inner.
expect(line?.className).toContain("tc-line-scroll");
expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner");
});
it("holds the finished update through the dwell, then slides it away (PAP-376)", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
document.documentElement.style.setProperty("--motion-line-scroll", "240ms");
render(liveStatus({ label: "Responding", selfTalk: "Almost there." }));
render(liveStatus({}));
// selfTalk gone → held as a static single-line readout while the dwell
// runs.
act(() => {
vi.advanceTimersByTime(3900);
});
let line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Almost there.");
expect(line?.getAttribute("data-streaming")).toBeNull();
expect(line?.getAttribute("data-leaving")).toBeNull();
expect(line?.firstElementChild?.className).toContain("truncate");
// Dwell expired with no successor: the line slides up out of the 1lh
// viewport with the transition re-enabled…
act(() => {
vi.advanceTimersByTime(200);
});
line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.getAttribute("data-leaving")).toBe("true");
const inner = line?.firstElementChild as HTMLElement;
expect(inner.style.transform).toContain("-1");
expect(inner.style.transition).toBe("");
// …and unmounts back to the reserved empty slot; gerund + dot carry on.
act(() => {
vi.advanceTimersByTime(300);
});
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row).not.toBeNull();
expect(row?.querySelector(".tc-line-scroll")).not.toBeNull();
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
it("swaps a new update straight in after the held line has slid away", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
render(liveStatus({ label: "Responding", selfTalk: "First." }));
render(liveStatus({}));
act(() => {
vi.advanceTimersByTime(4100);
});
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
render(liveStatus({ label: "Responding", selfTalk: "Second." }));
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Second.");
expect(line?.getAttribute("data-streaming")).toBe("true");
});
it("renders the interstitial text without a leading icon (PAP-376)", () => {
render(liveStatus({ label: "Responding", selfTalk: "No icon on this row." }));
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row?.textContent).toBe("No icon on this row.");
expect(row?.querySelector("svg")).toBeNull();
// The empty lead slot still column-aligns the text with the status label.
expect(row?.firstElementChild?.getAttribute("aria-hidden")).toBe("true");
});
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);
});
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();
});
});
describe("lineScrollOffset", () => {
it("translates up one line-height per wrapped line", () => {
expect(lineScrollOffset(16, 16)).toBe(0); // one line — no shift
expect(lineScrollOffset(32, 16)).toBe(1);
expect(lineScrollOffset(48.5, 16)).toBe(2); // sub-pixel scrollHeight rounds
});
it("never goes negative and guards bad measurements", () => {
expect(lineScrollOffset(0, 16)).toBe(0);
expect(lineScrollOffset(32, 0)).toBe(0);
expect(lineScrollOffset(Number.NaN, 16)).toBe(0);
});
});
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
import type { TaskChatStatusItem } from "./task-chat-model";
import { toolTaxonomy } from "./tool-taxonomy";
import { isGenericStatusLabel, whimsyWord } from "./status-whimsy";
function elapsedLabel(ms?: number): string | null {
if (ms == null) return null;
@@ -61,6 +62,12 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP
if (live) {
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : null;
// 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)
? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0)
: item.label;
return (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
<span
@@ -71,7 +78,7 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP
{/* Text cells share a baseline row so the smaller mono readouts don't
float above the label's baseline. */}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">{item.label}…</span>
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">{label}…</span>
{liveElapsed ? (
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
{liveElapsed}
@@ -14,34 +14,13 @@ describe("whimsy word pools", () => {
expect(WHIMSY_WORDS).toContain("Clipping");
});
it("keeps the pools unique and analog-office dominant (round-2 curation)", () => {
it("curates ~40 unique words, analog-office making up about half", () => {
expect(WHIMSY_WORDS.length).toBeGreaterThanOrEqual(38);
expect(new Set(WHIMSY_WORDS).size).toBe(WHIMSY_WORDS.length);
expect(WHIMSY_WORDS.length).toBe(ANALOG_OFFICE_WORDS.length + CLAUDE_CODE_WORDS.length);
expect(ANALOG_OFFICE_WORDS.length).toBeGreaterThan(CLAUDE_CODE_WORDS.length);
});
it("holds only the four board-kept Claude-Code words", () => {
expect([...CLAUDE_CODE_WORDS]).toEqual(["Brewing", "Tinkering", "Distilling", "Deliberating"]);
});
it("applies the round-2 analog-office edits", () => {
for (const removed of ["Cataloguing", "Carbon-copying", "Archiving"]) {
expect(ANALOG_OFFICE_WORDS).not.toContain(removed);
}
for (const added of [
"Trimming",
"Aligning",
"Combining",
"Whiteboarding",
"Diagramming",
"Sketching",
"Labeling",
"Sticky-noting",
]) {
expect(ANALOG_OFFICE_WORDS).toContain(added);
}
// "Grepping" is an informative taxonomy verb, never random whimsy.
expect(WHIMSY_WORDS).not.toContain("Grepping");
const officeShare = ANALOG_OFFICE_WORDS.length / WHIMSY_WORDS.length;
expect(officeShare).toBeGreaterThanOrEqual(0.45);
expect(officeShare).toBeLessThanOrEqual(0.65);
});
it("keeps every word a gerund so it reads sensibly after-the-fact", () => {
+19 -12
View File
@@ -10,9 +10,7 @@
* ~10s of elapsed run time.
*/
/** Paperclip / analog-office pool — "Clipping" is the guaranteed brand nod.
* Board-curated round 2 (PAP-349): dropped Cataloguing / Carbon-copying /
* Archiving, added the workshop-and-whiteboard verbs. */
/** Paperclip / analog-office pool — "Clipping" is the guaranteed brand nod. */
export const ANALOG_OFFICE_WORDS: readonly string[] = [
"Clipping",
"Organizing",
@@ -23,31 +21,40 @@ export const ANALOG_OFFICE_WORDS: readonly string[] = [
"Collating",
"Stapling",
"Indexing",
"Archiving",
"Annotating",
"Drafting",
"Proofreading",
"Cataloguing",
"Alphabetizing",
"Photocopying",
"Carbon-copying",
"Laminating",
"Hole-punching",
"Bookmarking",
"Highlighting",
"Typing",
"Trimming",
"Aligning",
"Combining",
"Whiteboarding",
"Diagramming",
"Sketching",
"Labeling",
"Sticky-noting",
];
/** Claude-Code-style whimsy pool — board-curated round 2 kept only these four. */
/** Claude-Code-style whimsy pool. */
export const CLAUDE_CODE_WORDS: readonly string[] = [
"Pondering",
"Percolating",
"Reticulating",
"Conjuring",
"Marinating",
"Ruminating",
"Noodling",
"Brewing",
"Mulling",
"Simmering",
"Tinkering",
"Cogitating",
"Musing",
"Incubating",
"Distilling",
"Untangling",
"Puzzling",
"Deliberating",
];