mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
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", () => {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user