From 182e5ab03dfd29fb1bcf7bbf6d3bc49b7039a801 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sat, 1 Aug 2026 22:04:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20task-chat=20whimsical=20Running=20w?= =?UTF-8?q?ords=20=E2=80=94=20analog-office=20+=20Claude-Code=20pools,=20d?= =?UTF-8?q?eterministic=2010s=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../task-chat/TaskChatStatusPill.test.tsx | 207 +----------------- .../task-chat/TaskChatStatusPill.tsx | 9 +- .../task-chat/status-whimsy.test.ts | 31 +-- ui/src/components/task-chat/status-whimsy.ts | 31 ++- 4 files changed, 33 insertions(+), 245 deletions(-) diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index 4ef908e3bfe..52d05d6d56c 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -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); - }); }); diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index 2b1275ecc11..0f70e2196fa 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -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 (
- {item.label}… + {label}… {liveElapsed ? ( {liveElapsed} diff --git a/ui/src/components/task-chat/status-whimsy.test.ts b/ui/src/components/task-chat/status-whimsy.test.ts index 988989f55ee..42c8e74c96c 100644 --- a/ui/src/components/task-chat/status-whimsy.test.ts +++ b/ui/src/components/task-chat/status-whimsy.test.ts @@ -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", () => { diff --git a/ui/src/components/task-chat/status-whimsy.ts b/ui/src/components/task-chat/status-whimsy.ts index b9bc3d23f09..aabc9f3e1c5 100644 --- a/ui/src/components/task-chat/status-whimsy.ts +++ b/ui/src/components/task-chat/status-whimsy.ts @@ -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", ];