Compare commits

..

63 Commits

Author SHA1 Message Date
alt-glitch
200fc3c794 test(installer): factor node-bootstrap test layout into one helper
/simplify quality pass: the 5-segment Termux link-dir path was re-derived
in _run_nb_link and three test bodies; centralize it in a single
_layout(tmp_path) NamedTuple helper so the paths can't drift. Test-only,
no behavior change.
2026-06-04 19:42:07 +05:30
alt-glitch
4361159cbc fix(installer): close review gaps in node-on-PATH FHS heal
Follow-up hardening for the off-PATH node heal whose core landed via
PR #38889 (which squash-merged only the fresh-install link-dir fix). A
review of the full change surfaced the following, fixed here:

- install.sh: ensure_mode/postinstall_mode now call resolve_install_layout
  before check_node, so a root FHS box reached via `install.sh --ensure
  node` (dep_ensure / acp_adapter / TUI fallback) links node into
  /usr/local/bin instead of the off-PATH ~/.local/bin — the original
  #38889 regression still bit on those two paths.
- install.sh / node-bootstrap.sh: the best-effort stale-link prune now
  uses `rm -f ... 2>/dev/null || true` and the link helpers end with
  `return 0`, so a non-removable shadow link (read-only parent dir, uid
  mismatch) can no longer abort the whole installer under `set -e`.
- node-bootstrap.sh _nb_get_link_dir / hermes_constants _is_root_fhs_layout:
  handle the explicit --dir/$HERMES_INSTALL_DIR root install (which keeps
  ~/.local/bin) by placing node where the `hermes` command actually
  landed, instead of re-deriving a layout that diverges from the installer.
- whatsapp.py: launch the bridge with the bundled-fallback node binary and
  put the bundled node bin dir on the bridge PATH, so a bundled-but-off-PATH
  install doesn't FileNotFoundError at bridge launch (the check + npm install
  already used the fallback; the spawn didn't).
- doctor.py: diagnose a dangling /usr/local/bin/node symlink as a stale
  target (lexists/is_symlink) rather than misreporting it as missing.
- tests: add tests/test_node_bootstrap_link_prune.py covering the migration
  relink + stale-prune, prune safety (real files and user nvm/fnm links are
  preserved), idempotency, and the set -e prune-abort guard.
- docstring cleanups for the layout-aware wrapper-dir helpers.
2026-06-04 15:52:44 +05:30
alt-glitch
85b03a0c91 fix(installer): heal off-PATH node on update/migration + harden node discovery
Follow-up to the FHS root-install node-PATH fix, addressing the high-risk
gaps a reviewer flagged: fresh-install passing does not mean an existing
broken install gets healed.

