From 86d11a4ada0fe58fefe483aa98e51e7023e8d11d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 18:36:15 +0000 Subject: [PATCH] feat(app): make the whole thinking experience feel alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orb becomes one continuous character: a running tool line now shows the same liquid orb (small) where the anonymous step spinner was, so when the thinking line yields to a running step the orb reads as moving into the work rather than vanishing. The unused step-spinner CSS goes. Around it, five new thinking-surface behaviors: - Tab status (lib/tab-status.ts): a pulsing orb-blue favicon dot and "●" title prefix while a turn runs; if the answer lands in a hidden tab the dot becomes a green check and the title says "✓" until the person next looks. No notification permission involved. - Turn receipt: a long turn no longer collapses into nothing — a quiet "Worked for 47s · 3 steps" line takes the thinking line's slot until the next turn starts. Turns under 10s earn no receipt. - Settle sweep: the answer that just finished gets a one-time soft band of orb-blue light sweeping across it, marking the done moment. - Live stream tail: while scrolled up mid-turn, the scroll-to-end pill quotes the trailing words of the answer currently streaming, so the conversation stays visibly alive from anywhere in history. - Minimap: conversations with six or more of the person's messages get a dot rail along the right edge — hover for the first words, click to jump — sampled down for very long histories, hidden on small screens. The thinking line also enters and exits through AnimatePresence instead of popping between frames. All movement is transform/opacity, and reduced motion is respected on every new piece. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKhN2rgAuEbGHyiLvpPY52 --- .../components/channels/chat-messages.test.ts | 82 ++++++ app/src/components/channels/chat-messages.ts | 39 +++ .../components/channels/chat-transcript.tsx | 244 ++++++++++++++++-- .../components/channels/conversation-view.tsx | 5 + .../channels/thinking-status.test.ts | 15 ++ .../components/channels/thinking-status.ts | 18 ++ app/src/components/channels/tool-line.tsx | 10 +- app/src/lib/tab-status.ts | 148 +++++++++++ app/src/routes/_authed/_app/bot.tsx | 5 + app/src/styles.css | 59 +++-- 10 files changed, 587 insertions(+), 38 deletions(-) create mode 100644 app/src/lib/tab-status.ts diff --git a/app/src/components/channels/chat-messages.test.ts b/app/src/components/channels/chat-messages.test.ts index b3cda96..c1cc7e8 100644 --- a/app/src/components/channels/chat-messages.test.ts +++ b/app/src/components/channels/chat-messages.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import type { Message } from "@ag-ui/core"; import { + latestStreamTail, searchableMessageIds, shouldShowThinking, + toolStepsSinceLastUser, toVisibleChatItems, + type VisibleChatItem, } from "./chat-messages"; const messages: Message[] = [ @@ -159,3 +162,82 @@ describe("visible chat messages", () => { ]); }); }); + +describe("latestStreamTail", () => { + const streaming: VisibleChatItem[] = [ + { kind: "text", id: "user-1", role: "user", text: "Question" }, + { + kind: "text", + id: "assistant-1", + role: "assistant", + text: "## Findings\nThe *third* option is `usually` the safest one to pick here", + }, + ]; + + test("quotes the trailing words of a streaming answer, markdown stripped", () => { + const tail = latestStreamTail(true, streaming); + expect(tail).toStartWith("…"); + expect(tail).toEndWith("the safest one to pick here"); + expect(tail).not.toContain("*"); + expect(tail).not.toContain("`"); + }); + + test("short answers pass through whole, without an ellipsis", () => { + expect( + latestStreamTail(true, [ + { kind: "text", id: "a", role: "assistant", text: "On it." }, + ]), + ).toBe("On it."); + }); + + test("null without a streaming answer to quote", () => { + expect(latestStreamTail(false, streaming)).toBeNull(); + expect(latestStreamTail(true, [])).toBeNull(); + expect( + latestStreamTail(true, [ + { kind: "text", id: "user-1", role: "user", text: "Question" }, + ]), + ).toBeNull(); + expect( + latestStreamTail(true, [ + { kind: "text", id: "a", role: "assistant", text: "***" }, + ]), + ).toBeNull(); + }); +}); + +describe("toolStepsSinceLastUser", () => { + const tool = (id: string, result?: string): VisibleChatItem => ({ + kind: "tool", + id, + messageId: `m-${id}`, + toolCall: { + id, + type: "function", + function: { name: "computer_click", arguments: "{}" }, + }, + ...(result === undefined ? {} : { result }), + }); + + test("counts only the turn being answered", () => { + expect( + toolStepsSinceLastUser([ + { kind: "text", id: "u1", role: "user", text: "First" }, + tool("t1", "done"), + { kind: "text", id: "a1", role: "assistant", text: "Answer" }, + { kind: "text", id: "u2", role: "user", text: "Second" }, + tool("t2", "done"), + tool("t3"), + ]), + ).toBe(2); + }); + + test("zero for a turn that used no tools, or an empty transcript", () => { + expect( + toolStepsSinceLastUser([ + { kind: "text", id: "u1", role: "user", text: "Hi" }, + ]), + ).toBe(0); + expect(toolStepsSinceLastUser([])).toBe(0); + }); +}); diff --git a/app/src/components/channels/chat-messages.ts b/app/src/components/channels/chat-messages.ts index c4b0818..31640f4 100644 --- a/app/src/components/channels/chat-messages.ts +++ b/app/src/components/channels/chat-messages.ts @@ -189,6 +189,45 @@ export function shouldShowThinking( return lastItem.role === "user"; } +/** + * The trailing words of the answer currently streaming, for the scroll-to-end affordance. + * + * A person scrolled up mid-turn sees a transcript that stopped moving and a bare arrow button; + * whether the Bot is still talking is invisible from there. Feeding the live tail of the streaming + * text into that button turns it from "there is more below" into "this is being said right now" — + * the conversation stays visibly alive from anywhere in history. + * + * Null whenever there is no streaming answer to quote, so the button can fall back to its arrow. + * Markdown marks are stripped crudely; this is a glimpse, not a rendering. + */ +export function latestStreamTail( + busy: boolean, + items: readonly VisibleChatItem[], +): string | null { + if (!busy) return null; + const last = items.at(-1); + if (last?.kind !== "text" || last.role !== "assistant") return null; + const flat = last.text + .replace(/[`*_#|>-]/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!flat) return null; + return flat.length <= 48 ? flat : `…${flat.slice(-46).trimStart()}`; +} + +/** Tool calls in the turn being (or just) answered: everything after the person last spoke. */ +export function toolStepsSinceLastUser( + items: readonly VisibleChatItem[], +): number { + let steps = 0; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || (item.kind === "text" && item.role === "user")) break; + if (item.kind === "tool") steps += 1; + } + return steps; +} + export function searchableMessageIds( messages: ReadonlyArray>, query: string, diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index b6aede9..e8c5a63 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -4,9 +4,11 @@ import { useRenderToolCall, } from "@copilotkit/react-core/v2"; import { + IconArrowDown, IconBox, IconCheck, IconChevronRight, + IconCircleCheck, IconCopy, IconFile, IconPencil, @@ -14,7 +16,7 @@ import { IconRefresh, } from "@tabler/icons-react"; import Avatar from "boring-avatars"; -import { motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { memo, type ReactNode, @@ -26,6 +28,7 @@ import { import { Streamdown } from "streamdown"; import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb"; import { + describeTurnReceipt, thinkingStatusText, useThinkingSeconds, } from "@/components/channels/thinking-status"; @@ -54,7 +57,9 @@ import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { readToolName } from "@/lib/plugins/tool-name"; import { asText, forDisplay, REFUSAL_MARKER } from "@/lib/plugins/tool-result"; import { + latestStreamTail, shouldShowThinking, + toolStepsSinceLastUser, toVisibleChatItems, type VisibleAttachment, type VisibleChatItem, @@ -204,14 +209,25 @@ async function copyText(text: string): Promise { */ function Thinking() { const seconds = useThinkingSeconds(); + const shouldReduceMotion = useReducedMotion(); return ( -

{/* * The shimmer lives on the TEXT SPAN, not the paragraph: `background-clip: text` makes the @@ -220,7 +236,37 @@ function Thinking() { */} {thinkingStatusText(seconds)} -

+ + ); +} + +/** + * What a long turn cost, left where the thinking line was. + * + * Thirty seconds of orb used to collapse into nothing the instant the answer landed, which retells + * the wait as if it never happened. This is the receipt — the duration and the number of steps — + * quiet, under the answer, gone when the next turn starts. `describeTurnReceipt` decides which + * turns earn one; a note about a two-second wait would read as an apology. + */ +function TurnReceipt({ text }: { text: string }) { + const shouldReduceMotion = useReducedMotion(); + + return ( + + + {text} + ); } @@ -693,6 +739,8 @@ type TranscriptMessageProps = { /** First seen by this browser; undefined renders no time at all. */ time?: number | undefined; searchTint?: "match" | "active" | undefined; + /** This answer's turn just ended: run the one-time settle sweep over it. */ + settling?: boolean | undefined; onQuote?: ((text: string, role: "user" | "assistant") => void) | undefined; onEdit?: ((text: string) => void) | undefined; /** Present only on the newest answer: retrying anything older would rewrite history. */ @@ -725,6 +773,7 @@ const TranscriptMessage = memo( attachments, time, searchTint, + settling, onQuote, onEdit, onRetry, @@ -757,11 +806,16 @@ const TranscriptMessage = memo( @@ -848,6 +902,7 @@ const TranscriptMessage = memo( prev.commandNames === next.commandNames && prev.time === next.time && prev.searchTint === next.searchTint && + prev.settling === next.settling && prev.onQuote === next.onQuote && prev.onEdit === next.onEdit && prev.onRetry === next.onRetry && @@ -1181,6 +1236,62 @@ function TranscriptToolGroup({ ); } +/** + * Below this many marks the rail is noise; above the cap it is soup, so long histories sample. + * The newest mark always survives sampling — it is the one most likely to be jumped back to. + */ +const MINIMAP_MIN_MARKS = 6; +const MINIMAP_MAX_MARKS = 20; + +/** + * The conversation's questions, as a rail of dots along the right edge. + * + * A long thread is navigated by what the PERSON asked — their messages are the section headings of + * the conversation — so each dot is one of their messages: its first words on hover, one click to + * jump there. It goes through `scrollToMessage` rather than any DOM query because the scroller owns + * this viewport's position, and two systems scrolling one element is where search jumps used to + * fight the autoscroll. + * + * Hidden below `md` — on a phone the rail would sit exactly where thumbs scroll — and absent + * entirely for short conversations, where a map of six inches of road is clutter. + */ +function TranscriptMinimap({ + marks, +}: { + marks: readonly { id: string; excerpt: string }[]; +}) { + const { scrollToMessage } = useMessageScroller(); + if (marks.length < MINIMAP_MIN_MARKS) return null; + + const step = Math.ceil(marks.length / MINIMAP_MAX_MARKS); + const shown = + step > 1 + ? marks.filter( + (_, index) => index % step === 0 || index === marks.length - 1, + ) + : marks; + + return ( + + ); +} + /** Placeholder rows while a channel's history is still on its way, distinct from a real void. */ function TranscriptSkeleton() { return ( @@ -1309,6 +1420,68 @@ export function ChatTranscript({ .reverse() .find((item) => item.kind === "text" && item.role === "assistant")?.id; + /* + * The turn's bookends, watched through refs so the effect below depends on `busy` alone. + * Both are projections this render already paid for; stashing them costs nothing and keeps the + * effect from re-running on every streamed chunk. + */ + const latestAssistantRef = useRef(latestAssistantId); + latestAssistantRef.current = latestAssistantId; + const stepsRef = useRef(0); + stepsRef.current = toolStepsSinceLastUser(items); + + const [receipt, setReceipt] = useState(null); + const [settlingId, setSettlingId] = useState(null); + const turnStartedAt = useRef(null); + const wasBusy = useRef(busy); + + /* + * The two "the turn just ended" moments, driven by `busy`'s edges rather than its level: the + * settle sweep over the answer, and the receipt for a long turn. Rising edge starts the clock + * and clears both; falling edge cashes them in. A remount mid-conversation starts with no clock, + * so restored history never sweeps or bills anybody for a turn this tab did not watch. + */ + useEffect(() => { + if (busy === wasBusy.current) return; + wasBusy.current = busy; + if (busy) { + turnStartedAt.current = Date.now(); + setReceipt(null); + setSettlingId(null); + return; + } + if (turnStartedAt.current === null) return; + const seconds = Math.round((Date.now() - turnStartedAt.current) / 1000); + turnStartedAt.current = null; + setReceipt(describeTurnReceipt(seconds, stepsRef.current)); + const answerId = latestAssistantRef.current; + if (answerId === undefined) return; + setSettlingId(answerId); + // Long enough for the sweep to finish, then the class comes off so a later remount of this + // message cannot replay it. + const timer = setTimeout(() => setSettlingId(null), 1600); + return () => clearTimeout(timer); + }, [busy]); + + /* + * The live tail of the streaming answer, quoted inside the scroll-to-end button, and the rail of + * the person's own messages for the minimap. Both are cheap projections of `items`, rebuilt per + * render on the same reasoning as `toVisibleChatItems` above. + */ + const streamTail = latestStreamTail(busy, items); + const minimapMarks = items.flatMap((item) => + item.kind === "text" && item.role === "user" && item.text.trim() + ? [ + { + id: item.id, + excerpt: + item.text.replace(/\s+/g, " ").trim().slice(0, 80) + + (item.text.trim().length > 80 ? "…" : ""), + }, + ] + : [], + ); + const searchMatches = searchMatchIds ? new Set(searchMatchIds.split(",")) : null; @@ -1447,6 +1620,9 @@ export function ChatTranscript({ onRetry={ item.id === latestAssistantId ? onRetryLatest : undefined } + settling={ + item.role === "assistant" && item.id === settlingId + } role={item.role} searchTint={ item.id === activeSearchMessageId @@ -1466,14 +1642,25 @@ export function ChatTranscript({ * anchored, and is gone by the next turn — giving one a `MessageScrollerItem` would ask * the scroller to measure and anchor something that exists for a second and a half. * - * One or the other, never both: a turn that ended has stopped being in flight, and a - * shimmering "Thinking" under a line saying the Bot stopped would contradict it. + * One at a time, never several: a turn that ended has stopped being in flight, and a + * shimmering "Thinking" under a line saying the Bot stopped would contradict it. The + * receipt takes the slot only once neither of the other two has a claim on it, and + * `AnimatePresence` lets the thinking line leave the way it arrived instead of + * vanishing between frames. */} - {stopped ? ( - - ) : waitingForBot ? ( - - ) : null} + + {stopped ? ( + + ) : waitingForBot ? ( + + ) : receipt ? ( + + ) : null} + {/* * Below the thinking line, and outside the item list for the same reason it is: these * are not yet turns. They have ids of their own, but they are this tab's ids and not the @@ -1493,7 +1680,30 @@ export function ChatTranscript({ ))} - + {/* + * The scroll-to-end pill, carrying the live tail of the streaming answer whenever there is + * one. From up in history the transcript looks stopped; the quote is proof it is not, and + * the same press that was already "take me down" becomes "take me to what's being said". + * The visible words are aria-hidden — the button's accessible name stays its action, not a + * mid-sentence fragment that changes every token. + */} + + {streamTail ? ( + <> + + + {streamTail} + + Scroll to end + + ) : undefined} + + diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx index ce45c46..fe2e24a 100644 --- a/app/src/components/channels/conversation-view.tsx +++ b/app/src/components/channels/conversation-view.tsx @@ -31,6 +31,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toolActivityLabel } from "@/lib/plugins/tool-name"; +import { useTabActivityStatus } from "@/lib/tab-status"; export function ConversationView({ messages, @@ -150,6 +151,10 @@ export function ConversationView({ const [running, setRunning] = useState(false); const inFlight = pending || running; + // The tab is a thinking surface too: a pulsing dot while the turn runs, a check if the answer + // lands while the person is in another tab. + useTabActivityStatus(inFlight); + /** What Quote and Edit put into the composer; an id per request so repeats land. */ const [insertion, setInsertion] = useState(); diff --git a/app/src/components/channels/thinking-status.test.ts b/app/src/components/channels/thinking-status.test.ts index 6592e60..e7e65f4 100644 --- a/app/src/components/channels/thinking-status.test.ts +++ b/app/src/components/channels/thinking-status.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + describeTurnReceipt, THINKING_SECONDS_SHOWN_AFTER, thinkingLabel, thinkingStatusText, @@ -34,3 +35,17 @@ describe("thinkingStatusText", () => { expect(thinkingStatusText(90)).toBe("Still working · 90s"); }); }); + +describe("describeTurnReceipt", () => { + test("short turns earn no receipt", () => { + expect(describeTurnReceipt(0, 3)).toBeNull(); + expect(describeTurnReceipt(9, 12)).toBeNull(); + }); + + test("bills seconds, then minutes once seconds stop being readable", () => { + expect(describeTurnReceipt(10, 0)).toBe("Worked for 10s"); + expect(describeTurnReceipt(47, 3)).toBe("Worked for 47s · 3 steps"); + expect(describeTurnReceipt(89, 1)).toBe("Worked for 89s · 1 step"); + expect(describeTurnReceipt(150, 4)).toBe("Worked for 3m · 4 steps"); + }); +}); diff --git a/app/src/components/channels/thinking-status.ts b/app/src/components/channels/thinking-status.ts index f156363..ea0a748 100644 --- a/app/src/components/channels/thinking-status.ts +++ b/app/src/components/channels/thinking-status.ts @@ -32,6 +32,24 @@ export function thinkingStatusText(seconds: number): string { return `${thinkingLabel(seconds)}${count}`; } +/** + * What a finished turn cost, said once, or null when the wait was too short to remark on. + * + * A long turn used to collapse into nothing the moment it ended: thirty seconds of orb, then the + * answer, and no trace of the work between. This is the receipt — "Worked for 47s · 3 steps" — + * shown under the answer until the next turn starts. Short turns get no receipt at all, because a + * note about a two-second wait reads as an apology for something nobody noticed. + */ +export function describeTurnReceipt( + seconds: number, + steps: number, +): string | null { + if (seconds < 10) return null; + const time = seconds >= 90 ? `${Math.round(seconds / 60)}m` : `${seconds}s`; + const work = steps > 0 ? ` · ${steps} step${steps === 1 ? "" : "s"}` : ""; + return `Worked for ${time}${work}`; +} + /** * Seconds since `active` last became true; 0 while inactive. * diff --git a/app/src/components/channels/tool-line.tsx b/app/src/components/channels/tool-line.tsx index 8ba2d23..04624e2 100644 --- a/app/src/components/channels/tool-line.tsx +++ b/app/src/components/channels/tool-line.tsx @@ -5,6 +5,7 @@ import { IconCircleCheck, } from "@tabler/icons-react"; import type { ReactNode } from "react"; +import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb"; /** * One line for one thing a Bot did. @@ -47,8 +48,13 @@ export function ToolLine({ const glyph = ( {running ? ( - // Inherits the line's colour through currentColor; reduced motion leaves a static ring. - + /* + * The SAME orb the thinking line shows, small. The thinking line yields to a running tool + * (see `shouldShowThinking`), so to the reader the orb does not disappear when work starts — + * it moves into the step being worked. One being, seen in different places, instead of an + * orb that hands off to an anonymous spinner exactly when something interesting happens. + */ + ) : refused ? ( ) : failed ? ( diff --git a/app/src/lib/tab-status.ts b/app/src/lib/tab-status.ts new file mode 100644 index 0000000..59cd980 --- /dev/null +++ b/app/src/lib/tab-status.ts @@ -0,0 +1,148 @@ +import { useEffect } from "react"; + +/** + * The browser tab as a thinking surface. + * + * People tab away during long turns constantly, and until now the tab said nothing: no favicon at + * all, a static title, and the first sign of a finished answer was switching back to look. While a + * turn is in flight the tab shows a small pulsing orb-blue dot and a "●" title prefix; if the turn + * finishes while the tab is hidden, the dot becomes a check and the title says "✓" until the person + * comes back, at which point the tab returns to normal. Nothing here asks for notification + * permission — the tab itself is the notification. + */ + +/** Matches the liquid orb's blue family so the tab and the transcript read as one product. */ +const ORB_BLUE = "#3b82f6"; +const ORB_BLUE_DEEP = "#1d4ed8"; +const DONE_GREEN = "#22c55e"; + +const FAVICON_ID = "tab-activity-favicon"; + +function drawDot(fill: string, deep: string, check: boolean): string { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const context = canvas.getContext("2d"); + if (!context) return ""; + + const gradient = context.createRadialGradient(24, 20, 4, 32, 32, 30); + gradient.addColorStop(0, "#dbeafe"); + gradient.addColorStop(0.35, fill); + gradient.addColorStop(1, deep); + context.fillStyle = gradient; + context.beginPath(); + context.arc(32, 32, 26, 0, Math.PI * 2); + context.fill(); + + if (check) { + context.strokeStyle = "#ffffff"; + context.lineWidth = 8; + context.lineCap = "round"; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(20, 33); + context.lineTo(29, 42); + context.lineTo(45, 23); + context.stroke(); + } + + return canvas.toDataURL("image/png"); +} + +function setFavicon(href: string | null) { + const existing = document.getElementById(FAVICON_ID); + if (href === null) { + existing?.remove(); + return; + } + const link = + existing instanceof HTMLLinkElement + ? existing + : document.createElement("link"); + link.id = FAVICON_ID; + link.rel = "icon"; + link.type = "image/png"; + link.href = href; + if (!link.isConnected) document.head.appendChild(link); +} + +/** + * The title as it was before any status prefix, captured once. The document owns its base title; + * this module only ever borrows it, and two screens cannot fight over it because at most one + * conversation surface is mounted at a time. + */ +let baseTitle: string | null = null; + +function setTitle(prefix: string | null) { + baseTitle ??= document.title; + document.title = prefix === null ? baseTitle : `${prefix} ${baseTitle}`; +} + +function restoreTab() { + setTitle(null); + setFavicon(null); +} + +/** + * The pending "seen it" listener, if a finished-while-hidden flag is up. Module-level so a new turn + * starting before the person looks can cancel it — otherwise the stale listener would wipe the new + * turn's dot the moment the tab was next focused. + */ +let cancelPendingSeen: (() => void) | null = null; + +/** + * Reflect a turn in flight into the favicon and tab title, and flag the answer's arrival when it + * lands in a hidden tab. + */ +export function useTabActivityStatus(active: boolean) { + useEffect(() => { + if (!active) return; + + // Two frames, alternated: the cheapest possible pulse, and one a pinned tab still shows. + const bright = drawDot(ORB_BLUE, ORB_BLUE_DEEP, false); + const dim = drawDot(ORB_BLUE_DEEP, ORB_BLUE_DEEP, false); + if (!bright) return; + + // A still-unseen "done" flag from the previous turn is superseded by the turn now starting. + cancelPendingSeen?.(); + setTitle("●"); + setFavicon(bright); + let showingBright = true; + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + const pulse = reducedMotion + ? null + : setInterval(() => { + showingBright = !showingBright; + setFavicon(showingBright ? bright : dim); + }, 900); + + return () => { + if (pulse !== null) clearInterval(pulse); + + /* + * The turn just ended (or the screen unmounted mid-turn). A visible tab needs no flag — + * the answer is on screen — so the tab simply returns to normal. A hidden tab keeps a + * check-marked dot until its next sighting, because the flag exists exactly for the person + * who is elsewhere. + */ + if (!document.hidden) { + restoreTab(); + return; + } + setTitle("✓"); + setFavicon(drawDot(DONE_GREEN, "#15803d", true)); + const onSeen = () => { + if (document.hidden) return; + restoreTab(); + cancelPendingSeen?.(); + }; + cancelPendingSeen = () => { + document.removeEventListener("visibilitychange", onSeen); + cancelPendingSeen = null; + }; + document.addEventListener("visibilitychange", onSeen); + }; + }, [active]); +} diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index 709b7ae..2b48518 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -20,6 +20,7 @@ import { import { useActiveBot } from "@/lib/copilot/active-bot"; import { useBotThread } from "@/lib/copilot/bot-thread"; import { useStoppedTurn } from "@/lib/copilot/stopped-turn"; +import { useTabActivityStatus } from "@/lib/tab-status"; /** Visible feedback while the packaged chat is waiting for the Bot's first token. */ export function BotThinkingCursor({ @@ -85,6 +86,10 @@ function BotChatViewComponent({ }: CopilotChatViewProps) { const [turnsInFlight, setTurnsInFlight] = useState(0); + // The tab mirrors the turn the same way the channel view's tab does: the submit promise covers + // the whole turn, `isRunning` covers the run currently on the wire, and either means working. + useTabActivityStatus(turnsInFlight > 0 || isRunning); + const trackTurn = useCallback((start: () => unknown) => { setTurnsInFlight((count) => count + 1); diff --git a/app/src/styles.css b/app/src/styles.css index 5f1b775..3273e3a 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -279,11 +279,20 @@ body { position: relative; display: block; flex: none; - width: 1.25rem; - height: 1.25rem; + width: var(--orb-size, 1.25rem); + height: var(--orb-size, 1.25rem); animation: liquid-thinking-orb-enter 0.24s cubic-bezier(0.23, 1, 0.32, 1); } +/* + * The orb at running-tool-line size, where the step spinner used to be. A CSS variable rather than + * a width override because the bloom, swirl, and sheen are all sized in percentages of the wrapper + * — one number scales the whole composition. + */ +.liquid-thinking-orb-inline { + --orb-size: 0.875rem; +} + .liquid-thinking-orb::before { content: ""; position: absolute; @@ -388,33 +397,45 @@ body { } /* - * The running-step spinner, drawn before a tool line's label. + * The settle pass over an answer that just finished: one soft band of the orb's blue light sweeps + * down-and-across the message, and the class is removed once it has run (see ChatTranscript). It is + * the "done" moment made visible — the same instant the tab's dot turns into a check — and it uses + * the orb's own colour so the thing that was working and the thing that finished read as related. * - * A ring rather than Tabler's loader glyph: the border trick spins on the compositor with no font - * metrics involved, scales with the surrounding text colour through `currentColor`, and reads at - * 12px where a stroked SVG glyph turns to mush. Reduced motion freezes it into a static ring — - * still visibly "not finished", just not animating. + * The band lives on an overlay pseudo-element and animates `transform` only. `overflow: hidden` is + * safe here because the class is transient: it exists for under two seconds on a message that has + * finished laying out. */ -.step-spinner { - display: inline-block; - width: 0.75rem; - height: 0.75rem; - flex-shrink: 0; - border-radius: 9999px; - border: 1.5px solid color-mix(in oklab, currentColor 28%, transparent); - border-top-color: currentColor; - animation: step-spinner-spin 0.8s linear infinite; +.answer-settle { + position: relative; + overflow: hidden; } -@keyframes step-spinner-spin { +.answer-settle::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + 105deg, + transparent 38%, + oklch(0.66 0.17 250 / 14%) 50%, + transparent 62% + ); + transform: translateX(-100%); + animation: answer-settle-sweep 1.1s cubic-bezier(0.23, 1, 0.32, 1) forwards; +} + +@keyframes answer-settle-sweep { to { - transform: rotate(1turn); + transform: translateX(100%); } } @media (prefers-reduced-motion: reduce) { - .step-spinner { + .answer-settle::after { animation: none; + background: none; } }