Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 13 additions & 22 deletions app/src/components/channels/activity-bar.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -12,35 +16,22 @@ 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,
actionLabel,
}: {
/** 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 (
<AnimatePresence initial={false}>
Expand All @@ -65,14 +56,14 @@ export function AgentActivityBar({
>
<LiquidThinkingOrb />
<span className="tool-line-running min-w-0 truncate text-muted-foreground text-sm">
{actionLabel ?? "Thinking"}
{actionLabel ?? thinkingLabel(seconds)}
</span>
{/* Tabular figures so the count ticks without the digits shifting under themselves. */}
<span
aria-hidden
className="ml-auto shrink-0 font-mono text-[11px] text-muted-foreground/80 tabular-nums"
>
{seconds >= 5 ? `${seconds}s` : ""}
{seconds >= THINKING_SECONDS_SHOWN_AFTER ? `${seconds}s` : ""}
</span>
</motion.div>
) : null}
Expand Down
24 changes: 9 additions & 15 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -195,23 +199,15 @@ async function copyText(text: string): Promise<boolean> {
* 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 (
<p
className="flex items-center gap-2 text-muted-foreground text-sm"
className="flex items-center gap-2 text-muted-foreground text-sm motion-safe:fade-in motion-safe:animate-in motion-safe:slide-in-from-bottom-1"
// `status` rather than `alert`: this is progress, not something that interrupts what somebody
// is doing. The text says it, so a screen reader is told the same thing the orb implies.
data-testid="transcript-thinking"
Expand All @@ -223,9 +219,7 @@ function Thinking() {
* shader and the text treatment never have to know how the other paints.
*/}
<LiquidThinkingOrb />
<span className="tool-line-running">
Thinking{seconds >= 5 ? ` · ${seconds}s` : ""}
</span>
<span className="tool-line-running">{thinkingStatusText(seconds)}</span>
</p>
);
}
Expand Down
23 changes: 17 additions & 6 deletions app/src/components/channels/liquid-thinking-orb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,20 @@ function getSharedRenderer(): Promise<SharedRenderer | null> {
* 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<HTMLCanvasElement>(null);
const shouldReduceMotion = useReducedMotion();
const [rendererReady, setRendererReady] = useState(false);
Expand Down Expand Up @@ -293,12 +300,16 @@ export function LiquidThinkingOrb({
}, [shouldReduceMotion]);

return (
<canvas
<span
{...props}
aria-hidden
className={["liquid-thinking-orb", className].filter(Boolean).join(" ")}
data-renderer={rendererReady ? "webgpu" : "fallback"}
ref={canvasRef}
/>
>
{/* 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. */}
<span className="liquid-thinking-orb-swirl" />
<canvas className="liquid-thinking-orb-canvas" ref={canvasRef} />
</span>
);
}
36 changes: 36 additions & 0 deletions app/src/components/channels/thinking-status.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
59 changes: 59 additions & 0 deletions app/src/components/channels/thinking-status.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Anchor status timers to the turn instead of each mount

When a channel turn runs for at least 45 seconds and a tool finishes, shouldShowThinking remounts the transcript's Thinking component while the activity bar remains mounted for the entire in-flight turn. Each hook instance captures a fresh start here, so the two simultaneously visible surfaces can report contradictory states such as “Thinking” in the transcript and “Still working · 45s” in the bar. Pass a shared turn start time or otherwise preserve elapsed time across thinking-line remounts.

Useful? React with 👍 / 👎.

const timer = setInterval(
() => setSeconds(Math.floor((Date.now() - started) / 1000)),
1000,
);
return () => clearInterval(timer);
}, [active]);

return seconds;
}
12 changes: 10 additions & 2 deletions app/src/routes/_authed/_app/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,22 +26,26 @@ export function BotThinkingCursor({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
const seconds = useThinkingSeconds();

return (
<div
{...props}
aria-live="polite"
className={[
"flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground",
"motion-safe:fade-in motion-safe:animate-in motion-safe:slide-in-from-bottom-1",
className,
]
.filter(Boolean)
.join(" ")}
data-testid="bot-thinking"
role="status"
>
{/* 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. */}
<LiquidThinkingOrb />
<span className="tool-line-running">Thinking…</span>
<span className="tool-line-running">{thinkingStatusText(seconds)}</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep per-second elapsed ticks out of the live region

For screen-reader users during waits longer than five seconds, this text changes every second inside an element with both role="status" and aria-live="polite", which can repeatedly announce or queue the entire status instead of providing occasional progress feedback. Keep the visible seconds in a separate aria-hidden element, as the activity bar does, and reserve the live region for milestone label changes.

Useful? React with 👍 / 👎.

</div>
);
}
Expand Down
Loading
Loading