From 8bea079e2ef1d00a3bee7be771d2f462f59a82b2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 05:31:54 -0400 Subject: [PATCH] docs(desktop): add judgment-first AGENTS guide and align DESIGN/README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture durable Desktop engineering principles from recent sessions — state by authority, workspace-switch shapes, resolver ladders, optimistic UI — and point root AGENTS.md at the scoped guide with current filenames. --- AGENTS.md | 8 +- apps/desktop/AGENTS.md | 200 +++++++++++++++++++++++++++++++++++++++++ apps/desktop/DESIGN.md | 143 +++++++++++++++++++++++++++-- apps/desktop/README.md | 64 ++++++++++++- 4 files changed, 400 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/AGENTS.md 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/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 d342f0255f65..e5a2649af049 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -85,7 +85,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 @@ -95,9 +151,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.