;
+ }) as unknown as typeof setInterval;
+ globalThis.clearInterval = (() => {
+ state.clears += 1;
+ }) as unknown as typeof clearInterval;
+ return {
+ calls,
+ get clears() {
+ return state.clears;
+ },
+ restore: () => {
+ globalThis.setInterval = originalSet;
+ globalThis.clearInterval = originalClear;
+ },
+ };
+}
+
+function mount(props: { verbs: readonly string[]; intervalMs?: number }) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(createElement(ThinkingLabel, props));
+ });
+ return {
+ container,
+ // Every verb stays mounted in the stacked grid — the visible one is the
+ // cell carrying opacity-100.
+ visible: () => container.querySelector('[aria-hidden] .opacity-100')?.textContent ?? "",
+ text: () => container.textContent ?? "",
+ status: () => container.querySelector('[role="status"]'),
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+describe("ThinkingLabel", () => {
+ const restores: Restore[] = [];
+ afterEach(() => {
+ while (restores.length > 0) restores.pop()?.();
+ });
+
+ test("empty verbs render nothing", () => {
+ const html = renderToStaticMarkup(createElement(ThinkingLabel, { verbs: [] }));
+ expect(html).toBe("");
+ });
+
+ test("reduced motion does not setInterval", () => {
+ restores.push(stubMatchMedia(true).restore);
+ const interval = stubInterval();
+ restores.push(interval.restore);
+
+ const handle = mount({ verbs: ["Reading", "Thinking"] });
+ expect(interval.calls).toHaveLength(0);
+ expect(handle.visible()).toBe("Reading");
+ handle.unmount();
+ });
+
+ test("turning reduced motion on live stops the interval", () => {
+ const media = stubMatchMedia(false);
+ restores.push(media.restore);
+ const interval = stubInterval();
+ restores.push(interval.restore);
+
+ const handle = mount({ verbs: ["Reading", "Thinking"] });
+ expect(interval.calls).toHaveLength(1);
+
+ act(() => {
+ interval.calls[0]?.fn();
+ });
+ expect(handle.visible()).toBe("Thinking");
+
+ act(() => media.set(true));
+ expect(interval.clears).toBeGreaterThan(0);
+ expect(handle.visible()).toBe("Reading");
+
+ handle.unmount();
+ });
+
+ test("rotation advances after intervalMs", () => {
+ restores.push(stubMatchMedia(false).restore);
+ const interval = stubInterval();
+ restores.push(interval.restore);
+
+ const handle = mount({ verbs: ["Reading", "Thinking", "Writing"], intervalMs: 50 });
+ expect(interval.calls).toHaveLength(1);
+ expect(interval.calls[0]?.ms).toBe(50);
+ expect(handle.visible()).toBe("Reading");
+
+ act(() => {
+ interval.calls[0]?.fn();
+ });
+ expect(handle.visible()).toBe("Thinking");
+
+ act(() => {
+ interval.calls[0]?.fn();
+ });
+ expect(handle.visible()).toBe("Writing");
+
+ handle.unmount();
+ });
+
+ test("status announces Working once and does not live-announce verb ticks", () => {
+ restores.push(stubMatchMedia(false).restore);
+ const interval = stubInterval();
+ restores.push(interval.restore);
+
+ const handle = mount({ verbs: ["Reading", "Thinking"], intervalMs: 50 });
+ expect(handle.status()?.textContent).toBe("Working");
+ expect(handle.status()?.getAttribute("aria-live")).toBeNull();
+ expect(handle.container.querySelector("[aria-live]")).toBeNull();
+
+ act(() => {
+ interval.calls[0]?.fn();
+ });
+ expect(handle.status()?.textContent).toBe("Working");
+ expect(handle.visible()).toBe("Thinking");
+
+ handle.unmount();
+ });
+});
diff --git a/src/ui/thinking-label.tsx b/src/ui/thinking-label.tsx
new file mode 100644
index 00000000..b6071819
--- /dev/null
+++ b/src/ui/thinking-label.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from "react";
+
+import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
+import { cn } from "../lib/utils.js";
+import { ShimmerText } from "./shimmer-text.js";
+
+export type ThinkingLabelProps = {
+ /** What the agent is doing, in its own words — rotated in order. */
+ readonly verbs: readonly string[];
+ /** Rotation cadence. A heartbeat, not a ticker — keep it at ~1s or slower. */
+ readonly intervalMs?: number;
+ /** What the status role announces, once. Defaults to "Working". */
+ readonly statusLabel?: string;
+ readonly className?: string;
+};
+
+const DEFAULT_INTERVAL_MS = 3_000;
+
+/**
+ * The shimmering "working" word, rotating through caller-supplied verbs on
+ * an interval. Every verb renders stacked in one grid cell, so the element's
+ * width is the widest verb and a swap crossfades instead of reflowing
+ * siblings. Visual rotation is decorative: a single `role="status"`
+ * announces "Working" once, so a screen reader is not re-interrupted every
+ * few seconds. Reduced motion is live via `usePrefersReducedMotion` and
+ * stops the rotation rather than just stilling the shimmer.
+ */
+export function ThinkingLabel({
+ verbs,
+ intervalMs = DEFAULT_INTERVAL_MS,
+ statusLabel = "Working",
+ className,
+}: ThinkingLabelProps) {
+ const [at, setAt] = useState(0);
+ const reduceMotion = usePrefersReducedMotion();
+
+ useEffect(() => {
+ if (verbs.length < 2 || !reduceMotion) return;
+ setAt(0);
+ }, [verbs.length, reduceMotion]);
+
+ useEffect(() => {
+ if (verbs.length < 2 || reduceMotion) return;
+ const timer = setInterval(() => setAt((current) => (current + 1) % verbs.length), intervalMs);
+ return () => clearInterval(timer);
+ }, [verbs.length, intervalMs, reduceMotion]);
+
+ if (verbs.length === 0) return null;
+
+ const active = at % verbs.length;
+
+ return (
+
+
+ {statusLabel}
+
+
+ {verbs.map((verb, index) => (
+
+ {verb}
+
+ ))}
+
+
+ );
+}
diff --git a/stories/primitives/shimmer-text.stories.tsx b/stories/primitives/shimmer-text.stories.tsx
new file mode 100644
index 00000000..17997331
--- /dev/null
+++ b/stories/primitives/shimmer-text.stories.tsx
@@ -0,0 +1,15 @@
+import { ShimmerText } from "../../src/ui/shimmer-text.js";
+
+export default { title: "Primitives / Shimmer text" };
+
+export const Default = () => (
+
+ Reading the codebase…
+
+);
+
+export const AsStatus = () => (
+
+ Searching…
+
+);
diff --git a/stories/primitives/thinking-label.stories.tsx b/stories/primitives/thinking-label.stories.tsx
new file mode 100644
index 00000000..862a0fcf
--- /dev/null
+++ b/stories/primitives/thinking-label.stories.tsx
@@ -0,0 +1,21 @@
+import { ThinkingLabel } from "../../src/ui/thinking-label.js";
+
+export default { title: "Primitives / Thinking label" };
+
+export const Default = () => (
+
+
+
+);
+
+export const FastRotation = () => (
+
+
+
+);
+
+export const SingleVerb = () => (
+
+
+
+);