From 2c704b61befe8c01a2c9209f213e3d92a7056871 Mon Sep 17 00:00:00 2001 From: Fathah KA <48355244+fathah@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:26:20 +0530 Subject: [PATCH] Loading --- lat.md/chat-commands.md | 2 +- lat.md/lat.md | 1 + lat.md/loading-indicators.md | 29 ++++ lat.md/sidebar-navigation.md | 2 +- lat.md/window-chrome.md | 2 +- package-lock.json | 129 ++---------------- package.json | 4 +- src/renderer/src/assets/main.css | 32 +++-- src/renderer/src/components/OrbLoader.tsx | 64 +++++++++ .../src/components/RegistryBrowserModal.tsx | 3 +- .../src/components/profile/ProfileModal.tsx | 5 +- .../components/profile/ProfileWalletPane.tsx | 3 +- src/renderer/src/screens/Agents/Agents.tsx | 3 +- src/renderer/src/screens/Chat/HistoryRow.tsx | 28 ++-- src/renderer/src/screens/Chat/MessageRow.tsx | 92 ++++--------- .../src/screens/Discover/Discover.tsx | 5 +- src/renderer/src/screens/Kanban/Kanban.tsx | 5 +- .../src/screens/Layout/ActiveSessionsBar.tsx | 10 +- src/renderer/src/screens/Memory/Memory.tsx | 3 +- .../src/screens/Schedules/Schedules.tsx | 3 +- .../src/screens/Sessions/Sessions.tsx | 5 +- src/renderer/src/screens/Skills/Skills.tsx | 3 +- src/renderer/src/screens/Soul/Soul.tsx | 3 +- src/renderer/src/screens/Tools/Tools.tsx | 3 +- src/renderer/src/test/setup.ts | 38 +----- 25 files changed, 204 insertions(+), 273 deletions(-) create mode 100644 lat.md/loading-indicators.md create mode 100644 src/renderer/src/components/OrbLoader.tsx diff --git a/lat.md/chat-commands.md b/lat.md/chat-commands.md index 913a9860a..13ee667db 100644 --- a/lat.md/chat-commands.md +++ b/lat.md/chat-commands.md @@ -59,7 +59,7 @@ The handler reads the ref, applies a delta, writes the ref back, then calls `set Streamed reasoning and tool calls are folded into compact, collapsible transcript rows rather than stacked bubbles, so a turn with heavy thinking or many tool calls stays scannable. -[[src/renderer/src/screens/Chat/HistoryRow.tsx#ReasoningRow]] renders the `Thought` / `Thinking…` row and [[src/renderer/src/screens/Chat/HistoryRow.tsx#ToolActivityGroup]] folds a contiguous run of tool calls/results into one row titled by [[src/renderer/src/screens/Chat/HistoryRow.tsx#toolActivityGroupTitle]]. Each row is collapsed by default and borderless (Codex-style): dim at rest, it brightens and reveals an expand chevron beside the title on hover/focus, and clicking toggles the body open. While the turn is still streaming the leading icon is a `Grid` loader (purple for reasoning, blue for tools); once finished it shows the brain/tool glyph. +[[src/renderer/src/screens/Chat/HistoryRow.tsx#ReasoningRow]] renders the `Thought` / `Thinking…` row and [[src/renderer/src/screens/Chat/HistoryRow.tsx#ToolActivityGroup]] folds a contiguous run of tool calls/results into one row titled by [[src/renderer/src/screens/Chat/HistoryRow.tsx#toolActivityGroupTitle]]. Each row is collapsed by default and borderless (Codex-style): dim at rest, it brightens and reveals an expand chevron beside the title on hover/focus, and clicking toggles the body open. While the turn is still streaming the leading icon is a thinking-orbs [[loading-indicators|OrbLoader]] (`solving` for reasoning, `working` for tools); once finished it shows the brain/tool glyph. ### Reasoning reconciliation diff --git a/lat.md/lat.md b/lat.md/lat.md index 8c1635da7..47621269f 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -9,6 +9,7 @@ This directory defines the high-level concepts, business logic, and architecture - [[reasoning-effort]] — the composer's Faster⟷Smarter effort control: a draggable `role="slider"` over six ordered levels that stays open until dismissed and commits one `onChange` per real change. - [[web-preview]] — the in-app split-screen webview and the `partition`-based gate that lets only it load remote HTTPS while staying sandboxed. - [[code-blocks]] — collapsible long code blocks, and why expansion state is keyed on source position to survive react-markdown's streaming remounts. +- [[loading-indicators]] — the thinking-orbs dotted-orb loaders behind every loading state, and the OrbLoader wrapper that pins dark/light from the Hermes theme registry instead of the library's auto-detection. - [[window-chrome]] — the browser-style title bar where open-conversation tabs sit on top of the window drag region, clickable while empty space still drags. - [[desktop-updates]] — GitHub release checks, startup upgrade button behavior, and the Settings auto-upgrade preference. - [[sidebar-navigation]] — the recent-sessions list under the Chat nav item, capped at five with a "Show more" button that opens the full session list in a modal. diff --git a/lat.md/loading-indicators.md b/lat.md/loading-indicators.md new file mode 100644 index 000000000..7b12a76d1 --- /dev/null +++ b/lat.md/loading-indicators.md @@ -0,0 +1,29 @@ +# Loading Indicators + +All renderer loading states use the [thinking-orbs](https://www.npmjs.com/package/thinking-orbs) dotted-orb canvas animations through one theme-aware wrapper, replacing the old `react-loader-spinner` Grid and the CSS `.loading-spinner` circle. + +## OrbLoader wrapper + +[[src/renderer/src/components/OrbLoader.tsx#OrbLoader]] pins the orb theme from Hermes' own theme registry instead of the library's auto-detection. + +The library's `auto` mode only recognises `data-theme="dark|light"` (or `.dark`/`.light` classes) and otherwise falls back to `prefers-color-scheme`. Hermes writes theme **ids** ("dracula", "nord", …) to `data-theme`, so auto-detection would follow the OS instead of the picked theme (e.g. light OS + Dracula theme would render dark ink on a dark background). The wrapper reads the resolved theme id from [[src/renderer/src/components/ThemeProvider.tsx#ThemeProvider]] and maps it to `dark`/`light` via each `ThemeDef.appearance` in `THEMES` ([[src/renderer/src/constants.ts]]). + +The wrapper also accepts **any numeric `size`**, not just the two shipped presets. ThinkingOrb's `resolvePreset` throws on any size other than 20 or 64, but callers still need arbitrary pixel footprints (e.g. a 30px chat avatar). So `OrbLoader` snaps the *design* (dot count / tuning) to the nearest preset around `PRESET_MIDPOINT` (42) while setting the *visual* footprint from the requested number via `style` — an explicit `style` from the caller still wins. Any number is therefore safe and never a runtime throw. + +An `invert` prop flips the ink relative to the app theme (light-theme ink while the app is dark, and vice versa). Use it when the orb sits on an inverted surface — a light circle on a dark page — so the ink contrasts the circle rather than the page. + +## Usage conventions + +The library ships two tuned size *designs* (separate tunings, not a scale factor): `20` inline with text, `64` for pane/screen-level loading. `OrbLoader` picks the design from the nearest of those two; the number given is the rendered footprint. + +- Chat reasoning summary ("Thinking…"): `state="solving"`, size 20 — [[src/renderer/src/screens/Chat/HistoryRow.tsx]]. +- Chat tool-call summary and slash-command pending bubble: `state="working"`, size 20 — [[src/renderer/src/screens/Chat/HistoryRow.tsx]], [[src/renderer/src/screens/Chat/MessageRow.tsx]]. +- Screen/pane data fetches (Discover, Memory, Tools, Schedules, Agents, Sessions, Skills, Soul, Kanban, registry browser, profile modal panes): `state="searching"`, size 64 (size 20 for the inline Kanban detail loader). +- Streaming conversation tab chip: `state="composing"`, size 20, in the avatar slot — [[src/renderer/src/screens/Layout/ActiveSessionsBar.tsx]] (see [[window-chrome]]). +- Generating agent turn avatar: `state="solving"` (nearest preset → the 64 design), `invert`ed on a `--text-primary` disc, scaled to the ~30px avatar footprint via `style={{ width, height }}` — [[src/renderer/src/screens/Chat/MessageRow.tsx#HermesAvatar]]. + +This replaced the former `loadingo.gif` avatar animation — the orb needs no loop-boundary stop dance (a canvas frame never freezes mid-loop the way the gif did), so `HermesAvatar` swaps straight to the agent's [[src/renderer/src/components/common/ProfileAvatar.tsx]] the instant `active` goes false, and falls back to the orb when no agent identity is known (the live typing indicator). + +Because the orb ink is monochrome and can wash out against the near-black page, the loading avatar carries a `.chat-avatar-orb` class that backs it with an **inverted circle** — `--text-primary`, which is light on dark themes and dark on light themes — and passes `invert` to [[src/renderer/src/components/OrbLoader.tsx#OrbLoader]] so the ink flips to match: a dark orb on a white disc under a dark theme, a light orb on a dark disc under a light theme. The canvas is inset slightly so the dots sit inside the circle instead of clipping at its edge. + +Tests mock `thinking-orbs` in [[src/renderer/src/test/setup.ts]] because its canvas + IntersectionObserver rendering has no jsdom equivalent. diff --git a/lat.md/sidebar-navigation.md b/lat.md/sidebar-navigation.md index 518091eff..f46d13105 100644 --- a/lat.md/sidebar-navigation.md +++ b/lat.md/sidebar-navigation.md @@ -64,7 +64,7 @@ Opening a sidebar session after switching profiles consumes that blank selected- The profile chip preserves the old app-brand label for an unrenamed default profile: when `listProfiles` returns the fallback `name === id === "default"`, the chip shows `common.appName`; once a custom name is stored, it shows that user-facing name. -The same per-profile appearance also drives the agent avatar inside the transcript. [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] passes `getAppearance(run.profile)` to each [[src/renderer/src/screens/Chat/Chat.tsx]] as `agentAppearance`, which forwards `{ name, color, avatar }` through [[src/renderer/src/screens/Chat/MessageList.tsx]] to every [[src/renderer/src/screens/Chat/MessageRow.tsx#HermesAvatar]] (and the reasoning/tool-activity rows in [[src/renderer/src/screens/Chat/HistoryRow.tsx]]). `HermesAvatar` plays the looping `loadingo.gif` only while a turn is generating (`active`); once generation stops it runs out the current gif loop, then swaps to the agent's [[src/renderer/src/components/common/ProfileAvatar.tsx]] so idle turns are identified by who produced them. The live typing indicator has no resolved agent yet, so it falls back to the gif. +The same per-profile appearance also drives the agent avatar inside the transcript. [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] passes `getAppearance(run.profile)` to each [[src/renderer/src/screens/Chat/Chat.tsx]] as `agentAppearance`, which forwards `{ name, color, avatar }` through [[src/renderer/src/screens/Chat/MessageList.tsx]] to every [[src/renderer/src/screens/Chat/MessageRow.tsx#HermesAvatar]] (and the reasoning/tool-activity rows in [[src/renderer/src/screens/Chat/HistoryRow.tsx]]). `HermesAvatar` shows the animated thinking-orb ([[loading-indicators]]) only while a turn is generating (`active`); once generation stops it swaps straight to the agent's [[src/renderer/src/components/common/ProfileAvatar.tsx]] so idle turns are identified by who produced them. The live typing indicator has no resolved agent yet, so it falls back to the orb. ### SSH tunnel profile routing diff --git a/lat.md/window-chrome.md b/lat.md/window-chrome.md index 850d576f7..d3256cdc9 100644 --- a/lat.md/window-chrome.md +++ b/lat.md/window-chrome.md @@ -40,7 +40,7 @@ Visually the strip is a Safari-style tab bar: the strip uses the darker `--bg-se The bar always renders so it is always a drag area, but chips stay hidden only while the sole conversation is still a blank scratch chat. -Chips show when more than one run is open, any run is loading, or any run has a session id/title (`showChips` in [[src/renderer/src/screens/Layout/ActiveSessionsBar.tsx#ActiveSessionsBar]]). When chips show, a browser-style new-tab **"+"** button (`.active-session-new`, `no-drag`) trails them and calls `onNew` → `handleNewChat` in [[src/renderer/src/screens/Layout/Layout.tsx]] to open a fresh conversation. +Chips show when more than one run is open, any run is loading, or any run has a session id/title (`showChips` in [[src/renderer/src/screens/Layout/ActiveSessionsBar.tsx#ActiveSessionsBar]]). A loading chip renders a thinking-orbs [[loading-indicators|OrbLoader]] (`composing`, size 20) in place of its profile avatar — the orb's canvas is transparent and theme-aware, so `.active-session-chip-orb` drops the colour-filled avatar circle rather than painting the profile colour behind it. When chips show, a browser-style new-tab **"+"** button (`.active-session-new`, `no-drag`) trails them and calls `onNew` → `handleNewChat` in [[src/renderer/src/screens/Layout/Layout.tsx]] to open a fresh conversation. Because the bar doubles as the drag strip, [[src/renderer/src/screens/Layout/Layout.tsx]] renders it as the first child of `.content`; the verify-warning banner (when shown) sits just below it, clear of the drag layer. diff --git a/package-lock.json b/package-lock.json index 6574f9279..8fc69c64e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hermes-desktop", - "version": "0.7.0", + "version": "0.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hermes-desktop", - "version": "0.7.0", + "version": "0.7.4", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.2", @@ -28,10 +28,10 @@ "react-file-icon": "^1.6.0", "react-hot-toast": "^2.6.0", "react-i18next": "^15.7.3", - "react-loader-spinner": "^8.0.2", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", + "thinking-orbs": "^0.1.1", "three": "^0.183.2", "troika-three-text": "^0.52.4", "vscode-material-icons": "^0.1.1", @@ -1072,21 +1072,12 @@ "node": ">= 10.0.0" } }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", @@ -6542,15 +6533,6 @@ "node": ">=6" } }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/camera-controls": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", @@ -6954,26 +6936,6 @@ "node": ">= 8" } }, - "node_modules/css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/css-to-react-native": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", - "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "license": "MIT", - "dependencies": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -13110,12 +13072,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, "node_modules/postject": { "version": "1.0.0-alpha.6", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", @@ -13540,23 +13496,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/react-loader-spinner": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-8.0.2.tgz", - "integrity": "sha512-N2gHEy88VKRaijzEFoRp/aHEPE6Lr6upkmPBPu9hfqZqdxftxsXd1B+lzOmVnn3BYHyXwvYrifWzMbR64lwQlw==", - "license": "MIT", - "dependencies": { - "styled-components": "^6.3.6", - "tinycolor2": "^1.6.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": ">=17.0.0 <20.0.0", - "react-dom": ">=17.0.0 <20.0.0" - } - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -14899,48 +14838,6 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/styled-components": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.2.tgz", - "integrity": "sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==", - "license": "MIT", - "dependencies": { - "@emotion/is-prop-valid": "1.4.0", - "css-to-react-native": "3.2.0", - "csstype": "3.2.3", - "stylis": "4.3.6" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/styled-components" - }, - "peerDependencies": { - "css-to-react-native": ">= 3.2.0", - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0", - "react-native": ">= 0.68.0" - }, - "peerDependenciesMeta": { - "css-to-react-native": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT" - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -15155,6 +15052,16 @@ "node": ">= 10.0.0" } }, + "node_modules/thinking-orbs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.1.1.tgz", + "integrity": "sha512-nLvLTGJtk74K13MmP7XiRdzFiQx7UsoZAIyBZNgXyl7Q3h2mdz0r3eKn9LCsuzb3DGOVjwDvMhtnAkIqJJRVQA==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/three": { "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", @@ -15227,12 +15134,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", diff --git a/package.json b/package.json index 0d15eb7ab..40f46855a 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,10 @@ "react-file-icon": "^1.6.0", "react-hot-toast": "^2.6.0", "react-i18next": "^15.7.3", - "react-loader-spinner": "^8.0.2", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", + "thinking-orbs": "^0.1.1", "three": "^0.183.2", "troika-three-text": "^0.52.4", "vscode-material-icons": "^0.1.1", @@ -92,4 +92,4 @@ "vite": "^7.2.6", "vitest": "^4.1.4" } -} \ No newline at end of file +} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4726e91f8..1d8ae3116 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -703,15 +703,6 @@ body:has(.shell-vibrant), background: var(--bg-primary); } -.loading-spinner { - width: 28px; - height: 28px; - border: 2.5px solid var(--border-bright); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - @keyframes spin { to { transform: rotate(360deg); @@ -2438,11 +2429,10 @@ body:has(.shell-vibrant), line-height: 1; } -.active-session-chip-spinner { - flex-shrink: 0; - /* Sits on the profile's colour-filled avatar circle. */ - color: #fff; - animation: spin 0.8s linear infinite; +/* Loading chips render a transparent, theme-aware thinking orb in the + avatar slot instead of the colour-filled circle, so no background here. */ +.active-session-chip-orb { + background: none; } .active-session-chip-title { @@ -4279,6 +4269,20 @@ body:has(.shell-vibrant), overflow: hidden; } +/* While loading, the avatar holds a monochrome thinking orb whose ink can wash + out against the near-black page. Back it with an *inverted* circle — light on + dark themes, dark on light themes (--text-primary) — and flip the orb ink to + match (the `invert` prop on OrbLoader) so it always reads as a dark orb on a + white disc (dark theme) or a light orb on a dark disc (light theme). Inset + the orb a touch so its dots sit inside the circle rather than clipping. */ +.chat-avatar-orb { + background: var(--text-primary); +} + +.chat-avatar-orb canvas { + transform: scale(0.82); +} + .chat-avatar-agent img { width: 100%; height: 100%; diff --git a/src/renderer/src/components/OrbLoader.tsx b/src/renderer/src/components/OrbLoader.tsx new file mode 100644 index 000000000..29b3a7d63 --- /dev/null +++ b/src/renderer/src/components/OrbLoader.tsx @@ -0,0 +1,64 @@ +import { ThinkingOrb, type ThinkingOrbProps, type OrbSize } from "thinking-orbs"; +import type { CSSProperties } from "react"; +import { THEMES } from "../constants"; +import { useTheme } from "./ThemeProvider"; + +const THEME_APPEARANCE = new Map(THEMES.map((t) => [t.id, t.appearance])); + +/** Boundary between the two shipped design presets (20 inline, 64 avatar). */ +const PRESET_MIDPOINT = 42; + +type OrbLoaderProps = Omit & { + /** + * Visual size in CSS px. Any number is accepted — the *design* (dot count / + * tuning) snaps to the nearest shipped preset (20 or 64), while this exact + * number drives the rendered footprint. Defaults to 64. + */ + size?: number; + /** + * Flip the ink relative to the app theme: light-theme ink (dark dots) while + * the app is dark, dark-theme ink (light dots) while the app is light. Use + * when the orb sits on an *inverted* surface (e.g. a white circle on a dark + * page) so the ink contrasts the circle, not the page. Defaults to false. + */ + invert?: boolean; +}; + +/** + * Theme-aware wrapper around `thinking-orbs`' ThinkingOrb. + * + * Two jobs the raw component can't do for us: + * 1. **Theme.** Hermes theme ids ("dracula", "nord", …) don't match the + * library's auto-detection (it only recognises `data-theme="dark|light"`), + * so the orb theme is pinned from the active theme's declared appearance. + * 2. **Off-preset sizes.** ThinkingOrb ships exactly two designs (20, 64) and + * `resolvePreset` throws on any other size. Callers still want arbitrary + * pixel footprints (e.g. a 30px chat avatar), so we snap the *design* to the + * nearest preset and set the *visual* size from the requested number — any + * number is safe, never a runtime throw. An explicit `style` still wins. + */ +export function OrbLoader({ + size, + style, + invert, + ...rest +}: OrbLoaderProps): React.JSX.Element { + const { resolved } = useTheme(); + const appearance = THEME_APPEARANCE.get(resolved) ?? "dark"; + const orbTheme = invert + ? appearance === "dark" + ? "light" + : "dark" + : appearance; + const designSize: OrbSize = size != null && size < PRESET_MIDPOINT ? 20 : 64; + const sizedStyle: CSSProperties | undefined = + size != null ? { width: size, height: size, ...style } : style; + return ( + + ); +} diff --git a/src/renderer/src/components/RegistryBrowserModal.tsx b/src/renderer/src/components/RegistryBrowserModal.tsx index 5e6e85bec..d6fba4c49 100644 --- a/src/renderer/src/components/RegistryBrowserModal.tsx +++ b/src/renderer/src/components/RegistryBrowserModal.tsx @@ -3,6 +3,7 @@ import toast from "react-hot-toast"; import { Search, X, Check, Plus } from "../assets/icons"; import { PROVIDERS } from "../constants"; import { useI18n } from "./useI18n"; +import { OrbLoader } from "./OrbLoader"; import BrandLogo from "./common/BrandLogo"; import type { ModelRegistry, @@ -136,7 +137,7 @@ function RegistryBrowserModal({
{loading ? (
-
+
) : registry?.error ? (
diff --git a/src/renderer/src/components/profile/ProfileModal.tsx b/src/renderer/src/components/profile/ProfileModal.tsx index 59b6fea43..d32b9927d 100644 --- a/src/renderer/src/components/profile/ProfileModal.tsx +++ b/src/renderer/src/components/profile/ProfileModal.tsx @@ -24,6 +24,7 @@ import type { MemoryData } from "../../screens/Memory/types"; import { AppModal, AppModalTitle } from "../modal/AppModal"; import ProfileWalletPane from "./ProfileWalletPane"; import ProfileSyncPane from "./ProfileSyncPane"; +import { OrbLoader } from "../OrbLoader"; import type { ProfileSection } from "./ProfileModalContext"; /** Mirrors the entry shape returned by `window.hermesAPI.listProfiles()`. */ @@ -503,7 +504,7 @@ export default function ProfileModal({
{memoryLoading && !memoryData ? (
-
+
) : memoryData ? ( ) : (
-
+
)} diff --git a/src/renderer/src/components/profile/ProfileWalletPane.tsx b/src/renderer/src/components/profile/ProfileWalletPane.tsx index fc87f8515..c8373b178 100644 --- a/src/renderer/src/components/profile/ProfileWalletPane.tsx +++ b/src/renderer/src/components/profile/ProfileWalletPane.tsx @@ -7,6 +7,7 @@ import { BASE_NETWORK_LABEL } from "../../../../shared/wallets"; import type { TokenBalancesResponse } from "../../../../shared/tokens"; import { AppModal, AppModalTitle } from "../modal/AppModal"; import { useI18n } from "../useI18n"; +import { OrbLoader } from "../OrbLoader"; /** Map token IDs to their Vite-resolved icon URLs. */ const TOKEN_ICONS: Record = { @@ -229,7 +230,7 @@ export default function ProfileWalletPane({ {loading ? (
-
+
) : wallets.length === 0 ? (
diff --git a/src/renderer/src/screens/Agents/Agents.tsx b/src/renderer/src/screens/Agents/Agents.tsx index 2dbdcbc8b..b36217ce9 100644 --- a/src/renderer/src/screens/Agents/Agents.tsx +++ b/src/renderer/src/screens/Agents/Agents.tsx @@ -3,6 +3,7 @@ import { Plus, ChatBubble, Pencil, X } from "../../assets/icons"; import ProfileAvatar from "../../components/common/ProfileAvatar"; import { AppModal, AppModalTitle } from "../../components/modal/AppModal"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; import { useProfileModal } from "../../components/profile/ProfileModalContext"; import type { AgentSyncResult, @@ -246,7 +247,7 @@ function Agents({ return (
-
+
); diff --git a/src/renderer/src/screens/Chat/HistoryRow.tsx b/src/renderer/src/screens/Chat/HistoryRow.tsx index 4a1548108..1ff47aa80 100644 --- a/src/renderer/src/screens/Chat/HistoryRow.tsx +++ b/src/renderer/src/screens/Chat/HistoryRow.tsx @@ -1,6 +1,6 @@ import { memo, useState } from "react"; -import { Grid } from "react-loader-spinner"; import { Brain, ChevronRight, Wrench } from "../../assets/icons"; +import { OrbLoader } from "../../components/OrbLoader"; import { useI18n } from "../../components/useI18n"; import { AttachmentChip } from "../../components/AttachmentChip"; import { ToolGlyph, humanizeToolName } from "../../components/toolMeta"; @@ -56,14 +56,11 @@ export const ReasoningRow = memo(function ReasoningRow({ onClick={() => setOpen((o) => !o)} > {active ? ( - ) : ( @@ -286,14 +283,11 @@ export const ToolActivityGroup = memo(function ToolActivityGroup({ onClick={() => setOpen((o) => !o)} > {active ? ( - ) : soloTool ? ( on each play session restarts the gif from frame 0 so - // the loop clock below is accurate. - const [playKey, setPlayKey] = useState(0); - // Timestamp (performance.now) of the current play session's frame 0; set in - // the effect, never during render. 0 = not yet started. - const playStartRef = useRef(0); - const stopTimer = useRef | undefined>( - undefined, - ); - - useEffect(() => { - if (active) { - // (Re)start the animation immediately when generation begins. - if (stopTimer.current) clearTimeout(stopTimer.current); - if (!playing) { - setPlayKey((k) => k + 1); - playStartRef.current = performance.now(); - setPlaying(true); - } else if (playStartRef.current === 0) { - // Mounted already playing (active on first render): anchor the loop clock. - playStartRef.current = performance.now(); - } - } else if (playing) { - // Generation stopped: run out the rest of the current loop, then swap to - // the profile avatar. - const elapsed = (performance.now() - playStartRef.current) % GIF_LOOP_MS; - const remaining = GIF_LOOP_MS - elapsed; - if (stopTimer.current) clearTimeout(stopTimer.current); - stopTimer.current = setTimeout(() => setPlaying(false), remaining); - } - return () => { - if (stopTimer.current) clearTimeout(stopTimer.current); - }; - }, [active, playing]); - - // Idle with no known agent (e.g. the typing indicator before any turn) still - // falls back to the gif's first frame rather than a blank/"?" avatar. - const showGif = playing || !agent; + const showOrb = active || !agent; return ( -
- {showGif ? ( - +
+ {showOrb ? ( + ) : ( - + {msg.content}
) : ( diff --git a/src/renderer/src/screens/Discover/Discover.tsx b/src/renderer/src/screens/Discover/Discover.tsx index d3a63bad3..8fc526e3a 100644 --- a/src/renderer/src/screens/Discover/Discover.tsx +++ b/src/renderer/src/screens/Discover/Discover.tsx @@ -16,6 +16,7 @@ import { import type { LucideIcon } from "lucide-react"; import { AgentMarkdown } from "../../components/AgentMarkdown"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; import type { RegistryKind, RegistryItem, @@ -450,7 +451,7 @@ export default function Discover({ )}
{detailLoading ? ( -
+ ) : ( <> {detailData?.rows && detailData.rows.length > 0 ? ( @@ -561,7 +562,7 @@ export default function Discover({ {loading ? (
-
+
) : error && !hasResults ? (
diff --git a/src/renderer/src/screens/Kanban/Kanban.tsx b/src/renderer/src/screens/Kanban/Kanban.tsx index e5440105b..381f3535f 100644 --- a/src/renderer/src/screens/Kanban/Kanban.tsx +++ b/src/renderer/src/screens/Kanban/Kanban.tsx @@ -12,6 +12,7 @@ import { Wand, } from "../../assets/icons"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; interface KanbanProps { profile?: string; @@ -680,7 +681,7 @@ function Kanban({ profile, visible }: KanbanProps): React.JSX.Element { return (
-
+
); @@ -1283,7 +1284,7 @@ function Kanban({ profile, visible }: KanbanProps): React.JSX.Element {
- {detailLoading &&
} + {detailLoading && } {detail && ( <>
diff --git a/src/renderer/src/screens/Layout/ActiveSessionsBar.tsx b/src/renderer/src/screens/Layout/ActiveSessionsBar.tsx index 4d03ffde4..59644e204 100644 --- a/src/renderer/src/screens/Layout/ActiveSessionsBar.tsx +++ b/src/renderer/src/screens/Layout/ActiveSessionsBar.tsx @@ -1,8 +1,8 @@ import { memo } from "react"; -import { Spinner, X, Plus } from "../../assets/icons"; +import { X, Plus } from "../../assets/icons"; +import { OrbLoader } from "../../components/OrbLoader"; import { useI18n } from "../../components/useI18n"; import ProfileAvatar from "../../components/common/ProfileAvatar"; -import { defaultColorForName } from "../../../../shared/profileColors"; import type { ChatRun } from "./chatRuns"; export interface ProfileAppearance { @@ -50,7 +50,6 @@ export const ActiveSessionsBar = memo(function ActiveSessionsBar({ const active = run.runId === activeRunId; const label = run.title || t("sessions.newConversation"); const appearance = getAppearance?.(run.profile); - const color = appearance?.color || defaultColorForName(run.profile); return (
{run.loading ? ( - + ) : (

{t("memory.title")}

-
+
); diff --git a/src/renderer/src/screens/Schedules/Schedules.tsx b/src/renderer/src/screens/Schedules/Schedules.tsx index d502afeb1..0678825f9 100644 --- a/src/renderer/src/screens/Schedules/Schedules.tsx +++ b/src/renderer/src/screens/Schedules/Schedules.tsx @@ -10,6 +10,7 @@ import { Alert, } from "../../assets/icons"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; const DELIVER_TARGETS = [ { value: "local", label: "Local" }, @@ -248,7 +249,7 @@ function Schedules({ profile }: SchedulesProps): React.JSX.Element { return (
-
+
); diff --git a/src/renderer/src/screens/Sessions/Sessions.tsx b/src/renderer/src/screens/Sessions/Sessions.tsx index f5a3c85cd..7aef9a12a 100644 --- a/src/renderer/src/screens/Sessions/Sessions.tsx +++ b/src/renderer/src/screens/Sessions/Sessions.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useRef, useCallback, useMemo, memo } from "react"; import { Plus, Search, X, ChatBubble, Trash, Pencil } from "../../assets/icons"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; interface CachedSession { id: string; @@ -734,12 +735,12 @@ function Sessions({ {/* Content */} {loading ? (
-
+
) : isShowingSearch ? ( isSearching ? (
-
+
) : searchResults.length === 0 ? (
diff --git a/src/renderer/src/screens/Skills/Skills.tsx b/src/renderer/src/screens/Skills/Skills.tsx index ca58a75b2..8e5a765fc 100644 --- a/src/renderer/src/screens/Skills/Skills.tsx +++ b/src/renderer/src/screens/Skills/Skills.tsx @@ -3,6 +3,7 @@ import toast from "react-hot-toast"; import { Search, X, Download, Trash, Refresh } from "../../assets/icons"; import { AgentMarkdown } from "../../components/AgentMarkdown"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; interface InstalledSkill { name: string; @@ -146,7 +147,7 @@ function Skills({ return (
-
+
); diff --git a/src/renderer/src/screens/Soul/Soul.tsx b/src/renderer/src/screens/Soul/Soul.tsx index 46b2b7769..8d3efe07e 100644 --- a/src/renderer/src/screens/Soul/Soul.tsx +++ b/src/renderer/src/screens/Soul/Soul.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { Refresh } from "../../assets/icons"; import { useI18n } from "../../components/useI18n"; +import { OrbLoader } from "../../components/OrbLoader"; interface SoulProps { profile?: string; @@ -67,7 +68,7 @@ function Soul({ profile }: SoulProps): React.JSX.Element { return (
-
+
); diff --git a/src/renderer/src/screens/Tools/Tools.tsx b/src/renderer/src/screens/Tools/Tools.tsx index f27711af0..273323be6 100644 --- a/src/renderer/src/screens/Tools/Tools.tsx +++ b/src/renderer/src/screens/Tools/Tools.tsx @@ -4,6 +4,7 @@ import { Wrench, Plug, Puzzle, Search, X } from "../../assets/icons"; import { TOOL_ICONS, FALLBACK_TOOL_ICON } from "../../components/toolMeta"; import Skills from "../Skills/Skills"; import RemoteNotice from "../../components/RemoteNotice"; +import { OrbLoader } from "../../components/OrbLoader"; interface ToolsetInfo { key: string; @@ -597,7 +598,7 @@ function Tools({ return (
-
+
); diff --git a/src/renderer/src/test/setup.ts b/src/renderer/src/test/setup.ts index 80fba3657..211545a62 100644 --- a/src/renderer/src/test/setup.ts +++ b/src/renderer/src/test/setup.ts @@ -2,40 +2,10 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach, vi } from "vitest"; -// Mock react-loader-spinner which fails to load in test environment -vi.mock("react-loader-spinner", () => ({ - Grid: () => null, - Audio: () => null, - BallTriangle: () => null, - Bars: () => null, - Circles: () => null, - CirclesWithBar: () => null, - ColorRing: () => null, - Comment: () => null, - Discuss: () => null, - DNA: () => null, - FallingLines: () => null, - FidgetSpinner: () => null, - Hearts: () => null, - InfinitySpin: () => null, - LineWave: () => null, - MagnifyingGlass: () => null, - MutatingDots: () => null, - Oval: () => null, - ProgressBar: () => null, - Puff: () => null, - Radio: () => null, - RevolvingDot: () => null, - Rings: () => null, - RotatingLines: () => null, - RotatingSquare: () => null, - RotatingTriangles: () => null, - TailSpin: () => null, - ThreeCircles: () => null, - ThreeDots: () => null, - Triangle: () => null, - Vortex: () => null, - Watch: () => null, +// Mock thinking-orbs — its canvas/IntersectionObserver rendering has no +// jsdom equivalent. +vi.mock("thinking-orbs", () => ({ + ThinkingOrb: () => null, })); afterEach(() => {