Migration repair (the #1 trap):
- node-bootstrap.sh ensure_node() and install.sh check_node() both
  early-returned when a bundled node already existed at HERMES_HOME/node/bin,
  only fixing the current shell PATH and never re-creating the /usr/local/bin
  symlinks. A previously-broken root box therefore stayed broken after
  `hermes update` / re-install.
- Both paths now call a shared link_bundled_node / _nb_link_bundled_node that
  idempotently re-creates the symlinks in the canonical command-link dir AND
  prunes stale links left in the other candidate dirs, so a migrated root
  install no longer keeps shadowing copies in ~/.local/bin (the #34536
  nvm-shadow class).

Parity (messy-middle edge case):
- _nb_get_link_dir() now mirrors resolve_install_layout()'s legacy-install
  carve-out: a root user with HERMES_HOME/hermes-agent/.git keeps ~/.local/bin,
  so the bootstrap path can no longer link node to a different dir than the
  installer placed the hermes command.

Canonical helper (kills the duplicated layout-logic root cause):
- hermes_constants now owns command_link_dir, command_link_display_dir,
  command_link_candidate_dirs, bundled_node_bin_dir, find_node_executable.
  doctor.py, profiles.py, uninstall.py, backup.py, main.py all consume it.

Doctor now catches this class of regression:
- new _resolve_node_for_doctor reports "Node.js installed but not on PATH"
  instead of a false "not found", verifies the /usr/local/bin symlink on
  root FHS, self-heals PATH for the rest of the run, and the npm-audit block
  no longer silently vanishes when npm is off-PATH.
- doctor command-link detection uses the canonical helper, so it no longer
  looks in ~/.local/bin on root FHS or creates a wrong duplicate symlink
  with --fix.

Profile-alias wrappers now land in the layout-aware dir (was hardcoded
~/.local/bin, off-PATH for root FHS); remove_wrapper_script and uninstall
scan all candidate dirs.

Defensive bundled-node fallback (find_node_executable) added to the dashboard
web-UI build, WhatsApp bridge, and LSP installer so an off-PATH bundled node
does not silently disable those features.

Tests: 9 new hermes_constants helper tests + 4 profiles wrapper-dir tests.
Verified on a throwaway VM: fresh-root install (node on PATH, dashboard
serves HTTP 200, tsc present) and the migration scenario (broken old layout
re-installed -> node restored to /usr/local/bin, stale ~/.local/bin pruned).
2026-06-04 15:42:15 +05:30
alt-glitch
aeec88c77f fix(installer): symlink bundled node/npm into command bin dir for FHS root installs
Root installs on Linux (FHS layout, #15608) put the `hermes` command in
`/usr/local/bin` (on PATH) but symlinked the bundled node/npm/npx into
`~/.local/bin`, which isn't on PATH for a stock root shell. `node`/`npm`
were 'command not found' and `hermes dashboard` failed with 'npm is not
available' because its build-on-demand fallback couldn't find npm.

Fix: `install_node()` now symlinks into `get_command_link_dir()` — the same
helper the `hermes` command link already uses — so node/npm/npx land
wherever the command does (`/usr/local/bin` on FHS root, `~/.local/bin`
otherwise, `$PREFIX/bin` on Termux). Non-root and Termux installs are
unchanged.

Also fixes:
- `scripts/lib/node-bootstrap.sh`: adds `_nb_get_link_dir()` mirroring
  the same root/Termux/user logic for the standalone bootstrap path
  (used by `hermes update`, TUI node bootstrap, etc.)
- `hermes_cli/uninstall.py`: `remove_node_symlinks()` now checks all
  candidate directories (`~/.local/bin`, `/usr/local/bin`, `$PREFIX/bin`)
  so root FHS uninstalls don't leave orphan symlinks

Regression from #15608, which created the FHS path for the command but
left `install_node` pointed at the legacy user-local dir.
2026-06-04 02:31:49 -07:00
Teknium
b1b0f4b668 fix(desktop): surface command approval even when its tool is in a collapsed group (#38829)
The desktop command-approval ApprovalBar renders inline inside ToolEntry,
which lives inside ToolGroupSlot. When 2+ tools group, the group body is
hidden until expanded, so an approval raised by a pending terminal/
execute_code call was buried behind "Tool actions · N steps" and required
manual expansion to act on (sudo/secret were unaffected — they use modal
overlays).

ToolGroupSlot now subscribes to $approvalRequest and force-opens its body
while an approval targeting one of its pending approval-eligible tools is in
flight, so the inline controls surface with nothing expanded. The group
reverts to the user's stored collapse state once the approval resolves.
2026-06-04 02:29:46 -07:00
Teknium
0175be3aa7 chore(desktop): silence Vite chunk-size warning for intentional single bundle (#38888)
The desktop renderer is bundled as one chunk on purpose (codeSplitting:
false) because Shiki's many dynamic chunks make electron-builder OOM
scanning thousands of files. That makes the ~22 MB bundle expected, but
Vite still nags with 'Some chunks are larger than 500 kB' on every build.

Raise chunkSizeWarningLimit to 25000 kB so the cosmetic warning stays
quiet while still firing as a regression alarm if the bundle grows well
past today's size. Config-only; codeSplitting:false is untouched.
2026-06-04 02:28:57 -07:00
Teknium
928f1ac0e1 fix(desktop): re-mint OAuth WS ticket on gateway reconnect (#38886)
attemptReconnect() connected with the stale cached conn.wsUrl. OAuth WS
tickets are single-use with a ~30s TTL, so the first sign-in (which goes
through boot() and re-mints via resolveGatewayWsUrl) succeeds, but every
reconnect (sleep/wake, network online, window refocus, socket drop, app
restart) reused a dead ticket and failed the WS upgrade with an opaque
"Could not connect to Hermes gateway" — even though backend resolution
(cookie + REST) reported ready.

attemptReconnect now mints a fresh ticket before connecting, mirroring
use-gateway-request.ts, and surfaces the reauth "sign in again" message
once on OAuth expiry instead of silently looping backoff against a dead
ticket. Local/token gateways are unaffected (re-mint is a no-op).
2026-06-04 02:28:43 -07:00
Teknium
4ed63170e4 fix(update): don't fail desktop rebuild / skills sync on mid-rebuild venv (#38885)
When 'hermes update' rebuilds the project venv (rmtree + uv venv on the
first managed-uv migration), the desktop-rebuild and profile-skills-sync
steps that follow both spawn sys.executable. Firing while the venv is
mid-rewrite makes the child interpreter abort with the bare stderr line
'No pyvenv.cfg file', surfacing as a spurious 'Desktop build failed' /
'default: sync failed' on an update that actually succeeded.

Add _wait_for_interpreter_venv_ready(): resolve the venv hosting
sys.executable and poll briefly for pyvenv.cfg to (re)appear before each
of those subprocess steps. No-op when the interpreter isn't venv-hosted.
The desktop rebuild also retries once after re-waiting, and keeps
streaming its output live (no capture). Best-effort throughout — callers
proceed regardless, so a genuinely broken venv still surfaces the real
error.
2026-06-04 02:20:11 -07:00
Teknium
bd12b3c232 feat(desktop): username/password login for remote gateways (#38851)
Surface the username/password dashboard-auth provider in Hermes Desktop's
remote-gateway connect flow. A password gateway gates the same way an OAuth
one does (auth_required + session cookie + ws-ticket), so the desktop already
drives it through the existing sign-in window; the only gaps were that the
probe dropped supports_password and the UI always said "OAuth".

- main.cjs: capture supports_password from /api/auth/providers in the probe.
- global.d.ts: add optional supportsPassword to DesktopAuthProvider.
- gateway-settings.tsx: derive isPasswordProvider; render a plain "Sign in"
  button + "username and password" copy instead of an OAuth provider label
  when every advertised provider is password-based. Login still flows through
  the gateway's /login credential form (POST /auth/password-login).
2026-06-04 01:33:23 -07:00
Teknium
fe709a4210 fix(test): expect 4404 close code for disabled embedded chat (#38841)
PR #38743 split the dashboard PTY WebSocket refusal codes (4404 = chat
disabled, 4403 = host/origin mismatch — see web_server.py refusal site
comment) but left test_rejects_when_embedded_chat_disabled asserting the
old 4403, so it has expected 4403 while the server sends 4404. Main CI has
been red on test (2)/(4) shards since that commit. Update the assertion to
4404 to match the disabled-chat path.
2026-06-04 01:13:03 -07:00
Ben
385a508e43 fix(desktop): don't fall back to a dead WS ticket on OAuth re-mint failure
The reconnect and boot paths resolved the WS URL with
`(await getGatewayWsUrl().catch(() => null)) || conn.wsUrl`. For OAuth
gateways the cached conn.wsUrl carries a single-use, ~30s-TTL ticket; the
desktop connection is memoized for the process lifetime, so on reconnect
that ticket is both expired and already consumed. A failed fresh mint
therefore fell back to a guaranteed-dead ticket and surfaced as an opaque
"connection closed", masking the gateway's actionable "session expired,
sign in again" message.

Extract resolveGatewayWsUrl() (with unit tests): in OAuth mode a mint
failure throws a tagged GatewayReauthRequiredError instead of falling back;
token/local modes keep the long-lived-token fallback. Thread that error
through the reconnect path so requestGateway surfaces the reauth message
rather than the generic transport error that triggered the retry.

Co-authored-by: Kenmege <205099287+Kenmege@users.noreply.github.com>
2026-06-04 01:11:34 -07:00
Ben
bf590c81d0 fix(desktop): hide gateway auth control until probe resolves the scheme
The remote-gateway settings rendered the session-token box for every gateway
during the idle/probing window before the first /api/status probe lands,
because authMode defaults to 'token'. Gate both the OAuth sign-in button and
the token box behind an authResolved flag so neither renders until the probe
resolves the scheme (or a previously-saved remote config is being re-shown,
so re-opening settings doesn't flicker).

The gateway-side WS Origin fix that lets the packaged desktop (file:// origin)
connect to an OAuth-gated remote gateway landed separately in #37870; this
branch is now purely the desktop client + this UI fix.
2026-06-04 01:11:34 -07:00
Ben
9d07927a23 desktop: OAuth-aware remote gateway connection
The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.

Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.

OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
  Electron session partition (persist:hermes-remote-oauth); main-process
  REST routes through electron net bound to that partition so the cookie
  attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
  resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
  POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
  getGatewayWsUrl() re-mints before every (re)connect since tickets are
  single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
  (Nous Portal contract v1 issues no refresh token).

Local and token modes are unchanged.

Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
2026-06-04 01:11:34 -07:00
Austin Pickett
9cbc37e25b feat(desktop): dedicated Providers settings + polished Accounts/API-keys UX (#38551)
* feat(desktop): dedicated Providers settings with Accounts/API-keys subnav

Rework provider configuration in the desktop app into its own Providers
page that mirrors the first-run onboarding picker, instead of burying
provider keys in the generic Tools & Keys list.

- Add a Providers settings page (providers-settings.tsx) reusing the
  onboarding picker cards/ApiKeyForm so the two surfaces stay identical
- Add a sidebar subnav (Accounts vs API keys) backed by a deep-linkable
  `pview` URL param; nested OverlayNavItem variant for a lighter active
  state so children don't compete with the parent item
- Scope provider search to the active sub-view in its native card format
  (no more accordion fallback); collapse the API-key grid to the top
  providers behind a "Show all" toggle to cut scrolling
- Launch real in-app OAuth from settings via startManualProviderOAuth;
  fix the misleading red "reason" banner that showed during an active
  connect (neutral style, hidden during a flow, omitted for direct
  per-provider launches)
- Expand PROVIDER_GROUPS and add longest-prefix matching so providers
  like xAI/Ollama group correctly instead of landing under "Other"
- Drop redundant messaging API keys from Tools & Keys (channel_managed)

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(desktop): Cursor-style provider key list with inline inputs

Replace the card-grid API-key form on the Providers page with a
per-provider list (mirrors Cursor's API keys section):

- One row per vendor with its primary key input inline; rows with extra
  vars (base URL, region, alt tokens) expand to reveal those on focus
- Set keys show their redacted value as the placeholder; Save appears on
  edit, Remove on a set key
- Hide redundant alias key fields (e.g. ANTHROPIC_TOKEN vs
  ANTHROPIC_API_KEY) unless already set, and label set aliases by env var
  name so they're unambiguous
- Smaller mono input text + compact height

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(desktop): flatten providers settings UI chrome

Tighten the providers settings surface to match the newer desktop style:
remove extra card rails/borders in API-key rows, reduce visual noise in the
providers subnav, replace bespoke link-like controls with shared text-button
variants, and improve key input readability.

* feat(desktop): rework providers settings UI

- Flatten the shared OAuth picker rows (accounts + onboarding): drop the
  rounded-2xl/border cards for flat hover-bg rows; Nous hero keeps a subtle
  tint plus an animated blue→purple arc border.
- Key fields collapse to a single input: a set key reads read-only (redacted)
  and edits in place on focus/click — no Replace/Cancel chrome. Save on type,
  Esc cancels (without closing the overlay), "Remove or esc to cancel" hint.
- Non-key overrides render boxless, content-sized (field-sizing) and
  right-anchored; advanced fields align under the primary key column.
- Add `xs` control size; size fields via padding (no fixed heights).
- Cards expand on key-input focus; chevron shows on hover/expanded; expanded
  state uses a ring + softer bg tier so hover ≠ focus.
- Relocate "Get a key" to the bottom-right of the expanded panel; drop the
  redundant provider description.
- Cmd+K: add Providers (accounts) and Provider API keys deep-links.

* fix(desktop): flatten provider fields, drop input shadows, fix Cmd+K provider rank

- KeyField: collapse to one stacked label-above-input form field (drop the
  bespoke `naked`/inline/column branches); empty advanced overrides fade until
  hover/focus/set
- styles: kill the resting + focus drop shadow on shared input chrome so form
  inputs sit flat (composer keeps its own shadow)
- Cmd+K: drop stray `providers` keyword from Skills & Tools so the Providers
  settings entry ranks first for "provider"

* fix(desktop): nous portal arc blue → orange

* fix(desktop): rank appearance above settings in Cmd+K

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-04 03:03:42 -05:00
Ben
b36a30db20 docs(dashboard-auth): document the username/password provider
Add a 'Username/password provider (no OAuth IDP)' section to the web
dashboard guide (config.yaml + env surfaces, the explicit-secret caveat,
the rate-limit/generic-401 properties, and a 'write your own password
provider' pointer to the supports_password extension point), and list the
HERMES_DASHBOARD_BASIC_AUTH_* env vars in the environment-variables
reference.
2026-06-04 01:02:25 -07:00
Ben
3a25912c14 test(dashboard-auth): cover password login route, provider, and plugin
- test_dashboard_auth_password_login.py: drives /auth/password-login
    end-to-end through the REAL gated_auth_middleware (login -> session
    cookie -> authenticated /api/auth/me -> transparent refresh via the RT
    cookie), plus protocol-extension checks, the generic-401/404 oracle
    properties, the rate limiter, and login-page rendering (form+script
    when supports_password, script-free otherwise, both for mixed
    providers). Reuses the existing StubAuthProvider harness convention.
  - test_basic_provider.py: scrypt hash/verify, login mint, kind-claim
    enforcement (access != refresh), cross-secret rejection, and the
    register() config/env precedence + skip reasons.

Mutation-tested: dropping the kind-claim check in verify_session makes
test_access_token_not_accepted_as_refresh fail, confirming the test isn't
theater.
2026-06-04 01:02:25 -07:00
Ben
acb0e2bacb feat(dashboard-auth): add BasicAuthProvider username/password plugin
A bundled, zero-infrastructure 'just put a password on my dashboard'
provider that uses the supports_password extension point. No external IDP,
no database: sessions are stateless HMAC-signed tokens the provider mints
and verifies itself, and passwords are hashed with stdlib scrypt (no
third-party dependency — deliberately avoids bcrypt to keep the dep
surface unchanged).

  - plugins/dashboard_auth/basic: BasicAuthProvider (scrypt verify with a
    constant-time dummy-hash path for unknown users so the endpoint is not
    a username-timing oracle; access/refresh tokens carry a 'kind' claim
    that verify/refresh enforce; cross-secret tokens are rejected). The
    register() entry point mirrors the Nous plugin's config/env precedence
    (env wins; empty treated as unset) and LAST_SKIP_REASON channel.
  - config.py: document the canonical dashboard.basic_auth.* surface
    (username / password_hash / password / secret / session_ttl_seconds).

Activates only when username + (password or password_hash) are set, so
OAuth users and loopback/--insecure operators are unaffected. Without an
explicit secret a random per-process key is generated (logged): fine for a
single process, but sessions then don't survive restart or span workers.
2026-06-04 01:02:25 -07:00
Ben
ed9e8ba097 feat(dashboard-auth): add pluggable password (non-redirect) login
The dashboard auth gate was OAuth-only: a DashboardAuthProvider could
authenticate only via a redirect to an IDP (start_login -> /auth/callback
-> complete_login). There was no first-class path for username/password
auth, so self-hosters who just want a password on their dashboard had no
clean option short of an external OAuth IDP.

Extend the provider framework with a parallel, non-redirect front door
that converges on the same Session + cookie + refresh machinery:

  - base.py: add the optional supports_password flag and
    complete_password_login(username, password) -> Session (default
    raises NotImplementedError so an OAuth-only provider that forgets the
    flag fails loudly). Add InvalidCredentialsError. OAuth providers are
    unaffected (flag defaults False; the method is never called).
  - routes.py: add POST /auth/password-login, mirroring the cookie-minting
    tail of /auth/callback but skipping PKCE/state/code. Returns JSON
    {ok, next} (the form POSTs via fetch). Generic 401 for both unknown
    user and wrong password (no enumeration oracle); 404 hides whether a
    provider exists or supports passwords; per-IP sliding-window rate
    limit (10/min -> 429). /api/auth/providers now reports
    supports_password so the login page can branch.
  - middleware.py: allowlist /auth/password-login (a bootstrap route).
    verify/refresh/revoke/ws-tickets/logout need zero changes — a password
    session is just a Session with provider-minted opaque tokens.
  - login_page.py: render a credential form (instead of a redirect button)
    for supports_password providers, wired by a small inline script that
    POSTs to /auth/password-login and navigates on success. OAuth-only
    pages stay script-free.
2026-06-04 01:02:25 -07:00
Ben Barclay
fe74a1acda fix(dashboard_auth): allow any http:// host in redirect_uri fast-fail (#38827)
The Nous dashboard OAuth login rejected any http:// redirect_uri whose
host was not localhost/127.0.0.1, surfacing "redirect_uri may only use
http:// for localhost/127.0.0.1" on the login screen. This broke
self-hosted dashboards reached over plain HTTP — LAN IPs, internal
hostnames, and reverse proxies that terminate TLS upstream.

The Portal-side check (agent-redirect-uri.ts) is authoritative on which
redirect_uris are permitted; this client-side _validate_redirect_uri is
only a fast-fail for obvious operator error and should not second-guess
valid http:// deployments.

Fix: drop the localhost-only branch on the http scheme. Validation now
enforces only that the scheme is http(s) and the path ends with
/auth/callback. Updated the docstring to explain the relaxed contract,
and replaced test_rejects_http_with_non_localhost (which pinned the old
behavior) with test_allows_http_with_arbitrary_host covering a Fly
hostname, a LAN IP, and an internal hostname.
2026-06-04 00:51:44 -07:00
Teknium
6717914e0a fix(dashboard): explain WHY a chat WS connection was refused (#38743)
* Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml

When config.yaml fails to parse, load_config() silently falls back to
DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs
the setup wizard or hermes config set (both rewrite config.yaml), their
broken-but-recoverable overrides are lost for good.

Adapts the policy-file recovery from gemini-cli#21541: on the first parse
warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak
(best-effort, symlink-guarded, size-deduped) and tell the user where it
landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a
clean state — hermes never silently mutates user config, and leaving it means
a hand-fixed file is re-read on the next load.

Tests: 3 new cases (backup created + content preserved + original untouched;
same-size backup dedup; symlink not copied). E2E verified with isolated
HERMES_HOME and a real tab-indented broken config.

* fix(dashboard): explain WHY a chat WS connection was refused

The embedded-chat PTY WebSocket (/api/pty) collapsed every rejection
into a bare close code: 4401 for any auth failure, 4403 for three
unrelated failures (host mismatch, origin mismatch, peer-IP). Neither
the server log nor the browser said which gate fired or why, so a
"chat won't connect" report was undiagnosable without a repro.

Server (web_server.py):
- _ws_auth_reason / _ws_host_origin_reason / _ws_client_reason return a
  short machine-parseable reason; old bool wrappers kept for callers/tests.
- pty_ws splits the overloaded 4403 into 4401 (auth), 4403 (host/origin),
  4408 (peer not allowed), 4404 (chat disabled), and sends the reason on
  the close frame (clamped to the 123-byte RFC6455 limit).
- Each path logs one line: 'pty auth rejected reason=.. mode=.. cred=.. peer=..'
  / 'pty refused: <reason> ..'. Accepted path logs 'pty accepted peer=..
  mode=.. cred=..' so an audit shows HOW a peer authed, not just that it did.

tui_gateway/ws.py:
- 'ws send/write failed' now logs error_type=<ExcName> so an exception
  whose str() is empty (closed-transport sends) no longer logs 'error='.

web/src/pages/ChatPage.tsx:
- console.warn the real close code + server reason on every close.
- Map 4404/4408 to specific banners; 4401/4403 banners echo the server
  reason; [session ended] prints the close code.

E2E verified all five reject paths + accepted path produce matching
close code, wire reason, and server log line.
2026-06-04 00:36:03 -07:00
Ben
c2ca3f01ab fix(dashboard): honor --portal-url / HERMES_DASHBOARD_PORTAL_URL override in register
The register command resolved the portal base URL purely from the stored
login, ignoring any override. That meant `HERMES_DASHBOARD_PORTAL_URL` (and
the absence of any flag) gave no way to point registration at a staging or
preview portal — the request always hit the login's portal, returning 404
against a branch that wasn't deployed there.

- _resolve_portal_base_url now takes an optional override (precedence:
  override > stored login portal > prod default).
- New --portal-url flag; falls back to HERMES_DASHBOARD_PORTAL_URL env.
- Documents that the access token must be valid at the overridden portal
  (it's minted by whoever you logged into).
- 3 new tests for override precedence.

Verified live against the PR #324 Vercel preview: CLI -> preview endpoint ->
real agent:{id} client_id written to .env.
2026-06-04 00:17:57 -07:00
Ben
bb291b6bbc feat(dashboard): hermes dashboard register for self-hosted OAuth client
Adds a CLI command that registers this install as a self-hosted dashboard
with the user's Nous Portal account, automating the manual browser flow on
/local-dashboards.

- New hermes_cli/dashboard_register.py: resolves a fresh Nous access token
  from auth.json (fast-fails with a `hermes setup` hint when not logged in),
  POSTs to {portal}/api/oauth/self-hosted-client, and writes
  HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env idempotently.
- Docker-style adjective_noun auto-naming; --name and --redirect-uri overrides.
- Persists HERMES_DASHBOARD_PORTAL_URL only when non-default and unset (so a
  Vercel preview / staging portal sticks, prod default stays implicit).
- Refuses in managed/hosted installs (the orchestrator stamps the client_id).
- Post-register hint explains the OAuth gate only engages on a non-loopback bind.
- Nested 'register' subparser leaves bare `hermes dashboard` unchanged.
- 9 unit tests (name gen, fast-fails, POST shape, env writes, redirect URI,
  portal-URL persistence, 401/403 mapping); dashboard lifecycle tests still green.

Depends on NousResearch/nous-account-service#324 (the portal endpoint).
2026-06-04 00:17:57 -07:00
kshitij
0401176c7a Merge pull request #38760 from helix4u/fix/prefill-config-compat
fix(config): align prefill messages key handling
2026-06-03 23:52:47 -07:00
Siddharth Balyan
f31c950182 refactor(supermemory): session-level ingest + kebab aliases (salvaged from #32487) (#38756)
* refactor(supermemory): session-level conversation ingest + kebab tool aliases

Salvaged from #32487 (by @MaheshtheDev), rebased onto current main.

- sync_turn now buffers cleaned turns; the full session is ingested once
  at session end / switch / shutdown via the conversations endpoint
- ingest_conversation() accepts and forwards functional document metadata
  (type, session_id, message_count, partial)
- register kebab-case tool aliases (supermemory-save/search/forget/profile)
  alongside the snake_case names
- README + docs (EN/zh-Hans) updated for the simplified session model

Source/vendor-attribution removed per project policy (no telemetry):
dropped x-sm-source header, sm_source metadata, and sm_capture_mode tags.
Preserved the post-branch atomic_json_write(mode=0o600) hardening that the
PR's stale base had reverted. Updated provider tests for the new behavior
and added maheshthedev@gmail.com to release.py AUTHOR_MAP.

Co-authored-by: alt-glitch <balyan.sid@gmail.com>

* feat(supermemory): restore x-sm-source for Spaces routing

Reinstates x-sm-source: hermes (SDK default_headers + conversations POST)
and sm_source: hermes document metadata. Per @Dhravya (Supermemory), this
is a functional routing key, not telemetry: it groups Hermes writes into a
dedicated "Hermes" Space in the Supermemory app so users can filter and
bulk-manage memories per source agent.

sm_capture_mode remains dropped (appears analytics-only; Spaces are routed
by sm_source) pending confirmation. Adds README note + a unit test covering
_merge_metadata sm_source stamping and legacy source->type migration.

---------

Co-authored-by: Mahesh Sanikommu <maheshthedev@gmail.com>
2026-06-04 11:50:02 +05:30
helix4u
ffb53767bf fix(config): align prefill messages key handling 2026-06-03 23:51:44 -06:00
brooklyn!
3c163cb035 feat(desktop): background needs-input indicator, clarify redesign, Cmd+K palette & UI consistency pass (#38631)
* fix(desktop): surface background-session clarify prompts instead of hanging

clarify.request is a one-shot blocking event: the gateway turn blocks on
clarify.respond. The desktop handler dropped it for any non-focused session
(`if (!isActiveEvent) return`) and stored at most one request in a single
global atom, so a background session that asked a clarifying question hung
forever and re-focusing it could never recover (the event was already gone).

- store/clarify.ts: key pending requests by runtime session id; expose the
  active session's request via a focus-scoped computed view (ClarifyTool is
  unchanged). clearClarifyRequest takes an optional session id for targeted
  clears, with a request-id fallback.
- use-message-stream.ts: park every session's clarify (drop the isActiveEvent
  early return); toast when one lands for a background session since the row
  otherwise just keeps spinning like normal work.
- clarify-tool.tsx: clear by session id so answering one chat can't wipe
  another's pending request.
- store/clarify.test.ts: concurrent independence, focus-scoped view,
  targeted/stale/fallback clears.

* feat(desktop): persistent needs-input indicator + icon button consolidation

Replace the background-clarify toast (expired on alt-tab, easy to miss) with a
persistent, glowing amber "needs input" dot on the session's sidebar row,
driven off a new ClientSessionState.needsInput flag mirrored into a
$attentionSessionIds store. The flag is set on clarify.request and cleared the
moment the turn resumes (tool.complete) or ends.

Also: redesign the clarify tool UI (borderless choices, pseudo-radio dots,
right-aligned checkmark, arc border, tighter padding), make Button the single
source of icon-button styling (4px radius, new icon-titlebar variant, titlebar
buttons rendered polymorphically via asChild, Codicons throughout), put the
file-tree refresh action first, and .trim() pasted composer text.

* style(desktop): padding-driven, square non-icon buttons

Default button sizing was vanilla-shadcn chunky (fixed h-9, 16px padding) and
inconsistent with the icon-button radius pass. Size text variants by
padding + line-height instead of fixed heights so they stay snug and scale
with content, and drop the radius on non-icon buttons (icon buttons keep the
shared 4px). Move the update-overlay CTAs off a hardcoded h-10 onto the
padding-based lg variant. Composer and the inline approval strip are untouched.

* style(desktop): shrink button scale, flush overlay sidebar, variant-ize stray buttons

- Buttons: smaller default font (14px -> 13px) and tighter padding-driven sizes
  across every variant; the chunky shadcn scale read as oversized in a dense
  desktop UI.
- Overlay split layout (settings / command center): the shared OverlayView top
  padding left the card surface showing as a gap above the sidebar. Move the
  titlebar clearance into each column so the sidebar background runs flush to
  the card's top edge.
- Consolidate buttons that hardcoded size/radius/font onto the proper size
  variants (tooltip-icon-button, overlay close, cron IconAction, SidebarTrigger,
  gateway system button, session-row actions radius, title chip radius, release
  notes link) so styling flows from variant props, not per-call overrides.
  Composer and the inline approval strip are intentionally left as-is.

* style(desktop): 12px button text, drop sparkle decoration + redundant settings titles

- Button base font down to 12px (text-xs) for the dense desktop scale.
- Remove the decorative Sparkles glyph from the model "Apply" button (keep the
  spinner while applying).
- Drop the page-level section titles that just restate the left nav ("Main
  model", "Appearance", "MCP servers") — the sidebar already labels the pane.
  Sub-section headings (Auxiliary models, LLM providers, etc.) stay.

* feat(desktop): add boxless `text` button variant; use for aux-model actions

New reusable `text` variant renders a button as inline label text (no
bg/border, muted -> foreground, underline-on-hover affordance). Emphasize the
actionable word by adding `font-semibold`/`underline` at the call site. Applied
to the auxiliary-model "Set to main" (plain), "Change" and "Reset all to main"
(bold + underlined) actions, replacing the boxed ghost/outline buttons.

* style(desktop): nudge button scale up + 2.5px radius on non-icon buttons

Bump default/sm vertical padding a step (the 12px pass read too small) and give
non-icon buttons a subtle 2.5px radius instead of square corners. Icon buttons
keep their 4px.

* style(desktop): unify Input/Textarea/SelectTrigger on shared controlVariants

Mirror the buttonVariants exercise for non-composer form controls: add a
single controlVariants source of truth (2.5px radius, 12px text,
padding-driven sizing, chrome via desktop-input-chrome) and consume it from
Input, Textarea, and SelectTrigger. Drop per-call radius/height/font
overrides that fought the shared look.

* style(desktop): flatten appearance settings — drop card-in-card sections

Remove the outer card chrome (border/bg/shadow/rounded) wrapping each
appearance section so they're flat headings + option grids instead of
boxes nested inside boxes, matching the other settings pages.

* style(desktop): de-box appearance options into flat rows + bare theme swatches

Color Mode and Tool Call Display become flat radio-style rows (no tile
border/fill, no inner icon box, no filled check badge — just a subtle active
bg and a check). Theme drops its outer card wrapper so only the preview
swatch shows, with a primary ring marking the active palette.

* style(desktop): primitive-level pointer cursor + borderless settings lists

Add a base-layer rule giving every interactive control (button, select,
menu item, switch, tab, summary) cursor:pointer, and strip the now-redundant
hardcoded cursor-pointer from those elements (plain clickable divs/labels
keep theirs). Remove the divide-y separators from settings list sections so
they breathe.

* style(desktop): Color Mode + Tool Call Display as one-row segmented controls

Replace the vertical option-row lists with a compact SegmentedControl
(grouped pill buttons on a single track), dropping the per-option
descriptions since the section subtitle already covers the context.

* style(desktop): drop redundant On/Off label next to boolean config switches

The switch already communicates state, so the text label was noise.

* style(desktop): add Switch xs size; move appearance controls inline-right

Add an xs size variant to the Switch primitive and use it for the provider
edit submenu toggles. In appearance settings, drop the redundant selection
Pills (the UI already shows the active choice), move the Color Mode and Tool
Call Display segmented controls into the section header's right side
(responsive: stacks under the heading on narrow widths), and shrink the
segmented control.

* feat(desktop): titlebar toggle to flip sidebar sides

Adds a top-left swap button (replacing the search icon) that mirrors the
layout: sessions sidebar ↔ file browser + preview rail. Persisted via
$panesFlipped. The left/right sidebar toggles, content inset, and pane
borders all follow the active side so the buttons stay accurate after a flip.

* feat(desktop): global Cmd+K palette + UI consistency overhaul

Builds on the clarify/needs-input work with a cross-cutting pass to make
the desktop surfaces feel like one app.

- Global Cmd+K command palette (cmdk): nav, settings deep-links, async
  API-key / MCP-server / archived-session groups, reusable theme sub-page
  (light/dark groups, stays open on pick), loop nav, fuzzy match. Replaces
  per-page settings search.
- Shared SearchField: borderless, underline-on-focus, `field-sizing`
  auto-width. Unifies sessions sidebar, pages, overlays, command center,
  cron; drops bespoke OverlaySearchInput.
- Cron & Profiles converted to OverlayView; flat token-driven panels
  (no card-in-card / divider borders) matching command center.
- `r` refresh hotkey via useRefreshHotkey; drop the visible refresh buttons.
- Button text/textStrong link variants applied across settings & views;
  shared PAGE_INSET_X content gutters.
- Math/ascii loaders replace "Loading…" text placeholders; x-icon close
  over text "Close"; cursor-pointer at the dropdown/select primitive level.

* style(desktop): tidy root error-boundary actions

Reload window → text link, Open logs pushed right (ml-auto), and the
error message box drops the oversized rounded-2xl for rounded-md.

* style(desktop): fix profiles sidebar — header + add-icon, drop text-link

The full-width `text` New-profile button drew an underline under the +
glyph on hover (text-decoration spans the icon). Replace with a proper
"PROFILES" section header + ghost add-icon button, matching the chat
sidebar's header/new-item pattern.

* style(desktop): kill focus rings globally

Tab/focus showed Tailwind's `focus-visible:ring-*` (a box-shadow) plus the
native outline. Drop both via an unlayered reset that nulls --tw-ring-*;
the composer / input soft-glow is untouched (those use direct box-shadows).

* style(desktop): shared Badge component; tidy profile metadata

Add a proper shadcn-style Badge (CVA tones, app radius — not a full pill)
and use it for the Default/.env tags instead of bespoke rounded-full spans.
Drop the oversized text-sm metadata values to text-xs.

* style(desktop): migrate bespoke pills to shared Badge; tidy cron/titlebar

- Sidebar toggles in the titlebar no longer carry an active highlight —
  they're plain show/hide affordances now.
- Replace every bespoke rounded-full status pill (cron, messaging,
  settings, skills) with the shared Badge (adds a `warn` tone). App radius,
  one component.
- Cron row actions use Codicons (play/debug-pause/zap/edit/trash) to match
  the rest of the chrome instead of stray lucide glyphs.

* style(desktop): drop active background on titlebar actions

Mute/haptics state reads from the icon glyph (and aria-pressed) — no
background highlight on any titlebar action.

* style(desktop): tighten error-boundary action gap

gap-4 → gap-2.5 between Try again / Reload window.

* style(desktop): hide search when there's nothing to search

Empty datasets no longer render a search field. Adds a `searchHidden` prop
to PageSearchShell (artifacts/skills/messaging) and gates cron + command
center sessions search on a non-empty list. The chat sidebar already did
this via showSessionSections.

* fix(desktop): composer wraps long text & expands at the real wrap point

Long unbroken input ran off horizontally and the stacked layout flipped
on a char-count guess (too early). Add wrap rules to the contentEditable
and drive expansion off the editor's actual rendered height via the
resize observer, so it stacks exactly when the text wraps to a 2nd line.

* feat(desktop): composer/intro polish + shared ErrorState

- Composer single-line row centers (was bottom-aligned); placeholder
  randomizes per session (starter vs follow-up) without mid-stream flip.
- Drop chat header on brand-new sessions (dead label + border).
- ⌘N flashes its sidebar hint; ⌘. toggles the command center.
- Intro wordmark fills width (drop 8rem fit cap).
- Unify error states on a shared ErrorState component (boundary + updates).

* style(desktop): satisfy lint across PR-touched files

* refactor(desktop): DRY/elegance pass over PR-touched files

- Shared useDeepLinkHighlight hook collapses 3 near-identical settings
  deep-link effects (keys/mcp); config kept inline (distinct bail-clear).
- command-center: table-driven SECTION_ICONS + single errorText helper.
- clarify-tool: OPTION_ROW_CLASS + RadioDot extracted from option rows.
- desktop-controller: merge Cmd+K / Cmd+. into one keydown handler.
- statusbar-controls: hoist shared action class.
- Misc: drop redundant cn()/cursor-pointer/dead fields; tidy switch.

* feat(desktop): Cmd+K jumps to sessions; drop API-key entries

Add active sessions to the palette (fuzzy jump-to-chat), remove the
low-value per-API-key entries, and move the lazy palette sources
(config/sessions/archived) to react-query instead of hand-rolled
useState + effect fetching. Hoist the shared nav helper.
2026-06-04 00:47:08 -05:00
Brooklyn Nicholson
86643d84e9 feat(desktop): Cmd+K jumps to sessions; drop API-key entries
Add active sessions to the palette (fuzzy jump-to-chat), remove the
low-value per-API-key entries, and move the lazy palette sources
(config/sessions/archived) to react-query instead of hand-rolled
useState + effect fetching. Hoist the shared nav helper.
2026-06-04 00:32:55 -05:00
Brooklyn Nicholson
bc9e33d66b refactor(desktop): DRY/elegance pass over PR-touched files
- Shared useDeepLinkHighlight hook collapses 3 near-identical settings
  deep-link effects (keys/mcp); config kept inline (distinct bail-clear).
- command-center: table-driven SECTION_ICONS + single errorText helper.
- clarify-tool: OPTION_ROW_CLASS + RadioDot extracted from option rows.
- desktop-controller: merge Cmd+K / Cmd+. into one keydown handler.
- statusbar-controls: hoist shared action class.
- Misc: drop redundant cn()/cursor-pointer/dead fields; tidy switch.
2026-06-04 00:28:57 -05:00
Brooklyn Nicholson
38acced687 style(desktop): satisfy lint across PR-touched files 2026-06-04 00:22:17 -05:00
Brooklyn Nicholson
5bb7156949 feat(desktop): composer/intro polish + shared ErrorState
- Composer single-line row centers (was bottom-aligned); placeholder
  randomizes per session (starter vs follow-up) without mid-stream flip.
- Drop chat header on brand-new sessions (dead label + border).
- ⌘N flashes its sidebar hint; ⌘. toggles the command center.
- Intro wordmark fills width (drop 8rem fit cap).
- Unify error states on a shared ErrorState component (boundary + updates).
2026-06-04 00:19:05 -05:00
Brooklyn Nicholson
3a5e36cfa5 fix(desktop): composer wraps long text & expands at the real wrap point
Long unbroken input ran off horizontally and the stacked layout flipped
on a char-count guess (too early). Add wrap rules to the contentEditable
and drive expansion off the editor's actual rendered height via the
resize observer, so it stacks exactly when the text wraps to a 2nd line.
2026-06-04 00:03:41 -05:00
Brooklyn Nicholson
aecdc75bb0 style(desktop): hide search when there's nothing to search
Empty datasets no longer render a search field. Adds a `searchHidden` prop
to PageSearchShell (artifacts/skills/messaging) and gates cron + command
center sessions search on a non-empty list. The chat sidebar already did
this via showSessionSections.
2026-06-03 23:55:04 -05:00
Brooklyn Nicholson
9e02b18828 style(desktop): tighten error-boundary action gap
gap-4 → gap-2.5 between Try again / Reload window.
2026-06-03 23:53:25 -05:00
Brooklyn Nicholson
fd68ae6331 style(desktop): drop active background on titlebar actions
Mute/haptics state reads from the icon glyph (and aria-pressed) — no
background highlight on any titlebar action.
2026-06-03 23:53:10 -05:00
Brooklyn Nicholson
e026fd88cd style(desktop): migrate bespoke pills to shared Badge; tidy cron/titlebar
- Sidebar toggles in the titlebar no longer carry an active highlight —
  they're plain show/hide affordances now.
- Replace every bespoke rounded-full status pill (cron, messaging,
  settings, skills) with the shared Badge (adds a `warn` tone). App radius,
  one component.
- Cron row actions use Codicons (play/debug-pause/zap/edit/trash) to match
  the rest of the chrome instead of stray lucide glyphs.
2026-06-03 23:52:51 -05:00
Brooklyn Nicholson
fd88d527af style(desktop): shared Badge component; tidy profile metadata
Add a proper shadcn-style Badge (CVA tones, app radius — not a full pill)
and use it for the Default/.env tags instead of bespoke rounded-full spans.
Drop the oversized text-sm metadata values to text-xs.
2026-06-03 23:49:45 -05:00
Brooklyn Nicholson
88bdb6b074 style(desktop): kill focus rings globally
Tab/focus showed Tailwind's `focus-visible:ring-*` (a box-shadow) plus the
native outline. Drop both via an unlayered reset that nulls --tw-ring-*;
the composer / input soft-glow is untouched (those use direct box-shadows).
2026-06-03 23:48:22 -05:00
Brooklyn Nicholson
ded620b711 style(desktop): fix profiles sidebar — header + add-icon, drop text-link
The full-width `text` New-profile button drew an underline under the +
glyph on hover (text-decoration spans the icon). Replace with a proper
"PROFILES" section header + ghost add-icon button, matching the chat
sidebar's header/new-item pattern.
2026-06-03 23:47:42 -05:00
Brooklyn Nicholson
311e80809f style(desktop): tidy root error-boundary actions
Reload window → text link, Open logs pushed right (ml-auto), and the
error message box drops the oversized rounded-2xl for rounded-md.
2026-06-03 23:46:49 -05:00
Brooklyn Nicholson
ac9de2e80c feat(desktop): global Cmd+K palette + UI consistency overhaul
Builds on the clarify/needs-input work with a cross-cutting pass to make
the desktop surfaces feel like one app.

- Global Cmd+K command palette (cmdk): nav, settings deep-links, async
  API-key / MCP-server / archived-session groups, reusable theme sub-page
  (light/dark groups, stays open on pick), loop nav, fuzzy match. Replaces
  per-page settings search.
- Shared SearchField: borderless, underline-on-focus, `field-sizing`
  auto-width. Unifies sessions sidebar, pages, overlays, command center,
  cron; drops bespoke OverlaySearchInput.
- Cron & Profiles converted to OverlayView; flat token-driven panels
  (no card-in-card / divider borders) matching command center.
- `r` refresh hotkey via useRefreshHotkey; drop the visible refresh buttons.
- Button text/textStrong link variants applied across settings & views;
  shared PAGE_INSET_X content gutters.
- Math/ascii loaders replace "Loading…" text placeholders; x-icon close
  over text "Close"; cursor-pointer at the dropdown/select primitive level.
2026-06-03 23:45:45 -05:00
Teknium
40420a619b fix(desktop): attachments on Enter, IME composition, scroll, fetchJson resets (salvage #38502) (#38677)
* fix(desktop): critical fixes — attachments, IME composition, scroll, fetchJson

DC2: Pass attachments to onSubmit() on direct Enter submit and call
clearComposerAttachments().  Previously attachments were silently
dropped — only text was sent while attachment pills remained visible.

DH1: Add 'open' to ThinkingDisclosure ResizeObserver effect deps.
When the disclosure toggles, refs point to new DOM but the observer
wasn't reattached, breaking live-scroll preview after expand/collapse
and leaking detached DOM nodes.

DH3+DH4: Add composition tracking via composingRef (set by
compositionstart/compositionend).  Guards handleEditorInput (skip
preedit state writes), handleEditorKeyDown (prefer composingRef over
unreliable isComposing), and form onSubmit (prevent IME Enter from
triggering submission).  Fixes IME Enter message splitting and preedit
text leaking into app state on CJK input.

DH6: Add res.on('error', reject) to fetchJson response stream.
Without this, a TCP reset mid-transfer left the promise hanging forever,
freezing the desktop UI.

All TypeScript compiles cleanly.

* chore: add copii.list@gmail.com to AUTHOR_MAP (stremtec)

* fix(desktop): prevent scroll snap-back during streaming, atomic config writes

DH2: Defer pinToBottom() in useLayoutEffect to rAF so that browser
scroll/wheel events from the current frame are processed first.
Previously an immediate pinToBottom() could snap the viewport back
to bottom against the user's trackpad scroll-up intent during
streaming — the wheel event hadn't fired yet so stickyBottomRef was
still true.

DH7: Add writeFileAtomic() helper (write to .tmp then rename) and
use it in writeDesktopConnectionConfig, writeDesktopUpdateConfig,
and writeBootstrapMarker.  Prevents partial writes on crash/power
loss that would corrupt JSON config files, requiring manual repair.

* fix(desktop): guard nativeTheme listener from duplicates, invalidate connection config cache

DM9: Guard nativeTheme.on('updated') with a one-shot flag so that
multiple createWindow() calls (e.g. macOS activate after all windows
closed) don't accumulate duplicate listeners on the process-wide
singleton.

DM3: Add mtime-based cache invalidation to readDesktopConnectionConfig.
Previously the cache was populated once and never invalidated — if an
external tool modified connection.json, the desktop ignored the change
until restart.  Now re-reads when the file's mtime differs.

* fix(desktop): widen fetchJson res.on('error') to sibling fetch + sort JSX props

Follow-up to salvaged #38502:
- resourceBufferFromUrl had the same mid-stream-reset hang class as
  fetchJson (req.on('error') present, res.on('error') missing). Add the
  response-stream error handler so a TCP reset during body read rejects
  instead of leaving the promise unsettled.
- Sort the new onComposition* JSX props to satisfy perfectionist/sort-jsx-props
  (was an introduced eslint error in the composer).

---------

Co-authored-by: asill-livestream <copii.list@gmail.com>
2026-06-03 23:38:58 -05:00
Ben Barclay
2e628ae971 fix(docker): add libolm-dev so matrix lazy-install can build python-olm (#33685)
Closes #25495 (matrix/synapse broken in the official docker image).

`tools/lazy_deps.py` routes `platform.matrix` to
`mautrix[encryption]==0.21.0`, which transitively depends on
`python-olm`. `python-olm` is a Cython extension that links against
`libolm`; without `libolm-dev` in the image's apt set the lazy-install
build fails. Add `libolm-dev` to the runtime apt install line so the
in-container source build succeeds on first matrix use.

Salvages #27795 by @konsisumer. Their PR targeted a pre-rework
Dockerfile (still had `build-essential nodejs npm` in the apt list,
no `ca-certificates`); cherry-pick conflicts on incidental apt-list
churn, so this re-applies the same one-word insert against the
current apt line plus the matching pyproject.toml comment update.

Co-authored-by: konsisumer <11262660+konsisumer@users.noreply.github.com>
2026-06-04 14:07:27 +10:00
Ben Barclay
30c7b787d1 fix(memory): fall back to pip when uv is unavailable (salvage #5954) (#38668)
`_install_dependencies` (hermes memory setup) hard-aborted with
"uv not found — cannot install dependencies" whenever `uv` was not on
PATH, even when a perfectly good `pip` was available. Slim container
images and some CI environments don't ship uv, so memory-provider
dependency installation dead-ended there for no good reason.

Now: use `uv pip install` when uv is present, otherwise fall back to
`<python> -m pip install` when pip3/pip is available, and only abort
(with the uv install hint) when neither is found. The "Run manually:"
hints reflect whichever installer was selected.

Salvages #5954 by @MustafaKara7. Their patch added redundant local
`import subprocess` / `import sys` (both are already in scope — module
-level `sys`, function-top `subprocess`); this salvage drops those and
adds a regression test (TestInstallDependenciesRunner) covering all
three paths (uv / pip-fallback / abort). Verified adversarially: the
pip-fallback test fails against origin/main's unfixed code with the
exact dead-end symptom and passes with the fix.

Closes #5954.

Co-authored-by: MustafaKara7 <186085093+MustafaKara7@users.noreply.github.com>
2026-06-04 14:03:02 +10:00
Ben Barclay
03ba06ebfb fix(docker): chown gateway install tree on UID remap (salvage #37928) (#38655)
Salvage of #37928 (@sarvesh1327), reduced to the still-needed delta.

`/opt/hermes/gateway` is a runtime-writable Python package: on first import
the supervised gateway writes `__pycache__` beneath it, and the image does
not set PYTHONDONTWRITEBYTECODE. When HERMES_UID/PUID is remapped at boot
(e.g. Unraid 99), `usermod -u` only re-chowns the hermes home dir; the build
trees under /opt/hermes keep the build-time UID (10000). main already chowns
`.venv`, `ui-tui`, and `node_modules` on remap (#38556) but missed `gateway`,
so the remapped gateway hits EACCES writing `__pycache__` (#27221).

Add `/opt/hermes/gateway` to both chown sites — the Dockerfile build-time
`chown -R hermes:hermes` line and the stage2-hook build-tree repair — so it
tracks the remapped UID like the sibling trees.

Differs from #37928 as submitted: dropped the `uid_gid_remapped` flag and the
`|| [ "$uid_gid_remapped" = true ]` chown gate. main's #38556 already solved
that half, and more correctly — it probes the actual tree ownership
(`venv_owner != actual_hermes_uid`) rather than tracking same-boot remaps,
which also catches pre-existing ownership drift and stays idempotent. Keeping
#37928's flag would regress that. The salvage is the `gateway`-tree addition
only.

Verified end-to-end against a real image build: on baseline main a remap to
UID 99 leaves `gateway` owned by 10000 and a write as uid 99 fails EACCES;
with this change `gateway` is chowned to 99:100 and the write succeeds, while
the default-uid (no-remap) path is unchanged.

Fixes #27221.

Co-authored-by: Sarvesh <sarveshagl1327@gmail.com>
2026-06-04 13:34:23 +10:00
Brooklyn Nicholson
e68fc4def2 feat(desktop): titlebar toggle to flip sidebar sides
Adds a top-left swap button (replacing the search icon) that mirrors the
layout: sessions sidebar ↔ file browser + preview rail. Persisted via
$panesFlipped. The left/right sidebar toggles, content inset, and pane
borders all follow the active side so the buttons stay accurate after a flip.
2026-06-03 22:30:47 -05:00
Teknium
e45dd2b0e7 refactor(web): unify main-slot model assignment base_url/context handling (#38593)
Both POST /api/model/set and the profile-model writer hand-rolled the same
provider/default/base_url/context_length reconciliation. Extract it into
_apply_main_model_assignment so the custom-vs-hosted base_url logic lives in
one place — removing the future-drift risk where one site learns about
custom base_url persistence and the other forgets.

Behavior unchanged; pinned with a direct helper unit test.
2026-06-03 20:25:33 -07:00
Ben Barclay
e2ea648a08 test(docker): make tty-passthrough probe robust to container boot-log noise (#38665)
`test_tty_passthrough_to_container` asserted `int(numeric_lines[0]) > 0`
where `numeric_lines` was every `.isdigit()` token in the FULL PTY stream
— but the container's s6 boot output (cont-init diagnostics, the preinit
`uid=0 ... egid=0` line, skills-sync summaries like
`Done: 90 new, 0 updated, 0 unchanged. 90 total bundled.`) is written to
the same PTY before the `tput cols` probe runs. So the test was really
asserting on "the first number anywhere in the boot log", which passed
only by luck on whatever that first digit happened to be.

Any PR that shifts boot output flips the first digit to a stray `0` and
breaks the test with `assert 0 > 0` — even when TTY passthrough is
working perfectly (`tput cols` returns the right value). This is a latent
landmine for every Docker PR that changes boot output (e.g. adding a
bundled dependency changes the skills-sync counts).

Fix: emit the probe result behind a unique marker
(`HERMES_TTY_COLS=<cols>` / `HERMES_TTY_COLS=NO_TTY`) and parse only the
marked value, ignoring all boot-log noise. The test's real intent — verify
`docker run -t` delivers a real TTY with a positive column count — is
preserved (NO_TTY and non-numeric values still fail).

Verified against a real build, adversarially:
- Built an image with extra boot output (the markdown core-dep change from
  #38649, which is what surfaced this) so the OLD logic grabs a stray `0`
  -> reproduced `assert 0 > 0` locally.
- The hardened test PASSES against that same image, and against a clean
  image. `tput cols` correctly returns 123 in both.
2026-06-04 13:19:13 +10:00
Brooklyn Nicholson
75e29f97ee style(desktop): add Switch xs size; move appearance controls inline-right
Add an xs size variant to the Switch primitive and use it for the provider
edit submenu toggles. In appearance settings, drop the redundant selection
Pills (the UI already shows the active choice), move the Color Mode and Tool
Call Display segmented controls into the section header's right side
(responsive: stacks under the heading on narrow widths), and shrink the
segmented control.
2026-06-03 22:17:26 -05:00
Brooklyn Nicholson
947f305f84 style(desktop): drop redundant On/Off label next to boolean config switches
The switch already communicates state, so the text label was noise.
2026-06-03 22:15:55 -05:00
Brooklyn Nicholson
41ede96304 style(desktop): Color Mode + Tool Call Display as one-row segmented controls
Replace the vertical option-row lists with a compact SegmentedControl
(grouped pill buttons on a single track), dropping the per-option
descriptions since the section subtitle already covers the context.
2026-06-03 22:15:27 -05:00
Brooklyn Nicholson
f15d2cb5e4 style(desktop): primitive-level pointer cursor + borderless settings lists
Add a base-layer rule giving every interactive control (button, select,
menu item, switch, tab, summary) cursor:pointer, and strip the now-redundant
hardcoded cursor-pointer from those elements (plain clickable divs/labels
keep theirs). Remove the divide-y separators from settings list sections so
they breathe.
2026-06-03 22:14:25 -05:00
Brooklyn Nicholson
2b762c5364 style(desktop): de-box appearance options into flat rows + bare theme swatches
Color Mode and Tool Call Display become flat radio-style rows (no tile
border/fill, no inner icon box, no filled check badge — just a subtle active
bg and a check). Theme drops its outer card wrapper so only the preview
swatch shows, with a primary ring marking the active palette.
2026-06-03 22:06:23 -05:00
Brooklyn Nicholson
75adf7d603 style(desktop): flatten appearance settings — drop card-in-card sections
Remove the outer card chrome (border/bg/shadow/rounded) wrapping each
appearance section so they're flat headings + option grids instead of
boxes nested inside boxes, matching the other settings pages.
2026-06-03 22:05:06 -05:00
Brooklyn Nicholson
0776d1b19c style(desktop): unify Input/Textarea/SelectTrigger on shared controlVariants
Mirror the buttonVariants exercise for non-composer form controls: add a
single controlVariants source of truth (2.5px radius, 12px text,
padding-driven sizing, chrome via desktop-input-chrome) and consume it from
Input, Textarea, and SelectTrigger. Drop per-call radius/height/font
overrides that fought the shared look.
2026-06-03 22:03:46 -05:00
Brooklyn Nicholson
d6e2c940e9 style(desktop): nudge button scale up + 2.5px radius on non-icon buttons
Bump default/sm vertical padding a step (the 12px pass read too small) and give
non-icon buttons a subtle 2.5px radius instead of square corners. Icon buttons
keep their 4px.
2026-06-03 22:00:39 -05:00
Brooklyn Nicholson
fb0250ef63 feat(desktop): add boxless text button variant; use for aux-model actions
New reusable `text` variant renders a button as inline label text (no
bg/border, muted -> foreground, underline-on-hover affordance). Emphasize the
actionable word by adding `font-semibold`/`underline` at the call site. Applied
to the auxiliary-model "Set to main" (plain), "Change" and "Reset all to main"
(bold + underlined) actions, replacing the boxed ghost/outline buttons.
2026-06-03 21:59:44 -05:00
Brooklyn Nicholson
1e1ab31ad6 style(desktop): 12px button text, drop sparkle decoration + redundant settings titles
- Button base font down to 12px (text-xs) for the dense desktop scale.
- Remove the decorative Sparkles glyph from the model "Apply" button (keep the
  spinner while applying).
- Drop the page-level section titles that just restate the left nav ("Main
  model", "Appearance", "MCP servers") — the sidebar already labels the pane.
  Sub-section headings (Auxiliary models, LLM providers, etc.) stay.
2026-06-03 21:58:47 -05:00
Brooklyn Nicholson
8c0f15478d style(desktop): shrink button scale, flush overlay sidebar, variant-ize stray buttons
- Buttons: smaller default font (14px -> 13px) and tighter padding-driven sizes
  across every variant; the chunky shadcn scale read as oversized in a dense
  desktop UI.
- Overlay split layout (settings / command center): the shared OverlayView top
  padding left the card surface showing as a gap above the sidebar. Move the
  titlebar clearance into each column so the sidebar background runs flush to
  the card's top edge.
- Consolidate buttons that hardcoded size/radius/font onto the proper size
  variants (tooltip-icon-button, overlay close, cron IconAction, SidebarTrigger,
  gateway system button, session-row actions radius, title chip radius, release
  notes link) so styling flows from variant props, not per-call overrides.
  Composer and the inline approval strip are intentionally left as-is.
2026-06-03 21:56:35 -05:00
Brooklyn Nicholson
712bf4d8e4 style(desktop): padding-driven, square non-icon buttons
Default button sizing was vanilla-shadcn chunky (fixed h-9, 16px padding) and
inconsistent with the icon-button radius pass. Size text variants by
padding + line-height instead of fixed heights so they stay snug and scale
with content, and drop the radius on non-icon buttons (icon buttons keep the
shared 4px). Move the update-overlay CTAs off a hardcoded h-10 onto the
padding-based lg variant. Composer and the inline approval strip are untouched.
2026-06-03 21:50:03 -05:00
Brooklyn Nicholson
35a750eedd feat(desktop): persistent needs-input indicator + icon button consolidation
Replace the background-clarify toast (expired on alt-tab, easy to miss) with a
persistent, glowing amber "needs input" dot on the session's sidebar row,
driven off a new ClientSessionState.needsInput flag mirrored into a
$attentionSessionIds store. The flag is set on clarify.request and cleared the
moment the turn resumes (tool.complete) or ends.

Also: redesign the clarify tool UI (borderless choices, pseudo-radio dots,
right-aligned checkmark, arc border, tighter padding), make Button the single
source of icon-button styling (4px radius, new icon-titlebar variant, titlebar
buttons rendered polymorphically via asChild, Codicons throughout), put the
file-tree refresh action first, and .trim() pasted composer text.
2026-06-03 21:44:30 -05:00
cornna
7402706c5e fix(docker): accept Unraid uid mappings (#38098)
Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-06-04 12:38:24 +10:00
Brooklyn Nicholson
72f556dfc4 Merge remote-tracking branch 'origin/main' into bb/desktop-background-clarify 2026-06-03 21:07:35 -05:00
Brooklyn Nicholson
58eb473baa fix(desktop): surface background-session clarify prompts instead of hanging
clarify.request is a one-shot blocking event: the gateway turn blocks on
clarify.respond. The desktop handler dropped it for any non-focused session
(`if (!isActiveEvent) return`) and stored at most one request in a single
global atom, so a background session that asked a clarifying question hung
forever and re-focusing it could never recover (the event was already gone).

- store/clarify.ts: key pending requests by runtime session id; expose the
  active session's request via a focus-scoped computed view (ClarifyTool is
  unchanged). clearClarifyRequest takes an optional session id for targeted
  clears, with a request-id fallback.
- use-message-stream.ts: park every session's clarify (drop the isActiveEvent
  early return); toast when one lands for a background session since the row
  otherwise just keeps spinning like normal work.
- clarify-tool.tsx: clear by session id so answering one chat can't wipe
  another's pending request.
- store/clarify.test.ts: concurrent independence, focus-scoped view,
  targeted/stale/fallback clears.
2026-06-03 21:07:33 -05:00
164 changed files with 9956 additions and 2930 deletions

View File

@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -185,13 +185,16 @@ RUN cd web && npm run build && \
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
# not chowned here.
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
USER root
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services

View File

@@ -245,6 +245,14 @@ def _install_npm(
needs ``typescript`` next to it; intelephense ships standalone).
"""
npm = shutil.which("npm")
if npm is None:
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
# (e.g. root FHS install whose symlink is missing, #38889).
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
if npm is None:
logger.info("[install] cannot install %s: npm not on PATH", pkg)
return None

View File

@@ -0,0 +1,118 @@
/**
* connection-config.cjs
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
* `X-Hermes-Session-Token` header; WS uses `?token=`.
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
* by an HttpOnly session cookie; WS upgrades require a single-use
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
if (!value) {
throw new Error('Remote gateway URL is required.')
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return parsed.toString().replace(/\/+$/, '')
}
function buildGatewayWsUrl(baseUrl, token) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
}
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
}
/**
* Classify a gateway's auth mode from its public /api/status body.
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
* Returns 'oauth' | 'token'.
*/
function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}
/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
return 'token'
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
module.exports = {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
}

View File

@@ -0,0 +1,180 @@
/**
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
* classification from /api/status, the coerce-time auth-mode resolution rules,
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
} = require('./connection-config.cjs')
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
})
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
})
test('normalizeRemoteBaseUrl rejects empty input', () => {
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
})
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
})
test('normalizeRemoteBaseUrl rejects garbage', () => {
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
})
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(
buildGatewayWsUrl('https://gw.example.com', 'tok123'),
'wss://gw.example.com/api/ws?token=tok123'
)
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(
buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'),
'ws://127.0.0.1:9119/api/ws?token=abc'
)
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(
buildGatewayWsUrl('https://host/hermes', 't'),
'wss://host/hermes/api/ws?token=t'
)
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(
buildGatewayWsUrl('https://host', 'a/b c+d'),
'wss://host/api/ws?token=a%2Fb%20c%2Bd'
)
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
assert.ok(!url.includes('token='))
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(
buildGatewayWsUrlWithTicket('https://host', 'a+b/c'),
'wss://host/api/ws?ticket=a%2Bb%2Fc'
)
})
// --- authModeFromStatus ---
test('authModeFromStatus returns oauth when auth_required is true', () => {
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
})
test('authModeFromStatus returns token when auth_required is false/missing', () => {
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
assert.equal(authModeFromStatus({}), 'token')
assert.equal(authModeFromStatus(null), 'token')
assert.equal(authModeFromStatus(undefined), 'token')
})
// --- resolveAuthMode ---
test('resolveAuthMode: explicit input wins over existing', () => {
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
})
test('resolveAuthMode: falls back to existing when input absent', () => {
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
})
test('resolveAuthMode: defaults to token when nothing is set', () => {
assert.equal(resolveAuthMode(undefined, undefined), 'token')
assert.equal(resolveAuthMode(null, null), 'token')
})
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
})
// --- cookiesHaveSession ---
test('cookiesHaveSession detects the bare access-token cookie', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
test('cookiesHaveSession handles non-arrays', () => {
assert.equal(cookiesHaveSession(null), false)
assert.equal(cookiesHaveSession(undefined), false)
assert.equal(cookiesHaveSession([]), false)
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, [
'__Host-hermes_session_at',
'__Secure-hermes_session_at',
'hermes_session_at'
])
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
assert.equal(tokenPreview(''), null)
assert.equal(tokenPreview(null), null)
})
test('tokenPreview returns set for short tokens', () => {
assert.equal(tokenPreview('12345678'), 'set')
})
test('tokenPreview returns a masked suffix for long tokens', () => {
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,15 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),

View File

@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View File

@@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
@@ -25,7 +24,9 @@ import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@@ -372,14 +373,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
const [refreshing, setRefreshing] = useState(false)
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [imagePage, setImagePage] = useState(1)
const [filePage, setFilePage] = useState(1)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
@@ -398,11 +396,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
} catch (err) {
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshArtifacts)
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
@@ -502,7 +500,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return (
<PageSearchShell
{...props}
filters={
onSearchChange={setQuery}
searchHidden={counts.all === 0}
searchPlaceholder="Search artifacts..."
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
@@ -518,23 +520,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</TextTab>
</>
}
onSearchChange={setQuery}
searchPlaceholder="Search artifacts..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
@@ -549,10 +534,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-3 px-2 pb-2">
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
@@ -578,7 +569,13 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
@@ -588,7 +585,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
@@ -660,11 +657,7 @@ interface ArtifactImageCardProps {
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
)}
>
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<div
className={cn(
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
@@ -674,7 +667,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
{!failedImage && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
@@ -702,7 +695,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<FolderOpen className="size-3" />
Chat
</Button>
@@ -741,10 +734,7 @@ function ArtifactCellAction({
return (
<button
className={cn(
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline',
'cursor-pointer'
)}
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
onClick={onClick}
title={title}
type="button"
@@ -863,7 +853,7 @@ function ArtifactTable({
))}
</tr>
</thead>
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
<tbody>
{artifacts.map(artifact => (
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {

View File

@@ -137,7 +137,7 @@ function PromptSnippetsDialog({
{snippets.map(snippet => (
<li key={snippet.label}>
<button
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)

View File

@@ -73,9 +73,39 @@ import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
const COMPOSER_STACK_BREAKPOINT_PX = 320
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
// vertical padding). Anything taller means the text wrapped to a second line,
// which is when the composer should expand to the stacked layout.
const COMPOSER_SINGLE_LINE_MAX_PX = 36
const COMPOSER_FADE_BACKGROUND =
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
// Resting composer placeholders. New sessions get open-ended starters; an
// existing chat gets phrasings that read as a continuation of the thread.
// One is picked at random per session (stable until the session changes).
const NEW_SESSION_PLACEHOLDERS = [
'What are we building?',
'Give Hermes a task',
"What's on your mind?",
'Describe what you need',
'What should we tackle?',
'Ask anything',
'Start with a goal'
]
const FOLLOW_UP_PLACEHOLDERS = [
'Send a follow-up',
'Add more context',
'Refine the request',
"What's next?",
'Keep it going',
'Push it further',
'Adjust or continue'
]
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
interface QueueEditState {
attachments: ComposerAttachment[]
draft: string
@@ -142,6 +172,7 @@ export function ChatBar({
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
@@ -157,6 +188,35 @@ export function ChatBar({
const showHelpHint = draft === '?'
const gatewayState = useStore($gatewayState)
// Resting placeholder: a starter for brand-new sessions, a continuation for
// existing ones. Picked once and only re-rolled when we genuinely move to a
// *different* conversation. Critically, the first id assignment of a freshly
// started session (null → id, on the first send) is treated as the same
// conversation so the placeholder doesn't visibly flip mid-stream.
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
)
const prevSessionIdRef = useRef(sessionId)
useEffect(() => {
const prev = prevSessionIdRef.current
prevSessionIdRef.current = sessionId
if (prev === sessionId) {
return
}
// null → id: the new session we're already in just got persisted. Keep the
// starter we showed instead of swapping to a follow-up under the user.
if (prev == null && sessionId) {
return
}
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
}, [sessionId])
// When the bar is disabled it's because the gateway isn't open. Distinguish a
// cold start ("Starting Hermes...") from a dropped connection we're trying to
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
@@ -164,7 +224,7 @@ export function ChatBar({
? gatewayState === 'closed' || gatewayState === 'error'
? 'Reconnecting to Hermes…'
: 'Starting Hermes...'
: 'Send follow-up'
: restingPlaceholder
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
@@ -255,14 +315,13 @@ export function ChatBar({
}
}, [urlOpen])
// Track expansion via cheap heuristics (newline or length threshold) instead
// of reading editor.scrollHeight on every keystroke. scrollHeight forces a
// synchronous layout flush — measured at 2.27 layouts per character typed
// (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on
// composer-default-width, this heuristic flips at roughly the right time
// and the user only notices if they type far past the wrap boundary
// without a newline; in that case the ResizeObserver below catches it via
// a height delta and we still expand.
// Expansion (input on its own full-width row, controls below) is driven by
// the editor's *actual* rendered height via the ResizeObserver in
// syncComposerMetrics — it only fires when the text genuinely wraps to a
// second line, so the layout flips exactly at the wrap point rather than at
// a guessed character count. We only handle the two cases the observer
// can't: an explicit newline (expand before layout settles) and an emptied
// draft (collapse back). We never read scrollHeight per keystroke.
useEffect(() => {
if (!draft) {
setExpanded(false)
@@ -274,7 +333,7 @@ export function ChatBar({
return
}
if (draft.includes('\n') || draft.length > 60) {
if (draft.includes('\n')) {
setExpanded(true)
}
}, [draft, expanded])
@@ -310,6 +369,18 @@ export function ChatBar({
}
}
// Expand once the input has actually wrapped past a single line. The
// observer only fires on real size changes, so this reads scrollHeight at
// most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
// min-height + padding); a second line clears ~36px. We only ever expand
// here — collapse is handled by the emptied-draft effect to avoid
// oscillating across the wrap boundary as the input switches widths.
const editor = editorRef.current
if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
setExpanded(true)
}
if (height > 0) {
const bucket = Math.round(height / 8) * 8
@@ -329,7 +400,7 @@ export function ChatBar({
}
}, [])
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef)
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
useEffect(() => {
return () => {
@@ -407,13 +478,19 @@ export function ChatBar({
return
}
const pastedText = event.clipboardData.getData('text')
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
if (DATA_IMAGE_URL_RE.test(pastedText)) {
event.preventDefault()
return
@@ -476,6 +553,13 @@ export function ChatBar({
}, [trigger])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
// During IME composition the DOM contains uncommitted preedit text
// mixed with real content. Skip state writes — compositionend will
// deliver the finalized text via a clean input event.
if (composingRef.current) {
return
}
const editor = event.currentTarget
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
@@ -576,13 +660,17 @@ export function ChatBar({
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// IME composition: Enter confirms composed text, not a message submission.
// Without this guard, pressing Enter to finalise a Korean/Japanese/Chinese
// IME preedit fires submitDraft() and splits the message mid-word.
if (event.nativeEvent.isComposing) {
// We check both composingRef (set by compositionstart/compositionend, robust
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
// preedit fires submitDraft() and splits the message mid-word.
if (composingRef.current || event.nativeEvent.isComposing) {
return
}
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
if (!busy) {
@@ -971,7 +1059,8 @@ export function ChatBar({
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
clearComposerAttachments()
void onSubmit(submitted, { attachments })
}
focusInput()
@@ -1100,7 +1189,7 @@ export function ChatBar({
autoCapitalize="off"
autoCorrect="off"
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
@@ -1110,6 +1199,12 @@ export function ChatBar({
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={() => {
composingRef.current = false
}}
onCompositionStart={() => {
composingRef.current = true
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
@@ -1158,6 +1253,9 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
@@ -1260,7 +1358,7 @@ export function ChatBar({
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>

View File

@@ -98,9 +98,12 @@ function ChatHeader({
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession =
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
// Pins live on the durable lineage-root id, but selectedSessionId is the live
// (tip) id — resolve through the loaded row so the menu reflects the pin
// state after auto-compression rotates the id.
@@ -110,6 +113,13 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId)
: false
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
return null
}
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
@@ -123,7 +133,7 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>

View File

@@ -82,7 +82,7 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}

View File

@@ -11,6 +11,7 @@ import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -481,7 +482,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
return <PageLoader label="Loading preview" />
}
if (state.error) {

View File

@@ -83,7 +83,7 @@ function PreviewLoadError({
body={
<>
<a
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
className="pointer-events-auto block font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
href={error.url}
onClick={event => {
event.preventDefault()
@@ -608,7 +608,7 @@ export function PreviewPane({
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"

View File

@@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { KbdGroup } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
import {
Sidebar,
SidebarContent,
@@ -36,6 +37,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarOpen,
@@ -214,6 +216,7 @@ export function ChatSidebar({
onNewSessionInWorkspace
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
@@ -227,8 +230,28 @@ export function ChatSidebar({
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const trimmedQuery = searchQuery.trim()
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
// the shortcut visibly pings its affordance in the sidebar.
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined
const onShortcut = () => {
setNewSessionKbdFlash(true)
clearTimeout(timeout)
timeout = setTimeout(() => setNewSessionKbdFlash(false), 140)
}
window.addEventListener('hermes:new-session-shortcut', onShortcut)
return () => {
window.removeEventListener('hermes:new-session-shortcut', onShortcut)
clearTimeout(timeout)
}
}, [])
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const dndSensors = useSensors(
@@ -406,7 +429,8 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
@@ -430,7 +454,7 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
@@ -445,7 +469,10 @@ export function ChatSidebar({
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
)}
</>
)}
@@ -458,28 +485,13 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 pb-1 pt-1">
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
<input
aria-label="Search sessions"
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearchQuery(event.target.value)}
placeholder="Search sessions…"
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
onClick={() => setSearchQuery('')}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
)}
</div>
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label="Search sessions"
onChange={setSearchQuery}
placeholder="Search sessions"
value={searchQuery}
/>
</div>
)}
@@ -554,7 +566,7 @@ export function ChatSidebar({
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
@@ -604,7 +616,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
@@ -645,7 +657,7 @@ function SidebarPinnedEmptyState() {
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift-click a chat to pin · drag to reorder</span>
<span>Shift-click a chat to pin</span>
</div>
)
}
@@ -848,7 +860,7 @@ function SidebarWorkspaceGroup({
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
@@ -863,7 +875,7 @@ function SidebarWorkspaceGroup({
{onNewSession && (
<button
aria-label={`New session in ${group.label}`}
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={() => onNewSession(group.path)}
title={`New session in ${group.label}`}
type="button"
@@ -895,7 +907,7 @@ function SidebarWorkspaceGroup({
{hiddenCount > 0 && (
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"
@@ -949,7 +961,7 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
return (
<button
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"

View File

@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
@@ -6,6 +7,7 @@ import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@@ -61,6 +63,10 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
const handleLabel = `Reorder ${title}`
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
return (
<SessionContextMenu
@@ -84,9 +90,9 @@ export function SidebarSessionRow({
style={style}
{...rest}
>
{isWorking && <span aria-hidden="true" className="arc-border" />}
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
@@ -114,16 +120,25 @@ export function SidebarSessionRow({
<span
{...dragHandleProps}
aria-label={handleLabel}
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row.
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
@@ -131,8 +146,8 @@ export function SidebarSessionRow({
/>
</span>
) : (
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
@@ -155,7 +170,7 @@ export function SidebarSessionRow({
>
<Button
aria-label={`Actions for ${title}`}
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title="Session actions"
variant="ghost"
@@ -169,7 +184,30 @@ export function SidebarSessionRow({
)
}
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
function SidebarRowDot({
isWorking,
needsInput = false,
className
}: {
isWorking: boolean
needsInput?: boolean
className?: string
}) {
// "Needs input" wins over "working": a clarify-blocked session is technically
// still running, but the actionable state is that it's waiting on the user.
// Amber + steady (no ping) reads as "your turn", distinct from the accent
// pulse of an active turn.
if (needsInput) {
return (
<span
aria-label="Needs your input"
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title="Waiting for your answer"
/>
)
}
return (
<span
aria-label={isWorking ? 'Session running' : undefined}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,476 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
Archive,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Clock,
Cpu,
Globe,
type IconComponent,
Info,
KeyRound,
MessageCircle,
Monitor,
Moon,
Package,
Palette,
Plus,
Settings,
Sun,
Users,
Wrench,
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { type ThemeMode, useTheme } from '@/themes/context'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
COMMAND_CENTER_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE
} from '../routes'
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { prettyName } from '../settings/helpers'
interface PaletteItem {
active?: boolean
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
keepOpen?: boolean
keywords?: string[]
label: string
/** Action to run when selected. Mutually exclusive with `to`. */
run?: () => void
/** Open a nested palette page (VS Code-style "choose X → options"). */
to?: string
}
interface PaletteGroup {
heading: string
items: PaletteItem[]
}
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
placeholder: string
title: string
}
interface SessionEntry {
id: string
preview?: string
title: string
}
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
preview: session.preview ?? undefined,
title: sessionTitle(session)
})
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
{
icon: Zap,
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
label: 'Providers',
tab: 'providers&pview=accounts'
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
label: 'Provider API keys',
tab: 'providers&pview=keys'
},
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' },
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
]
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
{ icon: Sun, label: 'Light', mode: 'light' },
{ icon: Moon, label: 'Dark', mode: 'dark' },
{ icon: Monitor, label: 'System', mode: 'system' }
]
function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
}
export function CommandPalette() {
const open = useStore($commandPaletteOpen)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listSessions(200, 0, 'only'),
enabled: open
})
const mcpServers = useMemo(() => {
const raw = configQuery.data?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
}, [configQuery.data])
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
// Reset the query/sub-page on close so it reopens clean.
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
}
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
return [
{
heading: 'Go to',
items: [
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
{ icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
{ icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
{ icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
{ icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
{ icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
]
},
{
heading: 'Command Center',
items: [
{
icon: Archive,
id: 'cc-sessions',
keywords: ['command center', 'sessions', 'pin'],
label: 'Sessions',
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
},
{
icon: Activity,
id: 'cc-system',
keywords: ['command center', 'system', 'status', 'logs'],
label: 'System',
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
},
{
icon: BarChart3,
id: 'cc-usage',
keywords: ['command center', 'usage', 'tokens', 'cost'],
label: 'Usage',
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
}
]
},
{
// Declared before Settings: cmdk keeps group order, so this keeps the
// theme/mode pickers on top for "theme"/"color" queries instead of
// buried under a fuzzy Settings match.
heading: 'Appearance',
items: [
{
icon: Palette,
id: 'appearance-theme',
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
label: 'Change theme…',
to: 'theme'
},
{
icon: Sun,
id: 'appearance-mode',
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
label: 'Change color mode…',
to: 'color-mode'
}
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
}
]
}, [go])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
// navigation entries on an empty palette.
const searchGroups = useMemo<PaletteGroup[]>(() => {
if (!search.trim()) {
return []
}
const result: PaletteGroup[] = []
if (sessions.length > 0) {
result.push({
heading: 'Sessions',
items: sessions.map(session => ({
icon: MessageCircle,
id: `session-${session.id}`,
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])],
label: session.title,
run: go(sessionRoute(session.id))
}))
})
}
const fieldItems = SECTIONS.flatMap(section =>
section.keys.map(key => ({
icon: section.icon,
id: `field-${key}`,
keywords: ['settings', key, section.label],
label: `${section.label}: ${fieldLabel(key)}`,
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
}))
)
result.push({ heading: 'Settings fields', items: fieldItems })
if (mcpServers.length > 0) {
result.push({
heading: 'MCP servers',
items: mcpServers.map(name => ({
icon: Wrench,
id: `mcp-${name}`,
keywords: ['mcp', 'server', 'tool'],
label: name,
run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
}))
})
}
if (archivedSessions.length > 0) {
result.push({
heading: 'Archived chats',
items: archivedSessions.map(session => ({
icon: Archive,
id: `archived-${session.id}`,
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
label: session.title,
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
}))
})
}
return result
}, [archivedSessions, go, mcpServers, search, sessions])
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
// and point a root item at it via `to`.
const subPages = useMemo<Record<string, PalettePage>>(
() => ({
theme: {
title: 'Theme',
placeholder: 'Choose a theme…',
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? 'Light' : 'Dark',
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
}))
},
'color-mode': {
title: 'Color mode',
placeholder: 'Choose color mode…',
groups: [
{
heading: 'Color mode',
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
keywords: ['appearance', 'brightness', entry.label],
label: entry.label,
run: () => setMode(entry.mode)
}))
}
]
}
}),
[availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
)
const activePage = page ? subPages[page] : null
const visibleGroups = activePage ? activePage.groups : groups
const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
const handleSelect = (item: PaletteItem) => {
if (item.to) {
setPage(item.to)
setSearch('')
return
}
item.run?.()
if (!item.keepOpen) {
closeCommandPalette()
}
}
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
<Command className="bg-transparent" loop>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setPage(null)}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>Back</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
)}
<CommandInput
onKeyDown={event => {
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
setPage(null)
}
}}
onValueChange={setSearch}
placeholder={placeholder}
value={search}
/>
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>No results found.</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={group.heading}
key={group.heading}
>
{group.items.map(item => {
const Icon = item.icon
return (
<CommandItem
className="gap-2.5"
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{item.to ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
) : (
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}

View File

@@ -1,7 +1,7 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
@@ -13,6 +13,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
@@ -25,12 +26,12 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view'
const DEFAULT_DELIVER = 'local'
@@ -86,23 +87,16 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
}
]
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
enabled: 'default',
scheduled: 'default',
running: 'default',
paused: 'warn',
disabled: 'muted',
error: 'bad',
error: 'destructive',
completed: 'muted'
}
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
bad: 'bg-destructive/10 text-destructive'
}
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}` : value)
@@ -305,14 +299,13 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
interface CronViewProps {
onClose: () => void
}
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
export function CronView({ onClose }: CronViewProps) {
const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
@@ -320,18 +313,16 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const result = await getCronJobs()
setJobs(result)
} catch (err) {
notifyError(err, 'Failed to load cron jobs')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
@@ -426,29 +417,21 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
}
return (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder="Search cron jobs..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
<OverlayView closeLabel="Close cron" onClose={onClose}>
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
{totalCount > 0 && (
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
<SearchField
containerClassName="max-w-[60vw]"
onChange={setQuery}
placeholder="Search cron jobs…"
value={query}
/>
</div>
)}
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
@@ -463,36 +446,37 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
<div>
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
)}
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@@ -519,7 +503,7 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
</OverlayView>
)
}
@@ -547,14 +531,20 @@ function CronJobRow({
return (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<button
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="min-w-0 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>}
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
{state}
</Badge>
{deliver && deliver !== DEFAULT_DELIVER && (
<Badge className="capitalize" variant="muted">
{deliver}
</Badge>
)}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
@@ -580,13 +570,13 @@ function CronJobRow({
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
>
{isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Zap className="size-3.5" />
<Codicon name="zap" size="0.875rem" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Pencil className="size-3.5" />
<Codicon name="edit" size="0.875rem" />
</IconAction>
<IconAction
aria-label="Delete cron"
@@ -594,7 +584,7 @@ function CronJobRow({
onClick={onDelete}
title="Delete"
>
<Trash2 className="size-3.5" />
<Codicon name="trash" size="0.875rem" />
</IconAction>
</div>
</div>
@@ -604,8 +594,8 @@ function CronJobRow({
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Button
className={cn('size-7 text-muted-foreground hover:text-foreground', className)}
size="icon"
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
variant="ghost"
{...props}
>
@@ -614,16 +604,6 @@ function IconAction({ children, className, ...props }: Omit<React.ComponentProps
)
}
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
>
{children}
</span>
)
}
function EmptyState({
actionLabel,
description,
@@ -768,7 +748,7 @@ function CronEditorDialog({
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label="Frequency">
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectTrigger id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -783,7 +763,7 @@ function CronEditorDialog({
<Field htmlFor="cron-deliver" label="Deliver to">
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectTrigger id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -13,7 +13,9 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { toggleCommandPalette } from '../store/command-palette'
import {
$panesFlipped,
$pinnedSessionIds,
$sessionsLimit,
bumpSessionsLimit,
@@ -58,6 +60,7 @@ import {
PREVIEW_RAIL_PANE_WIDTH
} from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
@@ -112,6 +115,7 @@ export function DesktopController() {
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
@@ -125,9 +129,11 @@ export function DesktopController() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
@@ -195,6 +201,31 @@ export function DesktopController() {
}
}, [])
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → command
// palette (the composer's "drain next queued" moved to Cmd+Shift+K), Cmd+. →
// command center (sessions / system / usage).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return
}
const key = event.key.toLowerCase()
if (key === 'k') {
event.preventDefault()
toggleCommandPalette()
} else if (key === '.') {
event.preventDefault()
toggleCommandCenter()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [toggleCommandCenter])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
@@ -285,7 +316,7 @@ export function DesktopController() {
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=keys`)
navigate(`${SETTINGS_ROUTE}?tab=providers`)
}, [navigate])
const modelMenuContent = useMemo(
@@ -414,6 +445,8 @@ export function DesktopController() {
event.preventDefault()
startFreshSessionDraft()
// Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable.
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
}
window.addEventListener('keydown', onKeyDown)
@@ -564,6 +597,7 @@ export function DesktopController() {
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
{settingsOpen && (
<Suspense fallback={null}>
@@ -592,7 +626,6 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@@ -603,6 +636,18 @@ export function DesktopController() {
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)
@@ -641,12 +686,52 @@ export function DesktopController() {
</div>
)
// Flipped layout mirrors the default: sessions sidebar → right, file
// browser + preview rail → left. Same panes, swapped sides.
const sidebarSide = panesFlipped ? 'right' : 'left'
const railSide = panesFlipped ? 'left' : 'right'
const previewPane = (
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
key="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
resizable
side={railSide}
width={PREVIEW_RAIL_PANE_WIDTH}
>
{chatOpen ? (
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
) : null}
</Pane>
)
const fileBrowserPane = (
<Pane
defaultOpen={false}
disabled={!chatOpen}
id="file-browser"
key="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side={railSide}
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onChangeCwd={changeSessionCwd}
/>
</Pane>
)
return (
<AppShell
commandCenterOpen={commandCenterOpen}
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
onOpenSearch={() => openCommandCenterSection('sessions')}
onOpenSettings={openSettings}
overlays={overlays}
statusbarItems={statusbarItems}
@@ -658,7 +743,7 @@ export function DesktopController() {
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
resizable
side="left"
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
@@ -691,25 +776,8 @@ export function DesktopController() {
}
path="artifacts"
/>
<Route
element={
<Suspense fallback={null}>
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="cron"
/>
<Route
element={
<Suspense fallback={null}>
<ProfilesView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</Suspense>
}
path="profiles"
/>
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={null} path="agents" />
@@ -718,35 +786,13 @@ export function DesktopController() {
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</PaneMain>
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
resizable
side="right"
width={PREVIEW_RAIL_PANE_WIDTH}
>
{chatOpen ? (
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
) : null}
</Pane>
<Pane
defaultOpen={false}
disabled={!chatOpen}
id="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side="right"
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onChangeCwd={changeSessionCwd}
/>
</Pane>
{/*
Order within a side maps to column order. Default (rail on the right):
main | preview | file-browser. Flipped (rail on the left): mirror it to
file-browser | preview | main so preview stays adjacent to the chat.
*/}
{panesFlipped ? fileBrowserPane : previewPane}
{panesFlipped ? previewPane : fileBrowserPane}
</AppShell>
)
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
applyDesktopBootProgress,
@@ -103,7 +104,15 @@ export function useGatewayBoot({
}
publish(conn)
await gateway.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
// dead on every reconnect after the initial boot — reusing it surfaces
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
// than connecting with a stale one). For local/token gateways the URL
// carries a long-lived token and the re-mint is a cheap no-op.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
@@ -113,8 +122,14 @@ export function useGatewayBoot({
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
} catch {
// Fall through to scheduleReconnect's backoff below.
} catch (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
// ticket that can never succeed. Transport failures fall through to the
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err)) {
notifyError(err, 'Gateway sign-in required')
}
} finally {
reconnecting = false
@@ -230,7 +245,13 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
await gateway.connect(conn.wsUrl)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
// failure, throws a reauth error rather than connecting with a dead
// ticket (which would surface as an opaque "connection closed").
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@@ -14,6 +15,10 @@ export function useGatewayRequest() {
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
// Holds the reauth error from the most recent failed reconnect so
// requestGateway can surface the gateway's "session expired, sign in again"
// message instead of the opaque "connection closed" that triggered the retry.
const reauthErrorRef = useRef<unknown>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
@@ -41,14 +46,26 @@ export function useGatewayRequest() {
return null
}
reauthErrorRef.current = null
try {
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
await existing.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is dead here;
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
// connecting with a stale ticket. Stash it so requestGateway can show
// the actionable "sign in again" message.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await existing.connect(wsUrl)
return existing
} catch {
} catch (error) {
if (isGatewayReauthRequired(error)) {
reauthErrorRef.current = error
}
connectionRef.current = null
setConnection(null)
@@ -81,6 +98,15 @@ export function useGatewayRequest() {
const recovered = await ensureGatewayOpen()
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session
// expired) over the generic transport error that triggered the retry.
const reauthError = reauthErrorRef.current
reauthErrorRef.current = null
if (reauthError) {
throw reauthError
}
throw error
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'
/**
* Binds the bare `r` key to a refresh action while the calling view is mounted.
* Ignored when a modifier is held, the event repeats, or focus is in an
* editable field (so typing "r" in a search/input never triggers it).
*/
export function useRefreshHotkey(onRefresh: () => void, enabled = true) {
const ref = useRef(onRefresh)
ref.current = onRefresh
useEffect(() => {
if (!enabled) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'r' && event.key !== 'R') {
return
}
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.repeat) {
return
}
const target = event.target as HTMLElement | null
if (
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return
}
event.preventDefault()
ref.current()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [enabled])
}

View File

@@ -0,0 +1,13 @@
// Responsive horizontal gutter for primary content bodies (settings right side,
// skills, artifacts, command center / sessions). Ratio-based so it scales with
// the window, but clamped so it never collapses on narrow widths or runs away
// on ultrawide displays. Headers/tabs intentionally keep their own tighter
// padding.
//
// NOTE: these must stay literal strings — Tailwind's scanner only picks up
// complete class names, so do not build them via template interpolation.
export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
// out to the gutter edges before re-applying PAGE_INSET_X.
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
@@ -17,6 +18,7 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@@ -41,11 +43,11 @@ const STATE_LABELS: Record<string, string> = {
startup_failed: 'Startup failed'
}
const PILL_TONE: Record<StatusTone, string> = {
good: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
bad: 'bg-destructive/10 text-destructive'
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
good: 'default',
muted: 'muted',
warn: 'warn',
bad: 'destructive'
}
const HINT_BY_STATE: Record<string, string> = {
@@ -213,6 +215,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [])
useRefreshHotkey(() => void refreshPlatforms())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
@@ -343,15 +347,15 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder="Search messaging..."
searchTrailingAction={null}
searchValue={query}
>
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
<aside className="min-h-0 overflow-y-auto p-2">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
@@ -406,8 +410,8 @@ function PlatformRow({
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
active
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@@ -482,7 +486,7 @@ function PlatformDetail({
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="outline">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
<ExternalLink className="size-3.5" />
@@ -560,19 +564,15 @@ function PlatformDetail({
</div>
</div>
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5">
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
@@ -651,7 +651,7 @@ function MessagingField({
</div>
<div className="flex items-center gap-2">
<Input
className="h-9 rounded-lg font-mono text-sm"
className="font-mono"
id={`messaging-field-${field.key}`}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
@@ -698,27 +698,13 @@ function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[tone]
)}
>
<Badge variant={TONE_VARIANT[tone]}>
<StatusDot tone={tone} />
{children}
</span>
</Badge>
)
}
function SetupPill({ active, children }: { active: boolean; children: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[active ? 'good' : 'muted']
)}
>
{children}
</span>
)
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
}

View File

@@ -1,77 +0,0 @@
import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
placeholder: string
value: string
onChange: (value: string) => void
containerClassName?: string
inputClassName?: string
loading?: boolean
onClear?: () => void
inputRef?: RefObject<HTMLInputElement | null>
trailingAction?: ReactNode
}
export function OverlaySearchInput({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef,
trailingAction
}: OverlaySearchInputProps) {
const clear = onClear ?? (() => onChange(''))
const hasTrailing = Boolean(trailingAction)
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn(
'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
{trailingAction}
{loading ? (
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
) : null}
</div>
</div>
)
}
export function PageSearchInput(props: OverlaySearchInputProps) {
return (
<OverlaySearchInput
{...props}
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)}
/>
)
}

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from 'react'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
interface OverlaySplitLayoutProps {
children: ReactNode
className?: string
@@ -22,6 +24,9 @@ interface OverlayNavItemProps {
active: boolean
icon: IconComponent
label: string
// Renders as an indented child of another nav item: smaller icon and a
// lighter active state so it never competes with the boxed parent item.
nested?: boolean
onClick: () => void
trailing?: ReactNode
}
@@ -43,7 +48,9 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
// pt clears the floating titlebar/header; the bg itself fills from the
// card's top edge so there's no surface-colored gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
className
)}
>
@@ -54,23 +61,41 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
<main
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
PAGE_INSET_X,
className
)}
>
{children}
</main>
)
}
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
nested
? active
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
: active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<Icon
className={cn(
'shrink-0',
nested ? 'size-3.5' : 'size-4',
active ? 'text-foreground/80' : 'text-muted-foreground/80'
)}
/>
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>

View File

@@ -64,23 +64,26 @@ export function OverlayView({
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
)}
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon"
size="icon-titlebar"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
</div>
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
{/* No top padding here: the split-layout columns own their own
titlebar clearance so their backgrounds run flush to the card top
(otherwise the card surface shows as a gap above the sidebar). */}
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
</div>
</div>
)

View File

@@ -1,26 +1,30 @@
import type { ReactNode } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
import { PageSearchInput } from './overlays/overlay-search-input'
interface PageSearchShellProps extends React.ComponentProps<'section'> {
children: ReactNode
/** Primary tabs shown on the top row, beside the search. */
tabs?: ReactNode
/** Secondary filters shown full-width on their own row below (expands). */
filters?: ReactNode
onSearchChange: (value: string) => void
searchPlaceholder: string
searchTrailingAction?: ReactNode
searchValue: string
/** Hide the search field when there's nothing to search (empty dataset). */
searchHidden?: boolean
}
export function PageSearchShell({
children,
className,
tabs,
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
searchHidden = false,
...props
}: PageSearchShellProps) {
return (
@@ -29,29 +33,42 @@ export function PageSearchShell({
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
{/*
This header sits in the titlebar row, so it overlaps the OS window-drag
region painted by the shell. Without `-webkit-app-region: no-drag` on
the search row, mousedown on the input gets intercepted as a window-
drag start and the input never receives focus (visible as "I can't
click the search box" on the messaging/cron/etc pages).
Header lives in the page body, below the window chrome (the shell floats
traffic lights over the top titlebar-height strip, which the `pt` clears
and leaves draggable). Top row: primary tabs + search. Second row:
secondary filters, full-width so they expand. Interactive bits opt out
of the drag region.
*/}
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
{/* Reserve the top-right titlebar tools + native window-controls
footprint so the full-width search input never slides under them. */}
<div
style={{
paddingRight:
'max(0px, calc(var(--titlebar-tools-right, 0px) + var(--titlebar-tools-width, 0px) - 0.75rem))'
}}
>
<PageSearchInput
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
</div>
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
{/*
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
full width over the band where the floating titlebar icon clusters live,
and an overlapping OS drag region eats their clicks at the compositor
level (pointer-events / no-drag carve-outs across separate stacking
contexts don't reliably fix it on macOS). The shell already supplies a
draggable titlebar strip that is `calc()`'d around the icon clusters
(see app-shell.tsx), so window dragging still works here.
*/}
<div className="shrink-0">
{(tabs || !searchHidden) && (
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
{tabs ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
) : null}
{!searchHidden && (
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
<SearchField
containerClassName="max-w-[45vw]"
onChange={onSearchChange}
placeholder={searchPlaceholder}
value={searchValue}
/>
</div>
)}
</div>
)}
{filters ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View File

@@ -1,7 +1,7 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
@@ -28,9 +28,9 @@ import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icon
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -40,26 +40,18 @@ function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
interface ProfilesViewProps {
onClose: () => void
}
export function ProfilesView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
export function ProfilesView({ onClose }: ProfilesViewProps) {
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
@@ -72,33 +64,15 @@ export function ProfilesView({
})
} catch (err) {
notifyError(err, 'Failed to load profiles')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
@@ -164,62 +138,58 @@ export function ProfilesView({
}, [pendingDelete, refresh])
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Profiles</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''}
</span>
</header>
<OverlayView closeLabel="Close profiles" onClose={onClose}>
{!profiles ? (
<PageLoader label="Loading profiles..." />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<div className="mb-1 flex items-center justify-between gap-2 pl-1.5 pr-0.5">
<span className="text-[0.7rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
Profiles
</span>
<Button
aria-label="New profile"
className="text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setCreateOpen(true)}
size="icon-xs"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
</Button>
</div>
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>
)}
</OverlaySidebar>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label="Loading profiles..." />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Codicon name="add" />
New profile
</Button>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{profiles.map(profile => (
<li key={profile.name}>
<ProfileRow
active={selected?.name === profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
</li>
))}
{profiles.length === 0 && (
<li className="px-2 py-4 text-center text-xs text-muted-foreground">No profiles yet.</li>
)}
</ul>
</aside>
<main className="min-h-0 overflow-hidden">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">Select a profile to view its details.</p>
</div>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">Select a profile to view its details.</p>
</div>
)}
</main>
</div>
)}
</div>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
@@ -250,7 +220,7 @@ export function ProfilesView({
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</OverlayView>
)
}
@@ -258,8 +228,10 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
return (
<button
className={cn(
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@@ -311,38 +283,30 @@ function ProfileDetail({
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
Default
</span>
)}
{profile.has_env && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
.env
</span>
)}
{profile.is_default && <Badge>Default</Badge>}
{profile.has_env && <Badge variant="muted">.env</Badge>}
</div>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
{profile.path}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-center gap-3">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
<Pencil />
Rename
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
<Terminal />
{copying ? 'Copying...' : 'Copy setup'}
</Button>
{!profile.is_default && (
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
className="hover:text-destructive hover:no-underline"
onClick={onDelete}
size="sm"
variant="ghost"
variant="text"
>
<Trash2 />
Delete
@@ -351,7 +315,7 @@ function ProfileDetail({
</div>
</div>
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<DetailRow label="Model">
{profile.model ? (
<>
@@ -387,7 +351,7 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
return (
<div className="flex flex-wrap items-baseline gap-2">
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
<dd className="text-sm text-foreground">{children}</dd>
<dd className="text-xs text-foreground">{children}</dd>
</div>
)
}
@@ -458,9 +422,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
</div>
{loading ? (
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
Loading SOUL.md...
</div>
<PageLoader className="min-h-44" label="Loading SOUL.md" />
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { PageLoader } from '@/components/page-loader'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
@@ -121,11 +122,7 @@ export function ProjectTree({
}
function TreeSizingState() {
return (
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
Loading files...
</div>
)
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
}
function ProjectTreeRow({

View File

@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentBranch, $currentCwd } from '@/store/session'
@@ -31,13 +32,14 @@ interface RightSidebarTab {
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', label: 'File system', icon: 'files' },
{ id: 'files', label: 'File system', icon: 'list-tree' },
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -96,7 +98,12 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
return (
<aside
aria-label="Right sidebar"
className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]"
className={cn(
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
panesFlipped
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
@@ -141,23 +148,24 @@ function RightSidebarChrome({
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{tabs.map(tab => (
<button
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground',
'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground'
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
title={tab.label}
type="button"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</button>
</Button>
))}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
@@ -178,8 +186,11 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
// base focus ring come from <Button size="icon-xs">. This constant exists
// purely to share the sidebar palette + the hover-reveal behavior below.
const HEADER_ACTION_CLASS =
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
@@ -213,11 +224,22 @@ function FilesystemTab({
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<Button
aria-label="Open folder"
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon"
size="icon-xs"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
@@ -228,23 +250,12 @@ function FilesystemTab({
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon"
size="icon-xs"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
@@ -264,7 +275,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {

View File

@@ -52,6 +52,21 @@ export const APP_ROUTES = [
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings'
])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)
}
export function isNewChatRoute(pathname: string): boolean {
return pathname === NEW_CHAT_ROUTE
}

View File

@@ -313,6 +313,7 @@ export function useMessageStream({
// commit and the synthetic harness shows longtask counts drop from ~5/5s
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
const sinceLast = performance.now() - lastFlushAtRef.current
const runFlush = () => {
flushHandleRef.current = null
lastFlushAtRef.current = performance.now()
@@ -531,7 +532,8 @@ export function useMessageStream({
streamId: null,
pendingBranchGroup: null,
awaitingResponse: false,
busy: false
busy: false,
needsInput: false
}
})
@@ -588,7 +590,8 @@ export function useMessageStream({
pendingBranchGroup: null,
sawAssistantPayload: true,
awaitingResponse: false,
busy: false
busy: false,
needsInput: false
}
})
},
@@ -786,6 +789,11 @@ export function useMessageStream({
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
// A pending clarify blocks the turn, so the first tool.complete after
// one is the clarify resolving — drop the "needs input" flag here so
// the sidebar indicator clears as soon as it's answered, not only at
// message.complete.
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
@@ -806,13 +814,16 @@ export function useMessageStream({
)
}
} else if (event.type === 'clarify.request') {
if (!isActiveEvent) {
return
}
// Surface the clarify tool's overlay. The Python side is blocked on
// `clarify.respond`, so without this handler the agent would hang
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
//
// Store the request for whichever session raised it — even a background
// one. clarify.request is a one-shot event; if we dropped it for an
// unfocused session, that session would block on `clarify.respond`
// indefinitely and re-focusing it could never recover (the event is
// gone). Parking it per-session lets the user answer once they switch
// over; the inline ClarifyTool reads the active session's entry.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
@@ -823,6 +834,15 @@ export function useMessageStream({
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
sessionId: sessionId ?? null
})
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'approval.request') {
if (!isActiveEvent) {

View File

@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -152,7 +152,13 @@ export function useSessionStateCache({
setSessionWorking(previous.storedSessionId, false)
}
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
setSessionAttention(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
setSessionAttention(next.storedSessionId, next.needsInput)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
@@ -160,6 +166,7 @@ export function useSessionStateCache({
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next

View File

@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@@ -111,29 +111,22 @@ export function AboutSettings() {
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<div className="flex items-start gap-2">
{statusTone === 'available' ? (
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
) : statusTone === 'error' ? null : (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
</div>
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="mt-3 flex flex-wrap items-center gap-4">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="outline"
variant="textStrong"
>
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking && <Loader2 className="size-3 animate-spin" />}
{checking ? 'Checking…' : 'Check now'}
</Button>
@@ -143,12 +136,7 @@ export function AboutSettings() {
</Button>
)}
<Button
asChild
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
size="sm"
variant="ghost"
>
<Button asChild className="ml-auto" size="sm" variant="text">
<a
href={RELEASE_NOTES_URL}
onClick={event => {
@@ -158,7 +146,6 @@ export function AboutSettings() {
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
Release notes
</a>
</Button>

View File

@@ -1,15 +1,16 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { Check } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { prettyName } from './helpers'
import { Pill, SectionHeading, SettingsContent } from './primitives'
import { SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -51,146 +52,80 @@ function ThemePreview({ name }: { name: string }) {
)
}
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
</div>
{control && <div className="shrink-0">{control}</div>}
</div>
)
}
export function AppearanceSettings() {
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(t => t.name === themeName)
return (
<SettingsContent>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title="Appearance" />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
chat surface styling.
</p>
</div>
<div className="grid gap-8">
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
chat surface styling.
</p>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Color Mode</div>
<div className="mt-1 text-xs text-muted-foreground">
Pick a fixed mode or let Hermes follow your system setting.
</div>
</div>
<Pill>{prettyName(mode)}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
const active = mode === id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
</button>
)
})}
</div>
<section>
<SectionHead
control={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={MODE_OPTIONS}
value={mode}
/>
}
description="Pick a fixed mode or let Hermes follow your system setting."
title="Color Mode"
/>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Tool Call Display</div>
<div className="mt-1 text-xs text-muted-foreground">
Product hides raw tool payloads; Technical shows full input/output.
</div>
</div>
<Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{
id: 'product',
label: 'Product',
description: 'Human-friendly tool activity with concise summaries.'
},
{
id: 'technical',
label: 'Technical',
description: 'Include raw tool args/results and low-level details.'
<section>
<SectionHead
control={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={
[
{ id: 'product', label: 'Product' },
{ id: 'technical', label: 'Technical' }
] as const
}
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
value={toolViewMode}
/>
}
description="Product hides raw tool payloads; Technical shows full input/output."
title="Tool Call Display"
/>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Theme</div>
<div className="mt-1 text-xs text-muted-foreground">
Desktop palettes only. The selected mode is applied on top.
</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<section className="grid gap-3">
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
className="group text-left"
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
@@ -198,8 +133,17 @@ export function AppearanceSettings() {
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div
className={cn(
'rounded-xl transition',
active
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'opacity-90 group-hover:opacity-100'
)}
>
<ThemePreview name={theme.name} />
</div>
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
@@ -208,11 +152,7 @@ export function AppearanceSettings() {
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
</div>
</button>
)

View File

@@ -1,5 +1,6 @@
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -17,10 +18,9 @@ import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
function ConfigField({
schemaKey,
@@ -53,8 +53,7 @@ function ConfigField({
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end gap-3">
<span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span>
<div className="flex items-center justify-end">
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
</div>
)
@@ -89,7 +88,7 @@ function ConfigField({
if (schema.type === 'number') {
return row(
<Input
className={cn('h-8', CONTROL_TEXT)}
className={CONTROL_TEXT}
onChange={e => {
const raw = e.target.value
const n = raw === '' ? 0 : Number(raw)
@@ -108,7 +107,7 @@ function ConfigField({
if (schema.type === 'list') {
return row(
<Input
className={cn('h-8', CONTROL_TEXT)}
className={CONTROL_TEXT}
onChange={e =>
onChange(
e.target.value
@@ -154,7 +153,7 @@ function ConfigField({
/>
) : (
<Input
className={cn('h-8', CONTROL_TEXT)}
className={CONTROL_TEXT}
onChange={e => onChange(e.target.value)}
placeholder="Not set"
value={String(value ?? '')}
@@ -165,12 +164,11 @@ function ConfigField({
}
export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: SearchProps & {
}: {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
@@ -265,37 +263,41 @@ export function ConfigSettings({
)
}, [schema])
const matched = useMemo(() => {
const q = query.trim().toLowerCase()
const fields = sectionFields.get(activeSectionId) ?? []
if (!schema || !q) {
return []
// Deep-link target from the command palette (?field=<key>): scroll the row
// into view and flash it, then drop the param so it doesn't re-fire.
const [searchParams, setSearchParams] = useSearchParams()
const targetField = searchParams.get('field')
useEffect(() => {
if (!targetField || !config || !schema) {
return
}
const seen = new Set<string>()
const element = document.getElementById(`setting-field-${targetField}`)
return SECTIONS.flatMap(s =>
s.keys.flatMap(k => {
if (seen.has(k) || !schema[k]) {
return []
}
if (!element) {
return
}
seen.add(k)
const label = prettyName(k.split('.').pop() ?? k)
const item = schema[k]
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
element.classList.add('setting-field-highlight')
const hit =
k.toLowerCase().includes(q) ||
label.toLowerCase().includes(q) ||
includesQuery(item.category, q) ||
includesQuery(item.description, q)
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
})
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('field')
return next
},
{ replace: true }
)
}, [schema, query])
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
return () => window.clearTimeout(timeout)
}, [config, schema, setSearchParams, targetField])
function handleImport(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
@@ -325,34 +327,30 @@ export function ConfigSettings({
return (
<SettingsContent>
{activeSectionId === 'model' && !query.trim() && (
{activeSectionId === 'model' && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
</div>
)}
{fields.length === 0 ? (
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
<EmptyState description="This section has no adjustable settings." title="Nothing to configure" />
) : (
<div className="divide-y divide-border/40">
<div className="grid gap-1">
{fields.map(([key, field]) => (
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
key={key}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
</div>
))}
</div>
)}

View File

@@ -15,34 +15,202 @@ import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
// Provider group definitions used to fold raw env-var names like
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
// description, and signup URL. Membership is determined by longest
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
// (``MINIMAX_``). New providers should be added here so they get their
// own card in Settings → Keys instead of being lumped into "Other".
interface ProviderPrefix {
prefix: string
name: string
/** Optional one-line tagline shown beneath the group name. */
description?: string
/** Optional canonical signup/console URL surfaced from the card header. */
docsUrl?: string
/** Lower numbers float to the top of the providers list. */
priority: number
}
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-[0.8125rem]'
export const CONTROL_TEXT = 'text-xs'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
{
prefix: 'NOUS_',
name: 'Nous Portal',
description: 'Hosted Hermes & Nous-trained models',
docsUrl: 'https://portal.nousresearch.com',
priority: 0
},
{
prefix: 'OPENROUTER_',
name: 'OpenRouter',
description: 'Aggregator for hundreds of frontier models',
docsUrl: 'https://openrouter.ai/keys',
priority: 1
},
{
prefix: 'ANTHROPIC_',
name: 'Anthropic',
description: 'Claude API access (Sonnet, Opus, Haiku)',
docsUrl: 'https://console.anthropic.com/settings/keys',
priority: 2
},
{
prefix: 'XAI_',
name: 'xAI',
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
docsUrl: 'https://console.x.ai/',
priority: 3
},
{
prefix: 'GOOGLE_',
name: 'Gemini',
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
docsUrl: 'https://aistudio.google.com/app/apikey',
priority: 4
},
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
{
prefix: 'DEEPSEEK_',
name: 'DeepSeek',
description: 'Direct DeepSeek API (V3.x, R1)',
docsUrl: 'https://platform.deepseek.com/api_keys',
priority: 5
},
{
prefix: 'DASHSCOPE_',
name: 'DashScope (Qwen)',
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
priority: 6
},
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
{
prefix: 'GLM_',
name: 'GLM / Z.AI',
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
docsUrl: 'https://z.ai/',
priority: 7
},
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
{
prefix: 'KIMI_',
name: 'Kimi / Moonshot',
description: 'Moonshot Kimi K2 / coding endpoints',
docsUrl: 'https://platform.moonshot.cn/',
priority: 8
},
{
prefix: 'KIMI_CN_',
name: 'Kimi (China)',
description: 'Moonshot China endpoint',
docsUrl: 'https://platform.moonshot.cn/',
priority: 9
},
{
prefix: 'MINIMAX_',
name: 'MiniMax',
description: 'MiniMax-M2 and Hailuo international endpoints',
docsUrl: 'https://www.minimax.io/',
priority: 10
},
{
prefix: 'MINIMAX_CN_',
name: 'MiniMax (China)',
description: 'MiniMax mainland China endpoint',
docsUrl: 'https://www.minimaxi.com/',
priority: 11
},
{
prefix: 'HF_',
name: 'Hugging Face',
description: 'Inference Providers — 20+ open models via router.huggingface.co',
docsUrl: 'https://huggingface.co/settings/tokens',
priority: 12
},
{
prefix: 'OPENCODE_ZEN_',
name: 'OpenCode Zen',
description: 'Pay-as-you-go access to curated coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 13
},
{
prefix: 'OPENCODE_GO_',
name: 'OpenCode Go',
description: '$10/month subscription for open coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 14
},
{
prefix: 'NVIDIA_',
name: 'NVIDIA NIM',
description: 'build.nvidia.com or your own local NIM endpoint',
docsUrl: 'https://build.nvidia.com/',
priority: 15
},
{
prefix: 'OLLAMA_',
name: 'Ollama Cloud',
description: 'Cloud-hosted open models from ollama.com',
docsUrl: 'https://ollama.com/settings',
priority: 16
},
{
prefix: 'LM_',
name: 'LM Studio',
description: 'Local LM Studio server (OpenAI-compatible)',
docsUrl: 'https://lmstudio.ai/docs/local-server',
priority: 17
},
{
prefix: 'STEPFUN_',
name: 'StepFun',
description: 'StepFun Step Plan coding models',
docsUrl: 'https://platform.stepfun.com/',
priority: 18
},
{
prefix: 'XIAOMI_',
name: 'Xiaomi MiMo',
description: 'MiMo-V2.5 and Xiaomi proprietary models',
docsUrl: 'https://platform.xiaomimimo.com',
priority: 19
},
{
prefix: 'ARCEEAI_',
name: 'Arcee AI',
description: 'Arcee-hosted small + medium models',
docsUrl: 'https://chat.arcee.ai/',
priority: 20
},
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
{
prefix: 'GMI_',
name: 'GMI Cloud',
description: 'GMI Cloud GPU + model serving',
docsUrl: 'https://www.gmicloud.ai/',
priority: 21
},
{
prefix: 'AZURE_FOUNDRY_',
name: 'Azure Foundry',
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
docsUrl: 'https://ai.azure.com/',
priority: 22
},
{
prefix: 'AWS_',
name: 'AWS Bedrock',
description: 'Authenticate via AWS profile + region',
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
priority: 23
}
]
export const BUILTIN_PERSONALITIES = [
@@ -289,21 +457,11 @@ export const SECTIONS: DesktopConfigSection[] = [
export interface ModeOption {
id: ThemeMode
label: string
description: string
icon: IconComponent
}
export const MODE_OPTIONS: ModeOption[] = [
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
{ id: 'light', label: 'Light', icon: Sun },
{ id: 'dark', label: 'Dark', icon: Moon },
{ id: 'system', label: 'System', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
}

View File

@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
import { Pill } from './primitives'
import type { EnvRowProps } from './types'
// Shared filter used by every credential surface (Providers + Keys pages):
// category gate first, then a free-text match across key name + description.
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
}
export function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/20 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
Cancel
</Button>
</div>
)}
</div>
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{count && <Pill>{count}</Pill>}
</div>
)
}
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
// credential pages (Providers, Keys) share one source of truth and one set of
// mutation handlers instead of duplicating the plumbing.
export function useEnvCredentials(): UseEnvCredentials {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Best-effort cleanup of a retired localStorage flag (global "Show
// advanced" toggle) — everything in these views is configuration-level.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
// Direct save for a known value (no edit-state round-trip) — used by the
// onboarding-style key form, which owns its own input. Returns a result so
// the form can surface inline errors instead of only toasting.
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
const trimmed = value.trim()
if (!trimmed) {
return { message: 'Enter a value first.', ok: false }
}
setSaving(key)
try {
await setEnvVar(key, trimmed)
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
clearLocalState(key)
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
return { ok: true }
} catch (err) {
notifyError(err, `Failed to save ${key}`)
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
return {
saveValue,
vars,
rowProps: {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
}
}
interface CategoryHeadingProps {
count?: string
icon: IconComponent
title: string
}
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
}
interface UseEnvCredentials {
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
vars: Record<string, EnvVarInfo> | null
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -10,10 +11,14 @@ import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
mode: Mode
remoteAuthMode: AuthMode
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@@ -22,6 +27,8 @@ interface GatewaySettingsState {
const EMPTY_STATE: GatewaySettingsState = {
envOverride: false,
mode: 'local',
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
@@ -71,10 +78,18 @@ export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const probeSeq = useRef(0)
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@@ -104,15 +119,128 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}, [])
const canUseRemote = useMemo(
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
[remoteToken, state.remoteTokenSet, state.remoteUrl]
)
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const seq = ++probeSeq.current
setProbeStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [state.mode, trimmedUrl])
// Effective auth mode: a reachable probe wins; otherwise fall back to the
// saved config's mode so a re-open of settings doesn't flicker.
const authMode: AuthMode = useMemo(() => {
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
return probe.authMode
}
return state.remoteAuthMode
}, [probe, probeStatus, state.remoteAuthMode])
// Whether we actually KNOW how this gateway authenticates yet. Until we do,
// neither the OAuth button nor the session-token box should render —
// `authMode` defaults to 'token', so without this gate the token box flashes
// for every gateway (including OAuth ones) during the idle/probing window
// before the first probe lands. The scheme is known when either:
// * the live probe finished (probeStatus 'done'), or
// * we're idle but showing a previously-saved remote config (re-opening
// settings for a gateway already signed-in or with a saved token), so
// its control appears immediately with no flicker.
// While probing (or after a probe error), the scheme is unknown and we show
// the probe status row instead of a control.
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
const authResolved = useMemo(() => {
if (probeStatus === 'done') {
return true
}
return probeStatus === 'idle' && hasSavedRemote
}, [probeStatus, hasSavedRemote])
const providerLabel = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
if (providers.length === 1) {
return providers[0].displayName || providers[0].name
}
if (providers.length > 1) {
return providers.map(p => p.displayName || p.name).join(' / ')
}
return 'your identity provider'
}, [probe])
// A username/password gateway authenticates through a credential form on the
// gateway's /login page (POST /auth/password-login) rather than an OAuth
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
// the persistent partition — is identical, so the desktop drives it through
// the same sign-in window; only the button copy changes. We treat the
// gateway as password-style only when EVERY advertised provider supports
// password, so a mixed deployment keeps the generic OAuth copy.
const isPasswordProvider = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
if (!trimmedUrl) {
return false
}
if (authMode === 'oauth') {
return oauthConnected
}
return Boolean(remoteToken.trim()) || state.remoteTokenSet
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
const payload = () => ({
mode: state.mode,
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const save = async (apply: boolean) => {
@@ -120,7 +248,10 @@ export function GatewaySettings() {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before switching to remote.'
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before switching to remote.'
: 'Enter a remote URL and session token before switching to remote.'
})
return
@@ -147,12 +278,73 @@ export function GatewaySettings() {
}
}
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
// the URL the login window needs), then open the gateway login window and
// refresh the connection status from the saved config once it completes.
const signIn = async () => {
if (!trimmedUrl) {
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
return
}
setSigningIn(true)
try {
// Save (don't apply/restart) so the login window has a URL to use and the
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
setState(saved)
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
}
} catch (err) {
notifyError(err, 'Sign-in failed')
} finally {
setSigningIn(false)
}
}
const signOut = async () => {
setSigningIn(true)
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
notifyError(err, 'Sign-out failed')
} finally {
setSigningIn(false)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before testing.'
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before testing.'
: 'Enter a remote URL and session token before testing.'
})
return
@@ -164,8 +356,9 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
@@ -229,7 +422,7 @@ export function GatewaySettings() {
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes backend using its session token."
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
@@ -237,7 +430,7 @@ export function GatewaySettings() {
/>
</div>
<div className="mt-5 divide-y divide-border/40">
<div className="mt-5 grid gap-1">
<ListRow
action={
<Input
@@ -251,49 +444,103 @@ export function GatewaySettings() {
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
title="Remote URL"
/>
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
Checking how this gateway authenticates
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
Could not reach this gateway yet. Check the URL the auth method will appear once it responds.
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> Signed in
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
Sign out
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
</Button>
)
}
description={
oauthConnected
? isPasswordProvider
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
: isPasswordProvider
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
}
title="Authentication"
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
) : null}
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap justify-end gap-3">
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
variant="outline"
size="sm"
variant="text"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
Test remote
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
Save for next restart
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
Save and reconnect
</Button>
</div>
<div className="mt-6 divide-y divide-border/40">
<div className="mt-6 grid gap-1">
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
<FileText className="size-4" />
Open logs
</Button>

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { getNested, setNested } from './helpers'
import { getNested, providerGroup, setNested } from './helpers'
describe('settings helpers', () => {
it('reads and writes nested config paths', () => {
@@ -20,4 +20,28 @@ describe('settings helpers', () => {
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
describe('providerGroup', () => {
it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
})
it('prefers the longest matching prefix so CN/regional buckets win', () => {
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
// KIMI_CN_ likewise must beat KIMI_.
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
})
it('falls back to "Other" for un-grouped env vars', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
})

View File

@@ -19,9 +19,30 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
// "Other" used by the Keys settings view for un-grouped env vars.
export const providerGroup = (key: string) => {
let best: (typeof PROVIDER_GROUPS)[number] | undefined
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
for (const candidate of PROVIDER_GROUPS) {
if (!key.startsWith(candidate.prefix)) {
continue
}
if (!best || candidate.prefix.length > best.prefix.length) {
best = candidate
}
}
return best?.name ?? 'Other'
}
export const providerMeta = (name: string) =>
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
PROVIDER_GROUPS.find(g => g.name === name)
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])

View File

@@ -1,29 +1,30 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import { useRef } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlaySearchInput } from '../overlays/overlay-search-input'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
'keys',
'mcp',
@@ -33,23 +34,17 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
about: '',
config: '',
gateway: '',
keys: '',
mcp: '',
sessions: ''
})
const openProviderView = (view: ProviderView) => {
setActiveView('providers')
setProviderView(view)
}
const searchInputRef = useRef<HTMLInputElement>(null)
const importInputRef = useRef<HTMLInputElement | null>(null)
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
const query = queries[queryKey]
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
@@ -80,35 +75,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
}
}
// OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
e.preventDefault()
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<OverlayView
closeLabel="Close settings"
headerContent={
<OverlaySearchInput
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
inputRef={searchInputRef}
onChange={setQuery}
placeholder={SEARCH_PLACEHOLDER[queryKey]}
value={query}
/>
}
onClose={onClose}
>
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
@@ -116,7 +84,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
return (
<OverlayNavItem
active={activeView === view && !queries.config.trim()}
active={activeView === view}
icon={s.icon}
key={s.id}
label={s.label}
@@ -125,6 +93,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
)
})}
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label="Providers"
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
label="Accounts"
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label="API keys"
nested
onClick={() => openProviderView('keys')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
@@ -134,7 +126,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="API Keys"
label="Tools & Keys"
onClick={() => setActiveView('keys')}
/>
<OverlayNavItem
@@ -182,7 +174,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
</div>
</OverlaySidebar>
<OverlayMain className="p-0">
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
@@ -195,14 +187,15 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
<KeysSettings />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : (
<SessionsSettings query={queries.sessions} />
<SessionsSettings />
)}
</OverlayMain>
</OverlaySplitLayout>

View File

@@ -1,431 +1,162 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
import { Settings2, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import {
asText,
includesQuery,
prettyName,
providerGroup,
providerPriority,
redactedValue,
withoutKey
} from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
import { EnvVarRow, useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
// Providers live on their own page; messaging-platform credentials live on the
// dedicated Messaging page (and are hidden here via `channel_managed`). This
// view covers tool API keys plus server/setting env vars (API server, webhook,
// gateway), which fold into the Settings tab.
const KEY_TABS = [
{ icon: Wrench, id: 'tool', label: 'Tools' },
{ icon: Settings2, id: 'setting', label: 'Settings' }
] as const
type KeyCategoryId = (typeof KEY_TABS)[number]['id']
const CATEGORY_LABELS: Record<KeyCategoryId, string> = {
setting: 'Settings',
tool: 'Tools'
}
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
// Backend categories that surface under each tab. Server/gateway vars carry the
// `messaging` category server-side but belong with general settings here, since
// the platform-credential half of `messaging` is owned by the Messaging page.
const TAB_CATEGORIES: Record<KeyCategoryId, readonly string[]> = {
setting: ['setting', 'messaging'],
tool: ['tool']
}
function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
function tabForCategory(category: string): KeyCategoryId | null {
for (const tab of KEY_TABS) {
if (TAB_CATEGORIES[tab.id].includes(category)) {
return tab.id
}
}
return (
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
Cancel
</Button>
</div>
)}
</div>
)
return null
}
function EnvProviderGroup({
group,
rowProps
function CategoryTabs({
active,
counts,
onSelect
}: {
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
active: KeyCategoryId
counts: Record<KeyCategoryId, number>
onSelect: (id: KeyCategoryId) => void
}) {
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0)
return (
<div className="overflow-hidden rounded-xl bg-background/60">
<button
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
onClick={() => setExpanded(e => !e)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<Zap className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{group.name === 'Other' ? 'Other providers' : group.name}
</span>
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
</span>
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
</button>
{expanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{group.entries.map(([key, info]) => (
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
)}
<div className="mb-4 inline-flex w-full gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/30 p-1">
{KEY_TABS.map(tab => {
const isActive = active === tab.id
const count = counts[tab.id]
return (
<button
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[length:var(--conversation-text-font-size)] font-medium transition-colors',
isActive
? 'bg-(--ui-chat-surface-background) text-foreground shadow-sm'
: 'text-(--ui-text-secondary) hover:text-foreground'
)}
key={tab.id}
onClick={() => onSelect(tab.id)}
type="button"
>
<tab.icon className="size-3.5 shrink-0" />
<span className="truncate">{tab.label}</span>
{count > 0 && (
<span
className={cn(
'rounded-full px-1.5 text-[0.6875rem] tabular-nums',
isActive ? 'bg-primary/12 text-primary' : 'bg-(--ui-bg-tertiary)/60 text-muted-foreground'
)}
>
{count}
</span>
)}
</button>
)
})}
</div>
)
}
export function KeysSettings({ query }: SearchProps) {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
export function KeysSettings() {
const { rowProps, vars } = useEnvCredentials()
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('tool')
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
// default-collapsed-unless-set keep the surface manageable.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
const groups = useMemo(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
return KEY_TABS.map(t => t.id).flatMap(tab => {
const cats = TAB_CATEGORIES[tab]
const entries = Object.entries(vars).filter(([key, info]) =>
filterEnv(info, key, q, 'provider', providerGroup(key))
)
const groups = new Map<string, [string, EnvVarInfo][]>()
for (const entry of entries) {
const name = providerGroup(entry[0])
groups.set(name, [...(groups.get(name) ?? []), entry])
}
return Array.from(groups, ([name, entries]) => ({
name,
priority: providerPriority(name),
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
hasAnySet: entries.some(([, info]) => info.is_set)
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}, [filterEnv, query, vars])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
setting: 'Settings'
}
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([key, info]) => filterEnv(info, key, q, cat))
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }]
})
}, [filterEnv, query, vars])
}, [vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
// Tab badge counts reflect how many keys are set per tab. Channel-managed
// credentials are owned by the Messaging page and excluded here.
const categoryCounts = useMemo<Record<KeyCategoryId, number>>(() => {
const counts: Record<KeyCategoryId, number> = { setting: 0, tool: 0 }
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
if (!vars) {
return counts
}
setSaving(key)
for (const info of Object.values(vars)) {
if (!info.is_set || info.channel_managed) {
continue
}
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
const tab = tabForCategory(asText(info.category))
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
if (tab) {
counts[tab] += 1
}
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
return counts
}, [vars])
if (!vars) {
return <LoadingState label="Loading API keys and credentials..." />
}
const rowProps = {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
const visible = groups.filter(g => g.category === activeCategory)
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading
icon={Zap}
meta={`${configuredCount} of ${providerGroups.length} configured`}
title="LLM providers"
/>
<div className="grid gap-2">
{providerGroups.map(group => (
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
))}
</div>
</div>
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
{otherGroups.map(group => (
<div className="mb-6" key={group.category}>
<SectionHeading
icon={Settings2}
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
title={group.label}
/>
{visible.map(group => (
<section className="mb-6" key={group.category}>
<div className="grid gap-2">
{group.entries.map(([key, info]) => (
{group.entries.map(([key, info]: [string, EnvVarInfo]) => (
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
</div>
</section>
))}
{visible.length === 0 && (
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Nothing configured in this category yet.
</div>
)}
</SettingsContent>
)
}

View File

@@ -1,20 +1,20 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
import { Package, Wrench } from '@/lib/icons'
import { Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import type { HermesConfigRecord } from '@/types/hermes'
import { includesQuery } from './helpers'
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
interface McpSettingsProps extends SearchProps {
interface McpSettingsProps {
gateway?: HermesGateway | null
onConfigSaved?: () => void
}
@@ -42,15 +42,7 @@ const transportLabel = (server: Record<string, unknown>) =>
? 'stdio'
: 'custom'
function serverMatches(name: string, server: Record<string, unknown>, query: string) {
if (!query) {
return true
}
return includesQuery(name, query) || includesQuery(JSON.stringify(server), query)
}
export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) {
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const activeSessionId = useStore($activeSessionId)
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [selected, setSelected] = useState<string | null>(null)
@@ -80,10 +72,13 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
const servers = useMemo(() => getServers(config), [config])
const names = useMemo(() => Object.keys(servers).sort(), [servers])
const filtered = useMemo(
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
[names, query, servers]
)
useDeepLinkHighlight({
block: 'nearest',
elementId: serverName => `mcp-server-${serverName}`,
onResolve: setSelected,
param: 'server',
ready: serverName => Boolean(config) && serverName in servers
})
useEffect(() => {
const server = selected ? servers[selected] : null
@@ -188,31 +183,32 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
return (
<SettingsContent>
<div className="mb-4 flex items-center justify-between gap-3">
<SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" />
<div className="flex items-center gap-2">
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
{reloading ? 'Reloading...' : 'Reload MCP'}
</OverlayActionButton>
</div>
<div className="mb-4 flex items-center justify-end gap-4">
<Button onClick={() => setSelected(null)} size="xs" variant="text">
New server
</Button>
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
{reloading ? 'Reloading...' : 'Reload MCP'}
</Button>
</div>
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
<OverlayCard className="min-h-64 overflow-hidden p-2">
{filtered.length === 0 ? (
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<div className="min-h-64">
{names.length === 0 ? (
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
) : (
<div className="grid gap-1">
{filtered.map(serverName => {
<div className="grid gap-0.5">
{names.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
}`}
className={cn(
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
)}
id={`mcp-server-${serverName}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
@@ -227,9 +223,9 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
})}
</div>
)}
</OverlayCard>
</div>
<OverlayCard className="grid gap-3 p-4">
<div className="grid content-start gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? 'Edit server' : 'New server'}
@@ -249,17 +245,23 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
</label>
<div className="flex items-center justify-between">
{selected ? (
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
<Button
className="text-destructive hover:text-destructive"
disabled={saving}
onClick={() => void removeServer(selected)}
size="xs"
variant="text"
>
Remove
</OverlayActionButton>
</Button>
) : (
<span />
)}
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
{saving ? 'Saving...' : 'Save server'}
</OverlayActionButton>
</Button>
</div>
</OverlayCard>
</div>
</div>
</SettingsContent>
)

View File

@@ -10,7 +10,7 @@ import {
} from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
import { Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
@@ -204,11 +204,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
return (
<div className="grid gap-6">
<section>
<SectionHeading
icon={Sparkles}
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
title="Main model"
/>
<p className="mb-3 text-xs text-muted-foreground">
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
</p>
@@ -238,7 +233,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
</div>
@@ -252,7 +247,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="outline"
variant="textStrong"
>
Reset all to main
</Button>
@@ -260,7 +255,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<p className="mb-2 text-xs text-muted-foreground">
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
</p>
<div className="divide-y divide-border/40">
<div className="grid gap-1">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
@@ -275,7 +270,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!mainModel || applying}
onClick={() => void setAuxiliaryToMain(meta.key)}
size="sm"
variant="ghost"
variant="text"
>
Set to main
</Button>
@@ -283,7 +278,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="outline"
variant="textStrong"
>
Change
</Button>
@@ -292,7 +287,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
below={
isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
value={auxDraft.provider}

View File

@@ -1,14 +1,17 @@
import type { ReactNode } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
export function SettingsContent({ children }: { children: ReactNode }) {
return (
<section className="min-h-0 overflow-hidden">
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
<div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>
<div className="mx-auto w-full max-w-4xl">{children}</div>
</div>
</section>
@@ -16,16 +19,7 @@ export function SettingsContent({ children }: { children: ReactNode }) {
}
export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]',
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
{children}
</span>
)
return <Badge variant={tone === 'primary' ? 'default' : 'muted'}>{children}</Badge>
}
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {

View File

@@ -0,0 +1,489 @@
import { useStore } from '@nanostores/react'
import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
import {
FEATURED_ID,
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { listOAuthProviders } from '@/hermes'
import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
import type { EnvRowProps } from './types'
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
// duplicate key input. URL-ish vars get a URL hint; everything else stays optional.
const advancedPlaceholder = (key: string, info: EnvVarInfo): string =>
isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional'
// Group the env catalog by provider so the keys view can render one collapsible
// row per vendor: a primary key field inline, with any secondary / advanced vars
// (base URL overrides, alt tokens) revealed when the row is focused/expanded.
// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g.
// Nous Portal's lone base-URL override) and the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
for (const [key, info] of Object.entries(vars)) {
if (info.category !== 'provider') {
continue
}
const name = providerGroup(key)
if (name === 'Other') {
continue
}
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
}
const groups: ProviderKeyGroup[] = []
for (const [name, entries] of buckets) {
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
if (!primary) {
continue
}
const meta = providerMeta(name)
groups.push({
// Advanced = the provider's non-key knobs (base URL, region, deployment).
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
// so we never render a second "Paste key" input — unless one is already
// set, in which case keep it visible so it stays clearable.
advanced: entries
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
.sort(([a], [b]) => a.localeCompare(b)),
description: meta?.description ?? primary[1].description,
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
hasAnySet: entries.some(([, i]) => i.is_set),
name,
primary,
priority: providerPriority(name)
})
}
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
function KeyField({
compact = false,
info,
label,
placeholder,
rowProps,
varKey
}: {
compact?: boolean
info: EnvVarInfo
label?: string
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
// Enter saves; Esc cancels in place without bubbling to the overlay's window
// Escape listener (which would otherwise close the whole settings panel).
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
// Advanced overrides render quieter (xs) than the primary key field so the key
// stays the visual anchor. Padding-driven sizing — no fixed heights.
const inputSize = compact ? 'xs' : 'sm'
const editType = info.is_password ? 'password' : 'text'
// A set value reads as a single filled, read-only field (showing the redacted
// value). Clicking it drops into edit mode in place — no Replace/Cancel chrome.
const control =
info.is_set && !editing ? (
<Input
className="cursor-pointer font-mono text-muted-foreground"
onFocus={startEdit}
readOnly
size={inputSize}
value={masked}
/>
) : (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className="min-w-0 flex-1 font-mono"
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
size={inputSize}
type={editType}
value={draft}
/>
{dirty && (
<Button disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
// Standard stacked form field: small muted label above, input below. Same shape
// for the primary key and every advanced override — just smaller when compact.
// Empty advanced inputs (not labels) fade back, brightening on hover/focus/set.
const dim = compact && !info.is_set
return (
<div className="grid gap-1.5">
{label && (
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{label}
</label>
)}
{dim ? (
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
) : (
control
)}
</div>
)
}
function ProviderKeyCard({
expanded,
group,
onExpand,
onToggle,
rowProps
}: {
expanded: boolean
group: ProviderKeyGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}) {
// Expandable when there's anything to reveal — advanced overrides and/or a
// "Get a key" docs link (which lives at the bottom of the expanded panel).
const expandable = group.advanced.length > 0 || Boolean(group.docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-2 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
<span
className={cn('size-2 shrink-0 rounded-full', group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
/>
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="w-full sm:w-80 sm:shrink-0"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="mt-3 grid gap-2.5 pl-4" onClick={e => e.stopPropagation()}>
{group.advanced.map(([key, info]) => (
<KeyField
compact
info={info}
key={key}
label={isKeyVar(key, info) ? key : friendlyFieldLabel(key, info)}
placeholder={advancedPlaceholder(key, info)}
rowProps={rowProps}
varKey={key}
/>
))}
{group.docsUrl && (
<a
className="inline-flex w-fit items-center gap-1 justify-self-end text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={group.docsUrl}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)}
</div>
)}
</div>
)
}
// Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
// are the exact shared components, so the two surfaces stay visually identical.
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
const [showAll, setShowAll] = useState(false)
const ordered = useMemo(() => sortProviders(providers), [providers])
if (ordered.length === 0) {
return null
}
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
const showOthers = !collapsible || showAll
return (
<section className="mb-5 grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
<Button
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
type="button"
variant="textStrong"
>
Have an API key instead?
</Button>
</div>
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Sign in with a subscription no API key to copy. Hermes runs the browser sign-in for you, right here in the
app.
</p>
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
Connected
</p>
{connected.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
</>
)}
{showOthers && (
<>
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={onWantApiKey} />
</>
)}
{collapsible && (
<Button
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
type="button"
variant="text"
>
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</Button>
)}
</section>
)
}
function NoProviderKeys() {
return (
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
No provider API keys available.
</div>
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
// Single-open accordion for the per-provider "advanced options" panels.
const [openProvider, setOpenProvider] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
useEffect(() => {
if (onboardingActive) {
return
}
let cancelled = false
// OAuth providers are best-effort — a failure here just hides the panel.
void (async () => {
try {
const { providers } = await listOAuthProviders()
if (!cancelled) {
setOauthProviders(providers)
}
} catch {
// Ignore — the OAuth panel just won't render.
}
})()
return () => void (cancelled = true)
}, [onboardingActive])
if (!vars) {
return <LoadingState label="Loading providers..." />
}
const hasOauth = oauthProviders.length > 0
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
// providers there's nothing for the "Accounts" view to show, so fall to keys.
const showApiKeys = view === 'keys' || !hasOauth
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
return (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyCard
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<NoProviderKeys />
)}
</SettingsContent>
)
}
return (
<SettingsContent>
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
</SettingsContent>
)
}
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
priority: number
}
interface ProvidersSettingsProps {
onViewChange: (view: ProviderView) => void
view: ProviderView
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
@@ -10,7 +10,7 @@ import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const ARCHIVED_FETCH_LIMIT = 200
@@ -30,7 +30,7 @@ function workspaceLabel(cwd: null | string | undefined): string {
)
}
export function SessionsSettings({ query }: SearchProps) {
export function SessionsSettings() {
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
@@ -87,17 +87,11 @@ export function SessionsSettings({ query }: SearchProps) {
}
}, [])
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) {
return sessions
}
return sessions.filter(session =>
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
)
}, [query, sessions])
useDeepLinkHighlight({
elementId: id => `archived-session-${id}`,
param: 'session',
ready: id => !loading && sessions.some(session => session.id === id)
})
if (loading) {
return <LoadingState label="Loading archived sessions…" />
@@ -117,50 +111,48 @@ export function SessionsSettings({ query }: SearchProps) {
archive it.
</p>
{filtered.length === 0 ? (
<EmptyState
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
title="Nothing archived"
/>
{sessions.length === 0 ? (
<EmptyState description="Archive a chat to hide it here." title="Nothing archived" />
) : (
<div className="divide-y divide-border/30">
{filtered.map(session => {
<div className="grid gap-1">
{sessions.map(session => {
const label = workspaceLabel(session.cwd)
const busy = busyId === session.id
return (
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="outline"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
</Button>
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
title="Delete permanently"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
key={session.id}
title={sessionTitle(session)}
/>
<div className="scroll-mt-6 rounded-lg" id={`archived-session-${session.id}`} key={session.id}>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="textStrong"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
</Button>
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
title="Delete permanently"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
title={sessionTitle(session)}
/>
</div>
)
})}
</div>
@@ -192,7 +184,10 @@ function DefaultProjectDirSetting() {
let alive = true
void settings.getDefaultProjectDir().then(result => {
if (!alive) return
if (!alive) {
return
}
setDir(result.dir)
setFallback(result.defaultLabel)
})
@@ -205,7 +200,9 @@ function DefaultProjectDirSetting() {
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
if (!settings) {
return
}
setBusy(true)
@@ -229,7 +226,9 @@ function DefaultProjectDirSetting() {
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) return
if (!settings) {
return
}
setBusy(true)
@@ -251,13 +250,19 @@ function DefaultProjectDirSetting() {
</p>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
<div className="flex items-center gap-3">
<Button
disabled={busy}
onClick={() => void choose()}
size="sm"
type="button"
variant="textStrong"
>
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>
{dir && (
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
Clear
</Button>
)}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
@@ -121,7 +122,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{revealed !== null ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="outline">
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
@@ -150,7 +151,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
<Button onClick={() => setEditing(false)} size="sm" variant="outline">
<Button onClick={() => setEditing(false)} size="sm" variant="text">
Cancel
</Button>
</div>
@@ -210,6 +211,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
@@ -250,12 +252,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
}, [cfg, loading, providers.length])
if (loading) {
return (
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading configuration...
</div>
)
return <PageLoader className="min-h-32" label="Loading configuration" />
}
if (emptyMessage) {

View File

@@ -4,8 +4,7 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
@@ -15,10 +14,6 @@ export interface SettingsPageProps {
onMainModelChanged?: (provider: string, model: string) => void
}
export interface SearchProps {
query: string
}
export interface ProviderGroup {
name: string
priority: number

View File

@@ -0,0 +1,60 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
interface DeepLinkHighlightOptions {
param: string
ready: (target: string) => boolean
elementId: (target: string) => string
onResolve?: (target: string) => void
block?: ScrollLogicalPosition
}
// Deep-link from the command palette (?<param>=<id>): once the target row is
// renderable, scroll it into view and flash it, then drop the param so it
// doesn't re-fire. Returns the pending target (null once consumed) so callers
// can force the row open before it mounts.
export function useDeepLinkHighlight({
param,
ready,
elementId,
onResolve,
block = 'center'
}: DeepLinkHighlightOptions): null | string {
const [searchParams, setSearchParams] = useSearchParams()
const target = searchParams.get(param)
useEffect(() => {
if (!target || !ready(target)) {
return
}
onResolve?.(target)
// Defer a frame so async state (expansion, selection) mounts the row first.
const scrollTimeout = window.setTimeout(() => {
const element = document.getElementById(elementId(target))
if (!element) {
return
}
element.scrollIntoView({ behavior: 'smooth', block })
element.classList.add('setting-field-highlight')
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
}, 80)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete(param)
return next
},
{ replace: true }
)
return () => window.clearTimeout(scrollTimeout)
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
return target
}

View File

@@ -6,6 +6,7 @@ import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_PANE_ID,
@@ -20,11 +21,9 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
commandCenterOpen?: boolean
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
onOpenSettings: () => void
onOpenSearch: () => void
overlays?: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
@@ -47,17 +46,16 @@ const viewportIsFullscreen = () =>
export function AppShell({
children,
commandCenterOpen = false,
leftStatusbarItems,
leftTitlebarTools,
onOpenSettings,
onOpenSearch,
overlays,
statusbarItems,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
@@ -69,7 +67,12 @@ export function AppShell({
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
const titlebarContentInset = sidebarOpen
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead.
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
const titlebarContentInset = leftEdgePaneOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
@@ -130,13 +133,7 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls
commandCenterOpen={commandCenterOpen}
leftTools={leftTitlebarTools}
onOpenSearch={onOpenSearch}
onOpenSettings={onOpenSettings}
tools={titlebarTools}
/>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">

View File

@@ -78,7 +78,7 @@ export function GatewayMenuPanel({
<div className="flex items-center">
<Button
aria-label="Open system panel"
className="size-7 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
title="Open system panel"

View File

@@ -2,10 +2,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['sessions', 'system', 'usage'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
const location = useLocation()
@@ -15,8 +14,10 @@ export function useOverlayRouting() {
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const agentsOpen = currentView === 'agents'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const chatOpen = currentView === 'chat'
const overlayOpen = OVERLAY_VIEWS.has(currentView)
const overlayOpen = isOverlayView(currentView)
// Overlay routes (settings/command-center/agents) stash the underlying path
// so closing them returns there instead of bouncing to /.
@@ -59,9 +60,11 @@ export function useOverlayRouting() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
}

View File

@@ -11,7 +11,6 @@ import {
DropdownMenuSubContent
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
@@ -184,24 +183,25 @@ export function ModelEditSubmenu({
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto cursor-pointer"
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
size="xs"
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
Fast
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
</DropdownMenuItem>
) : null}
{reasoning ? (
@@ -214,7 +214,7 @@ export function ModelEditSubmenu({
>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
className={dropdownMenuRow}
key={option.value}
onSelect={event => event.preventDefault()}
value={option.value}

View File

@@ -178,7 +178,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={cn(dropdownMenuRow, 'cursor-pointer')}
className={dropdownMenuRow}
hideChevron
onClick={activate}
onKeyDown={event => {
@@ -212,7 +212,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
Edit Models

View File

@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
// Shared chrome styling for interactive statusbar items (button / link / menu
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
const STATUSBAR_ACTION_CLASS =
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
export interface StatusbarMenuItem {
id: string
icon?: ReactNode
@@ -93,10 +98,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
title={title}
type="button"
@@ -167,10 +169,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.href || item.variant === 'link') {
return (
<a
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
href={item.href}
rel="noreferrer"
target="_blank"
@@ -183,10 +182,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<button
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
onClick={() => {
if (item.to) {

View File

@@ -1,7 +1,8 @@
import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
@@ -12,12 +13,18 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
toggleSidebarOpen
} from '@/store/layout'
import { PROFILES_ROUTE } from '../routes'
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
import { titlebarButtonClass } from './titlebar'
@@ -41,22 +48,16 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
tools?: readonly TitlebarTool[]
commandCenterOpen?: boolean
onOpenSettings: () => void
onOpenSearch: () => void
}
export function TitlebarControls({
leftTools = [],
tools = [],
commandCenterOpen = false,
onOpenSettings,
onOpenSearch
}: TitlebarControlsProps) {
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const navigate = useNavigate()
const location = useLocation()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const toggleHaptics = () => {
if (!hapticsMuted) {
@@ -70,38 +71,45 @@ export function TitlebarControls({
}
}
// Each titlebar button controls the pane physically on its side, so a flip
// swaps which pane each one toggles. Default: sessions left, file browser
// right. Flipped: file browser left, sessions right. Sidebar toggles never
// carry an active highlight — they're plain show/hide affordances.
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
const leftToolbarTools: TitlebarTool[] = [
{
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
onSelect: () => {
triggerHaptic('tap')
toggleSidebarOpen()
leftEdge.toggle()
}
},
{
active: commandCenterOpen,
icon: <Codicon name="search" />,
id: 'search',
label: 'Search',
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: 'Swap sidebar sides',
onSelect: () => {
triggerHaptic('open')
onOpenSearch()
triggerHaptic('tap')
togglePanesFlipped()
},
title: 'Search sessions, views, and actions'
title: 'Swap the sessions and file browser sides'
},
...leftTools
]
const rightSidebarTool: TitlebarTool = {
active: fileBrowserOpen,
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar',
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
onSelect: () => {
triggerHaptic('tap')
toggleFileBrowserOpen()
rightEdge.toggle()
}
}
@@ -109,7 +117,7 @@ export function TitlebarControls({
const systemTools: TitlebarTool[] = [
{
active: hapticsMuted,
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
onSelect: toggleHaptics
@@ -125,6 +133,14 @@ export function TitlebarControls({
}
]
// While a full-screen overlay (settings, command center, …) is open it should
// visually own the window. These control clusters are `fixed` at a higher
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
// and let the overlay's own chrome (close button, drag region) take over.
if (isOverlayView(appViewForPath(location.pathname))) {
return null
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
@@ -181,15 +197,20 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
<Button
aria-label="Profiles"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title="Profiles"
type="button"
variant="ghost"
>
<Codicon name="account" />
</button>
{/* Optical bump: the `account` glyph has more internal padding than
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
Nudge just this glyph to visually match its neighbours. */}
<Codicon name="account" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
<DropdownMenuLabel>
@@ -214,31 +235,30 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
}
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
const className = cn(
titlebarButtonClass,
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
tool.className
)
// Titlebar actions never show an active background — state reads from the
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
// for a11y.
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
if (tool.href) {
return (
<a
aria-label={tool.label}
className={className}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
</Button>
)
}
return (
<button
<Button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
@@ -251,10 +271,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>
{tool.icon}
</button>
</Button>
)
}

View File

@@ -12,8 +12,10 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
export const TITLEBAR_EDGE_INSET = 14
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
// Titlebar palette only. All sizing/radius/cursor/centering come from the
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
// Button is the single source of button styling.
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'

View File

@@ -2,8 +2,7 @@ import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
@@ -11,7 +10,9 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -72,25 +73,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
try {
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
setSkills(nextSkills)
setToolsets(nextToolsets)
} catch (err) {
notifyError(err, 'Skills failed to load')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshCapabilities)
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
@@ -181,65 +179,54 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
))}
</>
) : undefined
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</>
}
>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -265,7 +252,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
@@ -273,7 +260,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
@@ -287,7 +274,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
type="button"
>
@@ -333,14 +320,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
function StatusPill({ active, children }: { active: boolean; children: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
)}
<Badge
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
>
{children}
</span>
</Badge>
)
}

View File

@@ -73,4 +73,7 @@ export interface ClientSessionState {
sawAssistantPayload: boolean
pendingBranchGroup: string | null
interrupted: boolean
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { ErrorState } from '@/components/ui/error-state'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
@@ -146,11 +147,6 @@ function IdleView({
if (!status.supported) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body={status.message ?? 'This version of Hermes cant update itself from inside the app.'}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Update not available"
@@ -176,11 +172,6 @@ function IdleView({
if (behind === 0) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body="Youre running the latest version."
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title="Youre all set"
@@ -208,11 +199,13 @@ function IdleView({
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
{groups.map(group => (
<div key={group.id}>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
<ul className="mt-1.5 grid gap-1.5 text-sm text-foreground">
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</p>
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
<span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" />
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
<span className="leading-snug">{item}</span>
</li>
))}
@@ -222,7 +215,7 @@ function IdleView({
</div>
<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default">
<Button className="font-semibold" onClick={onInstall} size="lg">
Update now
</Button>
<button
@@ -267,9 +260,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
</div>
<button
type="button"
onClick={handleCopy}
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
onClick={handleCopy}
type="button"
>
<code className="select-all font-mono text-sm text-foreground">
<span className="text-muted-foreground">$ </span>
@@ -294,7 +287,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
Hermes will pick up the new version next time you launch it.
</p>
<Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline">
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
Done
</Button>
</div>
@@ -339,31 +332,22 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<AlertCircle className="size-7" />
</span>
<DialogTitle className="text-center text-xl">Update didnt finish</DialogTitle>
<DialogDescription className="text-center text-sm">
<ErrorState
className="px-6 pb-6 pt-7 pr-8"
description={
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
{message || 'No worries — nothing was lost. You can try again now.'}
</DialogDescription>
</div>
<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onRetry}>
Try again
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onDismiss}
type="button"
>
Not now
</button>
</div>
</div>
}
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didnt finish</DialogTitle>}
>
<Button className="font-semibold" onClick={onRetry} size="lg">
Try again
</Button>
<Button onClick={onDismiss} variant="text">
Not now
</Button>
</ErrorState>
)
}

View File

@@ -8,7 +8,7 @@ import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { triggerHaptic } from '@/lib/haptics'
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
@@ -33,6 +33,23 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
}
}
// Choice and "Other" rows share a layout; only color/hover differs.
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
function RadioDot({ selected }: { selected: boolean }) {
return (
<span
aria-hidden
className={cn(
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{selected && <span className="size-1.5 rounded-full bg-primary" />}
</span>
)
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
const isPending = props.result === undefined
@@ -74,6 +91,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const [typing, setTyping] = useState(false)
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Race: tool.start fires a tick before clarify.request, so request_id
@@ -103,7 +121,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
answer
})
triggerHaptic('submit')
clearClarifyRequest(matchingRequest.requestId)
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
@@ -140,72 +158,49 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[draft, respond]
)
const handleChoiceKey = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (typing || submitting) {
return
}
const numeric = Number.parseInt(event.key, 10)
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
event.preventDefault()
void respond(choices[numeric - 1]!)
}
},
[choices, respond, submitting, typing]
)
return (
<div
className={cn(
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
)}
className="relative mb-3 mt-2 grid gap-2 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
data-slot="clarify-inline"
>
<div className="flex items-start gap-2.5">
<span aria-hidden className="arc-border" />
<div className="flex items-center gap-2.5">
<span
aria-hidden
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
className="grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<HelpCircle className="size-3.5" />
</span>
<div className="grid flex-1 gap-0.5">
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Hermes is asking
</span>
<span className="whitespace-pre-wrap leading-snug text-foreground">
{question || <em className="text-muted-foreground/70">Loading question</em>}
</span>
</div>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
{question || <em className="font-normal text-muted-foreground/70">Loading question</em>}
</span>
</div>
{!typing && hasChoices && (
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
<div className="grid gap-0.5" role="group">
{choices.map((choice, index) => (
<button
className={cn(
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
OPTION_ROW_CLASS,
'text-foreground/95 hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55',
selectedChoice === choice && 'bg-accent/60'
)}
data-choice
disabled={!ready || submitting}
key={`${index}-${choice}`}
onClick={() => void respond(choice)}
onClick={() => {
setSelectedChoice(choice)
void respond(choice)
}}
type="button"
>
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
{index + 1}
</span>
<RadioDot selected={selectedChoice === choice} />
<span className="flex-1 wrap-anywhere">{choice}</span>
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
</button>
))}
<button
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
)}
className={cn(OPTION_ROW_CLASS, 'text-muted-foreground hover:bg-accent/40 hover:text-foreground')}
disabled={submitting}
onClick={() => {
setTyping(true)
@@ -213,12 +208,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}}
type="button"
>
<span
aria-hidden
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
>
<PencilLine className="size-3" />
</span>
<RadioDot selected={false} />
<span className="flex-1">Other (type your answer)</span>
</button>
</div>
@@ -227,7 +217,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{(typing || !hasChoices) && (
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
<Textarea
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
className="min-h-20 resize-y rounded-lg border-transparent bg-accent/40 text-sm focus-visible:bg-background/60"
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
@@ -270,10 +260,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
)}
{!typing && hasChoices && (
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
<span>1{choices.length} to pick</span>
<div className="flex justify-end">
<button
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
className="bg-transparent text-[0.6875rem] text-muted-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
type="button"

View File

@@ -430,7 +430,12 @@ function useThreadScrollAnchor({
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
pinToBottom()
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
// scroll-up during streaming can race with this effect: the wheel
// event hasn't fired yet so stickyBottomRef is still true, and the
// immediate pinToBottom() would snap the viewport back to bottom
// against the user's intent.
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()

View File

@@ -375,7 +375,9 @@ const ThinkingDisclosure: FC<{
observer.observe(content)
return () => observer.disconnect()
}, [isPreview])
// Re-run when the disclosure toggles so the observer attaches to the new
// DOM after expand/collapse (refs are conditionally rendered on `open`).
}, [isPreview, open])
return (
<div
@@ -616,13 +618,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
hideWhenSingleBranch
>
<BranchPickerPrimitive.Previous className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-left" size="0.875rem" />
</BranchPickerPrimitive.Previous>
<span className="tabular-nums">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-right" size="0.875rem" />
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
@@ -660,7 +662,7 @@ const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
const USER_ACTION_ICON_SIZE = '0.6875rem'
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
@@ -803,7 +805,7 @@ const UserMessage: FC<{
>
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
<BranchPickerPrimitive.Previous
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title="Restore previous checkpoint"
>
Restore checkpoint
@@ -812,7 +814,7 @@ const UserMessage: FC<{
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title="Restore next checkpoint"
>
Go forward

View File

@@ -0,0 +1,161 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $approvalRequest } from '@/store/prompts'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
// Regression coverage for the "approval buried behind a collapsed tool group"
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the
// pending tool's inline ApprovalBar lives inside the group body — which is
// `hidden` until expanded. A live approval must surface WITHOUT the user
// expanding anything, so ToolGroupSlot force-opens its body while an approval
// targeting one of its pending tools is in flight.
const createdAt = new Date('2026-06-03T00:00:00.000Z')
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
// A running assistant message with two tools: a completed read_file plus a
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them
// behind a collapsed "Tool actions · 2 steps" header.
function groupedPendingMessage(): ThreadMessage {
return {
id: 'assistant-group-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-1',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
},
{
type: 'tool-call',
toolCallId: 'term-1',
toolName: 'terminal',
args: { command: 'rm -rf /tmp/x' },
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
$approvalRequest.set(null)
$toolDisclosureStates.set({})
})
afterEach(() => {
cleanup()
$approvalRequest.set(null)
})
describe('ToolGroupSlot approval surfacing', () => {
it('hides the grouped pending tool body when there is no approval', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Group header renders collapsed; the inline approval strip lives in the
// hidden body, so with no live approval it must not render at all (the
// ApprovalBar returns null when $approvalRequest is empty).
await waitFor(() => {
expect(screen.getByText(/Tool actions/)).toBeTruthy()
})
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
})
it('force-opens the group body so the approval surfaces without expanding', async () => {
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Even though the group defaults collapsed, the live approval forces the
// body open so the inline controls are visible (and reachable, not in a
// hidden subtree) immediately.
await waitFor(() => {
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
expect(bar).not.toBeNull()
// The forced-open group body must not be hidden — assert no ancestor
// carries the `hidden` attribute that would keep the bar off-screen.
expect(bar?.closest('[hidden]')).toBeNull()
})
})
})

View File

@@ -39,7 +39,7 @@ import type { ToolPart } from './tool-fallback-model'
// approval at a time, so the single pending row of those tools IS the row that
// raised it. The command/description text comes from `$approvalRequest` (the
// event payload), which is the only place that data reliably exists.
const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'

View File

@@ -21,10 +21,11 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './tool-approval'
import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval'
import {
groupCopyText as buildGroupCopyText,
buildToolView,
@@ -389,7 +390,7 @@ function ToolEntry({ part }: ToolEntryProps) {
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary>
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{view.rawResult}
</pre>
@@ -458,7 +459,24 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
// tools append to the end), so user-driven open/close persists across
// streaming.
const disclosureId = `tool-group:${messageId}:${startIndex}`
const open = useDisclosureOpen(disclosureId)
const userOpen = useDisclosureOpen(disclosureId)
// A live approval request must NEVER be buried inside a collapsed group —
// the user has to be able to act on it without first expanding "Tool
// actions · N steps". When an approval is in flight and this group hosts
// the pending approval-eligible tool that raised it (terminal /
// execute_code with no result yet — see tool-approval.tsx for why the
// single pending row IS the one that raised it), force the body open so
// the inline ApprovalBar surfaces. The user can still collapse the group
// again once the approval resolves.
const approvalRequest = useStore($approvalRequest)
const hostsLiveApproval =
approvalRequest !== null &&
messageRunning &&
visibleParts.some(p => p.result === undefined && APPROVAL_TOOLS.has(p.toolName))
const open = userOpen || hostsLiveApproval
const enterRef = useEnterAnimation(messageRunning, disclosureId)
const status = groupStatus(visibleParts)

View File

@@ -14,11 +14,11 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
return (
<Button
size="icon"
size="icon-xs"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon size-6 p-1', className)}
className={cn('aui-button-icon', className)}
ref={ref}
title={tooltip}
>

View File

@@ -34,7 +34,7 @@ export function DisclosureRow({
// background fill, just the cursor + the affordance caret.
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
onToggle
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
: 'cursor-default'
)}
disabled={!onToggle}

View File

@@ -142,6 +142,8 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
}
const WORDMARK = 'HERMES AGENT'
function resolveCopy(personality?: string, seed?: number): IntroCopy {
const personalityKey = normalizeKey(personality)
@@ -163,15 +165,14 @@ export function Intro({ personality, seed }: IntroProps) {
>
<div className="w-full min-w-0">
<p
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
}
aria-label={WORDMARK}
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
>
<span>
<span>HERMES AGENT</span>
<span>{WORDMARK}</span>
</span>
<span aria-hidden="true">HERMES AGENT</span>
<span aria-hidden="true">{WORDMARK}</span>
</p>
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { getGlobalModelOptions } from '@/hermes'
import {
@@ -23,12 +24,14 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
cancelOnboardingFlow,
clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
@@ -46,7 +49,7 @@ interface DesktopOnboardingOverlayProps {
requestGateway: OnboardingContext['requestGateway']
}
interface ApiKeyOption {
export interface ApiKeyOption {
description: string
docsUrl: string
envKey: string
@@ -124,7 +127,7 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
const sortProviders = (providers: OAuthProvider[]) =>
export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
@@ -147,6 +150,36 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
}, [ctx, enabled, onboarding.requested])
// When the Providers settings page asked to connect a specific provider, the
// store stashed its id. Once the provider list has loaded and we're back at
// an idle picker, launch that exact OAuth flow so the user lands directly in
// sign-in instead of the picker they just came from.
useEffect(() => {
if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
return
}
const pendingId = peekPendingProviderOAuth()
if (!pendingId) {
return
}
const provider = onboarding.providers.find(p => p.id === pendingId)
if (provider) {
// Only clear once we've committed to launching it, so a failed/empty
// provider fetch doesn't silently drop the hand-off.
clearPendingProviderOAuth()
void startProviderOAuth(provider, ctx)
} else if (onboarding.providers.length > 0) {
// The list loaded but the id isn't a real provider — drop the stale
// hand-off. An empty list means the fetch isn't ready yet, so keep it
// and let a later refresh retry.
clearPendingProviderOAuth()
}
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
@@ -167,20 +200,20 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<Header />
{onboarding.manual ? (
<Button
aria-label="Close"
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => closeManualOnboarding()}
size="icon-sm"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
) : null}
<div className="grid gap-3 p-5">
{onboarding.manual ? (
<div className="flex justify-end">
<button
className="text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => closeManualOnboarding()}
type="button"
>
Close
</button>
</div>
) : null}
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
</div>
@@ -189,9 +222,12 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
)
}
// The launch reason is a prompt ("why am I seeing this"), not an error — real
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
// Keep it neutral so it never reads as a failure.
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
{reason}
</div>
)
@@ -245,7 +281,7 @@ function Header() {
)
}
const FEATURED_ID = 'nous'
export const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@@ -274,7 +310,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
return (
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
)
}
if (providers === null) {
@@ -323,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
function FeaturedProviderRow({
export function FeaturedProviderRow({
onSelect,
provider
}: {
@@ -334,17 +376,17 @@ function FeaturedProviderRow({
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
loggedIn && 'border-primary'
)}
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
onClick={() => onSelect(provider)}
type="button"
>
<span aria-hidden className="arc-border arc-reverse arc-nous" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
<span className="text-base font-semibold">{providerTitle(provider)}</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
{loggedIn ? (
<ConnectedTag />
) : (
@@ -356,7 +398,7 @@ function FeaturedProviderRow({
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
</div>
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
)
}
@@ -370,15 +412,15 @@ function ConnectedTag() {
)
}
function KeyProviderRow({ onClick }: { onClick: () => void }) {
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={onClick}
type="button"
>
<div className="min-w-0">
<span className="text-sm font-semibold">OpenRouter</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
@@ -386,22 +428,27 @@ function KeyProviderRow({ onClick }: { onClick: () => void }) {
)
}
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
export function ProviderRow({
onSelect,
provider
}: {
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
loggedIn && 'border-primary/30'
)}
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
@@ -411,13 +458,62 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
)
}
function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
// Presentational two-column key picker. Onboarding feeds it its curated
// options + a ctx-bound save; the Providers settings page feeds it the full
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
// double as a manage surface). Keep it free of store/ctx coupling so both
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
isSet,
onBack,
onClear,
onSave,
options = API_KEY_OPTIONS,
redactedValue
}: {
canGoBack: boolean
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
// Providers page wiring its search into this grid). Keep the selection valid
// by snapping back to the first remaining option when the current one drops.
useEffect(() => {
if (options.length > 0 && !options.some(o => o.id === option.id)) {
setOption(options[0])
setValue('')
setError(null)
}
}, [option.id, options])
// The catalog grid can be tall, leaving the entry field far below the fold.
// On selection we scroll the field into view and focus it so it's always
// obvious where to paste next.
const entryRef = useRef<HTMLDivElement>(null)
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
entryRef.current?.querySelector('input')?.focus()
})
}
const isLocal = option.envKey === 'OPENAI_BASE_URL'
const alreadySet = isSet?.(option.envKey) ?? false
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
// placeholder so users can eyeball that the right key is in place.
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
@@ -429,7 +525,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
setSaving(true)
setError(null)
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
const result = await onSave(option.envKey, value, option.name)
if (result.ok) {
setValue('')
@@ -445,7 +541,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
{canGoBack ? (
<button
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('oauth')}
onClick={onBack}
type="button"
>
<ChevronLeft className="size-3" />
@@ -454,30 +550,30 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
) : null}
<div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(o => (
{options.map(o => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={o.id}
onClick={() => {
setOption(o)
setValue('')
setError(null)
}}
onClick={() => pick(o)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.name}</span>
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
{option.id === o.id ? (
<Check className="size-4 text-primary" />
) : isSet?.(o.envKey) ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
</div>
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
</button>
))}
</div>
<div className="grid gap-2">
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
@@ -488,17 +584,24 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={option.placeholder || 'Paste API key'}
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
type={isLocal ? 'text' : 'password'}
value={value}
/>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<div className="flex justify-end">
<div className="flex items-center justify-between gap-3">
<div>
{alreadySet && onClear ? (
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
Remove
</Button>
) : null}
</div>
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Connecting' : 'Connect'}
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
</Button>
</div>
</div>
@@ -694,9 +797,11 @@ function ConfirmingModelPanel({
queryKey: ['onboarding-model-options', flow.providerSlug],
queryFn: () => getGlobalModelOptions()
})
const providerRow = options.data?.providers?.find(
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
)
const price = providerRow?.pricing?.[flow.currentModel]
const freeTier = providerRow?.free_tier

View File

@@ -1,7 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, RefreshCw } from '@/lib/icons'
import { ErrorState } from '@/components/ui/error-state'
export interface ErrorBoundaryFallbackProps {
error: Error
@@ -53,39 +53,25 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
return (
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
</p>
</div>
</div>
<div className="grid gap-4 p-5">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
{error.message || String(error)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>
<RefreshCw className="size-4" />
Try again
</Button>
<Button onClick={() => window.location.reload()} variant="outline">
Reload window
</Button>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
Open logs
</Button>
</div>
</div>
</div>
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
<ErrorState
className="w-full max-w-[28rem]"
description={error.message || 'The view hit an unexpected error. Your chats and settings are safe.'}
title="Something broke in the interface"
>
<Button className="font-semibold" onClick={reset} size="lg">
Try again
</Button>
<Button onClick={() => window.location.reload()} variant="text">
Reload window
</Button>
<Button
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
variant="text"
>
Open logs
</Button>
</ErrorState>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
@@ -86,7 +87,11 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
{modelOptions.isPending ? (
<BrailleSpinner className="mx-auto text-sm" />
) : (
'No authenticated providers.'
)}
</div>
) : (
providers.map(provider => {
@@ -118,7 +123,6 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
</span>
<Switch
checked={visible.has(key)}
className="cursor-pointer"
onCheckedChange={() => toggle(provider, family.id)}
/>
</label>

View File

@@ -132,7 +132,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
function NotificationDetail({ detail }: { detail: string }) {
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">
Details
</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">

View File

@@ -0,0 +1,35 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
// Small status/metadata tag. App radius (not a full pill); tones map to the
// shared accent/muted/destructive surfaces so badges read consistently.
const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] px-1.5 py-0.5 text-[0.65rem] font-medium leading-none whitespace-nowrap [&_svg]:size-3 [&_svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
destructive: 'bg-destructive/10 text-destructive',
outline: 'border border-(--ui-stroke-secondary) text-muted-foreground'
}
},
defaultVariants: { variant: 'default' }
}
)
export interface BadgeProps extends React.ComponentProps<'span'>, VariantProps<typeof badgeVariants> {
asChild?: boolean
}
export function Badge({ asChild = false, className, variant, ...props }: BadgeProps) {
const Comp = asChild ? Slot.Root : 'span'
return <Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} />
}
export { badgeVariants }

View File

@@ -4,8 +4,11 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
// Text buttons are square (no radius) and sized by padding + line-height — no
// fixed heights — so they stay snug and scale with content. Only icon buttons
// (inherently square) carry the shared 4px radius.
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
@@ -16,17 +19,24 @@ const buttonVariants = cva(
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
// Boxless inline-text action (no bg/border). Quiet by default — reads as
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
// Emphasized inline-text action: bold + always-underlined link. Use for
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'px-2.5 py-1 has-[>svg]:px-2',
lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4',
icon: 'size-9 rounded-[4px]',
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',
'icon-lg': 'size-10 rounded-[4px]',
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
}
},
defaultVariants: {

View File

@@ -0,0 +1,25 @@
import { cva, type VariantProps } from 'class-variance-authority'
// Single source of truth for non-composer form-control chrome — Input,
// Textarea, and SelectTrigger all consume this. Mirrors `buttonVariants`:
// 2.5px radius, 12px text, padding-driven sizing (no fixed heights). The visual
// chrome (background, border tint, hover, focus glow, invalid state) comes from
// the `desktop-input-chrome` CSS so every control shares one exact look.
export const controlVariants = cva(
'desktop-input-chrome w-full min-w-0 rounded-[2.5px] border text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {
xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
sm: 'px-2 py-1',
default: 'px-2.5 py-1.5',
lg: 'px-3 py-2 text-sm leading-5'
}
},
defaultVariants: {
size: 'default'
}
}
)
export type ControlVariantProps = VariantProps<typeof controlVariants>

View File

@@ -110,7 +110,7 @@ function DropdownMenuItem({
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
data-inset={inset}
@@ -131,7 +131,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-checkbox-item"
@@ -157,7 +157,7 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-radio-item"
@@ -226,7 +226,7 @@ function DropdownMenuSubTrigger({
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
className
)}
data-inset={inset}

View File

@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import { AlertCircle } from '@/lib/icons'
import { cn } from '@/lib/utils'
export interface ErrorStateProps {
/** Optional actions row/stack rendered below the copy. */
children?: ReactNode
className?: string
description?: ReactNode
/** Defaults to a destructive AlertCircle. */
icon?: ReactNode
title: ReactNode
}
// Shared, presentation-only error layout: a destructive icon chip over a
// centered title + body, with an optional actions stack. Used by both the
// top-level React error boundary and the in-dialog update error so every
// failure state reads the same. Title/description accept nodes so callers in a
// Radix Dialog can pass DialogTitle/DialogDescription for accessibility.
export function ErrorState({ children, className, description, icon, title }: ErrorStateProps) {
return (
<div className={cn('grid gap-5', className)}>
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
{icon ?? <AlertCircle className="size-7" />}
</span>
{typeof title === 'string' ? (
<h2 className="text-center text-xl font-semibold tracking-tight">{title}</h2>
) : (
title
)}
{typeof description === 'string' ? (
<p className="max-w-prose text-center text-sm leading-5 text-muted-foreground">{description}</p>
) : (
description
)}
</div>
{children && <div className="grid gap-2">{children}</div>}
</div>
)
}

View File

@@ -2,11 +2,19 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
import { type ControlVariantProps, controlVariants } from './control'
function Input({
className,
type,
size,
...props
}: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
<input
className={cn(
'desktop-input-chrome h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
controlVariants({ size }),
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
className
)}
data-slot="input"

View File

@@ -0,0 +1,78 @@
import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface SearchFieldProps {
placeholder: string
value: string
onChange: (value: string) => void
containerClassName?: string
inputClassName?: string
loading?: boolean
onClear?: () => void
inputRef?: RefObject<HTMLInputElement | null>
trailingAction?: ReactNode
'aria-label'?: string
}
/**
* Shared search field used everywhere (sessions sidebar, pages, overlays,
* command center, cron). No box — borderless until focus, then an underline.
* Width/placement come from `containerClassName`.
*/
export function SearchField({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef,
trailingAction,
'aria-label': ariaLabel
}: SearchFieldProps) {
const clear = onClear ?? (() => onChange(''))
return (
<div
className={cn(
'inline-flex max-w-full items-center gap-1.5 border-b border-transparent px-0.5 transition-colors focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
>
<Search className="pointer-events-none size-3.5 shrink-0 text-muted-foreground/70" />
<input
aria-label={ariaLabel}
className={cn(
// `field-sizing: content` grows the input to fit the placeholder/typed
// text, capped by the container's max-width — no awkward empty space.
'h-7 max-w-full bg-transparent text-sm text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
type="text"
value={value}
/>
{trailingAction}
{loading ? (
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
) : null}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
export interface SegmentedControlOption<T extends string> {
id: T
label: string
icon?: IconComponent
}
interface SegmentedControlProps<T extends string> {
options: readonly SegmentedControlOption<T>[]
value: T
onChange: (id: T) => void
className?: string
}
/**
* Grouped one-row toggle used for small mutually-exclusive choices
* (color mode, tool-call display, usage period, etc.). Flat by design —
* no per-option borders, just a tinted track with a raised active pill.
*/
export function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
return (
<div
className={cn(
'inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5',
className
)}
>
{options.map(({ id, label, icon: Icon }) => {
const active = value === id
return (
<button
aria-pressed={active}
className={cn(
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
key={id}
onClick={() => onChange(id)}
type="button"
>
{Icon && <Icon className="size-3" />}
{label}
</button>
)
})}
</div>
)
}

View File

@@ -2,17 +2,24 @@ import { Select as SelectPrimitive } from 'radix-ui'
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { type ControlVariantProps, controlVariants } from '@/components/ui/control'
import { cn } from '@/lib/utils'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
function SelectTrigger({
className,
children,
size,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & ControlVariantProps) {
return (
<SelectPrimitive.Trigger
className={cn(
'flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
controlVariants({ size }),
'flex items-center justify-between gap-2 whitespace-nowrap data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
className
)}
data-slot="select-trigger"
@@ -66,7 +73,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
return (
<SelectPrimitive.Item
className={cn(
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-xs outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-default data-disabled:opacity-50',
className
)}
data-slot="select-item"

View File

@@ -242,14 +242,14 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
return (
<Button
className={cn('size-7', className)}
className={className}
data-sidebar="trigger"
data-slot="sidebar-trigger"
onClick={event => {
onClick?.(event)
toggleSidebar()
}}
size="icon"
size="icon-sm"
variant="ghost"
{...props}
>

View File

@@ -1,24 +1,47 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Switch as SwitchPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
const switchVariants = cva(
'peer inline-flex shrink-0 items-center rounded-full border border-[color-mix(in_srgb,var(--dt-foreground)_18%,transparent)] bg-[color-mix(in_srgb,var(--dt-background)_58%,var(--dt-input))] shadow-[inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-foreground)_8%,transparent)] transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary',
{
variants: {
size: {
default: 'h-5 w-9',
xs: 'h-4 w-7'
}
},
defaultVariants: {
size: 'default'
}
}
)
const switchThumbVariants = cva(
'pointer-events-none block rounded-full bg-foreground shadow-[0_0.0625rem_0.1875rem_color-mix(in_srgb,var(--dt-background)_50%,transparent)] ring-0 transition-transform data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-background',
{
variants: {
size: {
default: 'size-4 data-[state=checked]:translate-x-4',
xs: 'size-3 data-[state=checked]:translate-x-3.5'
}
},
defaultVariants: {
size: 'default'
}
}
)
function Switch({
className,
size,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & VariantProps<typeof switchVariants>) {
return (
<SwitchPrimitive.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-[color-mix(in_srgb,var(--dt-foreground)_18%,transparent)] bg-[color-mix(in_srgb,var(--dt-background)_58%,var(--dt-input))] shadow-[inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-foreground)_8%,transparent)] transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-primary',
className
)}
data-slot="switch"
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block size-4 rounded-full bg-foreground shadow-[0_0.0625rem_0.1875rem_color-mix(in_srgb,var(--dt-background)_50%,transparent)] ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-background data-[state=unchecked]:translate-x-0'
)}
data-slot="switch-thumb"
/>
<SwitchPrimitive.Root className={cn(switchVariants({ size }), className)} data-slot="switch" {...props}>
<SwitchPrimitive.Thumb className={switchThumbVariants({ size })} data-slot="switch-thumb" />
</SwitchPrimitive.Root>
)
}

View File

@@ -2,13 +2,12 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
import { type ControlVariantProps, controlVariants } from './control'
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
return (
<textarea
className={cn(
'desktop-input-chrome min-h-16 w-full rounded-md border px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
className={cn(controlVariants({ size }), 'min-h-16', className)}
data-slot="textarea"
{...props}
/>

View File

@@ -4,11 +4,15 @@ declare global {
interface Window {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getGatewayWsUrl: () => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@@ -141,6 +145,7 @@ export interface HermesConnection {
baseUrl: string
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
@@ -163,6 +168,8 @@ export interface HermesWindowState {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@@ -170,6 +177,7 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
}
@@ -180,6 +188,36 @@ export interface DesktopConnectionTestResult {
version: string | null
}
export interface DesktopAuthProvider {
name: string
displayName: string
// True when this provider authenticates with a username + password
// (the gateway's /login page renders a credential form) rather than an
// OAuth redirect. The session/cookie/ws-ticket machinery is identical;
// only the login-page form and the desktop's button copy differ.
supportsPassword?: boolean
}
export interface DesktopConnectionProbeResult {
baseUrl: string
reachable: boolean
authMode: 'oauth' | 'token' | 'unknown'
providers: DesktopAuthProvider[]
version: string | null
error: string | null
}
export interface DesktopOauthLoginResult {
ok: boolean
baseUrl: string
connected: boolean
}
export interface DesktopOauthLogoutResult {
ok: boolean
connected: boolean
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean

View File

@@ -46,7 +46,8 @@ export function createClientSessionState(
streamId: null,
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false
interrupted: false,
needsInput: false
}
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
describe('resolveGatewayWsUrl', () => {
describe('oauth mode', () => {
it('uses the freshly minted URL', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh')
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
expect(getGatewayWsUrl).toHaveBeenCalledOnce()
})
it('throws a reauth error instead of falling back to the stale cached ticket', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired'))
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf(
GatewayReauthRequiredError
)
})
it('preserves the underlying mint failure as the cause', async () => {
const cause = new Error('401 cookie expired')
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
expect(error).toBeInstanceOf(GatewayReauthRequiredError)
expect((error as GatewayReauthRequiredError).cause).toBe(cause)
})
it('throws a reauth error when the preload cannot mint (no method)', async () => {
await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError)
})
it('never returns the stale cached ticket on failure', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom'))
const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw')
expect(result).toBe('threw')
expect(result).not.toBe(oauthConn.wsUrl)
})
})
describe('token / local mode', () => {
it('uses the minted URL when available', async () => {
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh')
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
})
it('falls back to the cached URL when minting fails (token is long-lived)', async () => {
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient'))
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl)
})
it('falls back to the cached URL when the preload method is absent', async () => {
await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl)
})
it('treats a missing authMode as non-oauth (falls back safely)', async () => {
await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl)
})
})
})
describe('isGatewayReauthRequired', () => {
it('detects the dedicated error class', () => {
expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true)
})
it('detects plain objects tagged with needsOauthLogin (from the main process)', () => {
expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true)
})
it('rejects generic errors', () => {
expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false)
expect(isGatewayReauthRequired(null)).toBe(false)
expect(isGatewayReauthRequired('string')).toBe(false)
})
})

View File

@@ -0,0 +1,85 @@
import type { HermesConnection } from '@/global'
/**
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
* consumed). For local/token gateways the URL carries a long-lived token and
* never needs re-minting.
*
* Resolution rules:
*
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
* fail with an opaque "connection closed" error. Instead, let the mint error
* propagate so the caller can surface the gateway's reauth message
* ("session has expired… Sign in again").
*
* - token / local, or when the preload method is genuinely absent (older
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
* the fallback is safe and preserves compatibility.
*
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
* callers can distinguish "the user must re-authenticate" from a generic
* transport failure.
*/
export interface ResolveGatewayWsUrlDeps {
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */
getGatewayWsUrl?: () => Promise<string>
}
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(
desktop: ResolveGatewayWsUrlDeps,
conn: Pick<HermesConnection, 'authMode' | 'wsUrl'>
): Promise<string> {
const mint = desktop.getGatewayWsUrl
if (conn.authMode === 'oauth') {
if (!mint) {
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
// dead, so connecting with it cannot succeed. Surface a reauth error
// rather than silently attempting a doomed connect.
throw new GatewayReauthRequiredError(
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
)
}
try {
return await mint()
} catch (error) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
{ cause: error }
)
}
}
// token / local: the URL carries a long-lived token. Re-mint when available
// (cheap, keeps parity), but the cached URL is a safe fallback.
if (mint) {
const fresh = await mint().catch(() => null)
if (fresh) {
return fresh
}
}
return conn.wsUrl
}

View File

@@ -49,6 +49,7 @@ import {
IconLoader2 as Loader2,
IconLoader2 as Loader2Icon,
IconLock as Lock,
IconLogin as LogIn,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@@ -148,6 +149,7 @@ export {
Loader2,
Loader2Icon,
Lock,
LogIn,
MessageCircle,
MessageSquareText,
Mic,

View File

@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
$clarifyRequest,
$clarifyRequests,
type ClarifyRequest,
clearClarifyRequest,
setClarifyRequest
} from './clarify'
import { $activeSessionId } from './session'
function clarify(sessionId: string | null, requestId: string): ClarifyRequest {
return {
requestId,
question: `question-${requestId}`,
choices: null,
sessionId
}
}
describe('clarify store', () => {
beforeEach(() => {
$clarifyRequests.set({})
$activeSessionId.set(null)
})
afterEach(() => {
$clarifyRequests.set({})
$activeSessionId.set(null)
})
it('keeps clarify requests from concurrent sessions independent', () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
expect($clarifyRequests.get()['session-a']?.requestId).toBe('req-a')
expect($clarifyRequests.get()['session-b']?.requestId).toBe('req-b')
})
it('exposes only the active session via the focus-scoped view', () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
$activeSessionId.set('session-a')
expect($clarifyRequest.get()?.requestId).toBe('req-a')
$activeSessionId.set('session-b')
expect($clarifyRequest.get()?.requestId).toBe('req-b')
$activeSessionId.set('session-c')
expect($clarifyRequest.get()).toBeNull()
})
it('clears only the targeted session, leaving the other pending', () => {
setClarifyRequest(clarify('session-a', 'req-a'))
setClarifyRequest(clarify('session-b', 'req-b'))
clearClarifyRequest('req-a', 'session-a')
expect($clarifyRequests.get()['session-a']).toBeUndefined()
expect($clarifyRequests.get()['session-b']?.requestId).toBe('req-b')
})
it('ignores a stale clear whose request id no longer matches', () => {
setClarifyRequest(clarify('session-a', 'req-a2'))
clearClarifyRequest('req-a1', 'session-a')
expect($clarifyRequests.get()['session-a']?.requestId).toBe('req-a2')
})
it('clears by request id across sessions when no session hint is given', () => {
setClarifyRequest(clarify('session-a', 'shared'))
setClarifyRequest(clarify('session-b', 'other'))
clearClarifyRequest('shared')
expect($clarifyRequests.get()['session-a']).toBeUndefined()
expect($clarifyRequests.get()['session-b']?.requestId).toBe('other')
})
})

View File

@@ -1,4 +1,6 @@
import { atom } from 'nanostores'
import { atom, computed } from 'nanostores'
import { $activeSessionId } from './session'
export interface ClarifyRequest {
requestId: string
@@ -7,26 +9,61 @@ export interface ClarifyRequest {
sessionId: string | null
}
// Holds the request_id (and metadata) for the most recent in-flight
// clarify call. The inline ClarifyTool component (rendered inside the
// assistant message stream) reads this to know which request_id to send
// back over `clarify.respond`.
export const $clarifyRequest = atom<ClarifyRequest | null>(null)
// Pending clarify requests keyed by the runtime session id that raised them.
// Storing per-session (instead of one shared slot) lets a *background* session
// park its clarify request while the user is looking at a different chat, then
// resolve it once they switch over — without a second concurrent clarify
// clobbering the first. A request with no session id lands under the empty key.
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $clarifyRequests = atom<Record<string, ClarifyRequest>>({})
// The clarify request for the currently-viewed session. The inline ClarifyTool
// only ever mounts inside the active session's transcript, so it reads this
// focus-scoped view rather than reaching into the whole map.
export const $clarifyRequest = computed(
[$clarifyRequests, $activeSessionId],
(requests, activeId) => requests[keyFor(activeId)] ?? null
)
export function setClarifyRequest(request: ClarifyRequest): void {
$clarifyRequest.set(request)
$clarifyRequests.set({ ...$clarifyRequests.get(), [keyFor(request.sessionId)]: request })
}
export function clearClarifyRequest(requestId?: string): void {
const current = $clarifyRequest.get()
export function clearClarifyRequest(requestId?: string, sessionId?: string | null): void {
const requests = $clarifyRequests.get()
// Targeted clear when the caller knows the session (the common path from the
// inline ClarifyTool answering its own request).
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = requests[key]
if (!current || (requestId && current.requestId !== requestId)) {
return
}
const next = { ...requests }
delete next[key]
$clarifyRequests.set(next)
if (!current) {
return
}
if (requestId && current.requestId !== requestId) {
return
// Fallback with no session hint: drop every entry matching the request id
// (or clear all when none is given).
const next: Record<string, ClarifyRequest> = {}
let changed = false
for (const [key, value] of Object.entries(requests)) {
if (requestId && value.requestId !== requestId) {
next[key] = value
} else {
changed = true
}
}
$clarifyRequest.set(null)
if (changed) {
$clarifyRequests.set(next)
}
}

View File

@@ -0,0 +1,20 @@
import { atom } from 'nanostores'
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
export const $commandPaletteOpen = atom(false)
export function openCommandPalette(): void {
$commandPaletteOpen.set(true)
}
export function closeCommandPalette(): void {
$commandPaletteOpen.set(false)
}
export function setCommandPaletteOpen(open: boolean): void {
$commandPaletteOpen.set(open)
}
export function toggleCommandPalette(): void {
$commandPaletteOpen.set(!$commandPaletteOpen.get())
}

View File

@@ -21,6 +21,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
@@ -53,11 +54,15 @@ export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_K
export const $sidebarPinsOpen = atom(true)
export const $sidebarRecentsOpen = atom(true)
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
// When true, the sessions sidebar moves to the right and the file browser +
// preview rail move to the left — a mirror of the default layout.
export const $panesFlipped = atom(storedBoolean(PANES_FLIPPED_STORAGE_KEY, false))
export const $isSidebarResizing = atom(false)
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
export function setSidebarWidth(width: number) {
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
@@ -76,6 +81,10 @@ export function toggleFileBrowserOpen() {
togglePane(FILE_BROWSER_PANE_ID)
}
export function togglePanesFlipped() {
$panesFlipped.set(!$panesFlipped.get())
}
export function selectRightRailTab(id: RightRailTabId) {
$rightRailActiveTabId.set(id)
}

View File

@@ -346,20 +346,49 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
// duplicating provider UI. Sets manual=true so the overlay shows the picker
// even though configured===true, and refreshes the provider list.
export function startManualOnboarding(reason = 'Add or switch inference provider.') {
export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
patch({
manual: true,
requested: true,
reason: reason.trim() || DEFAULT_ONBOARDING_REASON,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
void refreshProviders()
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
// flow instead of re-showing the picker the user just clicked through.
// Module-level (not store state) because it's consumed immediately on the next
// overlay render and never needs to persist or re-render anything itself.
let pendingProviderOAuthId: null | string = null
export function startManualProviderOAuth(providerId: string, reason: null | string = null) {
pendingProviderOAuthId = providerId
startManualOnboarding(reason)
}
// Read the pending provider id without clearing it. The overlay only clears it
// (via clearPendingProviderOAuth) once it has actually launched that provider,
// so a transient empty/failed provider fetch doesn't drop the hand-off and the
// deep-link can still auto-start after the list loads.
export function peekPendingProviderOAuth(): null | string {
return pendingProviderOAuthId
}
export function clearPendingProviderOAuth() {
pendingProviderOAuthId = null
}
// Dismiss a manually-opened provider selector without touching the existing
// (working) configuration. Only valid in the manual path — the unconfigured
// first-run flow has no close affordance because the app can't run yet.
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, flow: { status: 'idle' } })
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { mergeSessionPage, sessionPinId } from './session'
import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@@ -23,6 +23,34 @@ const session = (over: Partial<SessionInfo>): SessionInfo => ({
...over
})
describe('setSessionAttention', () => {
it('adds and removes a session id without duplicating it', () => {
$attentionSessionIds.set([])
setSessionAttention('s1', true)
setSessionAttention('s1', true)
expect($attentionSessionIds.get()).toEqual(['s1'])
setSessionAttention('s2', true)
expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
setSessionAttention('s1', false)
expect($attentionSessionIds.get()).toEqual(['s2'])
$attentionSessionIds.set([])
})
it('ignores empty ids and no-op clears', () => {
$attentionSessionIds.set([])
setSessionAttention(null, true)
setSessionAttention(undefined, true)
setSessionAttention('', true)
setSessionAttention('missing', false)
expect($attentionSessionIds.get()).toEqual([])
})
})
describe('sessionPinId', () => {
it('uses the live id when there is no compression lineage', () => {
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')

Some files were not shown because too many files have changed in this diff Show More