diff --git a/app/src/components/channels/activity-bar.tsx b/app/src/components/channels/activity-bar.tsx
index 1930472..9548dd2 100644
--- a/app/src/components/channels/activity-bar.tsx
+++ b/app/src/components/channels/activity-bar.tsx
@@ -1,6 +1,10 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
-import { useEffect, useState } from "react";
import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb";
+import {
+ THINKING_SECONDS_SHOWN_AFTER,
+ thinkingLabel,
+ useThinkingSeconds,
+} from "@/components/channels/thinking-status";
import { EASE_OUT } from "@/lib/motion";
/**
@@ -12,9 +16,10 @@ import { EASE_OUT } from "@/lib/motion";
* scroll. Whatever part of the transcript is on screen, the fact that the Bot is still working, what
* it was last seen doing, and for how long stay in one fixed place.
*
- * The action label is the latest tool call's humanised name ("Reading the page"), or plain thinking
- * before the first tool runs. Both shimmer through the same `.tool-line-running` treatment a running
- * tool line uses, so "working" reads identically everywhere it appears.
+ * The action label is the latest tool call's humanised name ("Reading the page"), or the shared
+ * escalating thinking words before the first tool runs and between steps. Both shimmer through the
+ * same `.tool-line-running` treatment a running tool line uses, so "working" reads identically
+ * everywhere it appears.
*/
export function AgentActivityBar({
active,
@@ -22,25 +27,11 @@ export function AgentActivityBar({
}: {
/** A turn is in flight. False collapses the bar entirely. */
active: boolean;
- /** What the Bot was last seen doing; undefined falls back to "Thinking". */
+ /** What the Bot was last seen doing; undefined falls back to the thinking words. */
actionLabel?: string | undefined;
}) {
const shouldReduceMotion = useReducedMotion();
- const [seconds, setSeconds] = useState(0);
-
- useEffect(() => {
- if (!active) {
- setSeconds(0);
- return;
- }
- const started = Date.now();
- setSeconds(Math.floor((Date.now() - started) / 1000));
- const timer = setInterval(
- () => setSeconds(Math.floor((Date.now() - started) / 1000)),
- 1000,
- );
- return () => clearInterval(timer);
- }, [active]);
+ const seconds = useThinkingSeconds(active);
return (
@@ -65,14 +56,14 @@ export function AgentActivityBar({
>
- {actionLabel ?? "Thinking"}
+ {actionLabel ?? thinkingLabel(seconds)}
{/* Tabular figures so the count ticks without the digits shifting under themselves. */}
- {seconds >= 5 ? `${seconds}s` : ""}
+ {seconds >= THINKING_SECONDS_SHOWN_AFTER ? `${seconds}s` : ""}
) : null}
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 98e1cf6..b6aede9 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -25,6 +25,10 @@ import {
} from "react";
import { Streamdown } from "streamdown";
import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb";
+import {
+ thinkingStatusText,
+ useThinkingSeconds,
+} from "@/components/channels/thinking-status";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
MessageContent,
@@ -195,23 +199,15 @@ async function copyText(text: string): Promise {
* thinking is indistinguishable from a Bot that failed silently — which this app has shipped before.
*
* It borrows the shimmer a running tool line uses, so "working on it" reads the same whether the
- * work is a tool call or a model that has not spoken yet. After a few seconds it starts counting,
- * which is the difference between "still working" and "you should wonder".
+ * work is a tool call or a model that has not spoken yet. The words and the count come from the
+ * shared thinking vocabulary, so a long wait escalates here exactly as it does in the activity bar.
*/
function Thinking() {
- const [seconds, setSeconds] = useState(0);
- useEffect(() => {
- const started = Date.now();
- const timer = setInterval(
- () => setSeconds(Math.floor((Date.now() - started) / 1000)),
- 1000,
- );
- return () => clearInterval(timer);
- }, []);
+ const seconds = useThinkingSeconds();
return (
-
- Thinking{seconds >= 5 ? ` · ${seconds}s` : ""}
-
+ {thinkingStatusText(seconds)}
);
}
diff --git a/app/src/components/channels/liquid-thinking-orb.tsx b/app/src/components/channels/liquid-thinking-orb.tsx
index 8daf386..eac2aa0 100644
--- a/app/src/components/channels/liquid-thinking-orb.tsx
+++ b/app/src/components/channels/liquid-thinking-orb.tsx
@@ -170,13 +170,20 @@ function getSharedRenderer(): Promise {
* The supplied liquid-glass animation, shared by every surface that means "the model is working".
*
* Each visible orb owns only a tiny canvas, uniform buffer, and render loop. The device, compiled
- * shader, and pipeline are shared across instances. Browsers without WebGPU keep the CSS-painted
- * glass fallback; reduced-motion users receive one still WebGPU frame instead of a loop.
+ * shader, and pipeline are shared across instances.
+ *
+ * The canvas is one layer of a small composition rather than the whole orb. Behind it sits a
+ * breathing bloom that both renderers share — the shader clips at its square, so the light it
+ * throws past its own limb has to be painted outside the canvas. In front of and behind it live a
+ * CSS-painted sphere (a rotating liquid swirl under a fixed glass sheen) that is the whole orb for
+ * browsers without WebGPU, and disappears the moment the shader lands its first frame.
+ * Reduced-motion users get one still WebGPU frame — or the still fallback sphere — with nothing
+ * animating around it.
*/
export function LiquidThinkingOrb({
className,
...props
-}: ComponentProps<"canvas">) {
+}: ComponentProps<"span">) {
const canvasRef = useRef(null);
const shouldReduceMotion = useReducedMotion();
const [rendererReady, setRendererReady] = useState(false);
@@ -293,12 +300,16 @@ export function LiquidThinkingOrb({
}, [shouldReduceMotion]);
return (
-
+ >
+ {/* The fallback sphere's moving body. Painted whenever the shader has no frame up yet, so
+ * there is never a blank beat between mount and WebGPU's first render. */}
+
+
+
);
}
diff --git a/app/src/components/channels/thinking-status.test.ts b/app/src/components/channels/thinking-status.test.ts
new file mode 100644
index 0000000..6592e60
--- /dev/null
+++ b/app/src/components/channels/thinking-status.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+import {
+ THINKING_SECONDS_SHOWN_AFTER,
+ thinkingLabel,
+ thinkingStatusText,
+} from "./thinking-status";
+
+describe("thinkingLabel", () => {
+ test("escalates as the wait grows", () => {
+ expect(thinkingLabel(0)).toBe("Thinking");
+ expect(thinkingLabel(7)).toBe("Thinking");
+ expect(thinkingLabel(8)).toBe("Still thinking");
+ expect(thinkingLabel(19)).toBe("Still thinking");
+ expect(thinkingLabel(20)).toBe("Working through it");
+ expect(thinkingLabel(44)).toBe("Working through it");
+ expect(thinkingLabel(45)).toBe("Still working");
+ expect(thinkingLabel(600)).toBe("Still working");
+ });
+});
+
+describe("thinkingStatusText", () => {
+ test("hides the count until it has earned its place", () => {
+ expect(thinkingStatusText(0)).toBe("Thinking");
+ expect(thinkingStatusText(THINKING_SECONDS_SHOWN_AFTER - 1)).toBe(
+ "Thinking",
+ );
+ });
+
+ test("appends elapsed seconds once the wait is long enough to wonder about", () => {
+ expect(thinkingStatusText(THINKING_SECONDS_SHOWN_AFTER)).toBe(
+ "Thinking · 5s",
+ );
+ expect(thinkingStatusText(12)).toBe("Still thinking · 12s");
+ expect(thinkingStatusText(90)).toBe("Still working · 90s");
+ });
+});
diff --git a/app/src/components/channels/thinking-status.ts b/app/src/components/channels/thinking-status.ts
new file mode 100644
index 0000000..f156363
--- /dev/null
+++ b/app/src/components/channels/thinking-status.ts
@@ -0,0 +1,59 @@
+import { useEffect, useState } from "react";
+
+/**
+ * The one vocabulary for "the Bot is working", shared by every thinking surface.
+ *
+ * Three places draw the wait — the transcript's thinking line, the packaged chat's cursor, and the
+ * activity bar above the composer — and each had grown its own timer and its own copy. A person who
+ * scrolls mid-turn sees two of them at once, so they must agree to the second and to the word.
+ */
+
+/** Elapsed seconds stay hidden below this; a count under it reads as nagging, not progress. */
+export const THINKING_SECONDS_SHOWN_AFTER = 5;
+
+/**
+ * What the wait is called, escalating with how long it has lasted.
+ *
+ * The escalation is the honest version of a spinner: "Thinking" for a beat is expected, but the
+ * same word at a minute reads as frozen. Changing the sentence tells the person the interface is
+ * still alive and still knows time is passing — the difference between "still working" and "you
+ * should wonder" — without promising anything about why it is slow.
+ */
+export function thinkingLabel(seconds: number): string {
+ if (seconds < 8) return "Thinking";
+ if (seconds < 20) return "Still thinking";
+ if (seconds < 45) return "Working through it";
+ return "Still working";
+}
+
+/** The label plus the elapsed count once it has earned its place. */
+export function thinkingStatusText(seconds: number): string {
+ const count = seconds >= THINKING_SECONDS_SHOWN_AFTER ? ` · ${seconds}s` : "";
+ return `${thinkingLabel(seconds)}${count}`;
+}
+
+/**
+ * Seconds since `active` last became true; 0 while inactive.
+ *
+ * The interval anchors to a captured start time rather than incrementing state, so a throttled
+ * background tab that fires late still reports true elapsed time when the person tabs back.
+ */
+export function useThinkingSeconds(active = true): number {
+ const [seconds, setSeconds] = useState(0);
+
+ useEffect(() => {
+ if (!active) {
+ setSeconds(0);
+ return;
+ }
+ const started = Date.now();
+ setSeconds(0);
+ const timer = setInterval(
+ () => setSeconds(Math.floor((Date.now() - started) / 1000)),
+ 1000,
+ );
+ return () => clearInterval(timer);
+ }, [active]);
+
+ return seconds;
+}
diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx
index 0af406e..709b7ae 100644
--- a/app/src/routes/_authed/_app/bot.tsx
+++ b/app/src/routes/_authed/_app/bot.tsx
@@ -13,6 +13,10 @@ import {
useState,
} from "react";
import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb";
+import {
+ thinkingStatusText,
+ useThinkingSeconds,
+} from "@/components/channels/thinking-status";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { useBotThread } from "@/lib/copilot/bot-thread";
import { useStoppedTurn } from "@/lib/copilot/stopped-turn";
@@ -22,12 +26,15 @@ export function BotThinkingCursor({
className,
...props
}: HTMLAttributes) {
+ const seconds = useThinkingSeconds();
+
return (
- {/* The same liquid orb as channel thinking, so waiting looks like one product. */}
+ {/* The same liquid orb — and the same escalating words — as channel thinking, so waiting
+ * looks like one product wherever it happens. */}
- Thinking…
+ {thinkingStatusText(seconds)}
);
}
diff --git a/app/src/styles.css b/app/src/styles.css
index b7e710d..5f1b775 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -254,56 +254,135 @@ body {
}
/*
- * The model-working cursor. WebGPU paints the supplied liquid-glass shader into this canvas; the
- * gradient is an immediate fallback while that renderer starts and for browsers without WebGPU.
- * The shader itself supplies the movement, so the canvas stays still once WebGPU has its first
- * frame. At 20 CSS pixels and up to 2x DPR, the detailed sphere remains crisp without rendering a
- * large off-screen texture for every thinking surface.
+ * The model-working orb, as a small composition rather than one painted canvas.
+ *
+ * WebGPU paints the supplied liquid-glass shader into the canvas layer. Around it:
+ *
+ * - `::before` is the bloom — the light the sphere throws past its own limb. The shader clips at
+ * its square, so this halo is the only thing that can glow OUTSIDE the orb, and it breathes so
+ * the orb reads as alive even from across the screen where the fluid's internal motion is too
+ * small to register. It stays up under both renderers.
+ * - `.liquid-thinking-orb-swirl` is the fallback sphere's body: a conic liquid gradient rotating
+ * under a fixed lens, standing in from first paint until the shader's first frame, and for
+ * browsers without WebGPU, permanently. Rotation is a compositor transform; nothing lays out.
+ * - `::after` is the fallback's glass — a fixed specular highlight and rim over the moving body,
+ * which is what makes the rotation underneath read as fluid inside a sphere rather than a
+ * spinning disc.
+ *
+ * Once WebGPU lands a frame the CSS sphere and its glass vanish and the shader owns the ball; the
+ * shader supplies its own movement, so the canvas itself stays still. At 20 CSS pixels and up to
+ * 2x DPR, the detailed sphere remains crisp without rendering a large off-screen texture for every
+ * thinking surface. Pseudo-elements and the swirl stack purely by source order: bloom, body,
+ * canvas, glass.
*/
.liquid-thinking-orb {
+ position: relative;
display: block;
flex: none;
width: 1.25rem;
height: 1.25rem;
+ animation: liquid-thinking-orb-enter 0.24s cubic-bezier(0.23, 1, 0.32, 1);
+}
+
+.liquid-thinking-orb::before {
+ content: "";
+ position: absolute;
+ inset: -35%;
+ border-radius: 9999px;
+ background: radial-gradient(
+ circle,
+ oklch(0.66 0.17 250 / 50%) 0 28%,
+ oklch(0.58 0.19 278 / 22%) 52%,
+ transparent 70%
+ );
+ filter: blur(2px);
+ animation: liquid-thinking-orb-breathe 2.8s ease-in-out infinite;
+}
+
+.liquid-thinking-orb-swirl {
+ position: absolute;
+ inset: 0;
border-radius: 9999px;
background:
radial-gradient(
- circle at 37% 30%,
- oklch(0.87 0.1 235 / 95%) 0 10%,
- transparent 25%
+ circle at 50% 58%,
+ oklch(0.3 0.15 265 / 85%) 0 22%,
+ transparent 56%
),
- radial-gradient(
- circle,
- oklch(0.43 0.18 255) 0 42%,
- oklch(0.69 0.17 240) 54%,
- transparent 61%
+ conic-gradient(
+ from 210deg,
+ oklch(0.45 0.19 262),
+ oklch(0.7 0.15 233),
+ oklch(0.52 0.2 290),
+ oklch(0.4 0.18 252),
+ oklch(0.45 0.19 262)
);
- filter: drop-shadow(0 0 0.22rem oklch(0.65 0.17 250 / 45%));
+ animation: liquid-thinking-orb-spin 5.2s linear infinite;
+}
+
+.liquid-thinking-orb-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 9999px;
}
-.liquid-thinking-orb[data-renderer="fallback"] {
- animation: liquid-thinking-orb-fallback 2s ease-in-out infinite;
+.liquid-thinking-orb::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 9999px;
+ background: radial-gradient(
+ circle at 36% 28%,
+ oklch(0.97 0.04 230 / 88%) 0 9%,
+ oklch(0.9 0.07 235 / 32%) 20%,
+ transparent 44%
+ );
+ box-shadow:
+ inset 0 0 0.16rem oklch(0.85 0.1 240 / 50%),
+ inset 0 -0.14rem 0.3rem oklch(0.32 0.16 278 / 60%);
}
-.liquid-thinking-orb[data-renderer="webgpu"] {
- background: transparent;
+/* The shader has the ball: drop the CSS sphere and its glass, keep the bloom. */
+.liquid-thinking-orb[data-renderer="webgpu"] .liquid-thinking-orb-swirl,
+.liquid-thinking-orb[data-renderer="webgpu"]::after {
+ display: none;
}
-@keyframes liquid-thinking-orb-fallback {
+@keyframes liquid-thinking-orb-enter {
+ from {
+ transform: scale(0.5);
+ opacity: 0;
+ }
+}
+
+@keyframes liquid-thinking-orb-breathe {
0%,
100% {
- transform: scale(1);
- opacity: 0.85;
+ transform: scale(0.9);
+ opacity: 0.65;
}
50% {
- transform: scale(1.18);
+ transform: scale(1.08);
opacity: 1;
}
}
-/* A still rendered frame or fallback for reduced-motion users: present, glowing, not moving. */
+@keyframes liquid-thinking-orb-spin {
+ to {
+ transform: rotate(1turn);
+ }
+}
+
+/*
+ * A still sphere for reduced-motion users: present, lit, glowing, not moving. WebGPU's side of the
+ * same promise — one rendered frame instead of a loop — lives in the component.
+ */
@media (prefers-reduced-motion: reduce) {
- .liquid-thinking-orb {
+ .liquid-thinking-orb,
+ .liquid-thinking-orb::before,
+ .liquid-thinking-orb-swirl {
animation: none;
}
}