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; } }