-
Notifications
You must be signed in to change notification settings - Fork 0
Level up the thinking orb and make the whole thinking UI/UX feel alive #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||
| }); | ||
| }); |
| 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); | ||
| const timer = setInterval( | ||
| () => setSeconds(Math.floor((Date.now() - started) / 1000)), | ||
| 1000, | ||
| ); | ||
| return () => clearInterval(timer); | ||
| }, [active]); | ||
|
|
||
| return seconds; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For screen-reader users during waits longer than five seconds, this text changes every second inside an element with both Useful? React with 👍 / 👎. |
||
| </div> | ||
| ); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a channel turn runs for at least 45 seconds and a tool finishes,
shouldShowThinkingremounts the transcript'sThinkingcomponent 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 👍 / 👎.