diff --git a/AGENTS.md b/AGENTS.md index e1dbaaa5c43c..a3d5e5be8413 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,18 +491,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes ### Electron Desktop Chat App (`apps/desktop/`) -A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. +A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`. **Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: - **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills. -- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover. +- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover. - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run. - `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`. - `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.) -- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt. +- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt. -**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install). +**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root). --- diff --git a/agent/agent_init.py b/agent/agent_init.py index 0f9376d6c795..d9207b0b3d94 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -412,13 +412,25 @@ def init_agent( agent.skip_context_files = skip_context_files agent.load_soul_identity = load_soul_identity agent.pass_session_id = pass_session_id - agent._credential_pool = credential_pool agent.log_prefix_chars = log_prefix_chars agent.log_prefix = f"{log_prefix} " if log_prefix else "" # Store effective base URL for feature detection (prompt caching, reasoning, etc.) agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" + if credential_pool is not None: + try: + from agent.credential_pool import credential_pool_matches_provider + + if not credential_pool_matches_provider( + credential_pool, + agent.provider, + base_url=agent.base_url, + ): + credential_pool = None + except Exception: + credential_pool = None + agent._credential_pool = credential_pool agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}: diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 9d5d81b2386f..28f908948969 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -445,6 +445,44 @@ def get_pool_strategy(provider: str) -> str: return STRATEGY_FILL_FIRST +def credential_pool_matches_provider( + pool_or_provider: Any, + provider: Optional[str], + *, + base_url: Optional[str] = None, +) -> bool: + """Return whether a pool belongs to the requested runtime provider. + + Named custom endpoints intentionally use two identities: the live agent is + ``custom`` while its pool is keyed ``custom:``. Accept that pair only + when the runtime base URL resolves to the exact same custom pool key. + Empty string identities fail closed. Legacy pool adapters without a + ``provider`` attribute remain compatible; production pools are scoped. + """ + raw_pool_provider = getattr(pool_or_provider, "provider", None) + if raw_pool_provider is None: + if isinstance(pool_or_provider, str): + raw_pool_provider = pool_or_provider + else: + # Backward compatibility for lightweight/unscoped pool adapters. + # Production CredentialPool instances always carry ``provider``; + # old plugins and tests may expose only select()/has_credentials(). + return True + pool_provider = str(raw_pool_provider or "").strip().lower() + provider_norm = str(provider or "").strip().lower() + if not pool_provider or not provider_norm: + return False + if pool_provider == provider_norm: + return True + if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX): + return False + try: + matched_pool = get_custom_provider_pool_key(base_url or "") + except Exception: + return False + return str(matched_pool or "").strip().lower() == pool_provider + + DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1 diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 605d58d3a7de..1f68b1aace55 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -289,7 +289,7 @@ def build_verify_on_stop_nudge( + "), read any failure, repair the code, and summarize what passed." ) else: - temp_dir = tempfile.gettempdir() + temp_dir = os.path.realpath(tempfile.gettempdir()) command_instruction = ( "No canonical test/lint/build command was detected. Create a focused " f"temporary verification script under `{temp_dir}` using an OS-safe " diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md new file mode 100644 index 000000000000..ca3a3d44d054 --- /dev/null +++ b/apps/desktop/AGENTS.md @@ -0,0 +1,200 @@ +# Desktop Engineering Guide + +How to build Hermes Desktop well. This is a judgment guide, not an inventory — +it teaches the invariants and the reasoning behind them so a change fits the app +even as files move. Read it with the repository `AGENTS.md` (root rules still +apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract. + +When a rule here and the code disagree, trust the code and fix whichever is +wrong — but never break an invariant to make a change easier. + +## What this app is + +Desktop is its own native chat surface. It is not the browser dashboard and it +does not embed the TUI. Three parties, each authoritative for one thing: + +- **Electron** owns the machine: process lifecycle, native filesystem/git/ + windows, install/update, and a narrow, typed capability bridge. +- **The renderer** owns the experience: navigation, presentation, and ephemeral + interaction state. +- **The agent backend** owns the work: sessions, tools, model calls, streaming. + +Keep the seams clean. The renderer never reaches for Node or Electron directly; +native power arrives through a deliberate capability, not a general escape hatch. +Agent behavior lives behind the gateway, never reimplemented in React. When a +change blurs a seam, that is the smell — fix the seam, don't widen it. + +## Decide state by authority + +The first question for any piece of state is *who is allowed to be right about +it*, not where it is convenient to store it. Put state with its authority: + +- The **backend** is authoritative for anything another Hermes surface can also + change. Treat the renderer's copy as a cache of that truth. +- **Electron** is authoritative for machine and runtime facts. +- The **renderer** owns only what is purely about this window's presentation. + +From that, everything else follows: shared renderer state lives in small stores +owned by the feature that owns the concern; request-shaped server data that wants +invalidation lives in the query layer; short-lived interaction detail stays in +the component; hot coordination that must not paint stays in a ref. Reach for the +narrowest home that still lets the state be correct. A new global store is a +claim that many distant surfaces need it — earn that claim. + +Persisted state must declare its scope in its own key: is this global, or does it +belong to a connection, a profile, a stored session, a project, or a window? +Getting the scope wrong is how one profile's setting bleeds into another. + +## Identity is not incidental + +Sessions have more than one identity, and conflating them is a recurring source +of "session not found" and vanishing history. Reason about which identity a +surface needs: durable navigation and anything the user pins or persists key off +the stable/durable identity; live streaming keys off the runtime identity; state +that must outlive compression keys off the lineage root. Keep the mapping between +them explicit and translate at the boundary rather than passing the wrong id +inward. + +## Server truth is cached, not owned + +The renderer paints from a cache of backend truth, so it must reconcile, not +assume: + +- **Merge, don't clobber.** A refresh is new information layered over what you + already know, not a replacement that can drop live or pinned rows. +- **Be optimistic, then honest.** Direct manipulation should paint immediately + from a snapshot; a failed write rolls back visibly and an authoritative + refresh gets the last word. +- **Guard against the past.** Async results can arrive out of order; a stale + response must never overwrite newer intent. Generation counters and request + tokens exist for this. +- **Isolate the foreground.** Only the surface the user is looking at may publish + into the shared view; background work updates its own cache quietly. +- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but + let terminal transitions (a turn finishing, needing input, failing) reach the + user immediately. +- **Preserve reference identity on no-ops.** Handing React a fresh array that + contains the same data re-renders expensive trees for nothing. + +## Switching context is a re-home, not a reboot + +Changing profile, connection, or mode is a workspace switch, not a cold start. +The shell and whatever the user was doing stay put; only the gateway-bound view +is cleared and repopulated, and the previous context must not leak into the next +one. Reserve the full-screen boot/connecting experience for a genuinely unusable +backend. + +There are three distinct switch shapes, and conflating them is the classic bug: + +- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home: + shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query + invalidation alone cannot evict live session stores — wipe them. +- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is + a hard re-home: the window legitimately reloads and state resets by remount. +- A **live profile swap** in the same window activates another profile's socket + while background profiles keep streaming; lists merge rather than wipe, and + only an explicit user selection starts a fresh foreground draft. + +Treating a soft switch as hard flickers the app; treating a hard one as soft +strands stale rows. After any swap, the active socket, active profile, and +connection atoms must agree, or REST and filesystem calls route to the wrong +backend. + +## Cross everything as an observable ladder + +Desktop lives at the seams: versions, profiles, local vs remote vs cloud, +partially installed runtimes, stale caches, older backends. The durable technique +for all of it is the same — an ordered ladder of candidates: + +1. Precedence is written down, in one place, as data or a pure function. +2. A candidate is trusted only after it is validated at the right boundary. + Existence is not proof; probe what you're about to rely on. +3. A failed *read* falls to the next rung; a failed *authoritative write* + surfaces or rolls back rather than silently retargeting. +4. A missing capability and a transient failure are different: the first may + enable a compatibility path or a disabled state; the second should retry. +5. Retries are bounded and end in a real recovery affordance — never an infinite + spinner or a hot loop. +6. One resolver owns each policy so every caller gets the same answer. Scatter is + how two call sites drift apart. + +This is the shape of backend discovery, command/version fallbacks, connection and +auth resolution, workspace-cwd selection, capability detection, and preview +normalization alike. Learn the shape, not a snapshot of the current rungs. + +Two auth-flavored corollaries worth naming because they are easy to get wrong: + +- **One-time credentials are never reused.** An OAuth gateway connection mints a + fresh WebSocket ticket on every dial; a mint failure means reauthentication, + not "fall back to the cached URL." Only long-lived token/local auth may reuse + a cached URL as a lower rung. +- **A connection test must exercise the leg you'll actually use.** An HTTP + status probe passing while the WebSocket/auth leg fails is a false positive + that ships as "it said connected but nothing works." + +## Compatibility without carrying the past forever + +Desktop and its runtime update on separate clocks, so a change can meet an older +backend. Keep those users working: preserve the current feature, keep the +fallback narrow and tied to an identified older runtime, and cover it with a +test. A fallback that quietly degrades the feature it's meant to protect is worse +than the crash it replaced. + +## Keep the waist narrow, grow at the edges + +The root contribution rubric governs here too. New capability should arrive at +the smallest surface that solves it: extend what exists, add a feature locally, +lean on an existing seam — before you invent a framework. The shell's internal +registries are composition seams, not a public plugin ABI; do not build a +universal extension system, a manifest, or a plugin adapter for a single +consumer. Design a shared contract only once more than one real consumer proves +its shape. "Plugin" means several unrelated things across Hermes — do not assume +one surface's extension model runs in another. + +## Respect the person using it + +Design and engineering meet at intent. The user's attention and context are +sacred: + +- Never navigate, move focus, or open a surface because something *happened* in + the background. Offer; don't hijack. +- The states around loading are distinct experiences — empty, loading, + reconnecting, degraded/stale, and exhausted-recovery each deserve their own + honest copy and their own way out. +- Keyboard ownership follows focus. The focused surface wins its keys; one + cancel gesture does exactly one thing. +- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden. + Visibility is not lifecycle. + +## Make it feel instant + +Performance is a feature the user feels, especially in drag, resize, scroll, +typing, streaming, and terminals. The principles are timeless even as the code +changes: keep hot-path state local or narrowly derived; don't subscribe heavy +trees to per-frame updates; coalesce pointer work; avoid reading layout right +after writing style; and don't mount expensive content mid-gesture. Prove speed +against realistic content — a fast empty demo proves nothing about a long +transcript. If motion is masking latency, remove the motion, don't tune it. + +## Testing as a habit of proof + +Test the behavior that would actually break a user, not a snapshot of today's +data. Favor invariants over frozen values. Exercise the real path for anything +at a seam — resolver precedence and its failure rungs, identity and scope +boundaries, optimistic rollback and stale-response ordering, and both sides of a +local/remote adapter with its profile routing intact. Match how the suite is +actually run rather than inventing a command; when in doubt, read the scripts. + +## The taste test before you hand off + +- Does every piece of state live with its authority, at the narrowest scope? +- Would a background event ever steal the foreground or the user's focus? +- Does each resolver have one home, a validated ladder, and a bounded, recoverable + end? +- Do local, remote, and profile routing still agree? +- Does async failure leave a usable UI and a way forward? +- Do hot interactions stay cheap under realistic load? +- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all + locales? + +If any answer is "not sure," that's the part to go verify. diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 54b5661cb1fc..8c4310385e36 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -6,12 +6,29 @@ concern, tokens over literals, flat over boxed.** If you reach for a raw color, a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop, there's already a primitive for it. +This file owns the visual and interaction contract. Read +[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and +testing rules. + +This doc contains two kinds of content, maintained differently: + +- **Principles** (flatness, intent, feedback, motion, cancellation) are durable. + They hold as components come and go. +- **Named contracts** (tokens, `Button` variants, primitive names) are the + design system's current API. They are maintained *with* the code: if you + change a primitive, token, or variant, update its entry here **in the same + change** — a stale name in this file is a bug, exactly like a stale type. + +When a rule and the code disagree, fix whichever is wrong rather than forking a +one-off at the call site. + ## Principles 1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel. Group with whitespace and a single hairline, never nested rounded boxes. -2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a - `--stroke-nous` hairline, not hard borders. +2. **Borderless elevation for floating panels.** Overlays float on + `shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel + structure may use token hairlines sparingly. 3. **One primitive per concern.** One `Button`, one set of control variants, one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't fork. @@ -20,11 +37,40 @@ there's already a primitive for it. 5. **Style lives in the primitive.** Variants and sizes own padding, radius, color, chrome. Call sites pass a `variant`/`size`, not `className` overrides that re-specify those. +6. **Intent before automation.** Surface useful actions and previews, but do not + open panes, move focus, or navigate because a tool happened to produce + something. +7. **Immediate feedback.** Direct manipulation updates the view first. Network + or disk persistence reconciles afterward and rolls back visibly on failure. + +## Information architecture + +- **Chat is the home surface.** The transcript and composer stay primary; tools, + previews, files, review, and terminal complement the conversation. +- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts + remain in shell chrome. Do not hide a distinct product noun inside an + unrelated page. +- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles, + Agents, and Starmap render as `OverlayView` cards and return to the previous + route on close. Model/session pickers and dialogs layer above the current + surface; they are not navigation stacks. +- **Panes are working context.** Preview, files, review, and terminal remain + attached to the current task. Their state survives temporary hiding and chat + switches where the underlying tool is meant to persist. +- **One action, one home.** A command may have keyboard, palette, and visible + affordances, but they invoke the same action and state. Do not fork behavior + per entry point. +- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and + worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow. + +Navigation must preserve context. A background session finishing, a tool result +arriving, or a project refresh may update badges and cached data; it must not +replace the foreground transcript or steal focus. ## Surfaces & elevation -Every overlay / dialog / toast (boot-failure, install, notifications, -model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses: +Floating panels (base `Dialog`, route overlays, boot/install/update surfaces, +model-picker, onboarding, prompt overlays, notifications) use: ``` shadow-nous /* downward-weighted, layered contact→ambient falloff */ @@ -35,6 +81,11 @@ Both are CSS vars in `src/styles.css` — tune in one place, everything inherits Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)` one-offs; if elevation needs to change, change the token. +Menus and popovers use their own shared `shadow-md` + +`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized +dashed targets and local blur. These are semantic surface classes, not licenses +for call-site shadow or border inventions. + ## Stroke & color tokens | Token | Use | @@ -62,8 +113,9 @@ fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel", "Open logs"). **Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons -that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon -family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`. +that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro` +(status-stack/table-footers), and the icon family `icon` / `icon-xs` / +`icon-sm` / `icon-lg` / `icon-titlebar`. Notes: - Text buttons are square (no radius) and sized by padding + line-height (no @@ -107,11 +159,36 @@ Notes: through for a11y. - **Logs:** `LogView` — no bg, hairline border, tight padding, small mono. Every place we surface raw logs uses it. -- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties. +- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay + master/detail empties with an icon and action. Don't hand-roll a third + centered empty. + +## Chat, tools & boot surfaces + +- The transcript and composer are built on `@assistant-ui/react`. Extend the + existing components under `src/components/assistant-ui` and + `src/app/chat/composer`; do not fork a second markdown, message, tool-call, or + approval renderer for one feature. +- A tool result may expose an inline action that opens a preview. It must not + open the rail automatically. +- Install, onboarding, connecting, boot failure, and reauthentication are + distinct states with shared visual primitives. Preserve their recovery + semantics when unifying appearance. +- Respect `AppShell` overlay ownership. Persistent terminal/content layers, + route overlays, dialogs, and boot surfaces must not compete through ad-hoc + z-index literals. ## Iconography & brand -- **`Codicon`** is the icon set. No mixing icon libraries inline. +- **Tabler** is the default component/chrome set. Import its curated aliases and + `iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly + in feature code. +- **`Codicon`** is the compact editor/tool/status vocabulary. Use + `src/components/ui/codicon.tsx`, including `codiconIcon()` where a + Tabler-shaped component is required. +- Pick the vocabulary by semantic context and reuse the existing icon for an + action. Do not introduce a third icon set or mix styles within one control + group. - **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the `nous-girl` mark on a white tile, softly rounded, identical in light/dark. It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it @@ -124,6 +201,43 @@ Notes: - Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element then settle the surface — the outer container's fade is *delayed* so it doesn't swallow the inner animation. Don't let a global fade race the detail. +- Motion follows state; it never delays state. Selection, drag targets, cancel, + and pressed feedback paint in the current frame. +- Do not animate layout geometry with `transition-all` on a hot interaction. + Name the properties, avoid backdrop-filter repaints during movement, and + remove animation before masking a performance problem. + +## Direct manipulation & performance + +The app should feel instant under real load — long transcripts, several panes, +live streams. Design toward that: + +- Direct manipulation paints first; persistence reconciles after and rolls back + visibly on failure. +- Keep interaction feedback cheap: hot-path state stays local or narrowly + derived, not wired into heavy trees; pointer work coalesces per frame. +- One drop region has one visual owner, and drop targets speak one affordance + language across files, sessions, tabs, and panes. Overlapping targets resolve + to the active one instead of stacking overlays. +- Forgiving geometry beats pixel-perfect triggers; edge actions live near their + edge, not clustered in the center. +- Expensive stateful surfaces stay mounted when hidden. Visibility is not + lifecycle. + +Prove speed with realistic content. A fast empty-state demo says nothing about a +long transcript or a busy terminal. + +## Keyboard & cancellation + +- Keyboard ownership follows focus. The focused surface wins its keys; shell + shortcuts must not steal a terminal's or editor's bindings. +- Register global shortcuts through the shared layer, not ad-hoc listeners. +- One cancel gesture does one thing: cancel the active interaction, or close the + topmost dismissable surface — never both, never the control underneath. +- Cancellation is synchronous in the UI even if cleanup is async: overlays, + cursors, and pending gesture state clear at once. +- Flows that deliberately cannot be dismissed (install/onboarding, destructive + confirmation) must make that explicit. ## i18n @@ -135,12 +249,15 @@ Notes: ## State (TypeScript) -Mirrors the repo TS style (see root `AGENTS.md`): +The detailed state contract lives in the scoped +[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials: - Shared/cross-component state → small **nanostores**, not prop-drilling. Each feature owns its atoms; shared atoms live in `src/store`. - Rendering components subscribe with `useStore`; non-render actions read with `$atom.get()`. +- Subscribe to derived coarse facts instead of high-frequency source atoms when + the component does not render the full value. - Colocated action modules over god hooks. A hook owns one narrow job. - Keep persistence beside the atom that owns it. Route roots stay thin. - Prefer `interface` for public props; extend React primitives @@ -163,5 +280,13 @@ Mirrors the repo TS style (see root `AGENTS.md`): - [ ] No `className` overriding a primitive's padding / size / radius / chrome? - [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border? - [ ] Flat — no card-in-card, no gratuitous row dividers? +- [ ] No automatic navigation, focus steal, or pane opening from background + events? +- [ ] Direct manipulation paints immediately and rolls back cleanly on failure? +- [ ] Hot interactions avoid broad subscriptions, layout thrash, and + `transition-all`? +- [ ] Keyboard ownership and single-action `Esc` behavior are correct? - [ ] All four locales updated for any new/changed string? - [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave? +- [ ] Touched a primitive, token, or variant? Its named-contract entry in this + file is updated in the same change. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 276704523f87..a1da176726d1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -87,7 +87,63 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig ### How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`. +The packaged app ships the Electron shell and a native React chat surface. On +first launch it can install the Hermes Agent runtime into `HERMES_HOME` +(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a +CLI install. + +The app has three boundaries: + +- **Electron** resolves and validates a runnable backend, owns native + filesystem/git/window capabilities, and exposes a narrow preload bridge. +- **React** owns the Desktop routes, panes, interaction state, and + `@assistant-ui/react` transcript. +- **Hermes Agent** runs as a headless `hermes serve` process and exposes the + `tui_gateway` JSON-RPC/WebSocket API. The renderer connects through + [`apps/shared`](../shared/), which is also used by the browser dashboard. + +Backend resolution is an ordered ladder: + +1. `HERMES_DESKTOP_HERMES_ROOT` +2. the current source checkout during development +3. a completed managed install +4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH` +5. a system Python that can import the Hermes runtime +6. the first-launch bootstrap installer + +Candidates are probed before use; an existing shim or interpreter is not enough. +A runtime that predates `serve` falls back to headless +`dashboard --no-open`. This is compatibility for the backend command only and +does not launch or embed the dashboard UI. + +The Electron orchestration entry point is `electron/main.ts`; pure resolution, +probe, hardening, and platform policies live in focused modules beside it. The +renderer is under `src/`, with shared atoms in `src/store` and transport/native +adapters in `src/lib`. + +Before changing the app, read: + +- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback, + transport, performance, and testing rules. +- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion, + direct manipulation, and keyboard behavior. + +### Connections, projects, and switching + +Desktop supports a managed local backend, explicit remote gateways, and Hermes +Cloud connections. Remote and cloud modes use the same remote-capability path; +authentication and discovery differ, not the renderer feature model. + +Projects are the workspace abstraction. A project may own multiple folders, +repositories, worktrees, and sessions; a bare new chat remains detached unless +the user enters a project or configures a default project directory. Use the +Projects UI rather than adding a second per-session folder-picker workflow. + +Changing profiles or connection modes is a soft workspace switch, not another +cold boot. The shell and current management overlay remain mounted while +gateway-bound nanostores are wiped, query-backed data is invalidated, and the +new connection repopulates skeletons. This prevents rows or transcripts from +the previous gateway bleeding into the next one. ### Verification @@ -97,9 +153,13 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl npm run fix npm run typecheck npm run lint -npm run test:desktop:all +npm run test:ui +npm run test:desktop:platforms ``` +Run `npm run test:desktop:all` for install, boot, update, packaging, or other +release-path changes. + ### Troubleshooting Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure. diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 6a87590b3cb1..fa9e00eb3497 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -19,6 +19,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' +import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' import { MemoryConnect } from './memory/connect' @@ -100,6 +101,13 @@ function ConfigField({ ) + // `fallback_providers` is a list of {provider, model} objects; the generic + // `list` branch below would stringify them to "[object Object]". Render the + // dedicated structured editor instead. + if (schemaKey === 'fallback_providers') { + return row(, true) + } + if (schema.type === 'boolean') { return row(
diff --git a/apps/desktop/src/app/settings/fallback-models-field.test.tsx b/apps/desktop/src/app/settings/fallback-models-field.test.tsx new file mode 100644 index 000000000000..8abc71a65214 --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.test.tsx @@ -0,0 +1,113 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +const getGlobalModelOptions = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: () => getGlobalModelOptions() +})) + +beforeEach(() => { + getGlobalModelOptions.mockResolvedValue({ + providers: [ + { name: 'GitHub Copilot', slug: 'copilot', models: ['gpt-5-mini', 'gpt-5.4-mini'] }, + { name: 'OpenAI Codex', slug: 'openai-codex', models: ['gpt-5.4-mini'] }, + { name: 'Nous', slug: 'nous', models: ['hermes-4'] } + ] + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderField(value: unknown, onChange = vi.fn()) { + const { FallbackModelsField } = await import('./fallback-models-field') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + ) + + return onChange +} + +async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) { + const { FallbackModelsField } = await import('./fallback-models-field') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = render( + + + + ) + + return (next: unknown) => + view.rerender( + + + + ) +} + +const CHAIN = [ + { provider: 'copilot', model: 'gpt-5-mini' }, + { provider: 'openai-codex', model: 'gpt-5.4-mini' } +] + +describe('FallbackModelsField', () => { + it('renders each {provider, model} entry as its own row (never "[object Object]")', async () => { + await renderField(CHAIN) + + // One Remove control per entry proves the object list became rows — the old + // generic `list` input stringified the array to "[object Object]". + expect(screen.getAllByLabelText('Remove')).toHaveLength(2) + expect(screen.getByText('Add fallback')).toBeTruthy() + expect(screen.queryByText(/\[object Object\]/)).toBeNull() + await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) + }) + + it('removing a row emits the remaining entries', async () => { + const onChange = await renderField(CHAIN) + + fireEvent.click(screen.getAllByLabelText('Remove')[0]) + + expect(onChange.mock.calls.at(-1)?.[0]).toEqual([{ provider: 'openai-codex', model: 'gpt-5.4-mini' }]) + }) + + it('adding a blank row does not persist a partial entry', async () => { + const onChange = await renderField(CHAIN) + + fireEvent.click(screen.getByText('Add fallback')) + + // The new empty row stays in the UI but only complete pairs are emitted. + expect(onChange.mock.calls.at(-1)?.[0]).toEqual(CHAIN) + expect(screen.getAllByLabelText('Remove')).toHaveLength(3) + }) + + it('shows an empty-state hint when there are no fallbacks', async () => { + await renderField([]) + + expect(screen.getByText(/No fallback models/)).toBeTruthy() + expect(screen.queryAllByLabelText('Remove')).toHaveLength(0) + }) + + it('resyncs rows when persisted config changes', async () => { + const rerender = await renderFieldWithRerender(CHAIN) + expect(screen.getAllByLabelText('Remove')).toHaveLength(2) + + rerender([{ provider: 'nous', model: 'hermes-4' }]) + + await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1)) + }) +}) diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx new file mode 100644 index 000000000000..d904d021f7b7 --- /dev/null +++ b/apps/desktop/src/app/settings/fallback-models-field.tsx @@ -0,0 +1,143 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { getGlobalModelOptions } from '@/hermes' +import { useI18n } from '@/i18n' +import { Plus, X } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { CONTROL_TEXT } from './constants' + +interface FallbackEntry { + provider: string + model: string +} + +// Normalize the raw config value (`fallback_providers`: a list of +// `{provider, model}` dicts) into editor rows. Defensive against legacy string +// entries ("provider/model") so the editor never crashes on odd data. +function normalizeEntries(value: unknown): FallbackEntry[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map(item => { + if (item && typeof item === 'object') { + const record = item as Record + + return { provider: String(record.provider ?? ''), model: String(record.model ?? '') } + } + + if (typeof item === 'string') { + const slash = item.indexOf('/') + + return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item } + } + + return { provider: '', model: '' } + }) +} + +/** + * Structured editor for the top-level `fallback_providers` config list — a + * chain of `{provider, model}` pairs tried in order when the default model + * fails. Replaces the generic comma-string `list` input, which stringified the + * objects to "[object Object], [object Object]". + * + * Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model + * selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in + * local state and only complete pairs are emitted upward, so the config + * autosave never persists a partial `{provider, model: ''}`. + */ +export function FallbackModelsField({ + value, + onChange +}: { + value: unknown + onChange: (next: FallbackEntry[]) => void +}) { + const { t } = useI18n() + const m = t.settings.model + + const modelOptions = useQuery({ + queryKey: ['model-options', 'global'], + queryFn: () => getGlobalModelOptions() + }) + + const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug) + + const [rows, setRows] = useState(() => normalizeEntries(value)) + + // Settings can reload after a profile/config change while this component + // stays mounted. Avoid displaying or saving the previous profile's chain. + useEffect(() => { + setRows(normalizeEntries(value)) + }, [value]) + + const commit = (next: FallbackEntry[]) => { + setRows(next) + onChange(next.filter(entry => entry.provider && entry.model)) + } + + const updateRow = (index: number, patch: Partial) => + commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry))) + + return ( +
+ {rows.length === 0 &&

{m.fallbackEmpty}

} + {rows.map((entry, index) => { + const providerRow = providers.find(provider => provider.slug === entry.provider) + const catalog = providerRow?.models ?? [] + // Keep an out-of-catalog model selectable so an existing custom + // provider/model renders instead of showing a blank box. + const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog + + return ( +
+ {index + 1} + + + +
+ ) + })} +
+ +
+
+ ) +} diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 040781942e29..fa7d15a5d1ed 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -298,23 +298,62 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { return moa.presets[selectedMoaPreset] || moa.presets[moa.default_preset] || Object.values(moa.presets)[0] || null }, [moa, selectedMoaPreset]) + // Mirror of `moa` so inline edits compute the next state purely (outside the + // setState updater) and hand it straight to the debounced autosave. + const moaRef = useRef(null) + + useEffect(() => { + moaRef.current = moa + }, [moa]) + + const moaSaveTimer = useRef(null) + + useEffect( + () => () => { + if (moaSaveTimer.current) { + window.clearTimeout(moaSaveTimer.current) + } + }, + [] + ) + + // Quiet debounced persist for inline MoA edits — mirrors the config page's + // autosave so slot/aggregator tweaks save themselves, matching the + // preset-level ops (set default / add / delete) that already persist on + // click. No `applying` spinner, so selecting stays responsive. + const scheduleMoaSave = useCallback((next: MoaConfigResponse) => { + if (moaSaveTimer.current) { + window.clearTimeout(moaSaveTimer.current) + } + + moaSaveTimer.current = window.setTimeout(() => { + void saveMoaModels(next) + .then(setMoa) + .catch(err => setError(err instanceof Error ? err.message : String(err))) + }, 600) + }, []) + const updateMoaPreset = useCallback( (updater: (preset: NonNullable) => NonNullable) => { - setMoa(prev => { - if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) { - return prev - } + const prev = moaRef.current - return { - ...prev, - presets: { - ...prev.presets, - [selectedMoaPreset]: updater(prev.presets[selectedMoaPreset]) - } + if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) { + return + } + + const next: MoaConfigResponse = { + ...prev, + presets: { + ...prev.presets, + [selectedMoaPreset]: updater(prev.presets[selectedMoaPreset]) } - }) + } + + moaRef.current = next + setMoa(next) + scheduleMoaSave(next) }, - [selectedMoaPreset] + [scheduleMoaSave, selectedMoaPreset] ) const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial): MoaModelSlot => { @@ -841,12 +880,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { {moa && currentMoaPreset && (
-
- - -
+

Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the acting model. diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx new file mode 100644 index 000000000000..a508b8471c51 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -0,0 +1,112 @@ +import { cleanup, render, screen } from '@testing-library/react' +import type { ToolCallMessagePartProps } from '@assistant-ui/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' + +import { ClarifyTool, readClarifyResult } from './clarify-tool' + +afterEach(() => { + cleanup() +}) + +function renderClarify(ui: ReactNode) { + return render( + + {ui} + + ) +} + +function settledClarifyProps( + args: ToolCallMessagePartProps['args'], + result: ToolCallMessagePartProps['result'], + toolCallId: string +): ToolCallMessagePartProps { + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + result, + resume: vi.fn(), + status: { type: 'complete' }, + toolCallId, + toolName: 'clarify', + type: 'tool-call' + } +} + +describe('readClarifyResult', () => { + it('reads question + user_response from the tool JSON payload', () => { + expect( + readClarifyResult({ + question: 'Which target?', + choices_offered: ['staging', 'prod'], + user_response: 'staging' + }) + ).toEqual({ + question: 'Which target?', + answer: 'staging', + error: undefined + }) + }) + + it('parses a JSON string result the same way as an object', () => { + expect( + readClarifyResult( + JSON.stringify({ + question: 'Ship it?', + user_response: 'yes' + }) + ) + ).toEqual({ + question: 'Ship it?', + answer: 'yes', + error: undefined + }) + }) + + it('keeps an empty user_response so Skip can render as skipped', () => { + expect(readClarifyResult({ question: 'Ok?', user_response: '' })).toEqual({ + question: 'Ok?', + answer: '', + error: undefined + }) + }) +}) + +describe('ClarifyTool settled view', () => { + it('keeps the question and answer visible after the tool completes', () => { + renderClarify( + + ) + + expect(screen.getByText('Which deployment target?')).toBeTruthy() + expect(screen.getByText('staging')).toBeTruthy() + expect(document.querySelector('[data-clarify-settled]')).toBeTruthy() + expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging') + }) + + it('labels an empty response as Skipped', () => { + renderClarify( + + ) + + expect(screen.getByText('Anything else?')).toBeTruthy() + expect(screen.getByText('Skipped')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index ed3ee6be8d47..967c9aac2e4c 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -19,55 +19,74 @@ import { Kbd } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Loader2, MessageQuestion } from '@/lib/icons' +import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { selectMessageRunning } from './tool/fallback-model' +import { parseMaybeObject } from './tool/fallback-model/format' interface ClarifyArgs { question?: string choices?: string[] | null } -function readClarifyArgs(args: unknown): ClarifyArgs { - if (!args || typeof args !== 'object') { - return {} - } +interface ClarifyResult { + question?: string + answer?: string + error?: string +} - const row = args as Record +function stringField(row: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = row[key] + + if (typeof value === 'string') { + return value + } + } +} + +function readClarifyArgs(args: unknown): ClarifyArgs { + const row = parseMaybeObject(args) const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null return { - question: typeof row.question === 'string' ? row.question : undefined, + question: stringField(row, 'question'), choices: choices && choices.length > 0 ? choices : null } } -// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing -// that letter — the badge doubles as the shortcut hint. +/** Parse clarify tool JSON (`question` + `user_response`). */ +export function readClarifyResult(result: unknown): ClarifyResult { + const row = parseMaybeObject(result) + + if (Object.keys(row).length === 0) { + return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {} + } + + return { + question: stringField(row, 'question'), + answer: stringField(row, 'user_response', 'answer'), + error: stringField(row, 'error') + } +} + const letterFor = (index: number): string => String.fromCharCode(65 + index) -// Choice and "Other" rows share a layout; only color differs. Mirrors a tool -// row's compact rhythm so the panel reads as part of the transcript. const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50' -// Content-sizing freeform field (CSS `field-sizing` — same primitive as the -// commit bar and search field): starts at one line, grows with what's typed, -// and never reflows the panel when focused. Bare so the "Other" row matches the -// choice rows above it. -const FREEFORM_INPUT_CLASS = - 'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50' +// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners. +const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none' -// Quiet inline panel that matches the surrounding tool rows: a single hairline -// border in the shared stroke token, a soft surface fill, and a faint primary -// accent that signals "this one needs you" without the loud animated ring. const CLARIFY_SHELL_CLASS = 'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)' +const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)' + function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) { return (

@@ -76,10 +95,20 @@ function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) ) } -// Selection lives on the letter badge alone — a solid primary fill — not the -// whole row, which stays a quiet hover target. `preview` is the focused-but-empty -// "Other" state: the badge outlines in primary to show it's armed, then fills -// once a value is actually typed. +function ClarifyLine({ + children, + className, + icon: Icon, + ...props +}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) { + return ( +
+
{children}
+ +
+ ) +} + function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) { return ( { + // Answered → settled Q&A (ToolFallback collapsed the answer away). + if (props.result !== undefined) { + return + } + + return +} + +function ClarifyToolLive(props: ToolCallMessagePartProps) { const messageRunning = useAuiState(selectMessageRunning) - // Only the live, still-blocked turn shows the interactive panel. Once the - // message stops running — answered, the turn ended, or the user hit Stop — - // fall back to the standard tool block so the Q/A settles like every other - // row instead of stranding a dead prompt the gateway no longer waits on. - const isPending = messageRunning && props.result === undefined - - if (!isPending) { + // Stopped mid-prompt with no result — don't leave a dead interactive panel. + if (!messageRunning) { return } return } +function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { + const { t } = useI18n() + const copy = t.assistant.clarify + const fromArgs = useMemo(() => readClarifyArgs(args), [args]) + const fromResult = useMemo(() => readClarifyResult(result), [result]) + + const question = fromResult.question || fromArgs.question || '' + const answer = fromResult.answer + const error = fromResult.error + const skipped = !error && answer !== undefined && !answer.trim() + const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim()) + + return ( + + {question ? ( + + {question} + + ) : null} + {answerText ? ( + +

+ {answerText} +

+
+ ) : null} +
+ ) +} + function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const { t } = useI18n() const copy = t.assistant.clarify @@ -175,8 +245,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { }) triggerHaptic('submit') clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId) - // The matching tool.complete will land shortly after, swapping this - // panel for the ToolFallback view above. + // tool.complete lands next → ClarifyToolSettled. } catch (error) { notifyError(error, copy.sendFailed) setSubmitting(false) @@ -327,17 +396,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { {choice} ))} - {/* "Other" is an inline content-sizing field, not a separate view. */} -