Compare commits

...
Author SHA1 Message Date
Brooklyn Nicholson fb1c886bf9 feat(desktop): browse + upload to the gateway filesystem on remote backends
Desktop file ops assumed the agent shared the client's filesystem, so on a
remote gateway (VPS over tailscale) image uploads, the Files sidebar, context
attachments, and the cwd picker all pointed at the wrong machine.

- gateway: fs.list / fs.read_text / fs.read_data_url / fs.git_root run on the
  agent host (shapes mirror the Electron fs IPC); image.attach_bytes writes
  client-uploaded bytes into $HERMES_HOME/images.
- renderer: desktop-fs facade routes reads + path selection through fs.* when
  $connection.mode === 'remote', else local IPC. Files sidebar, preview, and
  the image/file/folder/cwd pickers flow through it; a RemotePathPicker modal
  browses the gateway when native dialogs can't. Image attach falls back to
  byte upload when a client path can't resolve on the gateway.
2026-06-05 21:23:33 -05:00
brooklyn! e375c33f70 fix(tui): clean force-send of queued messages (#40235)
Force-sending a queued message (double-empty-enter, or interrupt-mode
submit) flipped busy→false optimistically, so the queue drain raced the
still-unwinding turn: duplicate user bubble, a stray "queued: …" note, and
the cancelled turn's "Operation interrupted…" reply leaking in.

interruptTurn gains `keepBusy`: hold busy until the gateway's real settle
edge (message.complete, suppressed while interrupted), which drains the
queued message exactly once — desktop "send now" parity. The interrupt
paths now queue + interrupt instead of optimistically sending.
2026-06-06 01:39:10 +00:00
brooklyn! ac177cea87 Merge pull request #40234 from NousResearch/bb/desktop-queue-arrow-edit-v2
feat(desktop): arrow-key history + queue editing in composer
2026-06-05 20:38:37 -05:00
Brooklyn Nicholson ce50030634 feat(desktop): integrate arrow history with the message queue
Builds on @naqerl's arrow up/down history (previous commit), making
ArrowUp do the right thing when a queue exists.

ArrowUp/ArrowDown priority:
1. Editing a queued turn → walk older/newer through queued entries,
   saving each edit; ArrowDown past the newest exits and restores the
   pre-edit draft.
2. Empty composer + queued turns → ArrowUp opens the newest queued entry
   for editing (the row's pencil), so Enter saves it back to the queue
   instead of firing a new message — the gap the history nav had alone.
3. Otherwise → sent-message history recall (unchanged).

Also: Esc cancels an in-progress queue edit (else interrupts).

Cleanups on the integrated code: fold the browse-state reset into the
existing session-change effect (drop the duplicate ref+effect); reuse
loadIntoComposer for history recall; sort imports; add curly braces +
the runDrain sessionId dep (lint).
2026-06-05 20:33:53 -05:00
naqerl f94363d1f0 feat(desktop): arrow up/down to navigate previous user messages 2026-06-05 20:32:29 -05:00
brooklyn! 0cbcc75935 fix(desktop): reliable composer message queue (#40221)
* fix(desktop): make composer message queue reliable

The queue felt 'dumb' because of three real bugs:

1. Drained-after-interrupt sends went silent. cancelRun sets
   interrupted:true and nothing reset it; submitPromptText's optimistic
   seed preserved it, and the message stream drops every delta while
   interrupted. So Send-now-while-busy and any interrupt+drain submitted
   the next turn into a muted session. Fix: a fresh submit is a new turn —
   seed interrupted:false.

2. Back-to-back queue drains stalled. The drain fires on the busy->false
   settle edge, but busyRef (synced from the busy store by a separate
   effect) can still read true on that same edge, so the drained send hit
   the busy guard, returned false, and the entry was never removed. Fix:
   fromQueue sends bypass the busyRef guard (the queue drain lock
   serializes them); the user path keeps the guard.

3. Double-enter-to-interrupt killed single non-queue turns. The hidden
   450ms timer meant a natural double-tap after sending stopped the agent.
   Fix: empty Enter while busy is a no-op; interrupting is explicit —
   Stop button or Esc.

Also: clean stop (no [interrupted] marker), Send-now works while busy
(promote + interrupt + auto-drain), settle on the interrupted completion
path. Adds regression tests and unblocks the prompt-actions suite by
completing its stale @/hermes mock.

* fix(desktop): float the queue panel as an overlay so the chat doesn't resize

The queue list rendered in-flow inside the composer root, so its height
fed --composer-measured-height (the composer rect drives the thread's
bottom padding + last-message clearance). Queuing a message grew that
rect and the whole chat visibly resized.

Anchor the panel out of flow above the composer (absolute bottom-full,
capped at 40vh with internal scroll). It no longer contributes to the
measured height, so the thread layout stays put and the list overlays the
(already faded) chat. Still collapsible via the panel's own
disclosure header.

* fix(desktop): queue panel collapsed by default + shared border with composer

- Default the queue disclosure to collapsed (compact 'N queued' pill)
  instead of expanded.
- Drop the gap and merge the panel into the composer: square bottom
  corners, no bottom border/radius, and overlap down by the Root's pt-2
  (-mb-2) so the panel's borderless bottom lands on the composer surface's
  top border — one continuous bordered shape.

* style(desktop): tighten queue panel padding

* style(desktop): trim queue-ux comments to house style

* style(desktop): drop 'Cursor' references from comments
2026-06-05 20:21:41 -05:00
Gille 0c0a707744 fix(desktop): repair macOS updater helper (#40217) 2026-06-05 20:05:32 -05:00
Teknium 78122c52cf test(slack): drop /q alias assertion now displaced by /version cap clamp
Slack's native-slash manifest hard-caps at 50 (_SLACK_MAX_SLASH_COMMANDS).
Adding the /version canonical claims a pass-1 slot, so the lowest-priority
pass-2 alias (/q for /quit) clamps off the end. /q stays reachable via
/hermes q. Surviving aliases (/btw /bg /reset) still prove alias parity.
2026-06-05 18:05:05 -07:00
Brooklyn Nicholson 30340eae2f Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available.
2026-06-05 18:05:05 -07:00
Brooklyn Nicholson 9c1bb8d2c7 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 18:05:05 -07:00
teknium1 aa52cd3b57 test(desktop): unmount between IME composition repro cases
The new IME repro test has two it() blocks but the desktop suite registers
no global testing-library auto-cleanup, so the first render() leaked its
editor into the second test and getByTestId('editor') matched two nodes.
Add afterEach(cleanup) so each case renders into a fresh DOM.
2026-06-05 18:05:00 -07:00
xxxigm da9425bf9b test(desktop): cover IME-composed send-button visibility (Chinese/Japanese/Korean)
DOM repro that drives compositionstart -> input(preedit) -> compositionend with
no trailing input event and asserts the composer payload (send button) becomes
visible for committed CJK/IME input. Regression guard for #39614.
2026-06-05 18:05:00 -07:00
xxxigm 8e629b9f38 fix(desktop): flush committed IME text on compositionend so the send button appears
Typing committed multi-character IME text (e.g. Chinese "你好", and equally
Japanese/Korean or any IME-composed script) left the send button hidden until
an unrelated edit. Input events during composition carry uncommitted preedit
text and are intentionally skipped; the code assumed a trailing input event
after compositionend would deliver the finalized text, but Chromium does not
reliably emit one on Windows IMEs. The committed text therefore never reached
composer state, so `hasComposerPayload` stayed false and the send button stayed
hidden (deleting a char fired a non-composition input that finally synced it).

Flush the live editor text into composer state in onCompositionEnd. Extract the
shared sync into flushEditorToDraft so input and compositionend both update
state.

Fixes #39614
2026-06-05 18:05:00 -07:00
teknium1 be2c64be02 fix(desktop): wire serializeJsonBody into OAuth request path
The salvaged helper exported serializeJsonBody but main.cjs still inline-built
the request body, leaving the export dead and the test decoupled from the real
path. Use it at the fetchJsonViaOauthSession site so the helper's coverage
exercises production body construction. Byte-identical output.
2026-06-05 18:04:45 -07:00
helix4u b8234e7599 fix(desktop): avoid restricted oauth request header 2026-06-05 18:04:45 -07:00
Teknium 3c231eb397 chore: release v0.16.0 (2026.6.5) (#40206)
The Surface Release — native desktop app, browser admin panel,
remote-gateway connect, Simplified Chinese desktop UI, leaner default
skill set, NVIDIA/skills trusted tap, fuzzy model picker, /undo.

874 commits · 542 PRs · 170 contributors · 399 issues closed.
2026-06-05 17:55:43 -07:00
Teknium ea266f43e9 fix(file-ops): make rg/grep search error guard reachable and preserve partial matches (#39858)
The error guard in _search_with_rg/_search_with_grep was unreachable and,
if it had fired, would have discarded valid results.

Two root causes:

1. Unreachable. Both methods pipe the search through `| head` with no
   pipefail, so the pipeline reported head's exit code (0), masking rg/grep's
   error code (2). The guard never fired. Worse, because _exec merges stderr
   into stdout (stderr=subprocess.STDOUT), the error text was then parsed as
   bogus match lines instead of being surfaced — the user got garbage matches
   with no indication the search failed.

2. Latent results-dropping. The original `not result.stdout.strip()` check
   was always False on error (error text lives in stdout), and the
   `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no
   stderr field). A naive broadening to `exit_code == 2` would have nuked
   real matches whenever rg/grep also hit a non-fatal error (e.g. one
   unreadable file in a tree that otherwise matched), which both tools signal
   with exit 2.

Fix:
- Prefix the piped command with `set -o pipefail` so rg/grep's real exit
  status propagates. rg exits 0 on a truncating head; grep exits 141
  (SIGPIPE), so the strict `== 2` guard ignores truncated-success.
- Add _split_tool_diagnostics() to separate tool diagnostics from match
  output by tool prefix and output shape. Diagnostics never become matches;
  on a hard error they are the message to surface.
- Only surface an error when exit==2 AND no usable match payload remains, so
  partial errors keep their real matches.

Tests: tests/tools/test_search_error_guard.py drives both methods through the
real local backend (hard error surfaced, partial error keeps matches,
truncation no false error, files_only/count exclude diagnostics) plus unit
coverage for the splitter.

Supersedes #39710.
2026-06-05 17:44:52 -07:00
kshitij 66a6b9c930 Merge pull request #39482 from liuhao1024/fix/rich-markup-error-on-session-resume
fix(cli): use Rich [dim] tag instead of ANSI escape in session resume messages
2026-06-05 13:12:17 -07:00
kshitij e6f7e217ce Merge pull request #40093 from kshitijk4poor/feat/named-custom-discover-models-18726
feat(model): honor discover_models in terminal hermes model named-custom flow (closes #18726)
2026-06-05 13:08:33 -07:00
kshitij b5d42daa53 Merge pull request #40080 from kshitijk4poor/salvage/discover-models-section4-29810
feat(model_switch): honor discover_models in custom_providers section 4 (salvage #29810)
2026-06-05 13:05:34 -07:00
kshitijk4poor 7ae8aac3b9 feat(model): honor discover_models in terminal hermes model named-custom flow
The terminal `hermes model` wizard (_model_flow_named_custom) always
live-probed a custom provider's /models endpoint, ignoring the configured
`models:` list. For plans whose endpoint exposes a large catalog (e.g. Baidu
Qianfan Coding Plan returns 100+ models for a 2-3 model plan) the picker
flooded with models the user can't use.

This wires `discover_models` (and the `models:` list) through
_named_custom_provider_map into the flow and honors `discover_models: false`
the same way the slash-command picker (model_switch.py sections 3 & 4) does:
- Default stays True — live probe, no behaviour change.
- discover_models: false → use the configured `models:` list verbatim,
  skip the probe (string 'false'/'no'/'0' normalised to False).
- If the probe is on but returns empty, fall back to the configured list
  instead of forcing manual entry.

Closes #18726
2026-06-06 01:29:41 +05:30
kshitijk4poor 53bba70854 chore: add ohMyJason to AUTHOR_MAP 2026-06-06 01:04:25 +05:30
ohMyJason 4b2d00f845 feat(model_switch): honor discover_models in custom_providers section 4
Section 3 (user `providers:`) already honors `discover_models: false` to
skip live /models discovery and keep the explicit `models:` list. Section 4
(`custom_providers:` list) did not — `should_probe` ignored the field, so any
grouped custom provider with an api_key always had its configured subset
replaced by the full live /models catalog.

This adds the same `discover_models` support to section 4:
- Default True — no behaviour change for existing configs.
- `discover_models: false` keeps the explicit `models:` list even when an
  api_key is present.
- String values ("false"/"no"/"0") are normalised to False, matching
  section 3.
- If any entry in a grouped endpoint opts out, the whole group opts out.

Use case: endpoints that expose a full aggregator catalog via /models but
only serve a configured subset.

Salvaged from #29810 — rebased onto current main. The PR's other change
(`key_env` resolution in section 4) landed independently in commit aa283d1e4
(custom provider picker credential isolation), so only the discover_models
portion is carried here.

Co-authored-by: ohMyJason <42903577+ohMyJason@users.noreply.github.com>
2026-06-06 01:04:13 +05:30
brooklyn! 6f6eb871d8 fix(gateway): new chats honor their profile in global-remote mode (#39993)
Follow-up to #39921. That PR scoped session.resume + prompt.submit to a
session's profile, but a BRAND-NEW chat (session.create) under a non-launch
profile was still built and persisted against the dashboard's launch profile.
Two visible symptoms in app-global remote mode (one dashboard, many profiles):

  1. "who are you" in profile S replied as the launch (default) profile/agent —
     the agent was built with the launch HERMES_HOME, so config/SOUL/identity
     came from the wrong profile.
  2. "session not found" on later resume — _ensure_session_db_row persisted the
     row into the launch profile's state.db via _get_db(), so the session lived
     in the wrong db, the unified list mis-tagged it (it showed up under BOTH
     profiles), and resume routed to the wrong one.

Fix — carry the owning profile through the create path too:

- session.create accepts an optional `profile`; resolves its home and stores
  `profile_home` on the session (alongside what resume already set).
- _start_agent_build binds that profile's HERMES_HOME while building the agent
  (config/skills/model/identity resolve to it) and hands the agent the profile's
  state.db so turns persist there.
- _ensure_session_db_row writes the row into the profile's state.db, not the
  launch db — fixing the duplicate row + mis-tag + resume 404.
- desktop sends the new-chat profile on session.create.

None/launch profile → unchanged (single-profile and per-profile-remote setups
take the same path). Verified live against a one-dashboard / multi-profile
remote: a new chat under `work` builds as work's agent (correct SOUL identity),
persists ONLY to work's state.db (launch db stays empty), the unified list tags
it `work` exactly once, and it resumes cleanly.

tests/test_tui_gateway_server.py: _make_agent mocks updated for the session_db
param added in #39921's build path.
2026-06-05 17:44:45 +00:00
Jim Liu 宝玉 1d9c3ebae0 feat(desktop): persist i18n language in config 2026-06-05 10:32:26 -07:00
Jim Liu 宝玉 4a1907bd10 feat(desktop): add i18n with Simplified Chinese (zh-Hans) support
Introduce a lightweight React context-based i18n layer for the desktop
app and translate the UI into Simplified Chinese.

- New apps/desktop/src/i18n module: typed Translations interface, en + zh
  locale tables, I18nProvider/useI18n, localStorage-persisted locale
  (defaults to English), and language endonym metadata for the picker.
- Wire I18nProvider at the app root in main.tsx.
- Refactor 24 desktop screens/components to read strings from the `t`
  object instead of hard-coded English.
- Add a unit test for the i18n context.
2026-06-05 10:32:26 -07:00
brooklyn! 02d6bf1c39 fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921)
* fix(desktop): cross-profile session history in app-global remote mode

#39894 made remote-profile sessions first-class for PER-PROFILE remote
overrides. But the common setup — Settings → Gateway → "All profiles" → Remote
— writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty
profiles map), which the intercept didn't recognize. Switching to a non-launch
profile then 404'd every session read, so no history showed for it.

In global remote mode a SINGLE backend serves every profile via ?profile= (it
reads each profile's state.db off the remote host's own disk — verified: one
dashboard returns /api/profiles and /api/profiles/sessions?profile=all across
all profiles). The fix: when no per-profile override matches but global remote
mode is active, route per-session reads/mutations to that one backend and KEEP
the ?profile= param so it opens the right state.db (instead of bailing to the
local path and dropping the profile scope).

- new globalRemoteActive() — true for connection.json mode:'remote' or the
  HERMES_DESKTOP_REMOTE_URL env override.
- per-session branch: per-profile override → route sans profile (own db);
  global mode → route to the single backend WITH ?profile= preserved.
- unified list is unchanged in global mode: it already passes through to the one
  backend, which aggregates all profiles natively.

Verified live against a one-dashboard / multi-profile remote (Austin's topology):
cross-profile transcript reads load (was 404), rename/delete route to the right
profile, unified list spans both profiles.

Known limitation (architectural, not fixed here): LIVE chat as a non-launch
profile still needs a per-profile dashboard on the remote — the dashboard binds
HERMES_HOME once at process start, so one global backend can't run an agent
turn as another profile. Session history/read/mutate now work regardless.

* fix(gateway): resume + chat any profile over one global-remote dashboard

The REST half of this branch made cross-profile session history visible in
app-global remote mode, but resume + chat still went over the WebSocket gateway,
which was hard-bound to the dashboard's launch profile. Resuming a non-launch
profile's session 404'd ("session not found") and sending spawned a new session
— because session.resume/prompt.submit had no profile concept and the live
agent + state.db were process-global to the launch profile's HERMES_HOME.

Make the WS gateway per-session profile-aware so ONE dashboard can serve every
local profile on its host (the app-global remote topology):

- session.resume accepts an optional `profile`. _profile_home() resolves that
  profile's home on this host; resume opens THAT profile's state.db, binds its
  HERMES_HOME (ContextVar override) while building the agent so config/skills/
  model resolve to it, and passes the profile db to the agent so turns persist
  to the right state.db. The owning profile_home is stored on the session.
- prompt.submit re-binds the stored profile_home for the turn thread (mid-turn
  home reads — memory, skills — resolve to the resumed profile), reset in finally.
- _make_agent gains an optional session_db param (defaults to _get_db()).
- _load_cfg honors the home override (falls back to _hermes_home) so a resumed
  profile loads its own config; cache keyed on resolved path.
- desktop: session.resume now sends the owning profile.

Omitted/launch profile → unchanged (single-profile and per-profile-remote setups
are byte-for-byte the same path). Verified live against a one-dashboard /
multi-profile remote: resuming a non-launch profile's session loads its history,
runs a real turn against THAT profile's home/env, and persists to its state.db.

tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param.
2026-06-05 12:22:55 -05:00
teknium1 e837856ecd chore(release): map ViewWay author email for AUTHOR_MAP 2026-06-05 09:10:26 -07:00
teknium1 2dda393f9f test(gateway): regression tests for max_tokens propagation chain (#20741) 2026-06-05 09:10:26 -07:00
teknium1 14275d7baa fix(gateway): honor per-provider max_output_tokens in max_tokens chain
Widens ViewWay's #20741 fix to the sibling config surface: a
custom_providers entry can pin its own output cap via max_output_tokens
(or max_tokens). _get_named_custom_provider now lifts it onto the
resolved runtime at all three return sites, and the gateway uses it as a
fallback only when the documented global model.max_tokens isn't set, so
the global key always wins.

Precedence: HERMES_MAX_TOKENS > model.max_tokens > provider
max_output_tokens > None. Closes the same #20741 truncation for users who
configure the cap per-provider rather than globally.

Picks up the intent of #19782 (alexcam1901), reimplemented to feed
ViewWay's max_tokens pipeline.
2026-06-05 09:10:26 -07:00
ViewWay 1c909e75e1 fix(cli,gateway): complete max_tokens propagation — CLI path + env var override
Previous commit only covered the gateway runtime path. This adds:
- CLI __init__: read max_tokens from model config with HERMES_MAX_TOKENS env override
- CLI AIAgent() calls (interactive + background): pass max_tokens
- Gateway _resolve_runtime_agent_kwargs: add HERMES_MAX_TOKENS env override

All three code paths (CLI, gateway runtime, session override) now
consistently propagate max_tokens to AIAgent.
2026-06-05 09:10:26 -07:00
ViewWay cf786593cd fix(gateway): propagate max_tokens from config.yaml to AIAgent
max_tokens set under model: in config.yaml was silently ignored.
The value was never read from config, never passed through
_resolve_runtime_agent_kwargs(), _resolve_turn_agent_config(),
or the session override path.  Added it to all three code paths
so custom/Ollama endpoints receive the correct output cap.

Closes #20741
2026-06-05 09:10:26 -07:00
brooklyn! 9af54b2f8c fix(desktop): make remote-profile sessions first-class (resume, read, rename/archive/delete) (#39894)
* fix(desktop): route remote-profile session reads to the owning remote backend

Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's
remote backend, but session list + transcript reads still assumed every
profile's state.db is a local file the primary can open. For a remote profile
the local file is absent or stale, so the IDs the sidebar shows 404 the moment
resume runs against the remote -- the "session not found -> new session" bug.

Intercept the three session-read GETs in the hermes:api handler and route them
to the owning remote backend (which serves its own state.db natively):

  GET /api/profiles/sessions        -> splice each remote profile's real rows in
  GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles

No remote profiles configured -> untouched local fast path. A dead remote
contributes nothing rather than breaking the sidebar.

Verified end-to-end against a live remote backend: a remote-profile session
resumes from remote history and continues on the remote across turns (history
grows in place, no new session spawned).

* fix(desktop): route remote-profile session mutations + fix unified-list pagination

Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.

Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.

- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
  profile and pass it as request.profile so Electron routes to that profile's
  backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
  DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
  param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
  GET/PATCH (local cross-profile delete).

Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.

Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
2026-06-05 10:13:10 -05:00
Brooklyn Nicholson 3045d54547 fix(desktop): route remote-profile session mutations + fix unified-list pagination
Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.

Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.

- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
  profile and pass it as request.profile so Electron routes to that profile's
  backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
  DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
  param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
  GET/PATCH (local cross-profile delete).

Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.

Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
2026-06-05 10:08:26 -05:00
Brooklyn Nicholson 83c13862f1 fix(desktop): route remote-profile session reads to the owning remote backend
Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's
remote backend, but session list + transcript reads still assumed every
profile's state.db is a local file the primary can open. For a remote profile
the local file is absent or stale, so the IDs the sidebar shows 404 the moment
resume runs against the remote -- the "session not found -> new session" bug.

Intercept the three session-read GETs in the hermes:api handler and route them
to the owning remote backend (which serves its own state.db natively):

  GET /api/profiles/sessions        -> splice each remote profile's real rows in
  GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles

No remote profiles configured -> untouched local fast path. A dead remote
contributes nothing rather than breaking the sidebar.

Verified end-to-end against a live remote backend: a remote-profile session
resumes from remote history and continues on the remote across turns (history
grows in place, no new session spawned).
2026-06-05 09:52:52 -05:00
adybag14-cyber af8b917dab fix(termux): scope frontend npm installs 2026-06-05 06:56:51 -07:00
Teknium 9ca11b35d5 perf(/model): prewarm picker provider-models cache in background (#39847)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* perf(/model): prewarm picker provider-models cache in background

The no-args /model picker calls list_authenticated_providers(), which
fetches each authenticated provider's live /v1/models list serially. On a
cold or stale (>1h TTL) cache that blocks ~1.5s on the user's critical path
the first time /model is opened in a session.

Warm that exact path off-thread during the idle window right after the CLI
banner is shown: a once-per-process daemon thread runs
list_authenticated_providers() to populate provider_models_cache.json for
every authed provider. By the time the user types /model, the picker hits
the warm disk cache (~136ms vs ~1500ms).

Process-level Event guard (mirrors run_agent's _openrouter_prewarm_done)
ensures at most one thread per process; fully exception-isolated so an
offline/no-creds provider can never affect the session.
2026-06-05 06:55:09 -07:00
Teknium ca1fb32c26 docs: remove --include-desktop install instructions (#39762)
* docs: remove --include-desktop install instructions

Drop the --include-desktop curl one-liner from the desktop app docs.
The flag remains in scripts/install.sh; these docs now point to the
desktop installer / website and the 'hermes desktop' path instead.

* docs: remove --include-desktop from install docs

Drop the redundant 'Hermes Desktop installer on Linux' block (which
used --include-desktop) from quickstart, installation, and index docs.
The website installer covers macOS/Windows desktop; the CLI-only path
covers Linux. Removes the flag from all user-facing docs.
2026-06-05 06:53:58 -07:00
Teknium 7583aedacd fix(completion): remove /model <arg> autocomplete from CLI/TUI (#39727)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(completion): remove /model <arg> autocomplete from CLI/TUI

The TUI frontend already suppressed /model argument completion in favor of
the two-step ModelPicker (useCompletion.ts), but the CLI prompt_toolkit
completer and the gateway-backed complete.slash RPC (TUI + desktop) still
emitted model aliases and probed LM Studio on every keystroke.

Drops the /model branch in SlashCommandCompleter.get_completions, the
_model_completions method, and the LM Studio probe/cache helper that only
fed it. Command-name completion (/mod -> model) and sibling arg completers
(/skin, /personality) are untouched. Removes the now-dead TestModelTabCompletion
tests.
2026-06-05 06:43:51 -07:00
brooklyn! 14fee4f112 fix(update/windows): retry handoff hermes update once on first-run crash (#39831)
The in-app updater (Hermes-Setup --update) runs `hermes update`, which lazily
imports the freshly-pulled modules — but the dependency-install step runs the
already-in-memory PRE-pull code for one invocation. When a release changes an
updater-path contract across that boundary, the FIRST update on the parked
population crashes even though the fix is already on disk.

Concretely this is #39780's `_UvResult`: its `__iter__` yields (path, bool), so
Windows `subprocess.list2cmdline([uv_bin, "pip", ...])` injects the bool and
dies with `TypeError: sequence item 1: expected str instance, bool found`
(fixed in #39820). A parked Windows user clicking Update pulls #39820 to disk,
then still crashes on the in-memory pre-merge module; only the SECOND click runs
clean. Field repro: ryanc's bootstrap.log (2026-06-05 12:41:41).

Fix: when the first `hermes update` exits non-zero (and it isn't the
concurrent-instance guard, exit 2, which a retry can't fix), retry once
automatically. The retry loads the now-current module from the start and
succeeds — so the parked user gets a working one-click update instead of a
scary crash + manual second attempt.

Verified: cargo check clean.
2026-06-05 08:37:16 -05:00
brooklyn! 98528c78c1 fix(desktop/windows): stop racing our own backend during in-app update (#39828)
* fix(desktop/windows): stop racing our own backend during in-app update

The Windows in-app update (Update button -> hermes-setup.exe --update handoff)
bricked because it raced a still-locked hermes.exe: the desktop quit
fire-and-forget without reaping its backend child + grandchildren, so when
the updater ran `hermes update`, the venv shim was still open. The quarantine
rename then failed, uv's `pip install -e .` hit "Access is denied", the git
path bailed to a full ZIP re-download, and the deps still couldn't write the
locked shim -- leaving a half-applied install. macOS is fine because it never
blocks REPLACE on a running executable.

Three coordinated fixes restore Mac-style parity (click Update -> progress ->
relaunch, no terminal):

A. Desktop (main.cjs): before spawning the updater, releaseBackendLockForUpdate()
   tree-kills the primary + pool backends (taskkill /T /F on Windows, to catch
   REPL/pty/gateway grandchildren that SIGTERM misses) and polls the venv shim
   until it is actually writable (bounded 15s) -- so the lock is gone before we
   hand off. Also fixes resolveHermesCliBinary to use venv\Scripts\hermes.exe on
   Windows.

B. Updater (update.rs): wait_for_venv_free no longer "proceeds anyway" on
   timeout -- it force-kills any lingering hermes.exe (excluding itself) and
   re-checks, so a straggler can't doom the install.

C. Updater (update.rs): pass --force to `hermes update`. By contract the desktop
   has exited + waited, and the wait force-kills stragglers, so the running-exe
   guard would only produce a false "Hermes is still running" dead-end.

Verified: node --check on main.cjs, cargo check on the updater (clean), and the
Windows-gated taskkill body type-checks standalone. Field repro: ryanc's
update.log (manual + handoff both hit the same lock cascade).

* review: scope backend kill+wait to Windows; drop meaningless POSIX pgid kill
2026-06-05 08:33:53 -05:00
brooklyn! d880b5be09 fix(update/windows): don't return _UvResult on Windows (subprocess argv crash) (#39820)
PR #39780 made ensure_uv() return a _UvResult — a str subclass whose
__iter__ yields (path, fresh_bootstrap) so old `uv_bin, fresh = ensure_uv()`
call sites survive the update boundary. That trick is unsafe on Windows.

The dependency installer passes uv straight into the command list
(`[uv_bin, "pip", "install", ...]`). On Windows, subprocess serializes argv
via subprocess.list2cmdline, which iterates every entry *as a string*
(`for c in arg`). Because _UvResult overrides __iter__, that iteration yields
(path, fresh_bootstrap) instead of characters, injecting the bool into the
command line and crashing the first update with:

    TypeError: sequence item 1: expected str instance, bool found

This bites the common single-assignment caller (`uv_bin = ensure_uv()`) on
its first update after #39780: the freshly pulled _UvResult flows into the
old in-memory call site and into the argv. Reported in the field on a
~10-commits-behind Windows install.

A single return value cannot satisfy both legacy 2-target unpacking and
Windows char-iteration — both use the iterator protocol with contradictory
results. So gate the wrapper to POSIX: Windows returns a plain str/None
(the historical, subprocess-safe contract). POSIX keeps _UvResult and the
#39780 update-boundary fix.

Tests: list2cmdline canary proving _UvResult breaks Windows, plus Windows
returns-plain-str and POSIX dual-contract coverage.
2026-06-05 07:54:08 -05:00
brooklyn! ca8c78e588 fix(desktop): heal stale runtime-id cache + model on profile switch (#39819)
Two switch-time regressions from the multi-profile rail work:

- "Session not found" (4007): pruneSecondaryGateways idle-reaps a
  non-active profile's backend; switching back respawns a *fresh*
  backend that mints new runtime ids, but runtimeIdByStoredSessionId is
  never pruned. resumeSession's cache fast-path then makes a dead runtime
  id active and returns, so session.usage + the next prompt 404. Probe
  the cached id; on rejection drop the stale mapping and fall through to
  a full resume that rebinds a live id.

- "Forgets the LLM setting": $currentModel is a nanostore set only by
  refreshCurrentModel (gatewayState->open, etc). A swap fires
  invalidateQueries() (react-query only) and keeps the socket 'open', so
  the model/pill kept showing the previous profile. Re-pull both when
  $activeGatewayProfile changes.
2026-06-05 12:52:44 +00:00
brooklyn! 1a3e608524 feat(desktop): per-profile remote gateway hosts (#39778)
* feat(desktop): per-profile remote gateway hosts

Profile switching silently failed whenever the desktop was connected to a
remote backend: the rail routed non-active profiles to a local pool backend,
but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a
remote Hermes backend", and the renderer swallowed the error into an infinite
reconnect backoff while still marking the profile active. Remote was also a
single app-global setting, so there was no way to give a profile its own host.

Add per-profile remote hosts so each profile can point at its own backend:

- connection.json gains a validated `profiles` map; profileRemoteOverride()
  (pure, unit-tested) selects an explicit per-profile remote.
- resolveRemoteBackend(profile) precedence: per-profile override → env override
  → global remote → local spawn. spawnPoolBackend now connects to a profile's
  remote (no local child) instead of throwing; startHermes resolves the primary
  profile's remote.
- coerce/sanitize connection config are scope-aware (global vs named profile)
  and preserve each other's entries; IPC get/save/apply/test thread an optional
  profile. Per-profile apply drops only that profile's pool backend.
- Settings → Gateway adds an "Applies to" scope selector reusing the existing
  URL/token/OAuth/test UX per profile.

Tests: connection-config pure suite (+6) and desktop platform suite pass;
tsc/eslint/vitest clean.

* refactor(desktop): DRY per-profile remote helpers

Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the
main.cjs copy), collapse the scope/auth ternaries, route the env remote through
buildRemoteConnection, and fold the duplicated remote-block validation into
buildRemoteBlock. No behavior change; pure suite + live E2E still green.
2026-06-05 12:14:18 +00:00
brooklyn! db204ae203 fix(update): make ensure_uv() survive the update boundary (no first-run crash) (#39780)
* fix(update): make ensure_uv() survive the update boundary (no first-run crash)

`hermes update` runs the `ensure_uv()` call site from the old, already-imported
`hermes_cli.main` against the *freshly pulled* `managed_uv` (managed_uv is only
ever lazily imported, so it loads from disk post-pull). `ensure_uv()`'s return
arity flipped from a single path string to `(path, fresh_bootstrap)` (4df280d51)
and back to a single string (fb853a178). Installs parked on a 2-tuple release
unpack `uv_bin, fresh_bootstrap = ensure_uv()` against the new single-value
module and crash the first update with
`ValueError: not enough values to unpack (expected 2, got 1)` — inside the
dependency-install step, *before* the PR #39763 subprocess hand-off can run.

Return a `_UvResult` (a `str` subclass) that is usable as the bare path AND
unpackable as `(path|None, fresh_bootstrap)`. Missing uv is `""` (falsy) instead
of `None` so legacy 2-target call sites can unpack a failure without raising,
while `if not uv_bin` keeps working for single-value callers. fresh_bootstrap is
always False (the rebuild-venv path it gated was scrapped in fb853a178).

* docs(update): correct the verified error string + mechanism for ensure_uv()

A hermetic repro (old 2-target call site vs the freshly-pulled single-value
module) shows the first-update crash is exactly the string from PR #39763's
report: `ValueError: too many values to unpack (expected 2)` — not "not enough".
The returned path is a plain `str`, which is iterable, so `uv_bin, fresh =
ensure_uv()` walks its characters; the failure path's `None` return raises
`TypeError: cannot unpack non-iterable NoneType`. Both are fixed by `_UvResult`.
Comment/test wording updated to match; no behavior change.
2026-06-05 07:08:43 -05:00
Tekniumandalt-glitch 72eb42d9ec feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean"

This reverts commit c8e80cd0bf.

* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"

This reverts commit 8a19884bf3.

* chore(install): keep npm ci desktop-build fix after stash revert

The destructive-clean reverts (#38542/#39568) pulled the desktop
workspace install back to bare `npm install`. The npm ci -> npm install
fallback is orthogonal build-correctness (avoids the Windows
workspace-hoisting flake where install reports up-to-date against a
stale marker while node_modules is empty, breaking tsc -b). Preserve it.

* feat(update): settable stash-or-discard for non-interactive local changes

Adds updates.non_interactive_local_changes (stash | discard, default
stash). Governs ONLY non-interactive updates (desktop/chat app, gateway,
--yes) — interactive terminal updates always stash-and-ask, unchanged.

- config.py: new key under existing updates section; _config_version 26->27.
- main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY),
  reads the setting; new _discard_stashed_changes() drops the stash
  (stash-and-drop, never reset --hard/clean -fd, so ignored paths survive).
  Post-pull restore site branches on it; the bail-out and up-to-date
  restores always preserve work.
- web_server.py + apps/desktop settings: exposes it as a stash/discard
  select (Advanced section, In-App Update Local Changes).
- docs + tests (discard drops, stash restores, interactive ignores setting,
  missing section defaults to stash).

* fix(install.ps1): stash/restore instead of reset --hard on Windows update

The PR reverted the destructive update path to stash/restore everywhere
except scripts/install.ps1, whose managed-clone update path still ran
`git reset --hard HEAD` before checkout — silently destroying agent-edited
tracked source on Windows (the same #38542 data-loss class the PR fixes).

- Replace `git reset --hard HEAD` with stash-before-checkout +
  restore-after-checkout, mirroring install.sh. Untracked files are
  included so agent-created dirs (e.g. tinker-atropos/) survive.
- Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made
  the stash necessary; it's also load-bearing for a clean restore).
- Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses
  `git pull --ff-only` so local commits are never clobbered.
- Only prompt to restore when a real console is attached (UserInteractive
  + non-redirected stdin/stdout + ConsoleHost); the desktop Update button
  and bootstrap have no usable console, so they default to restore and
  never hang on Read-Host.
- On restore conflict or a failed update, the stash is preserved with
  recovery instructions — work is never silently dropped.

Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean;
E2E non-conflicting restore applies+drops cleanly with ignored paths
(node_modules) untouched; conflicting restore preserves the stash.

---------

Co-authored-by: alt-glitch <balyan.sid@gmail.com>
2026-06-05 17:30:10 +05:30
Teknium 947e21b3d6 fix(gateway): log silent file-delivery drops (#39767)
When the agent's reply references a deliverable file path that does not
exist on disk, extract_local_files dropped it from native delivery with
no log line — the most common reason a promised file never arrives over
a messaging platform. Add an INFO log at that drop point so the gap is
visible in gateway.log instead of vanishing.

Also convert the two print() calls in Telegram's send_document /
send_video exception handlers to logger.warning(exc_info=True). print()
writes to stdout, which 'hermes logs' never captures, so outbound upload
failures (oversized files, Bot API rejections) were invisible.
2026-06-05 04:50:04 -07:00
Teknium d41427504e feat(delegation): uncap max_spawn_depth (floor 1, no ceiling) (#39772)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* feat(delegation): uncap max_spawn_depth to match max_concurrent_children

Removed the hard ceiling of 3 on delegation.max_spawn_depth. Depth now has
a floor of 1 and no upper limit, mirroring max_concurrent_children. Cost
(each level multiplies API spend) is the practical limiter, not a constant.

- delegate_tool.py: drop _MAX_SPAWN_DEPTH_CAP, _get_max_spawn_depth() floors
  at 1 instead of clamping to [1,3]; depth-limit error string reworded
- config.py / cli-config.yaml.example: doc comments say floor 1, no ceiling
- docs (configuration, delegation, delegation-patterns): range 1-3 -> >=1
- tests: convert clamp-above-3 change-detector into a no-ceiling invariant,
  drop the _MAX_SPAWN_DEPTH_CAP==3 snapshot assert, fix warning-text assert
2026-06-05 04:46:02 -07:00
Teknium 06268f11cc feat(gateway): explain /voice usage when toggled bare (#39766)
A bare /voice silently toggled on/off with a one-line result, leaving
users with no idea what the modes mean or that Discord also supports
TTS-all and live voice-channel join/leave. Bare /voice now still
toggles but appends a usage explainer covering on/off/tts/status, with
the Discord voice-channel lines shown only on adapters that support
them.

Adds gateway.voice.help + gateway.voice.help_channels across all 16
locales (placeholders {toggle}/{channels}).
2026-06-05 04:21:13 -07:00
Frowtek 3cd1bd971f fix(cli): require Chromium for local browser readiness in setup/status surfaces 2026-06-05 04:06:17 -07:00
Teknium ec46f5912e fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints (#39730)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints

Two distinct failures hit users on the gemini provider with only Google
AI Studio keys set.

1. Truncation loop: build_gemini_request() only set maxOutputTokens when
   max_tokens was non-None. Hermes passes None to mean "unlimited", but
   Gemini's native generateContent does NOT treat an absent maxOutputTokens
   as full budget — it applies a low internal default and stops early with
   finishReason=MAX_TOKENS, truncating tool calls. The agent then retries
   3x and refuses the incomplete call. Now default to the published 65,535
   ceiling (shared by all current Gemini text models) when max_tokens=None.

2. HTTP 400 on Gemini endpoint: the chat_completions transport assembles
   profile extra_body (Nous portal 'tags', reasoning, provider prefs) and
   sends it via the OpenAI client to whatever base_url is resolved. When a
   profile that emits extra_body (e.g. Nous) is active but the endpoint is a
   native Gemini base_url — typical when only Google creds exist and a
   fallback/aux call lands on Gemini — Google rejects the unknown 'tags'
   field with a non-retryable 400. Strip all non-thinking_config extra_body
   keys when the resolved endpoint is native Gemini.

Verified E2E against real transport code: tags stripped on native Gemini,
preserved on Nous and the /openai compat endpoint; maxOutputTokens=65535
on None, explicit values respected.
2026-06-05 03:53:59 -07:00
Shannon SandsandTeknium 6bf55a473e Add CLI Telegram QR onboarding
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-05 03:20:10 -07:00
Teknium 8a9ded5b21 feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS (#39659)
* feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS

Discord voice mode can now feel conversational: the bot speaks a short
acknowledgement before it starts working, and a subtle ambient 'thinking' bed
plays underneath while tools run, ducking under speech and swelling back — the
Grok-voice-mode feel.

discord.py plays only one audio stream per voice connection, so this adds a
software mixer (VoiceMixer, a discord.AudioSource) installed once per guild on
join. It sums an ambient loop, verbal acks, and TTS replies into that single
20ms/48kHz/stereo stream (numpy int16 add + clip), so they overlap instead of
stop-and-swap. Speech ducks the ambient gain down and releases it smoothly.

- plugins/platforms/discord/voice_mixer.py: VoiceMixer + MixerChild (gain,
  loop, fade, duck/release), decode_to_pcm (ffmpeg), synth_ambient_pcm (no
  asset needed — synthesised pad).
- adapter: install mixer on join, tear down on leave, route
  play_in_voice_channel through the mixer (legacy one-shot path kept as
  fallback), play_ack_in_voice, voice_mixer_active. Defensive getattr for the
  object.__new__ test helpers.
- gateway/run.py: tool_start_callback fires a one-time verbal ack on the first
  tool call of a turn when in a voice channel (independent of the text
  tool-progress gate). No system-prompt or message-flow changes.
- config: discord.voice_fx.* (OFF by default; ambient/duck/speech gains, ack
  phrases). All in config.yaml, not .env.
- docs + tests (mixer unit + adapter integration).

Verified: 19 new tests pass, existing voice suite green (2 pre-existing
davey-module env failures unchanged), and a real-mixer E2E confirms ambient
streams, TTS overlaps it, acks layer in, and teardown is clean.

* fix(discord): make voice mixer numpy import lazy (numpy is voice-extra-only)

numpy ships in the optional 'voice' extra, not [all,dev], so a module-level
'import numpy' broke CI test collection (and would break the always-imported
Discord adapter on any install without the voice extra). Defer numpy to the
functions that actually mix audio via _require_numpy(); guard the test module
with pytest.importorskip('numpy').
2026-06-05 03:10:40 -07:00
teknium1 3da44dbda7 fix(models): use deepseek-v4-flash as Nous silent default
Follow-up on the salvaged fix: point the Nous silent-default override at
deepseek/deepseek-v4-flash (a cheap chat model) instead of the nvidia
nemotron entry. Keeps the no-model-configured fallback off the priciest
flagship while landing on a low-cost, broadly-capable default.
2026-06-05 02:54:34 -07:00
xxxigm ef5e48f3fd test(models): guard Nous silent default against expensive-flagship escalation
Assert get_default_model_for_provider("nous") never returns the priciest
catalog entry (anthropic/claude-opus-4.8) and that an override pointing at a
model absent from the catalog falls back to catalog order. Regression for the
silent flagship-billing footgun.
2026-06-05 02:54:34 -07:00
xxxigm 2a82519b0d fix(models): don't silently default Nous to the most expensive flagship
When a provider is configured but no model is selected (e.g. a profile sets
provider: nous with no model), the gateway/CLI fall back to
get_default_model_for_provider(), which returned the first curated catalog
entry. The Nous Portal list is ordered most-capable-first, so entry [0] is
anthropic/claude-opus-4.8 — the single most expensive model ($5/$25 per Mtok).
A misconfigured profile therefore silently routed every call to the flagship
and billed it for traffic the user never opted into.

Pin the silent (non-interactive) default for metered aggregators to the cheapest
curated tier via _PROVIDER_SILENT_DEFAULT_OVERRIDES so a missing model can never
auto-escalate to the flagship. The interactive default (GUI onboarding /
`hermes model`) keeps using the richer free/paid-tier-aware resolver.

Fixes the unexpected anthropic/claude-opus-4.8 charges reported for a
free-tier Nous account whose new profile had no default model.
2026-06-05 02:54:34 -07:00
teknium1 397d492b3e chore(release): map harjoth.khara@gmail.com → harjothkhara for #38550 salvage 2026-06-05 02:54:32 -07:00
harjoth b459bac02c fix(cli): gitignore Desktop bootstrap marker so hermes update stops autostashing it
The Desktop bootstrap installer writes `.hermes-bootstrap-complete` into the
managed git checkout root. Because it wasn't gitignored, `hermes update`'s
`git stash push --include-untracked` treated it as a local change and created an
autostash on every run — prompting the user to restore "local changes" that were
really Hermes-managed runtime state (and risking the marker getting stranded in a
stash, which re-triggers Desktop bootstrap).

Add the marker to .gitignore; `git stash -u` and `git status --porcelain` both
skip ignored files, so the updater now sees a clean tree.

Fixes #38529
2026-06-05 02:54:32 -07:00
Coy Geek 3278b423d5 fix(dashboard): strip session token from subprocess env
Add HERMES_DASHBOARD_SESSION_TOKEN to the Hermes-managed subprocess environment blocklist so dashboard authorization material does not propagate into shell, PTY, or background process launches.

Extend the local environment blocklist regression coverage to prove the dashboard session token is stripped like other Hermes-managed secrets.
2026-06-05 02:31:19 -07:00
Ben Barclay 9ab9c923da docs(dashboard): clarify auth provider suitability + registration across dashboard/Docker/Desktop docs (#39633)
* docs(dashboard): clarify auth provider suitability + document dashboard registration

- Add a 'Registering a dashboard' subsection under the Nous Research
  provider covering both the 'hermes dashboard register' CLI command
  and the Portal /local-dashboards GUI page.
- Note that the Nous provider is the one suitable for public-internet
  exposure (logins verified against your Nous account).
- Add a warning that the username/password provider is for trusted
  networks / VPN only and is not suitable for direct public-internet
  exposure; point readers to the Nous / OIDC / custom OAuth providers.
- Surface the same distinction in the two-provider intro list.

* docs(dashboard): count three bundled auth providers, add self-hosted OIDC to intro

'Two providers ship in the box' undercounted — the bundled
plugins/dashboard_auth/self_hosted (generic OpenID Connect) is a third.
List all three in the gated-mode intro and link each to its section.

* docs(dashboard): extend auth provider updates to Docker and Desktop pages

- docker.md: list all three bundled gate providers (was username/password
  + OAuth only), adding the self-hosted OIDC provider and its env vars,
  and note username/password is not for public-internet exposure.
- desktop.md: reframe the remote-backend connection so OAuth (Nous Portal)
  is the preferred option for any backend reachable beyond the local
  machine, with username/password positioned for local / trusted-network
  use only. Cover the 'Sign in with <provider>' OAuth flow in the in-app
  steps and scope the VPN warning to the password path.

* docs(dashboard): align env-var, CLI, and remote-Desktop recipe with provider changes

- environment-variables.md: reframe the Web Dashboard & Hermes Desktop
  intro (OAuth preferred for remote/public, username/password for
  trusted networks), add the self-hosted OIDC env vars
  (HERMES_DASHBOARD_OIDC_*) that were missing from the table, and note
  hermes dashboard register provisions the OAuth client_id.
- cli-commands.md: document the 'hermes dashboard register' subcommand
  (flags, behavior, /local-dashboards GUI alternative).
- web-dashboard.md: apply the OAuth-preferred reframe to the bottom
  'Connecting Hermes Desktop to a remote backend' recipe and scope its
  VPN warning to the username/password path, matching desktop.md.

* docs(dashboard): move 'recommended remote Desktop path' framing from username/password to OAuth

The gated-mode intro list claimed the username/password provider was the
recommended path for a remote Hermes Desktop connection, contradicting the
OAuth-preferred framing established elsewhere. Move that recommendation onto
the OAuth (Nous Portal) item so the docs are consistent: OAuth is the
recommended provider for any remote/internet-facing backend; username/password
is for trusted networks only.

* docs(dashboard): drop unreleased managed/hosted-install provisioning notes

Remove the 'not available in managed/hosted installs, where the client id is
provisioned by the hosting platform' line from the dashboard register docs
(web-dashboard.md, cli-commands.md) and the 'provisioned by the Nous Portal for
hosted deploys' clause from the HERMES_DASHBOARD_OAUTH_CLIENT_ID env-var row —
that platform-provisioning path is unreleased.

* docs(dashboard): drop --portal-url / HERMES_DASHBOARD_PORTAL_URL from user docs

The portal-URL override targets a non-production Nous Portal and only works
for internal Nous usage — it won't function for end users (the access token
must be issued by the same portal). Remove it from the register CLI flags,
the Nous-provider config/env tables, and the verify-the-gate example so users
aren't pointed at an option that can't work for them.

* docs(dashboard): add worked examples for Nous and username/password providers

The self-hosted OIDC provider already had a full 'Worked example: Keycloak'
walkthrough; the Nous and username/password providers only had scattered
config snippets. Add parallel '#### Worked example' sections for both
(register/run/login + /api/status verification), mirroring the Keycloak
example's structure so all three bundled providers read consistently.

* docs(env): move HERMES_DESKTOP_REMOTE_URL to end of the dashboard auth table

It was sitting between the HERMES_DASHBOARD_BASIC_AUTH_* block and the
HERMES_DASHBOARD_OAUTH/OIDC block, splitting the dashboard-side vars. As the
only desktop-side var in the table, it belongs at the end so the dashboard
provider vars (basic, OAuth, OIDC) stay grouped together.

* docs(dashboard): remove Fly.io references from dashboard auth docs

Fly.io is the internal hosting implementation for hosted Hermes — it shouldn't
leak into user-facing dashboard auth docs. Reword the OAuth provider intro,
the env-var-path rationale, the public-URL-override section, the cookie Secure
note, and the verify-the-gate example to generic 'hosting platform' / 'reverse
proxy' / 'TLS terminator' phrasing.

Left the legitimate user-facing Fly.io mentions in telegram.md (a deliberate
cloud-deployment walkthrough) and work-with-skills.md (a generic example)
untouched.
2026-06-05 18:34:19 +10:00
AceanandTeknium b0d234f068 fix(cron): don't crash on cron list when a job's repeat is null
`cron_list` read `job.get("repeat", {})`, but the dict-default only
applies to a MISSING key. A one-shot job persisted with `"repeat": null`
returns None, and the next `.get("times")` raised AttributeError, taking
down the whole `cron list` output. Coalesce with `or {}` so a
present-but-null repeat renders as ∞ like the other cron readers already
do. Adds a regression test.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-05 00:19:45 -07:00
helix4u c8e80cd0bf fix(update): require managed marker before destructive clean 2026-06-05 00:05:30 -07:00
Baris SencanandTeknium ad69d3edc7 fix(terminal): guard os.getcwd() against a deleted CWD
`os.getcwd()` raises FileNotFoundError when the process's working
directory was removed out from under it (e.g. a scratch workspace
cleaned up mid-session), crashing terminal env setup.

Extract a `_safe_getcwd()` helper that falls back to TERMINAL_CWD, then
the user's home, on FileNotFoundError, and route all three `os.getcwd()`
call sites in terminal_tool.py through it (local default_cwd, the Docker
cwd-passthrough source, and the debug-config print) so the same crash
can't resurface at a sibling site. Adds unit tests for the real-cwd path
and both fallback branches.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-04 23:39:34 -07:00
Ben Barclay b1e399de95 fix(update-check): stop reporting phantom "N commits behind" inside Docker (#39559)
Inside the published Docker image, both the `--tui` banner and the
dashboard-embedded TUI report `1 commit behind — run docker pull
nousresearch/hermes-agent:latest to update` even though the container
has no git repo and no way to compute a commit delta.

Root cause: two independent update-detection paths, only one of which
knows it's running in Docker.

- `recommended_update_command()` → `detect_install_method()` reads the
  `.install_method` stamp that `docker/stage2-hook.sh` writes at boot →
  returns "docker", so the *command string* correctly says `docker pull`.
- `banner.check_for_updates()` (the source of the "N commits behind"
  *count*) has no notion of the docker install method. It only detects a
  build via `HERMES_REVISION` (nix-only, unset in the image) or a `.git`
  dir (excluded from the image by .dockerignore). Neither matches, so it
  silently falls through to `check_via_pypi()`, whose PyPI-version
  mismatch flag (1) is then rendered verbatim by the CLI banner
  (build_welcome_banner), the Ink TUI badge (branding.tsx), and `hermes
  version` as "1 commit behind" — a phantom count, no commit math
  involved. `hermes update` already refuses to run in-place in the
  container.

The dashboard's REST `/api/hermes/update/check` endpoint already
short-circuits docker (returns behind=None + the docker guidance). This
mirrors that guard inside `check_for_updates()` so the banner/TUI/version
surfaces agree: when `detect_install_method() == "docker"`, return None
before any git/pypi probe (and before writing a cache entry). None makes
the render guards (`typeof === 'number' && > 0`, `behind and behind > 0`)
stay false, so the badge/line disappears entirely — matching the System
page.

Fix is in one place (check_for_updates) because all three consumers route
through it via get_update_result()/_update_result.

Tests: test_check_for_updates_docker_returns_none asserts None + no
git/pypi probe + no cache write; test_check_for_updates_non_docker_still_checks
guards against over-broadening (pip still version-checks). Mutation-tested:
removing the guard fails the docker test.

Verified against a real `docker build` of the image — see PR description.
2026-06-05 15:37:19 +10:00
Ben 439f53cab8 fix(desktop): gate OAuth remote connect on AT-or-RT, not access token alone
The desktop OAuth remote-gateway path gated connectivity on
hasOauthSessionCookie(), which checks only the access-token cookie
(hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age
lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend()
and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing
a full IDP re-login every ~15 min — even though a valid 24h refresh-token
cookie (hermes_session_rt) was sitting in the same jar.

The desktop OAuth code (2026-06-04) was written against the obsolete
"contract v1 issues no refresh token" model, two days after #37247
re-introduced server-side transparent refresh: Portal now issues a 24h
rotating, reuse-detected refresh token, and the gateway middleware
(_attempt_refresh) rotates a fresh AT from the RT on the next
authenticated request. So an expired-AT/live-RT session is fully
connectable — the desktop just never let the request through.

Fix:
- connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession()
  (true when EITHER a live AT or RT cookie is present). Keep
  cookiesHaveSession() AT-only for callers that need that specific signal.
- main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth
  branch now early-outs only when NEITHER cookie is present, otherwise
  uses the ws-ticket mint as the authoritative liveness probe (that POST
  carries the RT cookie and triggers the server-side AT rotation). A real
  401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout
  report against the same AT-or-RT notion.
- Remove the stale "contract v1 / NO refresh token" docstrings in
  cookies.py and the verify_session comments in the Nous provider that
  contradicted #37247.

Tests: +57 lines in connection-config.test.cjs covering the RT-only
"still connectable" case. node --test: 32/32. dashboard-auth +
nous-provider Python suites: 223/223.

Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/)
are comment/docstring-only here, but this touches outside apps/desktop/ so
it needs Teknium review.
2026-06-04 22:18:46 -07:00
Brian DohertyandTeknium 899ee8c23d fix(gateway): tolerate non-UTF-8 status/pid files in gateway status reads
`_read_json_file` caught OSError but not UnicodeDecodeError, so a status
file holding binary/non-UTF-8 bytes (truncated or clobbered write) would
crash the gateway status path instead of being treated as unreadable.
UnicodeDecodeError is a ValueError subclass, not an OSError, so it
escaped the existing guard.

Widen the catch to (OSError, UnicodeDecodeError) at both read sites in
gateway/status.py — `_read_json_file` and the sibling `_read_pid_record`,
which had the identical gap. Adds tests covering binary input (returns
None) and valid input (still parses) for both.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-04 22:05:23 -07:00
TekniumandSahibzada Allahyar 7309f3bef7 fix(line): map inbound message types to the correct MessageType
The LINE adapter classified every non-text inbound message as
`MessageType.IMAGE`, which doesn't exist on the enum — so any image,
video, audio, file, sticker, or location message raised AttributeError
the moment it was constructed.

Beyond fixing the crash, every non-text message was being collapsed onto
a single type. The gateway routes on MessageType (voice → STT, files →
document handling, etc.), so misclassification silently mishandled media.
Replace the inline ternary with a `_LINE_MESSAGE_TYPES` lookup that maps
each LINE webhook type to its proper enum member (audio → VOICE to match
how Telegram/WhatsApp treat voice notes), falling back to TEXT for
unknown types. Adds regression tests covering the mapping and the old
AttributeError.

Co-authored-by: Sahibzada Allahyar <94376830+sahibzada-allahyar@users.noreply.github.com>
2026-06-04 21:55:20 -07:00
Ben 736dc0fd86 fix(nix): use fetchNpmDeps hash for npmDepsHash, not prefetch-npm-deps
The previous fix committed the hash from `prefetch-npm-deps`
(sha256-hgnqc...), but the actual `fetchNpmDeps` FOD (fetcherVersion 2)
that `nix flake check` builds wants sha256-cY+gM... . These two tools
disagree for this lockfile, so the build's npm-deps derivation failed
with a hash mismatch even though `fix-lockfiles --check` reported "ok".

Corrected to the build-verified value. Confirmed `nix build .#tui`,
`.#web`, and `.#desktop` all build cleanly with the new hash.
2026-06-04 21:30:23 -07:00
teknium1 6b77fd2a0f fix(nix): bump npmDepsHash for react-router 7.17.0 lockfile
The react-router-dom 7.14->7.17 lockfile change stales the pinned
npm-deps hash in nix/lib.nix, turning the nix flake checks red. Bump
to the hash CI's prefetch diagnostic computed for the new lockfile.
2026-06-04 21:30:23 -07:00
Ben 46c16b9288 fix(deps): bump react-router-dom to 7.17.0 (GHSA-8x6r-g9mw-2r78)
Clears the npm-audit React Router advisory CVE-2026-42342 in the web
and apps/desktop workspaces by bumping react-router-dom 7.14.x -> ^7.17.0
(patched in 7.15.0; both react-router and react-router-dom now resolve
to 7.17.0 in the root lockfile).

Note: the advisory's DoS only affects React Router *Framework Mode*
(the __manifest server endpoint). Both workspaces use Declarative Mode
(web: <BrowserRouter>, desktop: <HashRouter>) as pure client-side SPAs,
so we were never actually exploitable -- this is audit-hygiene only.

npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds
and tsc typecheck all green on 7.17.0.
2026-06-04 21:30:23 -07:00
liuhao1024 391b594752 fix(cli): use Rich [dim] tag instead of ANSI escape in _restore_session_cwd
Replace [{_DIM}] with [dim] in all _restore_session_cwd and
_preload_resumed_session messages that go through _console_print (Rich
Console.print).  _DIM is an ANSI escape (\x1b[2;3m) that Rich cannot
parse as a markup tag, causing MarkupError on session resume when the
stored cwd is missing or inaccessible.

Also uses [/dim] closing tag for explicit tag matching.

Fixes #39469
2026-06-05 10:00:21 +08:00
198 changed files with 13172 additions and 2364 deletions
+6
View File
@@ -108,6 +108,12 @@ docs/superpowers/*
# logs, and per-session caches are never artifacts of the codebase.
.hermes/
# Desktop/bootstrap install marker written into the managed checkout root by the
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.15.1",
"version": "0.16.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.15.1",
"package": "hermes-agent[acp]==0.16.0",
"args": ["hermes-acp"]
}
}
+55
View File
@@ -2720,6 +2720,61 @@ def run_conversation(
# compress history and retry, not abort immediately.
status_code = getattr(api_error, "status_code", None)
# ── Respect disabled auto-compaction on overflow ──────
# Ported from anomalyco/opencode#30749. When the user has
# turned auto-compaction off (``compression.enabled: false``),
# NO automatic compaction trigger may fire — including the
# provider/request-size overflow recovery paths below
# (long-context-tier 429, 413 payload-too-large, and
# context-overflow). Without this guard the proactive
# threshold path correctly honours the setting (see the
# preflight check and the post-response ``should_compress``
# gate) but a provider overflow error would still silently
# compress + rotate the session, bypassing the user's
# explicit choice. Surface a terminal error instead so the
# user can compact manually (``/compress``), start fresh
# (``/new``), switch to a larger-context model, or reduce
# attachments. Forced compaction via ``/compress``
# (``force=True``) is unaffected — it never reaches this loop.
_overflow_reasons = {
FailoverReason.long_context_tier,
FailoverReason.payload_too_large,
FailoverReason.context_overflow,
}
if (
classified.reason in _overflow_reasons
and not getattr(agent, "compression_enabled", True)
):
agent._flush_status_buffer()
agent._vprint(
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
f"(compression.enabled: false).",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
f"switch to a larger-context model, or reduce attachments.",
force=True,
)
logger.error(
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
f"auto-compaction disabled — not compressing."
)
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": (
"Context overflow and auto-compaction is disabled "
"(compression.enabled: false). Run /compress to compact manually, "
"/new to start fresh, or switch to a larger-context model."
),
"partial": True,
"failed": True,
"compaction_disabled": True,
}
# ── Anthropic Sonnet long-context tier gate ───────────
# Anthropic returns HTTP 429 "Extra usage is required for
# long context requests" when a Claude Max (or similar)
+19
View File
@@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
# passes max_tokens=None, because Gemini's native API otherwise applies a low
# internal default and truncates output (unlike OpenAI-compat endpoints where
# an omitted limit means full budget).
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
@@ -414,6 +421,18 @@ def build_gemini_request(
generation_config["temperature"] = temperature
if max_tokens is not None:
generation_config["maxOutputTokens"] = max_tokens
else:
# Gemini's native generateContent does NOT treat an omitted
# maxOutputTokens as "use the model's full output budget" — it applies
# a low internal default and the model stops early with
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
# then retries 3× and refuses the incomplete call). Every current
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
# 65,535 output tokens, so default to that ceiling when the caller
# passes None ("unlimited"). See the OpenAI-compat path where omitting
# the field genuinely means full budget — that assumption does not
# hold on the native API.
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
if top_p is not None:
generation_config["topP"] = top_p
if stop:
+22 -1
View File
@@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs[k] = v
if extra_body:
api_kwargs["extra_body"] = extra_body
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
# keys (tags, reasoning, provider, plugins, …) are unknown fields
# there and Gemini rejects the whole request with a non-retryable
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
# This happens when a profile that emits extra_body (e.g. the Nous
# profile's portal `tags`) is active but the resolved endpoint is a
# Gemini base_url — typical when only Google credentials are set and
# a fallback/aux call lands on Gemini. The native client only reads
# thinking_config from extra_body, so drop everything else here.
try:
from agent.gemini_native_adapter import is_native_gemini_base_url
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
except Exception:
_native_gemini = False
if _native_gemini:
extra_body = {
k: v for k, v in extra_body.items()
if k in ("thinking_config", "thinkingConfig")
}
if extra_body:
api_kwargs["extra_body"] = extra_body
return api_kwargs
@@ -17,6 +17,8 @@
//! the bootstrap-complete check.
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::process::Command;
use tracing_appender::non_blocking::WorkerGuard;
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
@@ -103,10 +105,37 @@ pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
repair_macos_installer_helper(&dest);
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
Ok(())
}
#[cfg(target_os = "macos")]
fn repair_macos_installer_helper(path: &Path) {
// The staged helper may inherit quarantine from the downloaded installer.
// Desktop later launches this exact file for in-app updates, so make it
// executable before the update handoff reaches LaunchServices/Gatekeeper.
let _ = Command::new("/usr/bin/xattr")
.args(["-cr"])
.arg(path)
.status();
let verify = Command::new("/usr/bin/codesign")
.arg("--verify")
.arg(path)
.status();
if !matches!(verify, Ok(status) if status.success()) {
let _ = Command::new("/usr/bin/codesign")
.args(["--force", "--sign", "-"])
.arg(path)
.status();
}
}
#[cfg(not(target_os = "macos"))]
fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
@@ -171,12 +171,19 @@ async fn run_update(app: AppHandle) -> Result<()> {
let child_env = update_child_env(&install_root);
let mut update_args: Vec<String> =
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
update_args.push("--branch".into());
update_args.push(update_branch);
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
let update = run_streamed(
let mut update = run_streamed(
&app,
&hermes,
&update_args,
@@ -185,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("update"),
)
.await?;
// Retry-once for the update-boundary crash. `hermes update` lazily imports
// the FRESHLY PULLED modules, but the dependency-install step still runs the
// already-in-memory pre-pull code for one invocation. A release that changed
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
// whose `__iter__` injected a bool into the argv and crashed Windows
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
// bool found`, fixed in #39820) therefore kills the FIRST update on the
// parked population — even though the fix is already on disk by then. A
// second `hermes update` runs clean because the now-current module is loaded
// from the start. Rather than make the parked user click Update twice (and
// stare at a scary crash first), retry once automatically. Skip the retry
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
// a retry can't fix.
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
emit_log(
&app,
Some("update"),
LogStream::Stdout,
"[update] first update attempt failed; retrying once (the fix it just \
pulled loads on the second run)…",
);
update = run_streamed(
&app,
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
}
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {
@@ -366,18 +405,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] timed out waiting for Hermes to exit; proceeding anyway",
"[update] Hermes still holding the venv shim; force-killing stragglers…",
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
);
}
return;
}
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
}
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
/// ne <self>` also spares this Tauri process, though it isn't named
/// hermes.exe.)
fn force_kill_other_hermes() {
if !cfg!(target_os = "windows") {
return;
}
#[cfg(target_os = "windows")]
{
let my_pid = std::process::id();
// /FI excludes our own PID; /T kills the tree; /F forces.
let _ = std::process::Command::new("taskkill")
.args([
"/F",
"/T",
"/IM",
"hermes.exe",
"/FI",
&format!("PID ne {my_pid}"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Best-effort lock probe: try to open the file for read+write. On Windows an
/// exclusively-held running .exe refuses the open with a sharing violation.
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
-6
View File
@@ -24,12 +24,6 @@
### Install with Hermes (recommended)
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
```
Already have the Hermes CLI? Just run:
```bash
+84 -2
View File
@@ -18,11 +18,24 @@
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// Bare + prefixed variants of the session cookies 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.
//
// Two cookies are in play (see that module):
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
// instant the AT expires.
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
// has lapsed but the RT cookie is still present, the gateway middleware
// transparently rotates a fresh AT on the next authenticated request
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
// AT cookie. A liveness check that looked only at the AT cookie would
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@@ -118,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
return buildGatewayWsUrl(baseUrl, token)
}
// Normalize a profile name to a connection scope key, or null for the global
// (default) connection. Shared by the resolver and the IPC layer.
function connectionScopeKey(profile) {
return String(profile ?? '').trim() || null
}
// Coerce a remote auth mode to one of the two supported values ('token' default).
function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -150,22 +198,56 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* 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).
*
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
* check. `cookiesHaveSession` remains exported for callers that specifically
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
/**
* True if the cookie jar holds a credential that can yield an authenticated
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
* transparently rotates a fresh AT from the RT on the next authenticated
* request. Gating connectivity on the AT alone would force a full IDP
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
*
* This answers "should we even attempt to connect / show as signed in?", not
* "is the access token unexpired?". The authoritative liveness check is still
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -15,16 +15,81 @@ const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- connectionScopeKey / normAuthMode ---
test('connectionScopeKey trims to a name or null for the global scope', () => {
assert.equal(connectionScopeKey(' coder '), 'coder')
assert.equal(connectionScopeKey(''), null)
assert.equal(connectionScopeKey(null), null)
assert.equal(connectionScopeKey(undefined), null)
})
test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('oauth'), 'oauth')
assert.equal(normAuthMode('token'), 'token')
assert.equal(normAuthMode(undefined), 'token')
assert.equal(normAuthMode('weird'), 'token')
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, ''), null)
assert.equal(profileRemoteOverride(config, null), null)
assert.equal(profileRemoteOverride(config, undefined), null)
})
test('profileRemoteOverride returns null when the profile has no entry', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, 'writer'), null)
})
test('profileRemoteOverride ignores local or url-less profile entries', () => {
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
})
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
const config = {
profiles: {
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
token: { value: 'sek' }
})
})
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
@@ -131,7 +196,10 @@ test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
// is NOT an access token, so this returns false. Connectivity callers must
// use cookiesHaveLiveSession instead (see below).
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
@@ -146,6 +214,56 @@ 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'])
})
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
})
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
// but the 24h RT cookie is still alive. The session is still connectable —
// the gateway rotates a fresh AT from the RT on the next request.
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: 'a' },
{ name: 'hermes_session_rt', value: 'r' }
]),
true
)
})
test('cookiesHaveLiveSession is false for empty values', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: '' },
{ name: 'hermes_session_rt', value: '' }
]),
false
)
})
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHaveLiveSession(null), false)
assert.equal(cookiesHaveLiveSession(undefined), false)
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
+565 -96
View File
@@ -28,12 +28,17 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -1309,6 +1314,136 @@ function resolveUpdaterBinary() {
return fileExists(candidate) ? candidate : null
}
function repairMacUpdaterHelper(updater) {
if (!IS_MAC || !updater) return
try {
execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' })
} catch (err) {
rememberLog(`[updates] macOS updater helper quarantine repair skipped: ${err.message}`)
}
try {
execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' })
return
} catch {
// Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper
// does not block the staged updater before it can run.
}
try {
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', updater], { stdio: 'ignore' })
rememberLog('[updates] repaired macOS updater helper signature')
} catch (err) {
rememberLog(`[updates] macOS updater helper signature repair skipped: ${err.message}`)
}
}
// Path to the venv shim whose lock decides whether `hermes update` can write
// fresh entry points. On Windows this is the file the running backend
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
function venvHermesShimPath(updateRoot) {
return IS_WINDOWS
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
: path.join(updateRoot, 'venv', 'bin', 'hermes')
}
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
// this practically always succeeds (no mandatory locking), so it returns false
// — correct, since the shim-contention brick is Windows-only.
function isShimLocked(shimPath) {
if (!IS_WINDOWS) return false
let fd
try {
fd = fs.openSync(shimPath, 'r+')
return false
} catch (err) {
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
// EACCES) on Windows means a live handle holds it.
return err && err.code !== 'ENOENT'
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd)
} catch {
void 0
}
}
}
}
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
// only signals the direct child, so on Windows a backend `hermes.exe` that
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
// the whole tree synchronously. Windows-only: this is called solely from the
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
}
}
// Before handing off the update on Windows, the desktop MUST stop every backend
// it spawned and WAIT for the venv shim to actually unlock. The old code did
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
// rename failed, uv's `pip install` hit "Access is denied", and the git path
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
// pool backends and poll the shim until it's writable (or a bounded timeout),
// so by the time we spawn the updater the lock is genuinely gone.
//
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
// aggressively SIGKILL-ing the backend here would be an untested behavior change
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
async function releaseBackendLockForUpdate(updateRoot) {
if (!IS_WINDOWS) return { unlocked: true }
// Collect every backend PID the desktop owns: primary window backend + pool.
const pids = []
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
for (const entry of backendPool.values()) {
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
}
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
if (hermesProcess && !hermesProcess.killed) {
try {
hermesProcess.kill('SIGTERM')
} catch {
void 0
}
}
stopAllPoolBackends()
for (const pid of pids) forceKillProcessTree(pid)
const shim = venvHermesShimPath(updateRoot)
const deadlineMs = Date.now() + 15000
while (Date.now() < deadlineMs) {
if (!isShimLocked(shim)) {
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
return { unlocked: true }
}
await new Promise(r => setTimeout(r, 300))
}
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
// line of defense, and we pass --force so the guard won't dead-end. Log it.
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
return { unlocked: false }
}
// applyUpdates — hand off to the installer's --update flow, then exit.
//
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
@@ -1364,6 +1499,7 @@ async function applyUpdates(opts = {}) {
}
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
repairMacUpdaterHelper(updater)
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
@@ -1375,6 +1511,12 @@ async function applyUpdates(opts = {}) {
}
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
// spawn the updater. Without this the updater races a still-locked
// hermes.exe (held by the backend child / its grandchildren) and the update
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
await releaseBackendLockForUpdate(updateRoot)
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
const child = spawn(updater, updaterArgs, {
@@ -3167,8 +3309,16 @@ function installMediaPermissions() {
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
// path is unconditionally rejected by gated gateways.
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
// a ~15-min TTL. On 401 we must re-run the login round trip.
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
// is still alive, the gateway middleware transparently rotates a fresh AT
// on the next authenticated request — so connectivity must NOT be gated on
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
// (which triggers that server-side refresh) and treat a real 401 as
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
// "is the user signed in at all?" gate / display signal.
// ---------------------------------------------------------------------------
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
@@ -3179,8 +3329,9 @@ function getOauthSession() {
return oauthSession
}
// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
// Bare + prefixed variants of the session cookies live in
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
// that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
@@ -3201,6 +3352,30 @@ async function hasOauthSessionCookie(baseUrl) {
}
}
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
// "is the user signed in at all?" check: an expired AT with a live RT is still
// a connectable session because the gateway rotates a fresh AT server-side on
// the next authenticated request. Gating on the AT alone forces a needless full
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
async function hasLiveOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return false
const parsed = new URL(baseUrl)
try {
const cookies = await sess.cookies.get({ url: baseUrl })
return cookiesHaveLiveSession(cookies)
} catch {
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHaveLiveSession(cookies)
} catch {
return false
}
}
}
async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return
@@ -3319,7 +3494,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const body = serializeJsonBody(options.body)
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const request = electronNet.request({
@@ -3329,8 +3504,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
useSessionCookies: true,
redirect: 'follow'
})
request.setHeader('Content-Type', 'application/json')
if (body) request.setHeader('Content-Length', String(body.length))
setJsonRequestHeaders(request)
let timedOut = false
const timer = setTimeout(() => {
@@ -3447,6 +3621,38 @@ function decryptDesktopSecret(secret) {
return value
}
// Validate + normalize the per-profile remote overrides map read from disk.
// Drops malformed names/entries and keeps only the recognized fields so a
// hand-edited or stale connection.json can't inject junk into resolution.
function sanitizeConnectionProfiles(raw) {
if (!raw || typeof raw !== 'object') {
return {}
}
const out = {}
for (const [name, entry] of Object.entries(raw)) {
if (!entry || typeof entry !== 'object') {
continue
}
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
cleaned.url = url
}
cleaned.authMode = normAuthMode(entry.authMode)
if (entry.token && typeof entry.token === 'object') {
cleaned.token = entry.token
}
out[name] = cleaned
}
return out
}
function readDesktopConnectionConfig() {
// Check if file changed on disk since last read (e.g. modified by another
// process or an external tool). Our own writes update the cache inline
@@ -3462,7 +3668,7 @@ function readDesktopConnectionConfig() {
return connectionConfigCache
}
let config = { mode: 'local', remote: {} }
let config = { mode: 'local', remote: {}, profiles: {} }
try {
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
@@ -3476,7 +3682,11 @@ function readDesktopConnectionConfig() {
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
// profileRemoteOverride() can resolve them; normalized lazily on save.
profiles: sanitizeConnectionProfiles(parsed.profiles)
}
}
} catch {
@@ -3528,104 +3738,114 @@ function writeActiveDesktopProfile(name) {
return value || null
}
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
const remoteToken = decryptDesktopSecret(config.remote?.token)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
const remoteUrl = String(config.remote?.url || '')
// Sanitize a connection config into the renderer-facing shape. With no
// `profile` this describes the global/default connection (the existing
// behavior); with a `profile` it describes that profile's per-profile remote
// override (or an empty "local/inherit" view when the profile has none).
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
const key = connectionScopeKey(profile)
const scoped = key ? config.profiles?.[key] || null : null
const block = key ? scoped || {} : config.remote || {}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = String(block.url || '')
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
// Display signal: treat a live RT cookie as "connected" even if the AT
// cookie has lapsed — the gateway refreshes the AT on the next request,
// so the session is still usable. The authoritative liveness check is
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
} catch {
remoteOauthConnected = false
}
}
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
mode,
// Echo the scope back so the UI knows which profile (if any) this reflects.
profile: key,
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
// authenticate via the login-window session cookie (verified at connect time in
// resolveRemoteBackend), so only token-auth remotes require a saved token.
function buildRemoteBlock(remoteUrl, authMode, token) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
authMode,
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
const nextToken = incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingBlock.token
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
// OAuth gateways authenticate via the session cookie established by the
// login window, NOT a static token — so no token is required here. The
// cookie presence is verified at connect time (resolveRemoteBackend).
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
throw new Error('Remote gateway session token is required.')
if (key) {
// Per-profile scope: a remote entry pins this profile to its own backend; a
// local entry clears the override so the profile inherits the default.
const profiles = { ...(existing.profiles || {}) }
if (mode === 'remote') {
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
} else {
delete profiles[key]
}
} else if (remoteUrl) {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
}
return { mode, remote: nextRemote }
const nextRemote =
mode === 'remote'
? buildRemoteBlock(remoteUrl, authMode, nextToken)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
// Preserve per-profile overrides when saving the global connection.
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
async function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
return {
baseUrl,
mode: 'remote',
source: 'env',
authMode: 'token',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
}
const config = readDesktopConnectionConfig()
if (config.mode !== 'remote') {
return null
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
// Verify the cookie is present, then mint a single-use WS ticket (the
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
// user needs to (re-)log in via Settings → Gateway.
if (!(await hasOauthSessionCookie(baseUrl))) {
// OAuth gateway: auth comes from the session cookies in the OAuth
// partition. Liveness is NOT "is the access-token cookie present?" —
// Portal issues a 24h rotating refresh token (hermes #37247), and the
// gateway middleware transparently rotates a fresh ~15-min access token
// from it on the next authenticated request. So a session with an expired
// AT cookie but a live RT cookie is still perfectly connectable. We
// early-out only when neither cookie is present, then mint a ws-ticket as
// the authoritative liveness check.
if (!(await hasLiveOauthSession(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
@@ -3649,7 +3869,7 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
@@ -3657,8 +3877,6 @@ async function resolveRemoteBackend() {
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
@@ -3669,13 +3887,92 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'token',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
// 3. global remote (connection.json `mode: 'remote'`)
// A null/empty profile resolves the env/global remote, so legacy callers and
// the connection test (which pass no profile) are unchanged.
async function resolveRemoteBackend(profile) {
const config = readDesktopConnectionConfig()
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
}
// 2. Env override (global, token-auth only).
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
}
// 3. Global remote.
if (config.mode !== 'remote') {
return null
}
const authMode = normAuthMode(config.remote?.authMode)
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
}
// A remote profile's sessions live on its remote host's state.db, not on a local
// file the primary can open — so reads for it must route to the remote backend,
// not the local-disk fast path. These three helpers drive that (see
// interceptSessionReadForRemote).
function profileHasRemoteOverride(profile) {
return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))
}
function configuredRemoteProfileNames() {
const config = readDesktopConnectionConfig()
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
}
// True when the app is in app-global remote mode (Settings → "All profiles" →
// Remote, or the env override): a SINGLE remote backend serves every profile via
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
function globalRemoteActive() {
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return true
}
return readDesktopConnectionConfig().mode === 'remote'
}
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
async function fetchJsonForProfile(profile, path) {
return requestJsonForProfile(profile, path, 'GET')
}
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
async function requestJsonForProfile(profile, path, method, body) {
const conn = await ensureBackend(profile)
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
return conn.authMode === 'oauth'
? fetchJsonViaOauthSession(url, opts)
: fetchJson(url, conn.token, opts)
}
async function probeRemoteAuthMode(rawUrl) {
// Determine how a remote gateway expects callers to authenticate, WITHOUT
// sending any credentials. ``/api/status`` is public on every Hermes
@@ -3743,6 +4040,11 @@ async function probeRemoteAuthMode(rawUrl) {
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
@@ -3750,17 +4052,17 @@ async function testDesktopConnectionConfig(input = {}) {
let baseUrl
let token = null
let authMode = 'token'
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
if (wantRemote && block?.url) {
baseUrl = normalizeRemoteBaseUrl(block.url)
authMode = normAuthMode(block.authMode)
if (authMode !== 'oauth') {
token = decryptDesktopSecret(config.remote.token)
token = decryptDesktopSecret(block.token)
}
} else {
const remote = (await resolveRemoteBackend()) || (await startHermes())
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
authMode = normAuthMode(remote.authMode)
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
@@ -3932,10 +4234,21 @@ function startPoolIdleReaper() {
// local-spawn portion of startHermes() but without the boot-progress UI,
// bootstrap, or remote handling (those belong to the primary backend only).
async function spawnPoolBackend(profile, entry) {
// Remote deployments are single-tenant; profiles only apply to local backends.
const remote = await resolveRemoteBackend()
// A profile may point at its OWN remote backend (connection.json
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
// In either case there is no local child to spawn — we just verify the
// remote is reachable and hand back its connection descriptor. The pool
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
// tolerate.
const remote = await resolveRemoteBackend(profile)
if (remote) {
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
await waitForHermes(remote.baseUrl, remote.token)
return {
...remote,
profile,
logs: hermesLog.slice(-80),
...getWindowState()
}
}
const port = await pickPort()
@@ -4036,7 +4349,9 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = await resolveRemoteBackend()
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@@ -4373,7 +4688,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
@@ -4388,22 +4705,36 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT) so a logout that left any session cookie behind is reflected
// as still-connected rather than silently signed-out.
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
await teardownPrimaryBackendAndWait()
const key = connectionScopeKey(payload?.profile)
mainWindow?.reload()
return sanitizeDesktopConnectionConfig(config)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
// re-resolves against the new remote/local target.
stopPoolBackend(key)
} else {
// Global connection, or the primary profile's connection: re-home the
// window backend by tearing it down and reloading the renderer.
await teardownPrimaryBackendAndWait()
mainWindow?.reload()
}
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
@@ -4431,7 +4762,145 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
return systemPreferences.askForMediaAccess('microphone')
})
// Re-route remote-profile session requests to the owning remote backend. Returns
// `undefined` when not interceptable (caller takes the normal local path), else
// the response. Reads tag the profile as ?profile=<name>; mutations carry it in
// request.profile. Either way, a remote profile's session lives only on its
// remote host, so the request must go there (where it serves its own state.db).
// GET /api/profiles/sessions → splice each remote profile's rows in
// GET /api/sessions/{id}[/messages] → read from remote
// DELETE /api/sessions/{id} → delete on remote
// PATCH /api/sessions/{id} → rename/archive on remote
async function interceptSessionRequestForRemote(request) {
if (typeof request?.path !== 'string') {
return undefined
}
const method = (request.method || 'GET').toUpperCase()
let parsed
try {
parsed = new URL(request.path, 'http://x')
} catch {
return undefined
}
const { pathname, searchParams } = parsed
if (method === 'GET' && pathname === '/api/profiles/sessions') {
const remoteProfiles = configuredRemoteProfileNames()
if (remoteProfiles.length === 0) {
return undefined // no remote profiles → local fast path
}
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
if (requested !== 'all') {
return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined
}
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
// (mutations). Two remote shapes:
// - per-profile override: route to that profile's own remote, sans profile
// param (it serves its own state.db natively).
// - global remote mode: ONE backend serves every profile via ?profile=, so
// route there and KEEP the profile param so it opens the right state.db.
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
const profile = (searchParams.get('profile') || request.profile || '').trim()
if (!profile) {
return undefined
}
if (profileHasRemoteOverride(profile)) {
if (method === 'GET') {
return fetchJsonForProfile(profile, pathname)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
if (body) delete body.profile
return requestJsonForProfile(profile, pathname, method, body)
}
if (globalRemoteActive()) {
// Single global backend: keep ?profile= so it opens the right state.db.
const sep = pathname.includes('?') ? '&' : '?'
const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}`
if (method === 'GET') {
return fetchJsonForProfile(null, path)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile }
return requestJsonForProfile(null, path, method, body)
}
return undefined
}
return undefined
}
const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : [])
// A remote profile's session list, read from its remote host and tagged with the
// desktop-facing profile name (the remote's /api/sessions doesn't know it).
async function remoteSessionList(profile, searchParams) {
const qs = new URLSearchParams(searchParams)
qs.delete('profile') // remote serves its own db; no cross-profile read there
const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`)
for (const s of rowsOf(data)) {
s.profile = profile
s.is_default_profile = false
}
return { ...data, sessions: rowsOf(data) }
}
// Unified list: primary's local aggregate, with each remote profile's stale local
// rows/totals swapped for the remote's real ones, re-sorted by recency and
// re-windowed to the requested page. A dead remote contributes nothing rather
// than breaking the sidebar.
async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
const limit = Math.max(1, Number(searchParams.get('limit')) || 20)
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'
const primary = await ensureBackend(null)
const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
// is correct for this page — mirrors the primary's per-profile over-fetch.
const remoteParams = new URLSearchParams(searchParams)
remoteParams.set('limit', String(limit + offset))
remoteParams.set('offset', '0')
const remoteSet = new Set(remoteProfiles)
const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile))
const profileTotals = { ...(base.profile_totals || {}) }
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
// Swap each remote profile's stale local rows/total for the remote's real ones.
await Promise.all(remoteProfiles.map(async name => {
const list = await remoteSessionList(name, remoteParams).catch(() => null)
if (!list) {
delete profileTotals[name] // dead remote → drop its stale local total too
return
}
const rows = rowsOf(list)
merged.push(...rows)
profileTotals[name] = Number(list.total) || rows.length
total += profileTotals[name]
}))
const recency = s => s?.[order] ?? s?.started_at ?? 0
merged.sort((a, b) => recency(b) - recency(a))
return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals }
}
ipcMain.handle('hermes:api', async (_event, request) => {
// Remote-profile session requests would otherwise hit the local primary off
// each profile's on-disk state.db — fine for local profiles, but a remote
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
// no-op) the moment they run there. Route reads + mutations to the remote.
const rerouted = await interceptSessionRequestForRemote(request)
if (rerouted !== undefined) {
return rerouted
}
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
@@ -0,0 +1,20 @@
/**
* Helpers for Electron net.request calls that ride the OAuth session partition.
*
* Electron's ClientRequest forbids app-set restricted headers such as
* Content-Length. Let Chromium frame the body itself; only set the JSON content
* type here.
*/
function serializeJsonBody(body) {
return body === undefined ? undefined : Buffer.from(JSON.stringify(body))
}
function setJsonRequestHeaders(request) {
request.setHeader('Content-Type', 'application/json')
}
module.exports = {
serializeJsonBody,
setJsonRequestHeaders
}
@@ -0,0 +1,34 @@
/**
* Tests for OAuth-session Electron net.request helpers.
*
* Run with: node --test electron/oauth-net-request.test.cjs
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
test('serializeJsonBody returns undefined for absent bodies', () => {
assert.equal(serializeJsonBody(undefined), undefined)
})
test('serializeJsonBody JSON-encodes request bodies', () => {
const body = serializeJsonBody({ archived: true })
assert.ok(Buffer.isBuffer(body))
assert.equal(body.toString('utf8'), '{"archived":true}')
})
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
const headers = []
const request = {
setHeader(name, value) {
headers.push([name, value])
}
}
setJsonRequestHeaders(request)
assert.deepEqual(headers, [['Content-Type', 'application/json']])
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
})
+1 -1
View File
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
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),
+2 -2
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 electron/connection-config.test.cjs electron/gateway-ws-probe.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 electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -84,7 +84,7 @@
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"shiki": "^4.0.2",
+46 -40
View File
@@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { FadeText } from '@/components/ui/fade-text'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -21,11 +22,11 @@ import { OverlayView } from '../overlays/overlay-view'
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
// same visual vocabulary as the chat tool blocks.
function statusGlyph(status: SubagentStatus): ReactNode {
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
if (status === 'running' || status === 'queued') {
return (
<BrailleSpinner
ariaLabel="Running"
ariaLabel={a.running}
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
spinner="breathe"
/>
@@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode {
}
if (status === 'failed' || status === 'interrupted') {
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
}
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
}
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
@@ -75,6 +76,7 @@ interface AgentsViewProps {
}
export function AgentsView({ onClose }: AgentsViewProps) {
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession)
@@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
return (
<OverlayView
closeLabel="Close agents"
closeLabel={t.agents.close}
contentClassName="px-5 pt-5 pb-4 sm:px-6"
onClose={onClose}
rootClassName="mx-auto max-w-3xl"
>
<header className="mb-3 shrink-0">
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2>
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p>
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
</header>
<SubagentTree tree={tree} />
</OverlayView>
)
}
const fmtDuration = (seconds?: number) => {
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
if (!seconds || seconds <= 0) {
return ''
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`
return a.durationSeconds(seconds.toFixed(1))
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
return `${m}m ${s}s`
return a.durationMinutes(m, s)
}
const fmtTokens = (value?: number) => {
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
if (!value) {
return ''
}
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
}
const fmtAge = (updatedAt: number, nowMs: number) => {
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
if (s < 2) {
return 'now'
return a.ageNow
}
if (s < 60) {
return `${s}s ago`
return a.ageSeconds(s)
}
const m = Math.floor(s / 60)
if (m < 60) {
return `${m}m ago`
return a.ageMinutes(m)
}
return `${Math.floor(m / 60)}h ago`
return a.ageHours(Math.floor(m / 60))
}
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
@@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
interface RootGroup {
id: string
label: string
delegationIndex: number
nodes: SubagentNode[]
taskCount: number
}
@@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
if (node.taskCount > 1) {
n += 1
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
continue
}
groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount })
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
}
return groups
}
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
const { t } = useI18n()
const flat = useMemo(() => flatten(tree), [tree])
const groups = useMemo(() => groupDelegations(tree), [tree])
const [nowMs, setNowMs] = useState(() => Date.now())
@@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
return (
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
When a turn delegates work, child agents stream their progress here.
</p>
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>
)
}
const summary = [
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`,
active > 0 ? `${active} active` : '',
failed > 0 ? `${failed} failed` : '',
tools > 0 ? `${tools} tools` : '',
files > 0 ? `${files} files` : '',
tokens > 0 ? fmtTokens(tokens) : '',
t.agents.agentsCount(flat.length),
active > 0 ? t.agents.activeCount(active) : '',
failed > 0 ? t.agents.failedCount(failed) : '',
tools > 0 ? t.agents.toolsCount(tools) : '',
files > 0 ? t.agents.filesCount(files) : '',
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
cost > 0 ? `$${cost.toFixed(2)}` : ''
].filter(Boolean)
@@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
}
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
const { t } = useI18n()
if (group.nodes.length === 1 && group.taskCount <= 1) {
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
}
@@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
return (
<section className="grid min-w-0 gap-3">
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
<span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
</p>
<div className="grid min-w-0 gap-4">
{group.nodes.map(node => (
@@ -275,6 +279,7 @@ function StreamLine({
parentRunning: boolean
rowKey: string
}) {
const { t } = useI18n()
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
const isMono = entry.kind === 'tool'
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
@@ -286,7 +291,7 @@ function StreamLine({
{entry.text}
{active ? (
<BrailleSpinner
ariaLabel="Streaming"
ariaLabel={t.agents.streaming}
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
spinner="breathe"
/>
@@ -297,6 +302,7 @@ function StreamLine({
}
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
const { t } = useI18n()
const running = node.status === 'running' || node.status === 'queued'
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
@@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
const subtitle = [
node.model,
fmtDuration(durationSeconds),
node.toolCount ? `${node.toolCount} tools` : '',
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)),
`updated ${fmtAge(node.updatedAt, nowMs)}`
fmtDuration(durationSeconds, t.agents),
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
].filter(Boolean)
return (
@@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
onClick={() => setOpen(v => !v)}
type="button"
>
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span>
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn(
@@ -366,7 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p>
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
{fileLines.slice(0, 8).map(line => (
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
{line}
@@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
))}
{fileLines.length > 8 ? (
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
+{fileLines.length - 8} more files
{t.agents.moreFiles(fileLines.length - 8)}
</p>
) : null}
</div>
+63 -31
View File
@@ -5,6 +5,7 @@ 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,
@@ -18,6 +19,7 @@ import {
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
@@ -311,15 +313,15 @@ function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number): string {
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
if (total === 0) {
return '0'
return a.zero
}
const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize)
return `${start}-${end} of ${total}`
return a.rangeOf(start, end, total)
}
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
@@ -356,21 +358,25 @@ type CellCtx = {
interface ArtifactColumn {
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
bodyClassName: string
header: (filter: ArtifactFilter) => string
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
id: 'location' | 'primary' | 'session'
width: (filter: ArtifactFilter) => string
}
const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
const { t } = useI18n()
const a = t.artifacts
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
@@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
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)))
@@ -393,12 +401,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
})
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp))
} catch (err) {
notifyError(err, 'Artifacts failed to load')
notifyError(err, a.failedLoad)
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
}, [a])
useRefreshHotkey(refreshArtifacts)
@@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, 'Open failed')
notifyError(err, a.openFailed)
}
}, [])
}, [a])
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={counts.all === 0}
searchPlaceholder="Search artifacts..."
searchPlaceholder={a.search}
searchTrailingAction={
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? a.refreshing : a.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
Images <TextTabMeta>({counts.image})</TextTabMeta>
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
Files <TextTabMeta>({counts.file})</TextTabMeta>
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
Links <TextTabMeta>({counts.link})</TextTabMeta>
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab>
</>
}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
<PageLoader label={a.indexing} />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
</div>
</div>
) : (
@@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
itemLabel={a.itemsImage}
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
@@ -579,7 +601,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
itemLabel={itemsLabel(kindFilter, a)}
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
@@ -608,12 +630,14 @@ interface ArtifactsPaginationProps {
}
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
const { t } = useI18n()
const a = t.artifacts
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
{pageRangeLabel(total, page, pageSize)} {itemLabel}
{pageRangeLabel(total, page, pageSize, a)} {itemLabel}
</div>
{pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end">
@@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
<PaginationEllipsis />
) : (
<PaginationButton
aria-label={`Go to ${itemLabel} page ${item}`}
aria-label={a.goToPage(itemLabel, item)}
isActive={page === item}
onClick={() => onPageChange(item)}
>
@@ -657,6 +681,10 @@ interface ArtifactImageCardProps {
}
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
const { t } = useI18n()
const a = t.artifacts
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<div
@@ -683,7 +711,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<FileImage className="size-3" />
{artifact.kind}
{kindLabel}
</div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
{artifact.label}
@@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<FolderOpen className="size-3" />
Chat
{a.chat}
</Button>
</div>
</div>
@@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
}
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const { t } = useI18n()
const isLink = artifact.kind === 'link'
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
const copyLabel = isLink ? 'Copy URL' : 'Copy path'
const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
@@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'),
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},
{
Cell: LocationCell,
bodyClassName: 'px-2.5 py-1.5',
header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'),
header: (filter, a) =>
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
id: 'location',
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
},
{
Cell: SessionCell,
bodyClassName: 'p-0',
header: () => 'Session',
header: (_filter, a) => a.colSession,
id: 'session',
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
}
@@ -843,13 +873,15 @@ function ArtifactTable({
ctx: CellCtx
filter: ArtifactFilter
}) {
const { t } = useI18n()
return (
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<tr>
{ARTIFACT_COLUMNS.map(col => (
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
{col.header(filter)}
{col.header(filter, t.artifacts)}
</th>
))}
</tr>
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
@@ -26,6 +27,8 @@ export function AttachmentList({
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const { t } = useI18n()
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
@@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${attachment.label}`)
throw new Error(c.couldNotPreview(attachment.label))
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, 'Preview unavailable')
notifyError(error, c.previewUnavailable)
}
}
@@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
@@ -97,7 +100,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
</button>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
aria-label={c.removeAttachment(attachment.label)}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
@@ -11,29 +11,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
{
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
label: 'Code review',
text: 'Please review this for bugs, regressions, and missing tests.'
},
{
description: 'Outline an approach before touching code so the diff stays focused.',
label: 'Implementation plan',
text: 'Please make a concise implementation plan before changing code.'
},
{
description: 'Walk through how the selected code works and link to the key files.',
label: 'Explain this',
text: 'Please explain how this works and point me to the key files.'
}
]
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
export function ContextMenu({
state,
@@ -44,6 +29,8 @@ export function ContextMenu({
onPickFolders,
onPickImages
}: ContextMenuProps) {
const { t } = useI18n()
const c = t.composer
// Prompt snippets used to be a Radix submenu. That submenu didn't open
// reliably when the parent menu was positioned at the bottom of the
// window (composer "+" anchor), so we promoted it to a real Dialog —
@@ -71,78 +58,81 @@ export function ContextMenu({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
{c.attachLabel}
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files…
{c.files}
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder…
{c.folder}
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images…
{c.images}
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
{c.pasteImage}
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL…
{c.url}
</ContextMenuItem>
<DropdownMenuSeparator />
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
Prompt snippets…
{c.promptSnippets}
</ContextMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
{c.tipPre}
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
{c.tipPost}
</div>
</DropdownMenuContent>
</DropdownMenu>
<PromptSnippetsDialog
onInsertText={onInsertText}
onOpenChange={setSnippetsOpen}
open={snippetsOpen}
snippets={PROMPT_SNIPPETS}
/>
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
</>
)
}
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
const { t } = useI18n()
const c = t.composer
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
<DialogHeader>
<DialogTitle>Prompt snippets</DialogTitle>
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
<DialogTitle>{c.snippetsTitle}</DialogTitle>
<DialogDescription>{c.snippetsDesc}</DialogDescription>
</DialogHeader>
<ul className="grid gap-1">
{snippets.map(snippet => (
<li key={snippet.label}>
<button
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)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
{SNIPPET_KEYS.map(key => {
const snippet = c.snippets[key]
return (
<li key={key}>
<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"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
}}
type="button"
>
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
<span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
</span>
</span>
</span>
</button>
</li>
))}
</button>
</li>
)
})}
</ul>
</DialogContent>
</Dialog>
@@ -175,15 +165,8 @@ interface ContextMenuProps {
state: ChatBarState
}
interface PromptSnippet {
description: string
label: string
text: string
}
interface PromptSnippetsDialogProps {
onInsertText: (text: string) => void
onOpenChange: (open: boolean) => void
open: boolean
snippets: readonly PromptSnippet[]
}
+26 -16
View File
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -55,6 +56,9 @@ export function ComposerControls({
voiceStatus: VoiceStatus
onDictate: () => void
}) {
const { t } = useI18n()
const c = t.composer
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
@@ -65,9 +69,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Tip label="Start voice conversation">
<Tip label={c.startVoice}>
<Button
aria-label="Start voice conversation"
aria-label={c.startVoice}
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
@@ -81,9 +85,9 @@ export function ComposerControls({
</Button>
</Tip>
) : (
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
@@ -113,25 +117,27 @@ function ConversationPill({
onToggleMute,
status
}: ConversationProps & { disabled: boolean }) {
const { t } = useI18n()
const c = t.composer
const speaking = status === 'speaking'
const listening = status === 'listening' && !muted
const label =
status === 'speaking'
? 'Speaking'
? c.speaking
: status === 'transcribing'
? 'Transcribing'
? c.transcribing
: status === 'thinking'
? 'Thinking'
? c.thinking
: muted
? 'Muted'
: 'Listening'
? c.muted
: c.listening
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-label={muted ? c.unmuteMic : c.muteMic}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
@@ -148,32 +154,34 @@ function ConversationPill({
</Tip>
{listening && (
<Button
aria-label="Stop listening and send"
aria-label={c.stopListening}
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
disabled={disabled}
onClick={() => {
triggerHaptic('submit')
onStopTurn()
}}
title={c.stopListening}
type="button"
variant="ghost"
>
<Square className="fill-current" size={11} />
<span>Stop</span>
<span>{c.stopShort}</span>
</Button>
)}
<Button
aria-label="End voice conversation"
aria-label={c.endConversation}
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled}
onClick={() => {
triggerHaptic('close')
onEnd()
}}
title={c.endConversation}
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
<span>End</span>
<span>{c.endShort}</span>
</Button>
<span className="sr-only" role="status">
{label}
@@ -220,10 +228,12 @@ function DictationButton({
status: VoiceStatus
onToggle: () => void
}) {
const { t } = useI18n()
const c = t.composer
const active = state.active || status !== 'idle'
const aria =
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
return (
<Tip label={aria}>
@@ -1,44 +1,32 @@
import type { ReactNode } from 'react'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMANDS: [string, string][] = [
['/help', 'full list of commands + hotkeys'],
['/clear', 'start a new session'],
['/resume', 'resume a prior session'],
['/details', 'control transcript detail level'],
['/copy', 'copy selection or last assistant message'],
['/quit', 'exit hermes']
]
const HOTKEYS: [string, string][] = [
['@', 'reference files, folders, urls, git'],
['/', 'slash command palette'],
['?', 'this quick help (delete to dismiss)'],
['Enter', 'send · Shift+Enter for newline'],
['Cmd/Ctrl+K', 'send next queued turn'],
['Cmd/Ctrl+L', 'redraw'],
['Esc', 'close popover · cancel run'],
['↑ / ↓', 'cycle popover / history']
]
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
export function HelpHint() {
const { t } = useI18n()
const c = t.composer
return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title="Common commands">
{COMMON_COMMANDS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} mono />
<Section title={c.commonCommands}>
{COMMON_COMMAND_KEYS.map(key => (
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
))}
</Section>
<Section title="Hotkeys">
{HOTKEYS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} />
<Section title={c.hotkeys}>
{HOTKEY_KEYS.map(key => (
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
))}
</Section>
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
<span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
</p>
</div>
)
@@ -0,0 +1,108 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's composer text wiring for IME input, driven
// through REAL DOM composition + input events on a contentEditable.
//
// Regression repro for #39614: typing committed multi-character IME text (e.g.
// Chinese "你好") used to leave the send button hidden. The input events fired
// during composition carry uncommitted preedit text and are intentionally
// skipped; Chromium then does NOT reliably emit a trailing input event after
// compositionend on Windows IMEs, so the finalized text never reached composer
// state and `hasPayload` stayed false until an unrelated edit forced a sync.
// The fix flushes the live DOM text in onCompositionEnd.
function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {
const editorRef = useRef<HTMLDivElement>(null)
const composingRef = useRef(false)
const draftRef = useRef('')
const [draft, setDraft] = useState('')
const flushEditorToDraft = (editor: HTMLDivElement) => {
const next = editor.textContent ?? ''
if (next !== draftRef.current) {
draftRef.current = next
setDraft(next)
}
}
onPayload(draft.trim().length > 0)
return (
<div
contentEditable
data-testid="editor"
onCompositionEnd={event => {
composingRef.current = false
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onInput={event => {
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer IME composition — send button visibility (#39614)', () => {
it('shows the send button after committing CJK text without a trailing edit', async () => {
let hasPayload = false
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
// Compose "你好" the way a Windows Chinese IME does: compositionstart, then
// input events carrying uncommitted preedit text, then compositionend with
// the committed text already in the DOM — and crucially NO input event
// afterwards.
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = '你'
fireEvent.input(editor)
editor.textContent = '你好'
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
// Before the fix this was false (button hidden) until a further edit.
expect(hasPayload).toBe(true)
expect(editor.textContent).toBe('你好')
})
it('also covers Japanese/Korean and any IME-composed script', async () => {
let hasPayload = false
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
const editor = getByTestId('editor')
for (const committed of ['こんにちは', '안녕하세요']) {
await act(async () => {
fireEvent.compositionStart(editor)
editor.textContent = committed
fireEvent.input(editor)
fireEvent.compositionEnd(editor)
})
expect(hasPayload).toBe(true)
// Clear for the next script.
await act(async () => {
editor.textContent = ''
fireEvent.input(editor)
})
expect(hasPayload).toBe(false)
}
})
})
+209 -79
View File
@@ -17,15 +17,24 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
@@ -84,29 +93,6 @@ 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 {
@@ -145,6 +131,7 @@ export function ChatBar({
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const scrolledUp = useStore($threadScrolledUp)
const sessionMessages = useStore($messages)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = useMemo(
@@ -158,12 +145,6 @@ export function ChatBar({
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const drainingQueueRef = useRef(false)
// Set when the user explicitly interrupts the running turn via the Stop
// button (busy + empty composer). It suppresses the next busy→false
// auto-drain so an explicit Stop actually halts instead of immediately
// firing the head of the queue. The queue is preserved; the user resumes
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
const userInterruptedRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@@ -190,7 +171,10 @@ export function ChatBar({
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?'
const { t } = useI18n()
const gatewayState = useStore($gatewayState)
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
// 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
@@ -198,7 +182,7 @@ export function ChatBar({
// 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)
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
)
const prevSessionIdRef = useRef(sessionId)
@@ -217,16 +201,17 @@ export function ChatBar({
return
}
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
}, [sessionId])
resetBrowseState(prev)
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, 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.
const placeholder = disabled
? gatewayState === 'closed' || gatewayState === 'error'
? 'Reconnecting to Hermes…'
: 'Starting Hermes...'
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
const focusInput = useCallback(() => {
@@ -568,16 +553,10 @@ 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
// Pull the live contentEditable text into draftRef + the AUI composer state
// (which drives `hasComposerPayload` → the send button). Shared by the input
// and compositionend paths so committed IME text reaches state through either.
const flushEditorToDraft = (editor: HTMLDivElement) => {
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
@@ -592,6 +571,17 @@ export function ChatBar({
window.setTimeout(refreshTrigger, 0)
}
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
// During IME composition the DOM contains uncommitted preedit text
// mixed with real content. Skip state writes — compositionend flushes
// the finalized text (see onCompositionEnd).
if (composingRef.current) {
return
}
flushEditorToDraft(event.currentTarget)
}
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@@ -734,6 +724,74 @@ export function ChatBar({
}
}
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
// place) then sent-message history. The history ring is derived from live
// session messages each press — single source of truth, no mirror.
if (event.key === 'ArrowUp') {
const currentDraft = draftRef.current
// Editing a queued turn → walk to the older entry.
if (queueEdit && stepQueuedEdit(-1)) {
event.preventDefault()
triggerKeyConsumedRef.current = true
return
}
// Empty composer + a queued turn → open the newest queued entry for edit
// (the row's pencil), not a text recall. Enter saves it back to the queue.
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
event.preventDefault()
triggerKeyConsumedRef.current = true
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
return
}
// Don't hijack a typed draft unless already browsing — they'd lose it.
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
return
}
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
loadIntoComposer(entry, $composerAttachments.get())
}
return
}
if (event.key === 'ArrowDown') {
// Editing a queued turn → walk to the newer entry (past the newest exits).
if (queueEdit) {
event.preventDefault()
triggerKeyConsumedRef.current = true
stepQueuedEdit(1)
return
}
// Browsing sent history → step toward the present, restoring the draft.
if (isBrowsingHistory(sessionId)) {
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
loadIntoComposer(result.text, $composerAttachments.get())
}
}
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
@@ -743,7 +801,32 @@ export function ChatBar({
return
}
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
if (busy && !hasComposerPayload) {
return
}
submitDraft()
return
}
if (event.key === 'Escape') {
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
if (queueEdit) {
event.preventDefault()
exitQueuedEdit('cancel')
return
}
// Otherwise Esc interrupts the running turn (Stop-button parity).
if (busy) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
}
}
@@ -909,6 +992,42 @@ export function ChatBar({
focusInput()
}
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
// saving the in-progress edit on each step. Stepping newer past the last
// entry exits edit mode and restores the pre-edit draft.
const stepQueuedEdit = (direction: -1 | 1) => {
if (!queueEdit) {
return false
}
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
const target = index + direction
if (index < 0 || target < 0) {
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
}
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
attachments: cloneAttachments($composerAttachments.get()),
text: draftRef.current
})
const next = queuedPrompts[target]
if (next) {
setQueueEdit({ ...queueEdit, entryId: next.id })
loadIntoComposer(next.text, next.attachments)
} else {
setQueueEdit(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
}
triggerHaptic(saved ? 'success' : 'selection')
focusInput()
return true
}
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
if (!queueEdit) {
return false
@@ -977,13 +1096,14 @@ export function ChatBar({
}
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts]
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const drainNextQueued = useCallback(
@@ -997,41 +1117,40 @@ export function ChatBar({
)
const sendQueuedNow = useCallback(
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
[queueEdit, runDrain]
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
return false
}
if (busy) {
// Promote to the head, then interrupt. The gateway always emits a
// settle (message.complete + session.info running:false) when the
// turn unwinds, and the busy→false auto-drain below sends this entry.
promoteQueuedPrompt(activeQueueSessionKey, id)
triggerHaptic('selection')
void Promise.resolve(onCancel())
return true
}
return runDrain(entries => entries.find(e => e.id === id))
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
)
// Auto-drain on busy → false (turn settled). An explicit user interrupt
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
// the user asked to halt, so we must not immediately re-send the queue.
// The queued turns stay intact and the user resumes them on demand.
// Auto-drain on busy → false (turn settled). Queued turns always flow once
// the session is idle again — whether the turn finished naturally or the
// user interrupted it. Interrupting to reach a queued message is the whole
// point of the queue, so we never suppress the drain. To cancel queued
// turns, the user deletes them from the panel.
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
// Clear the interrupt latch when a new turn starts (false → true). This
// guards the sub-frame race where a Stop click lands after busy already
// flipped false (button not yet unmounted): the stale latch can no longer
// survive into the next turn and wrongly suppress its natural auto-drain.
if (busy && !wasBusy) {
userInterruptedRef.current = false
return
}
const interrupted = userInterruptedRef.current
// Consume the interrupt latch on any settle so a later natural completion
// is not wrongly suppressed.
if (!busy && wasBusy && interrupted) {
userInterruptedRef.current = false
}
if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
userInterrupted: interrupted,
wasBusy
})
) {
@@ -1072,12 +1191,8 @@ export function ChatBar({
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button: an explicit interrupt must actually halt the running
// turn. Mark the interrupt so the busy→false auto-drain effect skips
// re-sending the queue — otherwise a queued follow-up would fire the
// instant we cancel and Stop would appear to "never work". Queued
// turns are preserved; the user sends them on demand.
userInterruptedRef.current = true
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@@ -1086,6 +1201,7 @@ export function ChatBar({
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
@@ -1155,6 +1271,7 @@ export function ChatBar({
}
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
await onSubmit(text)
}
@@ -1213,7 +1330,7 @@ export function ChatBar({
const input = (
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-label="Message"
aria-label={t.composer.message}
autoCapitalize="off"
autoCorrect="off"
className={cn(
@@ -1227,8 +1344,17 @@ export function ChatBar({
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={() => {
onCompositionEnd={event => {
composingRef.current = false
// The input events fired *during* composition were skipped (they
// carried uncommitted preedit text), and Chromium does NOT reliably
// emit a trailing input event after compositionend on Windows IMEs.
// Without flushing here, committed multi-character IME input (e.g.
// Chinese "你好", Japanese, Korean) never reaches composer state, so
// `hasComposerPayload` stays false and the send button stays hidden
// until an unrelated edit forces a sync (#39614).
flushEditorToDraft(event.currentTarget)
}}
onCompositionStart={() => {
composingRef.current = true
@@ -1303,7 +1429,11 @@ export function ChatBar({
)}
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
{activeQueueSessionKey && queuedPrompts.length > 0 && (
<div className="relative z-6 mb-1 px-0.5">
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
// queue). Overlaps -mb-2 onto the surface's top border for a shared
// edge; capped + scrollable. Overlays the chat instead of pushing it.
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@@ -16,37 +17,40 @@ interface QueuePanelProps {
onSendNow: (id: string) => void
}
const entryPreview = (entry: QueuedPromptEntry) =>
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const [collapsed, setCollapsed] = useState(false)
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(true)
if (entries.length === 0) {
return null
}
return (
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1">
<button
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
<span className="truncate">{entries.length} Queued</span>
<span className="truncate">{c.queued(entries.length)}</span>
</button>
{!collapsed && (
<div className="space-y-0.5 px-1.5 pb-0.5">
<div className="space-y-0.5 px-1 pb-0.5">
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
return (
<div
className={cn(
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
@@ -57,17 +61,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p>
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && (
<span>
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
{c.attachments(attachmentsCount)}
</span>
)}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
Editing in composer
{c.editingInComposer}
</span>
)}
</div>
@@ -81,9 +85,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)}
>
<Tip label="Edit queued turn">
<Tip label={c.editQueued}>
<Button
aria-label="Edit queued turn"
aria-label={c.editQueued}
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
@@ -94,11 +98,11 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} />
</Button>
</Tip>
<Tip label="Send queued turn now">
<Tip label={sendLabel}>
<Button
aria-label="Send queued turn now"
aria-label={sendLabel}
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
@@ -107,9 +111,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label="Delete queued turn">
<Tip label={c.deleteQueued}>
<Button
aria-label="Delete queued turn"
aria-label={c.deleteQueued}
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
@@ -1,3 +1,4 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
@@ -10,6 +11,8 @@ interface SkinSlashPopoverProps {
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { t } = useI18n()
const c = t.composer
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
@@ -21,7 +24,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
return (
<div
aria-label="Desktop theme suggestions"
aria-label={c.themeSuggestions}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
@@ -29,8 +32,10 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title="No matching themes.">
Try <span className="font-mono text-foreground/80">/skin list</span>.
<CompletionDrawerEmpty title={c.noMatchingThemes}>
{c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
</CompletionDrawerEmpty>
) : (
items.map(item => (
@@ -10,6 +10,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { Globe } from '@/lib/icons'
const URL_HINT = /^https?:\/\//i
@@ -29,6 +30,8 @@ export function UrlDialog({
open: boolean
value: string
}) {
const { t } = useI18n()
const c = t.composer
const trimmed = value.trim()
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
@@ -43,8 +46,8 @@ export function UrlDialog({
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>Attach a URL</DialogTitle>
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</div>
</DialogHeader>
<form
@@ -60,23 +63,24 @@ export function UrlDialog({
autoCorrect="off"
inputMode="url"
onChange={e => onChange(e.target.value)}
placeholder="https://example.com/post"
placeholder={c.urlPlaceholder}
ref={inputRef}
spellCheck={false}
value={value}
/>
{trimmed.length > 0 && !looksLikeUrl && (
<p className="text-xs text-muted-foreground/85">
Include the full URL, e.g. <span className="font-mono">https://…</span>
{c.urlHintPre}
<span className="font-mono">https://…</span>
</p>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={!looksLikeUrl} type="submit">
Attach
{c.attach}
</Button>
</DialogFooter>
</form>
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { stopVoicePlayback } from '@/lib/voice-playback'
@@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
}
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
const { t } = useI18n()
if (state.status === 'idle') {
return null
}
const recording = state.status === 'recording'
const title = recording ? 'Dictating' : 'Transcribing'
const title = recording ? t.composer.dictating : t.composer.transcribing
return (
<div
@@ -201,6 +204,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
}
export function VoicePlaybackActivity() {
const { t } = useI18n()
const playback = useStore($voicePlayback)
if (playback.status === 'idle') {
@@ -210,10 +214,10 @@ export function VoicePlaybackActivity() {
const preparing = playback.status === 'preparing'
const title = preparing
? 'Preparing audio'
? t.composer.preparingAudio
: playback.source === 'voice-conversation'
? 'Speaking response'
: 'Reading aloud'
? t.composer.speakingResponse
: t.composer.readingAloud
return (
<div
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { fsReadFileDataUrl, selectPaths } from '@/lib/desktop-fs'
import {
addComposerAttachment,
type ComposerAttachment,
@@ -36,6 +37,27 @@ function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
// Thumbnail source for an attached image. Locally-held paths (drag/paste saves,
// local picks) read off the client; when that fails on a remote backend the
// path lives on the gateway host, so fall back to the gateway data-url read.
async function loadImagePreviewDataUrl(filePath: string): Promise<string | undefined> {
try {
const local = await window.hermesDesktop?.readFileDataUrl(filePath)
if (local) {
return local
}
} catch {
// Path isn't on the client (remote-picked image) — try the gateway below.
}
try {
return await fsReadFileDataUrl(filePath)
} catch {
return undefined
}
}
export interface DroppedFile {
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
@@ -228,7 +250,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await window.hermesDesktop?.selectPaths({
const paths = await selectPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
@@ -291,19 +313,13 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const previewUrl = await loadImagePreviewDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, 'Image preview failed')
return true
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
}, [])
const attachImageBlob = useCallback(
@@ -338,7 +354,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
)
const pickImages = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
const paths = await selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
filters: [
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { fsReadFileDataUrl, fsReadFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -179,21 +180,19 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
return await fsReadFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
// Back-compat for a running Electron process whose preload hasn't been
// restarted since readFileText was added. readFileDataUrl already existed.
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const dataUrl = await fsReadFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
@@ -441,7 +440,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const dataUrl = await fsReadFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
+40 -26
View File
@@ -36,6 +36,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
@@ -176,13 +177,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || '__no_workspace__'
const label = baseName(path) || path || 'No workspace'
const label = baseName(path) || path || noWorkspaceLabel
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
@@ -233,6 +234,8 @@ export function ChatSidebar({
onArchiveSession,
onNewSessionInWorkspace
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
@@ -402,8 +405,8 @@ export function ChatSidebar({
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
[agentSessions, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -589,13 +592,15 @@ export function ChatSidebar({
onNavigate(item)
}}
tooltip={item.label}
tooltip={s.nav[item.id] ?? item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
@@ -615,9 +620,9 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label="Search sessions"
aria-label={s.searchAria}
onChange={setSearchQuery}
placeholder="Search sessions…"
placeholder={s.searchPlaceholder}
value={searchQuery}
/>
</div>
@@ -629,10 +634,10 @@ export function ChatSidebar({
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
No sessions match “{trimmedQuery}”.
{s.noMatch(trimmedQuery)}
</div>
}
label="Results"
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
@@ -653,7 +658,7 @@ export function ChatSidebar({
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
@@ -703,9 +708,9 @@ export function ChatSidebar({
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'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'
@@ -724,7 +729,7 @@ export function ChatSidebar({
) : null}
</div>
}
label="Sessions"
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
@@ -795,19 +800,25 @@ function SidebarSessionSkeletons() {
)
}
const SidebarAllPinnedState = () => (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
function SidebarAllPinnedState() {
const { t } = useI18n()
return (
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
{t.sidebar.allPinned}
</div>
)
}
function SidebarPinnedEmptyState() {
const { t } = useI18n()
return (
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
<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</span>
<span>{t.sidebar.shiftClickHint}</span>
</div>
)
}
@@ -1006,6 +1017,8 @@ function SidebarWorkspaceGroup({
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
@@ -1052,9 +1065,9 @@ function SidebarWorkspaceGroup({
/>
</button>
{(onNewSession || isProfileGroup) && (
<Tip label={`New session in ${group.label}`}>
<Tip label={s.newSessionIn(group.label)}>
<button
aria-label={`New session in ${group.label}`}
aria-label={s.newSessionIn(group.label)}
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"
// Profile groups start a fresh session in that profile but keep the
// all-profiles browse view (newSessionInProfile leaves the scope
@@ -1069,7 +1082,7 @@ function SidebarWorkspaceGroup({
{reorderable && (
<span
{...dragHandleProps}
aria-label={`Reorder workspace ${group.label}`}
aria-label={s.reorderWorkspace(group.label)}
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
@@ -1091,9 +1104,9 @@ function SidebarWorkspaceGroup({
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={`Show ${nextCount} more in ${group.label}`}>
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
aria-label={s.showMoreIn(nextCount, group.label)}
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)}
type="button"
@@ -1144,7 +1157,8 @@ interface SidebarLoadMoreRowProps {
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
@@ -16,6 +16,7 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
@@ -43,13 +44,15 @@ interface ItemSpec {
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
{
disabled: !onPin,
icon: 'pin',
label: pinned ? 'Unpin' : 'Pin',
label: pinned ? r.unpin : r.pin,
onSelect: () => {
triggerHaptic('selection')
onPin?.()
@@ -58,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !sessionId,
icon: 'copy',
label: 'Copy ID',
label: r.copyId,
onSelect: event => {
event.preventDefault()
triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
{
disabled: !sessionId,
icon: 'cloud-download',
label: 'Export',
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
@@ -77,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !sessionId,
icon: 'edit',
label: 'Rename',
label: r.rename,
onSelect: () => {
triggerHaptic('selection')
setRenameOpen(true)
@@ -86,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{
disabled: !onArchive,
icon: 'archive',
label: 'Archive',
label: r.archive,
onSelect: () => {
triggerHaptic('selection')
onArchive?.()
@@ -96,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
icon: 'trash',
label: 'Delete',
label: t.common.delete,
onSelect: () => {
triggerHaptic('warning')
onDelete?.()
@@ -132,6 +135,7 @@ interface SessionActionsMenuProps
}
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
return (
@@ -140,7 +144,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${actions.title}`}
aria-label={t.sidebar.row.actionsFor(actions.title)}
className="w-40"
sideOffset={sideOffset}
>
@@ -157,13 +161,14 @@ interface SessionContextMenuProps extends SessionActions {
}
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
@@ -181,6 +186,8 @@ interface RenameSessionDialogProps {
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
const { t } = useI18n()
const r = t.sidebar.row
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -211,10 +218,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
const result = await renameSession(sessionId, next, profile)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
notify({ durationMs: 2_000, kind: 'success', message: r.renamed })
onOpenChange(false)
} catch (err) {
notifyError(err, 'Rename failed')
notifyError(err, r.renameFailed)
} finally {
setSubmitting(false)
}
@@ -224,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename session</DialogTitle>
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
<DialogTitle>{r.renameTitle}</DialogTitle>
<DialogDescription>{r.renameDesc}</DialogDescription>
</DialogHeader>
<Input
autoFocus
@@ -239,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
onOpenChange(false)
}
}}
placeholder="Untitled session"
placeholder={r.untitledPlaceholder}
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={submitting} onClick={() => void submit()} type="button">
Save
{t.common.save}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
dragHandleProps?: React.HTMLAttributes<HTMLElement>
}
const AGE_TICKS: ReadonlyArray<[number, string]> = [
[86_400_000, 'd'],
[3_600_000, 'h'],
[60_000, 'm']
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
[86_400_000, 'ageDay'],
[3_600_000, 'ageHour'],
[60_000, 'ageMin']
]
function formatAge(seconds: number): string {
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
const delta = Math.max(0, Date.now() - seconds * 1000)
for (const [ms, suffix] of AGE_TICKS) {
for (const [ms, key] of AGE_TICKS) {
if (delta >= ms) {
return `${Math.floor(delta / ms)}${suffix}`
return `${Math.floor(delta / ms)}${r[key]}`
}
}
return 'now'
return r.ageNow
}
export function SidebarSessionRow({
@@ -61,8 +62,10 @@ export function SidebarSessionRow({
ref,
...rest
}: SidebarSessionRowProps) {
const { t } = useI18n()
const r = t.sidebar.row
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
const age = formatAge(session.last_active || session.started_at, r)
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
@@ -196,10 +199,10 @@ export function SidebarSessionRow({
title={title}
>
<Button
aria-label={`Actions for ${title}`}
aria-label={r.actionsFor(title)}
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"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
@@ -220,6 +223,9 @@ function SidebarRowDot({
needsInput?: boolean
className?: string
}) {
const { t } = useI18n()
const r = t.sidebar.row
// "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
@@ -227,17 +233,17 @@ function SidebarRowDot({
if (needsInput) {
return (
<span
aria-label="Needs your input"
aria-label={r.needsInput}
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title="Waiting for your answer"
title={r.waitingForAnswer}
/>
)
}
return (
<span
aria-label={isWorking ? 'Session running' : undefined}
aria-label={isWorking ? r.sessionRunning : undefined}
className={cn(
'rounded-full',
isWorking
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
@@ -32,12 +33,15 @@ export function CronJobActionsMenu({
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
const { t } = useI18n()
const c = t.cron
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
aria-label={c.actionsFor(title)}
className="w-44"
sideOffset={sideOffset}
>
@@ -49,7 +53,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -60,7 +64,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
<span>{c.triggerNow}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -70,7 +74,7 @@ export function CronJobActionsMenu({
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
<span>{c.edit}</span>
</DropdownMenuItem>
<DropdownMenuItem
@@ -81,7 +85,7 @@ export function CronJobActionsMenu({
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
const { t } = useI18n()
return (
<Button
aria-label={`Actions for ${title}`}
aria-label={t.cron.actionsFor(title)}
className={className}
size="icon-sm"
title="Cron job actions"
title={t.cron.actionsTitle}
variant="ghost"
{...props}
>
+178 -179
View File
@@ -1,10 +1,9 @@
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 { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Dialog,
DialogContent,
@@ -14,7 +13,6 @@ 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 {
@@ -27,78 +25,49 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
{ label: 'This desktop', value: 'local' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Slack', value: 'slack' },
{ label: 'Email', value: 'email' }
]
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{
expr: '0 9 * * *',
hint: 'Every day at 9:00 AM',
label: 'Daily',
value: 'daily'
},
{
expr: '0 9 * * 1-5',
hint: 'Monday through Friday at 9:00 AM',
label: 'Weekdays',
value: 'weekdays'
},
{
expr: '0 9 * * 1',
hint: 'Every Monday at 9:00 AM',
label: 'Weekly',
value: 'weekly'
},
{
expr: '0 9 1 * *',
hint: 'The first day of each month at 9:00 AM',
label: 'Monthly',
value: 'monthly'
},
{
expr: '0 * * * *',
hint: 'At the top of every hour',
label: 'Hourly',
value: 'hourly'
},
{
expr: '*/15 * * * *',
hint: 'Every 15 minutes',
label: 'Every 15 minutes',
value: 'every-15-minutes'
},
{
hint: 'Cron syntax or natural language',
label: 'Custom',
value: 'custom'
}
{ expr: '0 9 * * *', value: 'daily' },
{ expr: '0 9 * * 1-5', value: 'weekdays' },
{ expr: '0 9 * * 1', value: 'weekly' },
{ expr: '0 9 1 * *', value: 'monthly' },
{ expr: '0 * * * *', value: 'hourly' },
{ expr: '*/15 * * * *', value: 'every-15-minutes' },
{ value: 'custom' }
]
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
enabled: 'default',
scheduled: 'default',
running: 'default',
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
paused: 'warn',
disabled: 'muted',
error: 'destructive',
error: 'bad',
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)
@@ -155,19 +124,8 @@ function cronParts(expr: string): null | string[] {
return parts.length === 5 ? parts : null
}
function dayName(value: string): string {
const names: Record<string, string> = {
'0': 'Sunday',
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
}
return names[value] ?? `day ${value}`
function dayName(value: string, c: Translations['cron']): string {
return c.days[value] ?? c.dayFallback(value)
}
function formatCronTime(minute: string, hour: string): string {
@@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1]
}
function scheduleSummary(option: ScheduleOption, expr: string): string {
function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string {
const parts = cronParts(expr)
if (!parts) {
return option.hint
return c.scheduleHints[option.value] ?? ''
}
const [minute, hour, dayOfMonth, , dayOfWeek] = parts
if (option.value === 'daily') {
return `Every day at ${formatCronTime(minute, hour)}`
return c.everyDayAt(formatCronTime(minute, hour))
}
if (option.value === 'weekdays') {
return `Weekdays at ${formatCronTime(minute, hour)}`
return c.weekdaysAt(formatCronTime(minute, hour))
}
if (option.value === 'weekly') {
return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}`
return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour))
}
if (option.value === 'monthly') {
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}`
return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
}
if (option.value === 'hourly') {
return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}`
return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0'))
}
return option.hint
return c.scheduleHints[option.value] ?? ''
}
function formatTime(iso?: null | string): string {
@@ -301,26 +259,35 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps {
interface CronViewProps extends React.ComponentProps<'section'> {
onClose: () => void
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose }: CronViewProps) {
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
const { t } = useI18n()
const c = t.cron
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' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
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')
notifyError(err, c.failedLoad)
} finally {
setRefreshing(false)
}
}, [])
}, [c])
useRefreshHotkey(refresh)
@@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) {
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({
kind: 'success',
title: isPaused ? 'Cron resumed' : 'Cron paused',
title: isPaused ? c.resumed : c.paused,
message: truncate(jobTitle(job), 60)
})
} catch (err) {
notifyError(err, 'Failed to update cron job')
notifyError(err, c.failedUpdate)
} finally {
setBusyJobId(null)
}
@@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) {
try {
const updated = await triggerCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
} catch (err) {
notifyError(err, 'Failed to trigger cron job')
notifyError(err, c.failedTrigger)
} finally {
setBusyJobId(null)
}
}
async function handleConfirmDelete() {
if (!pendingDelete) {
return
}
setDeleting(true)
try {
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
notifyError(err, c.failedDelete)
} finally {
setDeleting(false)
}
}
async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
@@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) {
})
setJobs(current => (current ? [...current, created] : [created]))
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, {
prompt: values.prompt,
@@ -392,61 +378,67 @@ export function CronView({ onClose }: CronViewProps) {
})
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
}
setEditor({ mode: 'closed' })
}
return (
<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>
)}
<OverlayView closeLabel={c.close} onClose={onClose}>
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder={c.search}
searchTrailingAction={
<Button
aria-label={refreshing ? c.refreshing : c.refresh}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? c.refreshing : c.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
<PageLoader label={c.loading} />
) : 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
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
actionLabel={totalCount === 0 ? c.createFirst : undefined}
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
/>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
<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
{c.active(enabledCount, totalCount)}
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
{c.newCron}
</Button>
</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}
c={c}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
@@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
</div>
</div>
)}
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
It will stop firing immediately.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setPendingDelete(null)}
onConfirm={async () => {
if (!pendingDelete) {
return
}
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
}}
open={pendingDelete !== null}
title="Delete cron job?"
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{c.deleteTitle}</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
{c.deleteDescPrefix}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
{c.deleteDescSuffix}
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
{t.common.cancel}
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? c.deleting : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
</OverlayView>
)
}
function CronJobRow({
busy,
c,
job,
onDelete,
onEdit,
@@ -501,6 +491,7 @@ function CronJobRow({
onTrigger
}: {
busy: boolean
c: Translations['cron']
job: CronJob
onDelete: () => void
onEdit: () => void
@@ -516,19 +507,15 @@ 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 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
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"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
{state}
</Badge>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<Badge className="capitalize" variant="muted">
{deliver}
</Badge>
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
@@ -537,8 +524,12 @@ function CronJobRow({
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
@@ -569,6 +560,16 @@ function CronJobRow({
)
}
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,
@@ -605,6 +606,8 @@ function CronEditorDialog({
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
const { t } = useI18n()
const c = t.cron
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const initial = isEdit ? editor.job : null
@@ -647,7 +650,7 @@ function CronEditorDialog({
}
}
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@@ -655,7 +658,7 @@ function CronEditorDialog({
const trimmedSchedule = schedule.trim()
if (!trimmedPrompt || !trimmedSchedule) {
setError('Prompt and schedule are required.')
setError(c.promptScheduleRequired)
return
}
@@ -671,7 +674,7 @@ function CronEditorDialog({
schedule: trimmedSchedule
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save cron job')
setError(err instanceof Error ? err.message : c.failedSave)
} finally {
setSaving(false)
}
@@ -681,60 +684,56 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the schedule, prompt, or delivery target. Changes apply on next run.'
: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'}
</DialogDescription>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="cron-name" label="Name" optional>
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder="Morning briefing"
placeholder={c.namePlaceholder}
value={name}
/>
</Field>
<Field htmlFor="cron-prompt" label="Prompt">
<Field htmlFor="cron-prompt" label={c.promptLabel}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder="Summarize my unread Slack threads and email me the top 5..."
placeholder={c.promptPlaceholder}
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label="Frequency">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger id="cron-frequency">
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{c.scheduleLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label="Deliver to">
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver">
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{DELIVERY_VALUES.map(value => (
<SelectItem key={value} value={value}>
{c.deliveryLabels[value]}
</SelectItem>
))}
</SelectContent>
@@ -743,15 +742,15 @@ function CronEditorDialog({
</div>
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label="Custom schedule">
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder="0 9 * * * or weekdays at 9am"
placeholder={c.customPlaceholder}
value={schedule}
/>
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
<FieldHint>{c.customHint}</FieldHint>
</Field>
) : (
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
@@ -771,10 +770,10 @@ function CronEditorDialog({
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button>
</DialogFooter>
</form>
@@ -787,18 +786,20 @@ function Field({
children,
htmlFor,
label,
optional
optional,
optionalLabel
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label>
{children}
</div>
@@ -820,7 +821,5 @@ interface EditorValues {
interface ScheduleOption {
expr?: string
hint: string
label: string
value: string
}
+23 -1
View File
@@ -29,7 +29,7 @@ import {
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -68,6 +68,7 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RemotePathPicker } from './remote-path-picker'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@@ -506,6 +507,25 @@ export function DesktopController() {
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill. Both are nanostores, so the blanket
// invalidateQueries() the profile store fires on swap doesn't touch them —
// without this the statusbar keeps showing the previous profile's model
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
// swap (background sockets persist), so the open→open effect won't re-run.
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
}
lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel()
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -653,6 +673,7 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<RemotePathPicker />
{settingsOpen && (
<Suspense fallback={null}>
@@ -681,6 +702,7 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@@ -0,0 +1,265 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $gatewayState } from '@/store/session'
import { useGatewayBoot } from './use-gateway-boot'
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
// bug that drives the REAL useGatewayBoot hook + REAL HermesGateway through a
// fake WebSocket we fully control. No Docker / no real port: from the desktop's
// point of view a "remote VPS" is just a WebSocket that opens once and later
// refuses to reopen, so that is exactly (and only) what we fake.
//
// The previous test (gateway-connecting-overlay.test.tsx) hand-set the stores
// and asserted the overlays; this one proves the HOOK actually PRODUCES that
// stuck store combo — closing the "inferred by reading code" gap on the
// post-boot reconnect loop.
type Listener = (ev: unknown) => void
// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()
// touches: readyState, add/removeEventListener('open'|'error'|'close'), close().
class FakeWebSocket {
static OPEN = 1
static CLOSED = 3
// Flipped by the test: 'open' = next socket connects; 'fail' = next socket
// errors (a dead remote). Mirrors a VPS going away after the first connect.
static mode: 'open' | 'fail' = 'open'
static instances: FakeWebSocket[] = []
readyState = 0
private listeners: Record<string, Set<Listener>> = {}
constructor(public url: string) {
FakeWebSocket.instances.push(this)
const willOpen = FakeWebSocket.mode === 'open'
// Resolve on the next microtask/macrotask so connect()'s promise wiring is
// in place before open/error fires (matches real async socket handshake).
setTimeout(() => {
if (willOpen) {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
} else {
this.readyState = FakeWebSocket.CLOSED
this.emit('error', {})
}
}, 0)
}
addEventListener(type: string, fn: Listener) {
;(this.listeners[type] ??= new Set()).add(fn)
}
removeEventListener(type: string, fn: Listener) {
this.listeners[type]?.delete(fn)
}
close() {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', {})
}
// Force-drop an open socket, as a sleeping laptop / restarted remote would.
drop() {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', {})
}
private emit(type: string, ev: unknown) {
for (const fn of this.listeners[type] ?? []) fn(ev)
}
}
function fakeDesktop() {
const conn = {
authMode: 'token' as const,
baseUrl: 'https://vps.example.com',
profile: 'default',
token: 't',
wsUrl: 'wss://vps.example.com/api/ws?token=t'
}
return {
getConnection: vi.fn(async () => conn),
getGatewayWsUrl: vi.fn(async () => conn.wsUrl),
getBootProgress: vi.fn(async () => ({
error: null,
fakeMode: false,
message: '',
phase: 'init',
progress: 0,
running: true,
timestamp: Date.now()
})),
onBootProgress: vi.fn(() => () => undefined),
onBackendExit: vi.fn(() => () => undefined),
onPowerResume: vi.fn(() => () => undefined),
onWindowStateChanged: vi.fn(() => () => undefined),
touchBackend: vi.fn(async () => undefined),
profile: { get: vi.fn(async () => ({ profile: 'default' })) }
}
}
function Harness() {
useGatewayBoot({
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
refreshHermesConfig: async () => undefined,
refreshSessions: async () => undefined
})
return null
}
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.instances = []
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
$gatewayState.set('idle')
$desktopBoot.set({
error: null,
fakeMode: false,
message: '',
phase: 'init',
progress: 0,
running: true,
timestamp: Date.now(),
visible: true
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
delete (window as { hermesDesktop?: unknown }).hermesDesktop
})
// Let pending microtasks (awaits) AND the queued 0ms socket open/error fire.
async function flushAsync() {
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
}
// Drive the exponential backoff forward by its full cap so the next scheduled
// reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the
// attempt's async work settles.
async function advanceBackoff() {
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000)
})
}
describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => {
// The report's actual path: a fresh launch pointed at an unreachable VPS.
// startHermes()'s remote branch awaits waitForHermes() for 45s before it
// throws, so the renderer's `await desktop.getConnection()` stays pending
// that whole window. During it: gatewayState is still 'idle' (connect was
// never reached) and boot.error is null → connecting=true → the fullscreen
// CONNECTING overlay, latched, blocking Settings.
let rejectConn: (e: Error) => void = () => undefined
const desktop = fakeDesktop()
desktop.getConnection = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectConn = reject
})
)
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
await flushAsync()
// getConnection is still pending — the dead-VPS wait. No socket was ever
// created, gatewayState never left idle, boot.error is null.
expect(FakeWebSocket.instances).toHaveLength(0)
expect($gatewayState.get()).not.toBe('open')
expect($desktopBoot.get().error).toBeNull()
// ^ connecting === true here → fullscreen CONNECTING, no Settings.
// After ~45s waitForHermes gives up and getConnection rejects → boot()
// catch → failDesktopBoot → the BootFailureOverlay recovery surface.
await act(async () => {
rejectConn(new Error('Hermes backend did not become ready: timeout'))
await vi.advanceTimersByTimeAsync(0)
})
expect($desktopBoot.get().error).toBeTruthy()
})
it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {
render(<Harness />)
await flushAsync()
// Initial boot connected.
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
expect(FakeWebSocket.instances).toHaveLength(1)
// The remote VPS goes away: drop the live socket, and make every reopen
// fail from here on.
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
// Burn a couple backoff cycles BEFORE the escalation threshold (<6 attempts,
// ~the first ~15s). This is the window where stock and fixed behave the
// same: socket down, hook retrying, gatewayState non-open, boot.error still
// null → CONNECTING covers the screen with no recovery surface. (Past ~45s
// the fix raises boot.error; that's asserted in the next test.)
await advanceBackoff()
expect($gatewayState.get()).not.toBe('open')
expect($desktopBoot.get().error).toBeNull()
// It is actively retrying, not idle — more sockets were minted.
expect(FakeWebSocket.instances.length).toBeGreaterThan(1)
})
it('FIX: after the prolonged drop the hook raises a recoverable boot error (the escape hatch)', async () => {
render(<Harness />)
await flushAsync()
expect($desktopBoot.get().error).toBeNull()
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
// Walk the backoff past the >=6 attempt threshold (~45s of failures).
for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}
// The hook surfaced the recoverable error → BootFailureOverlay (Use local
// gateway / Sign in / Retry) becomes reachable instead of CONNECTING.
expect($desktopBoot.get().error).toBeTruthy()
})
it('FIX: a successful reconnect clears the recoverable error', async () => {
render(<Harness />)
await flushAsync()
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}
expect($desktopBoot.get().error).toBeTruthy()
// The remote comes back: next reconnect attempt opens.
FakeWebSocket.mode = 'open'
await advanceBackoff()
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
})
})
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -151,7 +152,7 @@ export function useGatewayBoot({
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, 'Gateway sign-in required')
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
}
} finally {
reconnecting = false
+94 -77
View File
@@ -3,7 +3,6 @@ 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'
@@ -14,6 +13,7 @@ import {
type MessagingPlatformInfo,
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -33,31 +33,15 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
type EditMap = Record<string, Record<string, string>>
const STATE_LABELS: Record<string, string> = {
connected: 'Connected',
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
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> = {
pending_restart: 'Restart the gateway from the status bar to apply this change.',
gateway_stopped: 'Start the gateway from the status bar to connect.'
}
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) {
@@ -86,7 +70,7 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
TELEGRAM_BOT_TOKEN: {
label: 'Bot token',
help: 'Create a bot with @BotFather, then paste the token it gives you.',
placeholder: '123456:ABC...'
placeholder: 'Paste Telegram bot token'
},
TELEGRAM_ALLOWED_USERS: {
label: 'Allowed Telegram user IDs',
@@ -153,13 +137,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
placeholder: 'xoxb-...'
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
placeholder: 'Paste Slack bot token'
},
SLACK_APP_TOKEN: {
label: 'Slack app token',
help: 'Starts with xapp-. Required for Socket Mode.',
placeholder: 'xapp-...'
help: 'Use the app-level token required for Socket Mode.',
placeholder: 'Paste Slack app token'
},
SLACK_ALLOWED_USERS: {
label: 'Allowed Slack user IDs',
@@ -219,18 +203,21 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
}
}
function fieldCopy(field: MessagingEnvVarInfo) {
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
const copy = FIELD_COPY[field.key] || {}
const localized = m.fieldCopy[field.key] || {}
return {
label: copy.label || field.prompt || field.key,
help: copy.help || field.description,
placeholder: copy.placeholder || field.prompt,
label: localized.label || copy.label || field.prompt || field.key,
help: localized.help || copy.help || field.description,
placeholder: localized.placeholder || copy.placeholder || field.prompt,
advanced: Boolean(copy.advanced || field.advanced)
}
}
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
@@ -249,14 +236,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setPlatforms(result.platforms)
} catch (err) {
if (!silent) {
notifyError(err, 'Messaging platforms failed to load')
notifyError(err, m.loadFailed)
}
} finally {
if (!silent) {
setRefreshing(false)
}
}
}, [])
}, [m])
useRefreshHotkey(() => void refreshPlatforms())
@@ -330,11 +317,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
)
notify({
kind: 'success',
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
message: 'Restart the gateway for this change to take effect.'
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply
})
} catch (err) {
notifyError(err, `Failed to update ${platform.name}`)
notifyError(err, m.failedUpdate(platform.name))
} finally {
setSaving(null)
}
@@ -355,11 +342,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
await refreshPlatforms()
notify({
kind: 'success',
title: `${platform.name} setup saved`,
message: 'Restart the gateway to reconnect with the new credentials.'
title: m.setupSaved(platform.name),
message: m.restartToReconnect
})
} catch (err) {
notifyError(err, `Failed to save ${platform.name}`)
notifyError(err, m.failedSave(platform.name))
} finally {
setSaving(null)
}
@@ -378,9 +365,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
await refreshPlatforms()
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
} catch (err) {
notifyError(err, `Failed to clear ${key}`)
notifyError(err, m.failedClear(key))
} finally {
setSaving(null)
}
@@ -391,11 +378,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder="Search messaging..."
searchPlaceholder={m.search}
searchValue={query}
>
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
<PageLoader label={m.loading} />
) : (
<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 p-2">
@@ -485,12 +472,14 @@ function PlatformDetail({
platform: MessagingPlatformInfo
saving: string | null
}) {
const { t } = useI18n()
const m = t.messaging
const [showAdvanced, setShowAdvanced] = useState(false)
const hasEdits = Object.keys(trimEdits(edits)).length > 0
const requiredFields = platform.env_vars.filter(field => field.required)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
const hiddenCount = advancedFields.length
const isSavingEnv = saving === `env:${platform.id}`
@@ -506,11 +495,11 @@ function PlatformDetail({
{platform.description}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
<SetupPill active={platform.configured}>
{platform.configured ? 'Credentials set' : 'Needs setup'}
{platform.configured ? m.credentialsSet : m.needsSetup}
</SetupPill>
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
</div>
<PlatformHint platform={platform} />
</div>
@@ -524,14 +513,14 @@ function PlatformDetail({
)}
<section>
<SectionTitle>Get your credentials</SectionTitle>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform)}
{introCopy(platform, m)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
{m.openSetupGuide}
<ExternalLink className="size-3.5" />
</a>
</Button>
@@ -539,7 +528,7 @@ function PlatformDetail({
</section>
<section>
<SectionTitle>Required</SectionTitle>
<SectionTitle>{m.required}</SectionTitle>
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
@@ -554,7 +543,7 @@ function PlatformDetail({
))
) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
This platform does not need a token here. Use the setup guide above, then enable it below.
{m.noTokenNeeded}
</p>
)}
</div>
@@ -562,7 +551,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<SectionTitle>{m.recommended}</SectionTitle>
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
@@ -585,7 +574,7 @@ function PlatformDetail({
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
<span>Advanced ({hiddenCount})</span>
<span>{m.advanced(hiddenCount)}</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
@@ -609,19 +598,23 @@ function PlatformDetail({
<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">
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<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 ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? m.enabled : m.disabled}
</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save />
{isSavingEnv ? 'Saving...' : 'Save changes'}
{isSavingEnv ? m.saving : m.saveChanges}
</Button>
</div>
</div>
@@ -636,7 +629,7 @@ const PLATFORM_INTRO: Record<string, string> = {
discord:
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
slack:
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the bot token and app-level token.',
mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
@@ -667,7 +660,8 @@ const PLATFORM_INTRO: Record<string, string> = {
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
}
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
const introCopy = (platform: MessagingPlatformInfo, m: Translations['messaging']) =>
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
function MessagingField({
edits,
@@ -682,7 +676,9 @@ function MessagingField({
onEdit: (key: string, value: string) => void
saving: string | null
}) {
const copy = fieldCopy(field)
const { t } = useI18n()
const m = t.messaging
const copy = fieldCopy(field, m)
const fieldId = `messaging-field-${field.key}`
return (
@@ -693,12 +689,12 @@ function MessagingField({
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
@@ -709,7 +705,7 @@ function MessagingField({
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
title={m.clearField(field.key)}
variant="ghost"
>
<Trash2 className="size-3.5" />
@@ -721,7 +717,7 @@ function MessagingField({
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
</span>
}
/>
@@ -733,24 +729,45 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
const { t } = useI18n()
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
const hint =
platform.state === 'pending_restart'
? t.messaging.hintPendingRestart
: platform.gateway_running
? null
: t.messaging.hintGatewayStopped
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
}
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
return (
<Badge variant={TONE_VARIANT[tone]}>
<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]
)}
>
<StatusDot tone={tone} />
{children}
</Badge>
</span>
)
}
function SetupPill({ active, children }: { active: boolean; children: string }) {
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
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>
)
}
@@ -0,0 +1,37 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
containerClassName?: string
inputRef?: RefObject<HTMLInputElement | null>
loading?: boolean
onChange: (value: string) => void
placeholder: string
value: string
}
export function OverlaySearchInput({
containerClassName,
inputRef,
loading = false,
onChange,
placeholder,
value
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={cn(
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
inputClassName="h-8 text-[0.8125rem]"
inputRef={inputRef}
loading={loading}
onChange={onChange}
placeholder={placeholder}
value={value}
/>
)
}
@@ -11,6 +11,7 @@ interface PageSearchShellProps extends React.ComponentProps<'section'> {
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
@@ -23,6 +24,7 @@ export function PageSearchShell({
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
searchHidden = false,
...props
@@ -58,6 +60,7 @@ export function PageSearchShell({
containerClassName="max-w-[45vw]"
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
</div>
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader } from '@/components/ui/loader'
import type { HermesReadDirEntry } from '@/global'
import { fsReadDir } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import { $remotePathPicker, resolveRemotePathPicker } from '@/store/remote-path-picker'
function parentDir(path: string): string | null {
const trimmed = path.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
if (idx <= 0) {
return idx === 0 ? '/' : null
}
return trimmed.slice(0, idx)
}
function baseName(path: string): string {
return (
path
.replace(/[\\/]+$/, '')
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? path
)
}
// Browses the GATEWAY filesystem (via fs.list) so users on a remote backend can
// pick files/folders that exist on the agent host rather than their own machine.
// Mirrors the native selectPaths contract: resolves with absolute gateway paths
// (or [] when cancelled).
export function RemotePathPicker() {
const request = useStore($remotePathPicker)
if (!request) {
return null
}
return <RemotePathPickerDialog key={request.id} />
}
function RemotePathPickerDialog() {
const request = useStore($remotePathPicker)
const options = request?.options ?? {}
const directoriesMode = Boolean(options.directories)
const allowMultiple = options.multiple !== false && !directoriesMode
const [dir, setDir] = useState<string>(options.defaultPath ?? '')
const [entries, setEntries] = useState<HermesReadDirEntry[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const allowedExtensions = useMemo(() => {
const exts = (options.filters ?? []).flatMap(filter => filter.extensions)
return exts.length > 0 ? new Set(exts.map(ext => ext.toLowerCase().replace(/^\./, ''))) : null
}, [options.filters])
const load = useCallback(async (target: string) => {
setLoading(true)
setError(null)
const result = await fsReadDir(target)
setDir(result.path ?? target)
setEntries(result.entries ?? [])
setError(result.error ?? null)
setSelected(new Set())
setLoading(false)
}, [])
// Loads the initial directory. `load` is stable and defaultPath is fixed for
// this keyed instance, so this runs once; navigation calls `load` directly.
useEffect(() => {
void load(options.defaultPath ?? '')
}, [load, options.defaultPath])
const visibleEntries = useMemo(() => {
return entries.filter(entry => {
if (entry.isDirectory) {
return true
}
if (directoriesMode) {
return false
}
if (!allowedExtensions) {
return true
}
const ext = baseName(entry.name).split('.').pop()?.toLowerCase() ?? ''
return allowedExtensions.has(ext)
})
}, [allowedExtensions, directoriesMode, entries])
const cancel = useCallback(() => resolveRemotePathPicker([]), [])
const confirm = useCallback(() => {
if (directoriesMode) {
resolveRemotePathPicker([dir])
return
}
if (selected.size > 0) {
resolveRemotePathPicker([...selected])
}
}, [dir, directoriesMode, selected])
const onEntryClick = useCallback(
(entry: HermesReadDirEntry) => {
if (entry.isDirectory) {
void load(entry.path)
return
}
if (directoriesMode) {
return
}
if (!allowMultiple) {
resolveRemotePathPicker([entry.path])
return
}
setSelected(prev => {
const next = new Set(prev)
if (next.has(entry.path)) {
next.delete(entry.path)
} else {
next.add(entry.path)
}
return next
})
},
[allowMultiple, directoriesMode, load]
)
const parent = parentDir(dir)
const title = options.title || (directoriesMode ? 'Select a folder' : 'Select files')
const confirmLabel = directoriesMode ? 'Use this folder' : `Attach${selected.size > 1 ? ` (${selected.size})` : ''}`
const confirmDisabled = directoriesMode ? !dir : selected.size === 0
return (
<Dialog onOpenChange={value => !value && cancel()} open>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
<Button
aria-label="Up one folder"
disabled={!parent || loading}
onClick={() => parent && void load(parent)}
size="icon-xs"
variant="ghost"
>
<Codicon name="arrow-up" size="0.9rem" />
</Button>
<span className="truncate font-mono" title={dir}>
{dir || '…'}
</span>
</div>
<div className="h-72 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-background/40">
{loading ? (
<div className="flex h-full items-center justify-center">
<Loader />
</div>
) : error ? (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-destructive">
Could not read this folder ({error}).
</div>
) : visibleEntries.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)">
{directoriesMode ? 'No subfolders here.' : 'No matching files here.'}
</div>
) : (
<ul className="py-1">
{visibleEntries.map(entry => {
const isSelected = selected.has(entry.path)
return (
<li key={entry.path}>
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-(--chrome-action-hover)',
isSelected && 'bg-(--chrome-action-hover)'
)}
onClick={() => onEntryClick(entry)}
type="button"
>
<Codicon
className={entry.isDirectory ? 'text-(--ui-accent)' : 'text-(--ui-text-tertiary)'}
name={entry.isDirectory ? 'folder' : 'file'}
size="0.95rem"
/>
<span className="flex-1 truncate">{entry.name}</span>
{!entry.isDirectory && isSelected && <Codicon name="check" size="0.9rem" />}
{entry.isDirectory && <Codicon name="chevron-right" size="0.85rem" />}
</button>
</li>
)
})}
</ul>
)}
</div>
<DialogFooter>
<Button onClick={cancel} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={confirmDisabled} onClick={confirm} type="button">
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,7 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = fsGitRoot(key)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
const listing = await fsReadDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -138,11 +131,7 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
}
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
if (!window.hermesDesktop) {
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await fsReadDir(dirPath)
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { selectPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -68,7 +69,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -437,11 +437,18 @@ export function useMessageStream({
const completedState = updateSessionState(sessionId, state => {
// Late completion from an already-cancelled turn: cancelRun has
// already finalized the bubble and added the [interrupted] marker;
// re-running the dedupe below would erase that marker and replace
// the partial with the (just-cancelled) full text.
// already finalized the bubble (kept the partial text, dropped it if
// empty). Re-running the dedupe below would replace the partial with
// the just-cancelled full text, so we settle and bail instead.
if (state.interrupted) {
return state
return {
...state,
awaitingResponse: false,
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null
}
}
const streamId = state.streamId
@@ -9,6 +9,8 @@ import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn(),
transcribeAudio: vi.fn()
}))
@@ -39,27 +41,31 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
interface HarnessHandle {
submitText: (text: string) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
busyRef,
onReady,
onSeedState,
refreshSessions,
requestGateway
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const busyRef = { current: false }
const localBusyRef = busyRef ?? { current: false }
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef,
busyRef: localBusyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
handleSkinCommand: () => '',
refreshSessions,
@@ -67,8 +73,18 @@ function Harness({
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) =>
updater({ messages: [], busy: false, awaitingResponse: false } as never)
updateSessionState: (_sessionId, updater) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
const next = updater({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never) as unknown as Record<string, unknown>
onSeedState?.(next)
return next as never
}
})
useEffect(() => {
@@ -164,3 +180,82 @@ describe('usePromptActions /title', () => {
expect($sessions.get()[0]?.title).toBe('Old title')
})
})
describe('usePromptActions submit / queue drain semantics', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('clears a leftover interrupted flag on a fresh submit (so the new turn streams)', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('hello after a stop')
// The optimistic seed must reset interrupted:false even though the prior
// session state had interrupted:true — otherwise the message stream drops
// every delta of this brand-new turn.
expect(seeds.length).toBeGreaterThan(0)
expect(seeds.every(s => s.interrupted === false)).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'hello after a stop'
})
})
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
// drained queue send would otherwise hit the busy guard and silently no-op.
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('queued message', { fromQueue: true })
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'queued message'
})
})
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('should be blocked')
expect(accepted).toBe(false)
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
})
})
@@ -2,10 +2,9 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
INTERRUPTED_MARKER,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
@@ -178,6 +177,42 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
// Remote gateways (e.g. a VPS over tailscale) cannot see the client's local
// filesystem, so a path-based `image.attach` fails with "image not found".
// Fall back to uploading the bytes the Electron client already holds.
const uploadImageAttachmentBytes = useCallback(
async (sessionId: string, attachment: ComposerAttachment): Promise<ImageAttachResponse | null> => {
const path = attachment.path
if (!path) {
return null
}
let data = attachment.previewUrl
if (!data && window.hermesDesktop?.readFileDataUrl) {
try {
data = await window.hermesDesktop.readFileDataUrl(path)
} catch {
return null
}
}
if (!data) {
return null
}
const result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
filename: pathLabel(path),
data
})
return result.attached ? result : null
},
[requestGateway]
)
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
@@ -192,14 +227,28 @@ export function usePromptActions({
continue
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
let result: ImageAttachResponse | null = null
if (!result.attached) {
try {
const pathResult = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (pathResult.attached) {
result = pathResult
}
} catch {
result = null
}
if (!result) {
result = await uploadImageAttachmentBytes(sessionId, attachment)
}
if (!result?.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
throw new Error(result?.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
@@ -215,7 +264,7 @@ export function usePromptActions({
}
}
},
[requestGateway]
[requestGateway, uploadImageAttachmentBytes]
)
const submitPromptText = useCallback(
@@ -237,7 +286,11 @@ export function usePromptActions({
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
if (!text || (!options?.fromQueue && busyRef.current)) {
return false
}
@@ -270,7 +323,10 @@ export function usePromptActions({
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: state.interrupted
// Fresh submit = new turn — clear any leftover interrupt flag, else
// mutateStream/completeAssistantMessage drop every delta of this turn
// (what made drained-after-interrupt sends go silent).
interrupted: false
}),
selectedStoredSessionIdRef.current
)
@@ -531,6 +587,7 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
@@ -689,24 +746,24 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
const finalizeMessages = (messages: ChatMessage[]) =>
messages.map(message =>
message.pending
? {
...message,
parts: chatMessageText(message).trim()
? appendTextPart(message.parts, INTERRUPTED_MARKER)
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
pending: false
}
: message
)
// Interrupting keeps whatever was already generated and just
// stops — no "[interrupted]" marker. A pending/streaming message with no
// body text is dropped entirely so we never leave an empty bubble behind.
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(
message =>
!((message.pending || message.id === streamId) && !chatMessageText(message).trim())
)
.map(message =>
message.pending || message.id === streamId ? { ...message, pending: false } : message
)
if (!sessionId) {
setMutableRef(busyRef, false)
setBusy(false)
setMessages(finalizeMessages($messages.get()))
return
@@ -715,24 +772,12 @@ export function usePromptActions({
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = streamId
? state.messages.map(message =>
message.id === streamId
? {
...message,
parts: chatMessageText(message).trim()
? appendTextPart(message.parts, INTERRUPTED_MARKER)
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
pending: false
}
: message
)
: finalizeMessages(state.messages)
const messages = finalizeMessages(state.messages, streamId)
return {
...state,
messages,
busy: false,
busy: true,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
@@ -743,6 +788,8 @@ export function usePromptActions({
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch (err) {
setMutableRef(busyRef, false)
setBusy(false)
notifyError(err, 'Stop failed')
}
}, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
@@ -331,7 +331,14 @@ export function useSessionActions({
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
})
const stored = created.stored_session_id ?? null
if (
@@ -453,15 +460,31 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
.then(usage => {
if (isCurrentResume() && usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
})
.catch(() => undefined)
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
return
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
}
}
setFreshDraftReady(false)
@@ -513,7 +536,11 @@ export function useSessionActions({
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96
cols: 96,
// Owning profile: in app-global remote mode one backend serves every
// profile, so the gateway opens this profile's state.db + home to
// resume + persist the right session (no-op for single/launch profile).
...(sessionProfile ? { profile: sessionProfile } : {})
})
if (!isCurrentResume()) {
@@ -747,7 +774,7 @@ export function useSessionActions({
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
}
await deleteSession(storedSessionId)
await deleteSession(storedSessionId, removed?.profile)
clearQueuedPrompts(storedSessionId)
if (closingRuntimeId) {
@@ -823,7 +850,7 @@ export function useSessionActions({
}
try {
await setSessionArchived(storedSessionId, true)
await setSessionArchived(storedSessionId, true, archived?.profile)
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
} catch (err) {
if (archived) {
@@ -2,7 +2,8 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@@ -18,29 +19,31 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
function relativeTime(ms: number | undefined) {
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
if (!ms) {
return 'never'
return a.never
}
const diff = Date.now() - ms
if (diff < 60_000) {
return 'just now'
return a.justNow
}
if (diff < 3_600_000) {
return `${Math.round(diff / 60_000)} min ago`
return a.minAgo(Math.round(diff / 60_000))
}
if (diff < 86_400_000) {
return `${Math.round(diff / 3_600_000)} hours ago`
return a.hoursAgo(Math.round(diff / 3_600_000))
}
return `${Math.round(diff / 86_400_000)} days ago`
return a.daysAgo(Math.round(diff / 86_400_000))
}
export function AboutSettings() {
const { t } = useI18n()
const a = t.settings.about
const version = useStore($desktopVersion)
const status = useStore($updateStatus)
const apply = useStore($updateApply)
@@ -69,21 +72,21 @@ export function AboutSettings() {
let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) {
statusLine = status?.message ?? "This build can't update itself from inside the app."
statusLine = status?.message ?? a.cantUpdate
statusTone = 'error'
} else if (status?.error) {
statusLine = "We couldn't reach the update server."
statusLine = a.cantReach
statusTone = 'error'
} else if (applying) {
statusLine = 'An update is currently installing.'
statusLine = a.installing
statusTone = 'available'
} else if (behind > 0) {
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
statusLine = a.updateReady(behind)
statusTone = 'available'
} else if (status) {
statusLine = "You're on the latest version."
statusLine = a.onLatest
} else {
statusLine = 'Tap "Check now" to look for updates.'
statusLine = a.tapCheck
}
return (
@@ -93,15 +96,15 @@ export function AboutSettings() {
<Sparkles className="size-8" />
</span>
<div>
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
</p>
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title="Updates" />
<SectionHeading icon={RefreshCw} title={a.updates} />
<div
className={cn(
@@ -111,12 +114,19 @@ export function AboutSettings() {
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)}
>
<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 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">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
@@ -126,13 +136,13 @@ export function AboutSettings() {
size="sm"
variant="textStrong"
>
{checking && <Loader2 className="size-3 animate-spin" />}
{checking ? 'Checking…' : 'Check now'}
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? a.checking : a.checkNow}
</Button>
{behind > 0 && supported && !applying && (
<Button onClick={() => openUpdatesWindow()} size="sm">
See what&apos;s new
{a.seeWhatsNew}
</Button>
)}
@@ -146,16 +156,17 @@ export function AboutSettings() {
rel="noreferrer"
target="_blank"
>
Release notes
<ExternalLink className="size-3" />
{a.releaseNotes}
</a>
</Button>
</div>
</div>
<ListRow
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
title="Automatic updates"
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
</div>
</SettingsContent>
@@ -1,16 +1,16 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check } from '@/lib/icons'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { SettingsContent } from './primitives'
import { Pill, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -52,80 +52,193 @@ 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 { t, isSavingLocale, locale, setLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
const locales = Object.keys(LOCALE_META) as Locale[]
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
return
}
triggerHaptic('selection')
try {
await setLocale(code)
triggerHaptic('success')
} catch (error) {
notifyError(error, t.language.saveError)
}
}
return (
<SettingsContent>
<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>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
</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 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">{t.language.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
</div>
<Pill>{LOCALE_META[locale].name}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{locales.map(code => {
const active = locale === code
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)'
)}
disabled={isSavingLocale}
key={code}
onClick={() => void selectLocale(code)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
{LOCALE_META[code].name}
</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)] uppercase tracking-wide text-(--ui-text-tertiary)">
{code}
</div>
</button>
)
})}
</div>
</section>
<section>
<SectionHead
control={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={
[
{ id: 'product', label: 'Product' },
{ id: 'technical', label: 'Technical' }
] as const
}
value={toolViewMode}
/>
}
description="Product hides raw tool payloads; Technical shows full input/output."
title="Tool Call Display"
/>
<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">{a.colorMode}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
</div>
<Pill>{t.settings.modeOptions[mode].label}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
const active = mode === id
const copy = t.settings.modeOptions[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">{copy.label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.description}
</div>
</button>
)
})}
</div>
</section>
<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">
<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">{a.toolViewTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
</div>
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{ id: 'product', label: a.product, description: a.productDesc },
{ id: 'technical', label: a.technical, description: a.technicalDesc }
] 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>
</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">{a.themeTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className="group text-left"
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)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
@@ -133,17 +246,8 @@ export function AppearanceSettings() {
}}
type="button"
>
<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">
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
@@ -152,7 +256,11 @@ export function AppearanceSettings() {
{theme.description}
</div>
</div>
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
{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>
)}
</div>
</button>
)
@@ -13,6 +13,7 @@ import {
getHermesConfigSchema,
saveHermesConfig
} from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
@@ -37,9 +38,20 @@ function ConfigField({
optionLabels?: Record<string, string>
onChange: (value: unknown) => void
}) {
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const { t } = useI18n()
const label =
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
const rawDescription = (
t.settings.fieldDescriptions[schemaKey] ??
FIELD_DESCRIPTIONS[schemaKey] ??
schema.description ??
''
).trim()
const normalizedDesc = normalize(rawDescription)
const description =
+9 -4
View File
@@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'memory.provider': ['', 'builtin', 'honcho'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
export const FIELD_LABELS: Record<string, string> = {
@@ -309,7 +310,8 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
'voice.auto_tts': 'Automatically speak assistant responses.',
'stt.enabled': 'Enable local or provider-backed speech transcription.',
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
'updates.non_interactive_local_changes':
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
}
// Curated desktop config surface: only fields a user might tune from the app.
@@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort'
'delegation.reasoning_effort',
'updates.non_interactive_local_changes'
]
}
]
@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -6,6 +7,7 @@ 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'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
@@ -74,6 +76,23 @@ function ModeCard({
)
}
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
return (
<button
className={cn(
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
{label}
</button>
)
}
export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -83,6 +102,16 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
const [scope, setScope] = useState<null | string>(null)
const profiles = useStore($profiles)
useEffect(() => {
void refreshActiveProfile()
}, [])
// 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).
@@ -100,8 +129,14 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}
setLoading(true)
// Clear scope-local entry state so a token from one scope can't leak into
// the next when switching profiles.
setRemoteToken('')
setLastTest(null)
desktop
.getConnectionConfig()
.getConnectionConfig(scope)
.then(config => {
if (cancelled) {
return
@@ -117,7 +152,7 @@ export function GatewaySettings() {
})
return () => void (cancelled = true)
}, [])
}, [scope])
// 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
@@ -223,6 +258,10 @@ export function GatewaySettings() {
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
// The 'default' profile uses the global ("All profiles") connection, so the
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@@ -239,6 +278,7 @@ export function GatewaySettings() {
const payload = () => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -296,6 +336,7 @@ export function GatewaySettings() {
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
@@ -305,7 +346,7 @@ export function GatewaySettings() {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
@@ -327,7 +368,7 @@ export function GatewaySettings() {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
@@ -357,6 +398,7 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -395,10 +437,35 @@ export function GatewaySettings() {
</div>
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
its own remote host.
</p>
</div>
{namedProfiles.length > 0 ? (
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
Applies to
</div>
<div className="flex flex-wrap gap-1.5">
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
{namedProfiles.map(profile => (
<ScopeChip
active={scope === profile.name}
key={profile.name}
label={profile.name}
onSelect={() => setScope(profile.name)}
/>
))}
</div>
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{scope === null
? 'Default connection for every profile that has no override of its own.'
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
</p>
</div>
) : null}
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
+15 -13
View File
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
@@ -34,6 +35,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
]
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
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.
@@ -64,12 +66,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, 'Export failed')
notifyError(err, t.settings.exportFailed)
}
}
const resetConfig = async () => {
if (!window.confirm('Reset all settings to Hermes defaults?')) {
if (!window.confirm(t.settings.resetConfirm)) {
return
}
@@ -78,12 +80,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, 'Reset failed')
notifyError(err, t.settings.resetFailed)
}
}
return (
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
@@ -94,7 +96,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
active={activeView === view}
icon={s.icon}
key={s.id}
label={s.label}
label={t.settings.sections[s.id] ?? s.label}
onClick={() => setActiveView(view)}
/>
)
@@ -127,13 +129,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
label="Gateway"
label={t.settings.nav.gateway}
onClick={() => setActiveView('gateway')}
/>
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="Tools & Keys"
label={t.settings.nav.apiKeys}
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
@@ -157,29 +159,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label="MCP"
label={t.settings.nav.mcp}
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label="Archived Chats"
label={t.settings.nav.archivedChats}
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
label="About"
label={t.settings.nav.about}
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<Tip label="Export config">
<Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Import config">
<Tip label={t.settings.importConfig}>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
@@ -189,7 +191,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<IconUpload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Reset to defaults">
<Tip label={t.settings.resetToDefaults}>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
@@ -57,7 +57,7 @@ export function SessionsSettings() {
setBusyId(session.id)
try {
await setSessionArchived(session.id, false)
await setSessionArchived(session.id, false, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
// Surface it again in the sidebar without waiting for a full refresh.
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
@@ -78,7 +78,7 @@ export function SessionsSettings() {
setBusyId(session.id)
try {
await deleteSession(session.id)
await deleteSession(session.id, session.profile)
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
triggerHaptic('warning')
} catch (err) {
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
@@ -44,6 +45,7 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const hapticsMuted = useStore($hapticsMuted)
@@ -76,7 +78,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
@@ -85,12 +87,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: 'Swap sidebar sides',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
},
title: 'Swap the sessions and file browser sides'
title: t.titlebar.swapSidebarSidesTitle
},
...leftTools
]
@@ -98,7 +100,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const rightSidebarTool: TitlebarTool = {
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
@@ -111,13 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',
label: 'Open settings',
label: t.titlebar.openSettings,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
@@ -199,6 +201,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
@@ -221,6 +224,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>
+1 -1
View File
@@ -81,7 +81,7 @@ describe('SkillsView toolset management', () => {
await renderSkills()
expect(screen.getByText('Cron Jobs')).toBeTruthy()
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
expect(screen.queryByText(/⏰/)).toBeNull()
})
+77 -57
View File
@@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, 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 { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { useI18n } from '@/i18n'
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, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -70,33 +72,39 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
}
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
const { t } = useI18n()
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
const [query, setQuery] = useState('')
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')
notifyError(err, t.skills.skillsLoadFailed)
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshCapabilities)
}, [t])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
.catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
}, [t])
useRefreshHotkey(refreshCapabilities)
useEffect(() => {
void refreshCapabilities()
@@ -148,11 +156,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
title: enabled ? t.skills.skillEnabled : t.skills.skillDisabled,
message: t.skills.appliesToNewSessions(skill.name)
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
notifyError(err, t.skills.failedToUpdate(skill.name))
} finally {
setSavingSkill(null)
}
@@ -169,11 +177,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
})
} catch (err) {
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
} finally {
setSavingToolset(null)
}
@@ -183,54 +191,66 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
</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 active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</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)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
))}
</>
) : undefined
{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>
)}
</>
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</TextTab>
</>
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
searchTrailingAction={
<Button
aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
title={refreshing ? t.skills.refreshing : t.skills.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
<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)">
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -239,7 +259,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{asText(skill.description) || 'No description.'}
{asText(skill.description) || t.skills.noDescription}
</p>
</div>
<Switch
@@ -256,15 +276,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
</div>
<div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
@@ -277,19 +297,19 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label={t.skills.configureToolset(label)}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() =>
setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
}
type="button"
>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
{toolset.configured ? t.skills.configured : t.skills.needsKeys}
</StatusPill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
aria-label={t.skills.toggleToolset(label)}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
@@ -297,7 +317,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || 'No description.'}
{asText(toolset.description) || t.skills.noDescription}
</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
@@ -117,10 +117,6 @@ function messageContentText(content: unknown): string {
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
export const Thread: FC<{
clampToComposer?: boolean
cwd?: string | null
@@ -220,7 +216,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
if (isPlaceholder) {
@@ -236,10 +231,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
ref={enterRef}
>
<div
className={cn(
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
)}
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
@@ -260,7 +252,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{messageText.trim().length > 0 && !interruptedOnly && (
{messageText.trim().length > 0 && (
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
@@ -27,6 +28,7 @@ type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
export function BootFailureOverlay() {
const boot = useStore($desktopBoot)
const onboarding = useStore($desktopOnboarding)
const { t } = useI18n()
const [busy, setBusy] = useState<BusyAction>(null)
const [logs, setLogs] = useState<string[]>([])
const [showLogs, setShowLogs] = useState(false)
@@ -141,7 +143,7 @@ export function BootFailureOverlay() {
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
if (result?.connected) {
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
notify({ kind: 'success', title: t.boot.failure.signedInTitle, message: t.boot.failure.signedInMessage })
window.location.reload()
return
@@ -149,19 +151,24 @@ export function BootFailureOverlay() {
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
} catch (err) {
notifyError(err, 'Sign-in failed')
notifyError(err, t.boot.failure.signInFailed)
} finally {
setBusy(null)
}
}
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const copy = t.boot.failure
const label = signInLabel(remoteReauth)
const label = signInLabel(remoteReauth, {
identityProvider: copy.identityProvider,
remoteGateway: copy.signInToRemoteGateway,
withProvider: copy.signInWithProvider
})
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
@@ -172,12 +179,10 @@ export function BootFailureOverlay() {
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
{remoteReauth ? copy.remoteTitle : copy.title}
</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
{remoteReauth
? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
{remoteReauth ? copy.remoteDescription : copy.description}
</p>
</div>
</div>
@@ -197,28 +202,26 @@ export function BootFailureOverlay() {
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
{copy.retry}
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway
{copy.useLocalGateway}
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
Open logs
{copy.openLogs}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{remoteReauth
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
{remoteReauth ? copy.remoteSignInHint : copy.repairHint}
</p>
</div>
@@ -229,7 +232,7 @@ export function BootFailureOverlay() {
onClick={() => setShowLogs(v => !v)}
type="button"
>
{showLogs ? 'Hide' : 'Show'} recent logs
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
@@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
return {
envOverride: false,
mode: 'remote',
profile: null,
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,
@@ -14,6 +14,18 @@ export interface RemoteReauth {
providerLabel: string
}
interface SignInCopy {
identityProvider: string
remoteGateway: string
withProvider: (provider: string) => string
}
const DEFAULT_SIGN_IN_COPY: SignInCopy = {
identityProvider: 'your identity provider',
remoteGateway: 'Sign in to remote gateway',
withProvider: provider => `Sign in with ${provider}`
}
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
@@ -58,10 +70,12 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
}
// Button copy for the remote sign-in action.
export function signInLabel(reauth: RemoteReauth | null): string {
export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFAULT_SIGN_IN_COPY): string {
if (reauth?.isPassword) {
return 'Sign in to remote gateway'
return copy.remoteGateway
}
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
return copy.withProvider(provider ?? copy.identityProvider)
}
@@ -0,0 +1,143 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'
import { $gatewayState, setGatewayState } from '@/store/session'
import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.
//
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
// INITIAL boot() throws. After the first successful connect (bootCompleted),
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
// against the dead remote and never sets boot.error. So gatewayState sits at
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
// never appears, settings unreachable.
function resetStores() {
setGatewayState('idle')
$desktopBoot.set({
error: null,
fakeMode: false,
message: 'ready',
phase: 'renderer.ready',
progress: 100,
running: false,
timestamp: Date.now(),
visible: false
})
$desktopOnboarding.set({
configured: true,
flow: { status: 'idle' },
mode: 'oauth',
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
}
beforeEach(resetStores)
afterEach(cleanup)
// The connecting overlay renders "CONN" + a scrambled tail inside one
// uppercase span; match that node specifically so the recovery overlay's
// "Lost connection…" copy doesn't read as a false positive.
const isConnectingShown = () =>
screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0
const isRecoveryShown = () =>
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
describe('connecting overlay vs recovery surface', () => {
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
// failDesktopBoot() ran: error set, gateway never opened.
$desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true })
setGatewayState('error')
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect(isRecoveryShown()).toBe(true)
// Connecting overlay bows out when boot.error is set.
expect(isConnectingShown()).toBe(false)
})
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
const { rerender } = render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect(isConnectingShown()).toBe(false)
// 2. The remote VPS socket drops (sleep/wake, remote restart, network).
// bootCompleted is true, so useGatewayBoot routes this through
// scheduleReconnect() — boot.error stays NULL.
setGatewayState('closed')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
// The connecting overlay reappears and latches...
expect(isConnectingShown()).toBe(true)
// ...with NO recovery surface, because boot.error was never set.
expect(isRecoveryShown()).toBe(false)
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
// closed → error → closed, boot.error never gets set. The user is
// pinned on CONNECTING with no path to Settings indefinitely.
setGatewayState('error')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
expect($desktopBoot.get().error).toBeNull()
expect(isConnectingShown()).toBe(true)
expect(isRecoveryShown()).toBe(false)
})
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI
// from the dead-end CONNECTING overlay to the recovery surface.
setGatewayState('error')
$desktopBoot.set({
...$desktopBoot.get(),
error: 'Lost connection to the Hermes gateway and could not reconnect.',
running: false,
visible: true
})
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
// Escape hatch is now reachable; the connecting overlay bows out.
expect(isRecoveryShown()).toBe(true)
expect(screen.getByText(/use local gateway/i)).toBeTruthy()
expect(isConnectingShown()).toBe(false)
})
})
+16 -8
View File
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -30,8 +31,10 @@ const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
export function NotificationStack() {
const notifications = useStore($notifications)
const { t } = useI18n()
const lastNotificationIdRef = useRef<string | null>(null)
const [expanded, setExpanded] = useState(false)
const copy = t.notifications
useEffect(() => {
if (notifications.length <= 1) {
@@ -72,7 +75,7 @@ export function NotificationStack() {
// scope, so fall back to its constant (34px) when mounted on <body>.
return createPortal(
<div
aria-label="Notifications"
aria-label={copy.region}
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
@@ -81,10 +84,10 @@ export function NotificationStack() {
{overflowCount > 0 && (
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
</button>
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
Clear all
{copy.clearAll}
</button>
</div>
)}
@@ -97,6 +100,8 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
const { t } = useI18n()
const copy = t.notifications
return (
<Alert
@@ -126,7 +131,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
</AlertDescription>
</div>
<button
aria-label="Dismiss notification"
aria-label={copy.dismiss}
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => dismissNotification(notification.id)}
type="button"
@@ -138,9 +143,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
}
function NotificationDetail({ detail }: { detail: string }) {
const { t } = useI18n()
const copy = t.notifications
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
@@ -148,12 +156,12 @@ function NotificationDetail({ detail }: { detail: string }) {
<CopyButton
appearance="inline"
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
errorMessage="Could not copy notification detail"
errorMessage={copy.copyDetailFailed}
iconClassName="size-3"
label="Copy detail"
label={copy.copyDetail}
text={detail}
>
Copy detail
{copy.copyDetail}
</CopyButton>
</div>
</details>
+10 -1
View File
@@ -12,7 +12,7 @@ declare global {
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
@@ -190,6 +190,9 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
@@ -199,6 +202,9 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
@@ -366,6 +372,9 @@ export interface HermesReadDirEntry {
export interface HermesReadDirResult {
entries: HermesReadDirEntry[]
error?: string
// Absolute directory the entries were read from. Set by the gateway `fs.list`
// RPC (remote backends); the local Electron readDir omits it.
path?: string
}
export interface HermesPreviewFileChanged {
+21 -4
View File
@@ -29,6 +29,7 @@ import type {
OAuthSubmitResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionMessagesResponse,
@@ -80,6 +81,7 @@ export type {
PaginatedSessions,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
RpcEvent,
@@ -166,8 +168,13 @@ export async function listAllProfileSessions(
}
}
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
// Mutations take the owning `profile` so Electron routes them to that profile's
// backend (remote pool or local primary) via request.profile — matching the
// read path. A remote session's row lives only on its remote host, so a mutation
// that hit the local primary would no-op or 404. Omit for the current/default.
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
@@ -180,8 +187,10 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
})
}
// `profile` reads another profile's transcript straight off its state.db via the
// primary backend (no spawn). Omit for the current/default profile.
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for
// the current/default profile.
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
@@ -190,8 +199,9 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
})
}
export function deleteSession(id: string): Promise<{ ok: boolean }> {
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'DELETE'
})
@@ -203,6 +213,7 @@ export function renameSession(
profile?: string | null
): Promise<{ ok: boolean; title: string }> {
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title, ...(profile ? { profile } : {}) }
@@ -554,6 +565,12 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return window.hermesDesktop.api<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),
+8
View File
@@ -0,0 +1,8 @@
import { en } from './en'
import type { Locale, Translations } from './types'
import { zh } from './zh'
export const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh
}
+168
View File
@@ -0,0 +1,168 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesConfigRecord } from '@/hermes'
import { type I18nConfigClient, I18nProvider, useI18n } from './context'
import type { Locale } from './types'
function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
const { isLoadingConfig, isSavingLocale, locale, saveError, setLocale, t } = useI18n()
return (
<div>
<p data-testid="locale">{locale}</p>
<p data-testid="label">{t.language.label}</p>
<p data-testid="loading">{String(isLoadingConfig)}</p>
<p data-testid="saving">{String(isSavingLocale)}</p>
<p data-testid="save-error">{saveError?.message ?? ''}</p>
<button onClick={() => void setLocale(target).catch(() => undefined)} type="button">
switch
</button>
</div>
)
}
describe('I18nProvider', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('defaults to English without a config client', () => {
render(
<I18nProvider configClient={null}>
<LanguageProbe />
</I18nProvider>
)
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
})
it('normalizes an initial locale alias and switches translations', async () => {
render(
<I18nProvider configClient={null} initialLocale="zh-CN">
<LanguageProbe target="en" />
</I18nProvider>
)
expect(screen.getByTestId('locale').textContent).toBe('zh')
expect(screen.getByTestId('label').textContent).toBe('语言')
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
expect(screen.getByTestId('label').textContent).toBe('Language')
})
it('loads the initial locale from display.language config', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-Hans' } }),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('zh')
expect(screen.getByTestId('label').textContent).toBe('语言')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('keeps English usable when config loading fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient} initialLocale="zh">
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('does not overwrite unsupported configured languages', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }),
saveConfig: vi.fn()
}
render(
<I18nProvider configClient={configClient} initialLocale="zh">
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
expect(configClient.saveConfig).not.toHaveBeenCalled()
})
it('reads latest config before saving language and preserves unrelated values', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const latestConfig: HermesConfigRecord = {
display: { language: 'en', skin: 'slate' },
terminal: { cwd: '/new' }
}
const configClient: I18nConfigClient = {
getConfig: vi
.fn()
.mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' }, terminal: { cwd: '/old' } })
.mockResolvedValueOnce(latestConfig),
saveConfig
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
expect(saveConfig).toHaveBeenCalledWith({
display: { language: 'zh', skin: 'slate' },
terminal: { cwd: '/new' }
})
})
it('rolls back the visible locale when saving fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
saveConfig: vi.fn().mockRejectedValue(new Error('save failed'))
}
render(
<I18nProvider configClient={configClient}>
<LanguageProbe />
</I18nProvider>
)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('save failed'))
expect(screen.getByTestId('locale').textContent).toBe('en')
expect(screen.getByTestId('label').textContent).toBe('Language')
})
})
+183
View File
@@ -0,0 +1,183 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes'
import { TRANSLATIONS } from './catalog'
import { DEFAULT_LOCALE, localeConfigValue, normalizeLocale } from './languages'
import { setRuntimeI18nLocale } from './runtime'
import type { Locale, Translations } from './types'
export { LOCALE_META } from './languages'
export interface I18nConfigClient {
getConfig: () => Promise<HermesConfigRecord>
saveConfig: (config: HermesConfigRecord) => Promise<{ ok: boolean }>
}
const defaultConfigClient: I18nConfigClient = {
getConfig: () => {
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
return Promise.resolve({})
}
return getHermesConfigRecord()
},
saveConfig: config => {
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
return Promise.resolve({ ok: true })
}
return saveHermesConfig(config)
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function getConfigDisplayLanguage(config: HermesConfigRecord): unknown {
return isRecord(config.display) ? config.display.language : undefined
}
export function withConfigDisplayLanguage(config: HermesConfigRecord, locale: Locale): HermesConfigRecord {
const display = isRecord(config.display) ? config.display : {}
return {
...config,
display: {
...display,
language: localeConfigValue(locale)
}
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export interface I18nContextValue {
configLoadError: Error | null
isLoadingConfig: boolean
isSavingLocale: boolean
locale: Locale
saveError: Error | null
setLocale: (next: Locale) => Promise<void>
t: Translations
}
const I18nContext = createContext<I18nContextValue>({
configLoadError: null,
isLoadingConfig: false,
isSavingLocale: false,
locale: DEFAULT_LOCALE,
saveError: null,
setLocale: async () => {},
t: TRANSLATIONS[DEFAULT_LOCALE]
})
export interface I18nProviderProps {
children: ReactNode
configClient?: I18nConfigClient | null
initialLocale?: unknown
}
export function I18nProvider({ children, configClient = defaultConfigClient, initialLocale }: I18nProviderProps) {
const [locale, setLocaleState] = useState<Locale>(() => normalizeLocale(initialLocale))
const [isLoadingConfig, setIsLoadingConfig] = useState(false)
const [isSavingLocale, setIsSavingLocale] = useState(false)
const [configLoadError, setConfigLoadError] = useState<Error | null>(null)
const [saveError, setSaveError] = useState<Error | null>(null)
const localeRef = useRef(locale)
useEffect(() => {
localeRef.current = locale
setRuntimeI18nLocale(locale)
}, [locale])
useEffect(() => {
if (!configClient) {
return
}
let cancelled = false
setIsLoadingConfig(true)
setConfigLoadError(null)
configClient
.getConfig()
.then(config => {
if (!cancelled) {
setLocaleState(normalizeLocale(getConfigDisplayLanguage(config)))
}
})
.catch(error => {
if (!cancelled) {
setConfigLoadError(toError(error))
setLocaleState(DEFAULT_LOCALE)
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingConfig(false)
}
})
return () => {
cancelled = true
}
}, [configClient, initialLocale])
const setLocale = useCallback(
async (next: Locale) => {
const previousLocale = localeRef.current
setSaveError(null)
setLocaleState(next)
if (!configClient) {
return
}
setIsSavingLocale(true)
try {
const latestConfig = await configClient.getConfig()
const result = await configClient.saveConfig(withConfigDisplayLanguage(latestConfig, next))
if (!result.ok) {
throw new Error('Failed to save language')
}
} catch (error) {
const nextError = toError(error)
setLocaleState(previousLocale)
setSaveError(nextError)
throw nextError
} finally {
setIsSavingLocale(false)
}
},
[configClient]
)
const value = useMemo<I18nContextValue>(
() => ({
configLoadError,
isLoadingConfig,
isSavingLocale,
locale,
saveError,
setLocale,
t: TRANSLATIONS[locale]
}),
[configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale]
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
return useContext(I18nContext)
}
+745
View File
@@ -0,0 +1,745 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import type { Translations } from './types'
export const en: Translations = {
common: {
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
delete: 'Delete',
refresh: 'Refresh',
retry: 'Retry',
on: 'On',
off: 'Off'
},
boot: {
ready: 'Hermes Desktop is ready',
desktopBootFailedWithMessage: message => `Desktop boot failed: ${message}`,
steps: {
connectingGateway: 'Connecting live desktop gateway',
loadingSettings: 'Loading Hermes settings',
loadingSessions: 'Loading recent sessions',
startingDesktopConnection: 'Starting desktop connection',
startingHermesDesktop: 'Starting Hermes Desktop…'
},
errors: {
backgroundExited: 'Hermes background process exited.',
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
backendStopped: 'Backend stopped',
desktopBootFailed: 'Desktop boot failed',
gatewaySignInRequired: 'Gateway sign-in required',
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
},
failure: {
title: "Hermes couldn't start",
description:
"The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
remoteTitle: 'Remote gateway sign-in required',
remoteDescription:
'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.',
retry: 'Retry',
repairInstall: 'Repair install',
useLocalGateway: 'Use local gateway',
openLogs: 'Open logs',
repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.',
remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.',
hideRecentLogs: 'Hide recent logs',
showRecentLogs: 'Show recent logs',
signedInTitle: 'Signed in',
signedInMessage: 'Reconnecting to the remote gateway…',
signInIncompleteTitle: 'Sign-in incomplete',
signInIncompleteMessage: 'The login window closed before authentication finished.',
signInFailed: 'Sign-in failed',
signInToRemoteGateway: 'Sign in to remote gateway',
signInWithProvider: provider => `Sign in with ${provider}`,
identityProvider: 'your identity provider'
}
},
notifications: {
region: 'Notifications',
hide: 'Hide',
show: 'Show',
more: count => `${count} more ${count === 1 ? 'notification' : 'notifications'}`,
clearAll: 'Clear all',
dismiss: 'Dismiss notification',
details: 'Details',
copyDetail: 'Copy detail',
copyDetailFailed: 'Could not copy notification detail',
backendOutOfDateTitle: 'Backend out of date',
backendOutOfDateMessage:
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
updateHermes: 'Update Hermes',
updateReadyTitle: 'Update ready',
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
seeWhatsNew: "See what's new",
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
methodNotAllowed:
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
microphonePermission: 'Microphone permission was denied.',
openaiRejectedApiKey: 'OpenAI rejected the API key.',
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
}
},
titlebar: {
hideSidebar: 'Hide sidebar',
showSidebar: 'Show sidebar',
search: 'Search',
searchTitle: 'Search sessions, views, and actions',
swapSidebarSides: 'Swap sidebar sides',
swapSidebarSidesTitle: 'Swap the sessions and file browser sides',
hideRightSidebar: 'Hide right sidebar',
showRightSidebar: 'Show right sidebar',
muteHaptics: 'Mute haptics',
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings'
},
language: {
label: 'Language',
description: 'Choose the language for the desktop interface.',
saving: 'Saving language…',
saveError: 'Language update failed'
},
settings: {
closeSettings: 'Close settings',
exportConfig: 'Export config',
importConfig: 'Import config',
resetToDefaults: 'Reset to defaults',
resetConfirm: 'Reset all settings to Hermes defaults?',
exportFailed: 'Export failed',
resetFailed: 'Reset failed',
nav: {
gateway: 'Gateway',
apiKeys: 'Tools & Keys',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About'
},
sections: {
model: 'Model',
chat: 'Chat',
appearance: 'Appearance',
workspace: 'Workspace',
safety: 'Safety',
memory: 'Memory & Context',
voice: 'Voice',
advanced: 'Advanced'
},
searchPlaceholder: {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
},
modeOptions: {
light: { label: 'Light', description: 'Bright desktop surfaces' },
dark: { label: 'Dark', description: 'Low-glare workspace' },
system: { label: 'System', description: 'Follow OS appearance' }
},
appearance: {
title: 'Appearance',
intro:
'These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and chat surface styling.',
colorMode: 'Color Mode',
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
toolViewTitle: 'Tool Call Display',
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
product: 'Product',
productDesc: 'Human-friendly tool activity with concise summaries.',
technical: 'Technical',
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
about: {
heading: 'Hermes Desktop',
version: value => `Version ${value}`,
versionUnavailable: 'Version unavailable',
updates: 'Updates',
checkNow: 'Check now',
checking: 'Checking…',
seeWhatsNew: "See what's new",
releaseNotes: 'Release notes',
onLatest: "You're on the latest version.",
installing: 'An update is currently installing.',
cantUpdate: "This build can't update itself from inside the app.",
cantReach: "We couldn't reach the update server.",
tapCheck: 'Tap "Check now" to look for updates.',
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
lastChecked: age => `Last checked ${age}`,
justNowSuffix: ' · just now',
automaticUpdates: 'Automatic updates',
automaticUpdatesDesc:
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
never: 'never',
justNow: 'just now',
minAgo: count => `${count} min ago`,
hoursAgo: count => `${count} hours ago`,
daysAgo: count => `${count} days ago`
}
},
skills: {
tabSkills: 'Skills',
tabToolsets: 'Toolsets',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search toolsets...',
refresh: 'Refresh skills',
refreshing: 'Refreshing skills',
loading: 'Loading capabilities...',
noSkillsTitle: 'No skills found',
noSkillsDesc: 'Try a broader search or different category.',
noToolsetsTitle: 'No toolsets found',
noToolsetsDesc: 'Try a broader search query.',
noDescription: 'No description.',
configured: 'Configured',
needsKeys: 'Needs keys',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
configureToolset: label => `Configure ${label}`,
toggleToolset: label => `Toggle ${label} toolset`,
skillsLoadFailed: 'Skills failed to load',
toolsetsRefreshFailed: 'Toolsets failed to refresh',
skillEnabled: 'Skill enabled',
skillDisabled: 'Skill disabled',
toolsetEnabled: 'Toolset enabled',
toolsetDisabled: 'Toolset disabled',
appliesToNewSessions: name => `${name} applies to new sessions.`,
failedToUpdate: name => `Failed to update ${name}`
},
agents: {
close: 'Close agents',
title: 'Spawn tree',
subtitle: 'Live subagent activity for the current turn.',
emptyTitle: 'No live subagents',
emptyDesc: 'When a turn delegates work, child agents stream their progress here.',
running: 'Running',
failed: 'Failed',
done: 'Done',
streaming: 'Streaming',
files: 'Files',
moreFiles: count => `+${count} more files`,
delegation: index => `Delegation ${index}`,
workers: count => `${count} workers`,
workersActive: count => `${count} active`,
agentsCount: count => `${count} ${count === 1 ? 'agent' : 'agents'}`,
activeCount: count => `${count} active`,
failedCount: count => `${count} failed`,
toolsCount: count => `${count} tools`,
filesCount: count => `${count} files`,
updatedAgo: age => `updated ${age}`,
ageNow: 'now',
ageSeconds: seconds => `${seconds}s ago`,
ageMinutes: minutes => `${minutes}m ago`,
ageHours: hours => `${hours}h ago`,
durationSeconds: seconds => `${seconds}s`,
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
tokensK: k => `${k}k tok`,
tokens: value => `${value} tok`
},
commandCenter: {
close: 'Close command center',
searchPlaceholder: 'Search sessions, views, and actions',
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
sectionDescriptions: {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
usage: 'Token, cost, and skill activity over time'
},
nav: {
newChat: { title: 'New session', detail: 'Start a fresh session' },
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
},
sectionEntries: {
sessions: { title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
system: { title: 'System panel', detail: 'Gateway status, logs, restart/update' },
usage: { title: 'Usage panel', detail: 'Token, cost, and skill activity' }
},
providerNavigate: 'Navigate',
providerSessions: 'Sessions',
refresh: 'Refresh',
refreshing: 'Refreshing...',
noResults: 'No matching results found.',
pinSession: 'Pin session',
unpinSession: 'Unpin session',
exportSession: 'Export session',
deleteSession: 'Delete session',
noSessions: 'No sessions yet.',
gatewayRunning: 'Messaging gateway running',
gatewayStopped: 'Messaging gateway stopped',
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
restartMessaging: 'Restart messaging',
updateHermes: 'Update Hermes',
actionRunning: 'running',
actionDone: 'done',
actionFailed: 'failed',
actionStartedWaiting: 'Action started, waiting for status...',
loadingStatus: 'Loading status...',
recentLogs: 'Recent logs',
noLogs: 'No logs loaded yet.',
days: count => `${count}d`,
statSessions: 'Sessions',
statApiCalls: 'API calls',
statTokens: 'Tokens in/out',
statCost: 'Est. cost',
actualCost: cost => `actual ${cost}`,
loadingUsage: 'Loading usage...',
noUsage: period => `No usage in the last ${period} days.`,
retry: 'Retry',
dailyTokens: 'Daily tokens',
input: 'input',
output: 'output',
noDailyActivity: 'No daily activity.',
topModels: 'Top models',
noModelUsage: 'No model usage yet.',
topSkills: 'Top skills',
noSkillActivity: 'No skill activity yet.',
actions: count => `${count} actions`
},
messaging: {
search: 'Search messaging...',
loading: 'Loading messaging platforms...',
loadFailed: 'Messaging platforms failed to load',
states: {
connected: 'Connected',
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
startup_failed: 'Startup failed'
},
unknown: 'Unknown',
hintPendingRestart: 'Restart the gateway from the status bar to apply this change.',
hintGatewayStopped: 'Start the gateway from the status bar to connect.',
credentialsSet: 'Credentials set',
needsSetup: 'Needs setup',
gatewayStopped: 'Messaging gateway stopped',
getCredentials: 'Get your credentials',
openSetupGuide: 'Open setup guide',
required: 'Required',
recommended: 'Recommended',
advanced: count => `Advanced (${count})`,
noTokenNeeded: 'This platform does not need a token here. Use the setup guide above, then enable it below.',
enabled: 'Enabled',
disabled: 'Disabled',
unsavedChanges: 'Unsaved changes',
saving: 'Saving...',
saveChanges: 'Save changes',
saved: 'Saved',
replaceValue: 'Replace current value',
openDocs: 'Open docs',
clearField: key => `Clear ${key}`,
enableAria: name => `Enable ${name}`,
disableAria: name => `Disable ${name}`,
platformEnabled: name => `${name} enabled`,
platformDisabled: name => `${name} disabled`,
restartToApply: 'Restart the gateway for this change to take effect.',
setupSaved: name => `${name} setup saved`,
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
keyCleared: key => `${key} cleared`,
setupUpdated: name => `${name} setup was updated.`,
failedUpdate: name => `Failed to update ${name}`,
failedSave: name => `Failed to save ${name}`,
failedClear: key => `Failed to clear ${key}`,
fieldCopy: {},
platformIntro: {}
},
profiles: {
close: 'Close profiles',
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
title: 'Profiles',
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
loading: 'Loading profiles...',
newProfile: 'New profile',
noProfiles: 'No profiles yet.',
selectPrompt: 'Select a profile to view its details.',
refresh: 'Refresh profiles',
refreshing: 'Refreshing profiles',
default: 'default',
skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`,
env: 'env',
defaultBadge: 'Default',
rename: 'Rename',
copySetup: 'Copy setup',
copying: 'Copying...',
modelLabel: 'Model',
skillsLabel: 'Skills',
notSet: 'Not set',
soulDesc: 'The system prompt and persona instructions baked into this profile.',
unsavedChanges: 'Unsaved changes',
loadingSoul: 'Loading SOUL.md...',
emptySoul: 'Empty SOUL.md — start writing the persona...',
saving: 'Saving...',
saveSoul: 'Save SOUL.md',
deleteTitle: 'Delete profile?',
deleteDescPrefix: 'This will delete ',
deleteDescMid: ' and remove its ',
deleteDescSuffix: ' directory. This cannot be undone.',
deleting: 'Deleting...',
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
nameLabel: 'Name',
cloneFromDefault: 'Clone from default',
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
invalidName: hint => `Invalid name. ${hint}`,
nameRequired: 'Name is required.',
creating: 'Creating...',
createAction: 'Create profile',
renameTitle: 'Rename profile',
renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ',
renameDescSuffix: '.',
newNameLabel: 'New name',
renaming: 'Renaming...',
created: 'Profile created',
renamed: 'Profile renamed',
deleted: 'Profile deleted',
setupCopied: 'Setup command copied',
soulSaved: 'SOUL.md saved',
failedLoad: 'Failed to load profiles',
failedDelete: 'Failed to delete profile',
failedCopy: 'Failed to copy setup command',
failedLoadSoul: 'Failed to load SOUL.md',
failedSaveSoul: 'Failed to save SOUL.md',
failedCreate: 'Failed to create profile',
failedRename: 'Failed to rename profile'
},
cron: {
close: 'Close cron',
search: 'Search cron jobs...',
refresh: 'Refresh cron jobs',
refreshing: 'Refreshing cron jobs',
loading: 'Loading cron jobs...',
states: {
enabled: 'enabled',
scheduled: 'scheduled',
running: 'running',
paused: 'paused',
disabled: 'disabled',
error: 'error',
completed: 'completed'
},
deliveryLabels: {
local: 'This desktop',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: 'Email'
},
scheduleLabels: {
daily: 'Daily',
weekdays: 'Weekdays',
weekly: 'Weekly',
monthly: 'Monthly',
hourly: 'Hourly',
'every-15-minutes': 'Every 15 minutes',
custom: 'Custom'
},
scheduleHints: {
daily: 'Every day at 9:00 AM',
weekdays: 'Monday through Friday at 9:00 AM',
weekly: 'Every Monday at 9:00 AM',
monthly: 'The first day of each month at 9:00 AM',
hourly: 'At the top of every hour',
'every-15-minutes': 'Every 15 minutes',
custom: 'Cron syntax or natural language'
},
days: {
'0': 'Sunday',
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
},
dayFallback: value => `day ${value}`,
everyDayAt: time => `Every day at ${time}`,
weekdaysAt: time => `Weekdays at ${time}`,
everyDayOfWeekAt: (day, time) => `Every ${day} at ${time}`,
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
topOfHour: 'At the top of every hour',
everyHourAt: minute => `Every hour at :${minute}`,
active: (enabled, total) => `${enabled}/${total} active`,
newCron: 'New cron',
createFirst: 'Create first cron',
emptyDescNew:
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
emptyDescSearch: 'Try a broader search query.',
emptyTitleNew: 'No scheduled jobs yet',
emptyTitleSearch: 'No matches',
last: 'Last:',
next: 'Next:',
actionsFor: title => `Actions for ${title}`,
actionsTitle: 'Cron job actions',
resume: 'Resume cron',
pause: 'Pause cron',
resumeTitle: 'Resume',
pauseTitle: 'Pause',
triggerNow: 'Trigger now',
edit: 'Edit cron',
deleteTitle: 'Delete cron job?',
deleteDescPrefix: 'This will remove ',
deleteDescSuffix: ' permanently. It will stop firing immediately.',
deleting: 'Deleting...',
resumed: 'Cron resumed',
paused: 'Cron paused',
triggered: 'Cron triggered',
deleted: 'Cron deleted',
created: 'Cron created',
updated: 'Cron updated',
failedLoad: 'Failed to load cron jobs',
failedUpdate: 'Failed to update cron job',
failedTrigger: 'Failed to trigger cron job',
failedDelete: 'Failed to delete cron job',
failedSave: 'Failed to save cron job',
editTitle: 'Edit cron job',
createTitle: 'New cron job',
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
createDesc:
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
nameLabel: 'Name',
namePlaceholder: 'Morning briefing',
promptLabel: 'Prompt',
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
frequencyLabel: 'Frequency',
deliverLabel: 'Deliver to',
customScheduleLabel: 'Custom schedule',
customPlaceholder: '0 9 * * * or weekdays at 9am',
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
optional: 'Optional',
promptScheduleRequired: 'Prompt and schedule are required.',
saveChanges: 'Save changes',
createAction: 'Create cron'
},
artifacts: {
search: 'Search artifacts...',
refresh: 'Refresh artifacts',
refreshing: 'Refreshing artifacts',
indexing: 'Indexing recent session artifacts',
tabAll: 'All',
tabImages: 'Images',
tabFiles: 'Files',
tabLinks: 'Links',
noArtifactsTitle: 'No artifacts found',
noArtifactsDesc: 'Generated images and file outputs will appear here as sessions produce them.',
failedLoad: 'Artifacts failed to load',
openFailed: 'Open failed',
itemsImage: 'images',
itemsLink: 'links',
itemsFile: 'files',
itemsGeneric: 'items',
zero: '0',
rangeOf: (start, end, total) => `${start}-${end} of ${total}`,
goToPage: (itemLabel, page) => `Go to ${itemLabel} page ${page}`,
colTitleLink: 'Link title',
colTitleFile: 'Name',
colTitleDefault: 'Title / name',
colLocationLink: 'URL',
colLocationFile: 'Path',
colLocationDefault: 'Location',
colSession: 'Session',
kindImage: 'image',
kindFile: 'file',
kindLink: 'link',
chat: 'Chat',
copyUrl: 'Copy URL',
copyPath: 'Copy path'
},
sidebar: {
nav: {
'new-session': 'New session',
skills: 'Skills & Tools',
messaging: 'Messaging',
artifacts: 'Artifacts'
},
searchAria: 'Search sessions',
searchPlaceholder: 'Search sessions…',
clearSearch: 'Clear search',
noMatch: query => `No sessions match “${query}”.`,
results: 'Results',
pinned: 'Pinned',
sessions: 'Sessions',
groupAriaGrouped: 'Show sessions as a single list',
groupAriaUngrouped: 'Group sessions by workspace',
groupTitleGrouped: 'Ungroup sessions',
groupTitleUngrouped: 'Group by workspace',
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
shiftClickHint: 'Shift-click a chat to pin · drag to reorder',
noWorkspace: 'No workspace',
newSessionIn: label => `New session in ${label}`,
reorderWorkspace: label => `Reorder workspace ${label}`,
showMoreIn: (count, label) => `Show ${count} more in ${label}`,
loading: 'Loading…',
loadMore: 'Load more',
loadCount: step => `Load ${step} more`,
row: {
pin: 'Pin',
unpin: 'Unpin',
copyId: 'Copy ID',
export: 'Export',
rename: 'Rename',
archive: 'Archive',
copyIdFailed: 'Could not copy session ID',
actionsFor: title => `Actions for ${title}`,
sessionActions: 'Session actions',
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
renameDesc: 'Give this chat a memorable title. Leave empty to clear.',
untitledPlaceholder: 'Untitled session',
ageNow: 'now',
ageDay: 'd',
ageHour: 'h',
ageMin: 'm'
}
},
composer: {
message: 'Message',
placeholderStarting: 'Starting Hermes...',
placeholderReconnecting: 'Reconnecting to Hermes…',
placeholderFollowUp: 'Send follow-up',
newSessionPlaceholders: [
'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'
],
followUpPlaceholders: [
'Send a follow-up',
'Add more context',
'Refine the request',
"What's next?",
'Keep it going',
'Push it further',
'Adjust or continue'
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
transcribing: 'Transcribing',
thinking: 'Thinking',
muted: 'Muted',
listening: 'Listening',
muteMic: 'Mute microphone',
unmuteMic: 'Unmute microphone',
stopListening: 'Stop listening and send',
stopShort: 'Stop',
endConversation: 'End voice conversation',
endShort: 'End',
stopDictation: 'Stop dictation',
transcribingDictation: 'Transcribing dictation',
voiceDictation: 'Voice dictation',
commonCommands: 'Common commands',
hotkeys: 'Hotkeys',
helpFooter: 'opens the full panel · backspace dismisses',
commandDescs: {
'/help': 'full list of commands + hotkeys',
'/clear': 'start a new session',
'/resume': 'resume a prior session',
'/details': 'control transcript detail level',
'/copy': 'copy selection or last assistant message',
'/quit': 'exit hermes'
},
hotkeyDescs: {
'@': 'reference files, folders, urls, git',
'/': 'slash command palette',
'?': 'this quick help (delete to dismiss)',
Enter: 'send · Shift+Enter for newline',
'Cmd/Ctrl+K': 'send next queued turn',
'Cmd/Ctrl+L': 'redraw',
Esc: 'close popover · cancel run',
'↑ / ↓': 'cycle popover / history'
},
attachUrlTitle: 'Attach a URL',
attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.',
urlPlaceholder: 'https://example.com/post',
urlHintPre: 'Include the full URL, e.g. ',
attach: 'Attach',
queued: count => `${count} Queued`,
attachmentOnly: 'Attachment-only turn',
emptyTurn: 'Empty turn',
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
editingInComposer: 'Editing in composer',
editQueued: 'Edit queued turn',
sendQueuedNext: 'Send queued turn next',
sendQueuedNow: 'Send queued turn now',
deleteQueued: 'Delete queued turn',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,
removeAttachment: label => `Remove ${label}`,
dictating: 'Dictating',
preparingAudio: 'Preparing audio',
speakingResponse: 'Speaking response',
readingAloud: 'Reading aloud',
themeSuggestions: 'Desktop theme suggestions',
noMatchingThemes: 'No matching themes.',
themeTryPre: 'Try ',
themeTryPost: '.',
attachLabel: 'Attach',
files: 'Files…',
folder: 'Folder…',
images: 'Images…',
pasteImage: 'Paste image',
url: 'URL…',
promptSnippets: 'Prompt snippets…',
tipPre: 'Tip: type ',
tipPost: ' to reference files inline.',
snippetsTitle: 'Prompt snippets',
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
snippets: {
codeReview: {
label: 'Code review',
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
text: 'Please review this for bugs, regressions, and missing tests.'
},
implementationPlan: {
label: 'Implementation plan',
description: 'Outline an approach before touching code so the diff stays focused.',
text: 'Please make a concise implementation plan before changing code.'
},
explainThis: {
label: 'Explain this',
description: 'Walk through how the selected code works and link to the key files.',
text: 'Please explain how this works and point me to the key files.'
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
export { TRANSLATIONS } from './catalog'
export {
getConfigDisplayLanguage,
type I18nConfigClient,
type I18nContextValue,
I18nProvider,
LOCALE_META,
useI18n,
withConfigDisplayLanguage
} from './context'
export {
DEFAULT_LOCALE,
isLocale,
isSupportedLocaleValue,
LOCALE_OPTIONS,
localeConfigValue,
normalizeLocale
} from './languages'
export { setRuntimeI18nLocale, translateNow } from './runtime'
export type { Locale, Translations } from './types'
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LOCALE,
isLocale,
isSupportedLocaleValue,
localeConfigValue,
normalizeLocale
} from './languages'
describe('desktop i18n languages', () => {
it('normalizes supported locale aliases', () => {
expect(normalizeLocale('en')).toBe('en')
expect(normalizeLocale('EN-US')).toBe('en')
expect(normalizeLocale('zh')).toBe('zh')
expect(normalizeLocale('zh-CN')).toBe('zh')
expect(normalizeLocale('zh-Hans')).toBe('zh')
expect(normalizeLocale(' zh_hans_cn ')).toBe('zh')
})
it('falls back to English for empty or unsupported values', () => {
expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE)
expect(normalizeLocale('')).toBe(DEFAULT_LOCALE)
expect(normalizeLocale('ja')).toBe(DEFAULT_LOCALE)
})
it('distinguishes exact locale ids from supported config aliases', () => {
expect(isSupportedLocaleValue('zh-CN')).toBe(true)
expect(isSupportedLocaleValue('ja')).toBe(false)
expect(isLocale('zh-CN')).toBe(false)
expect(isLocale('zh')).toBe(true)
})
it('returns the persisted config value for supported locales', () => {
expect(localeConfigValue('en')).toBe('en')
expect(localeConfigValue('zh')).toBe('zh')
})
})
+56
View File
@@ -0,0 +1,56 @@
import type { Locale } from './types'
export const DEFAULT_LOCALE: Locale = 'en'
export const LOCALE_OPTIONS = [
{
id: 'en',
name: 'English',
configValue: 'en'
},
{
id: 'zh',
name: '简体中文',
configValue: 'zh'
}
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
// Endonyms (native names) for the language picker so users recognize their
// language regardless of the current UI language. No country flags:
// languages are not countries.
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
) as Record<Locale, { name: string }>
const LOCALE_ALIASES: Record<string, Locale> = {
en: 'en',
'en-us': 'en',
en_us: 'en',
zh: 'zh',
'zh-cn': 'zh',
zh_cn: 'zh',
'zh-hans': 'zh',
zh_hans: 'zh',
'zh-hans-cn': 'zh',
zh_hans_cn: 'zh'
}
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && LOCALE_OPTIONS.some(locale => locale.id === value)
}
export function normalizeLocale(value: unknown): Locale {
if (typeof value !== 'string') {
return DEFAULT_LOCALE
}
return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE
}
export function isSupportedLocaleValue(value: unknown): boolean {
return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null
}
export function localeConfigValue(locale: Locale): string {
return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE
}
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { setRuntimeI18nLocale, translateNow } from './runtime'
describe('desktop i18n runtime translator', () => {
beforeEach(() => {
setRuntimeI18nLocale('en')
})
afterEach(() => {
setRuntimeI18nLocale('en')
})
it('translates string paths for the active runtime locale', () => {
setRuntimeI18nLocale('zh')
expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪')
})
it('passes arguments to function translations', () => {
expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.')
})
it('returns the key when no locale can resolve a path', () => {
setRuntimeI18nLocale('zh')
expect(translateNow('missing.path')).toBe('missing.path')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { TRANSLATIONS } from './catalog'
import { DEFAULT_LOCALE } from './languages'
import type { Locale, Translations } from './types'
let runtimeLocale: Locale = DEFAULT_LOCALE
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function resolvePath(catalog: Translations, key: string): unknown {
return key.split('.').reduce<unknown>((current, part) => {
if (!isRecord(current)) {
return undefined
}
return current[part]
}, catalog)
}
function renderTranslation(value: unknown, args: unknown[]): string | null {
if (typeof value === 'string') {
return value
}
if (typeof value === 'function') {
return (value as (...args: unknown[]) => string)(...args)
}
return null
}
export function setRuntimeI18nLocale(locale: Locale) {
runtimeLocale = locale
}
export function translateNow(key: string, ...args: unknown[]): string {
const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args)
if (active !== null) {
return active
}
if (runtimeLocale !== DEFAULT_LOCALE) {
const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args)
if (fallback !== null) {
return fallback
}
}
return key
}
+611
View File
@@ -0,0 +1,611 @@
// Desktop i18n type contract.
//
// `Translations` is the single source of truth for every translatable string
// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
// so a missing key is a compile error — that's the completeness guard for
// "full" coverage as more surfaces are migrated off hardcoded English.
export type Locale = 'en' | 'zh'
interface ModeOptionCopy {
label: string
description: string
}
export interface Translations {
common: {
save: string
saving: string
cancel: string
close: string
confirm: string
delete: string
refresh: string
retry: string
on: string
off: string
}
boot: {
ready: string
desktopBootFailedWithMessage: (message: string) => string
steps: {
connectingGateway: string
loadingSettings: string
loadingSessions: string
startingDesktopConnection: string
startingHermesDesktop: string
}
errors: {
backgroundExited: string
backgroundExitedDuringStartup: string
backendStopped: string
desktopBootFailed: string
gatewaySignInRequired: string
ipcBridgeUnavailable: string
}
failure: {
title: string
description: string
remoteTitle: string
remoteDescription: string
retry: string
repairInstall: string
useLocalGateway: string
openLogs: string
repairHint: string
remoteSignInHint: string
hideRecentLogs: string
showRecentLogs: string
signedInTitle: string
signedInMessage: string
signInIncompleteTitle: string
signInIncompleteMessage: string
signInFailed: string
signInToRemoteGateway: string
signInWithProvider: (provider: string) => string
identityProvider: string
}
}
notifications: {
region: string
hide: string
show: string
more: (count: number) => string
clearAll: string
dismiss: string
details: string
copyDetail: string
copyDetailFailed: string
backendOutOfDateTitle: string
backendOutOfDateMessage: string
updateHermes: string
updateReadyTitle: string
updateReadyMessage: (count: number) => string
seeWhatsNew: string
errors: {
elevenLabsNeedsKey: string
elevenLabsRejectedKey: string
methodNotAllowed: string
microphonePermission: string
openaiRejectedApiKey: string
openaiRejectedApiKeyWithStatus: (status: string) => string
openaiTtsNeedsKey: string
}
}
titlebar: {
hideSidebar: string
showSidebar: string
search: string
searchTitle: string
swapSidebarSides: string
swapSidebarSidesTitle: string
hideRightSidebar: string
showRightSidebar: string
muteHaptics: string
unmuteHaptics: string
openSettings: string
}
language: {
label: string
description: string
saving: string
saveError: string
}
settings: {
closeSettings: string
exportConfig: string
importConfig: string
resetToDefaults: string
resetConfirm: string
exportFailed: string
resetFailed: string
nav: {
gateway: string
apiKeys: string
mcp: string
archivedChats: string
about: string
}
sections: Record<string, string>
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy>
appearance: {
title: string
intro: string
colorMode: string
colorModeDesc: string
toolViewTitle: string
toolViewDesc: string
product: string
productDesc: string
technical: string
technicalDesc: string
themeTitle: string
themeDesc: string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
about: {
heading: string
version: (value: string) => string
versionUnavailable: string
updates: string
checkNow: string
checking: string
seeWhatsNew: string
releaseNotes: string
onLatest: string
installing: string
cantUpdate: string
cantReach: string
tapCheck: string
updateReady: (count: number) => string
lastChecked: (age: string) => string
justNowSuffix: string
automaticUpdates: string
automaticUpdatesDesc: string
branchCommit: (branch: string, commit: string) => string
never: string
justNow: string
minAgo: (count: number) => string
hoursAgo: (count: number) => string
daysAgo: (count: number) => string
}
}
skills: {
tabSkills: string
tabToolsets: string
all: string
searchSkills: string
searchToolsets: string
refresh: string
refreshing: string
loading: string
noSkillsTitle: string
noSkillsDesc: string
noToolsetsTitle: string
noToolsetsDesc: string
noDescription: string
configured: string
needsKeys: string
toolsetsEnabled: (enabled: number, total: number) => string
configureToolset: (label: string) => string
toggleToolset: (label: string) => string
skillsLoadFailed: string
toolsetsRefreshFailed: string
skillEnabled: string
skillDisabled: string
toolsetEnabled: string
toolsetDisabled: string
appliesToNewSessions: (name: string) => string
failedToUpdate: (name: string) => string
}
agents: {
close: string
title: string
subtitle: string
emptyTitle: string
emptyDesc: string
running: string
failed: string
done: string
streaming: string
files: string
moreFiles: (count: number) => string
delegation: (index: number) => string
workers: (count: number) => string
workersActive: (count: number) => string
agentsCount: (count: number) => string
activeCount: (count: number) => string
failedCount: (count: number) => string
toolsCount: (count: number) => string
filesCount: (count: number) => string
updatedAgo: (age: string) => string
ageNow: string
ageSeconds: (seconds: number) => string
ageMinutes: (minutes: number) => string
ageHours: (hours: number) => string
durationSeconds: (seconds: string) => string
durationMinutes: (minutes: number, seconds: number) => string
tokensK: (k: string) => string
tokens: (value: number) => string
}
commandCenter: {
close: string
searchPlaceholder: string
sections: Record<'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
providerNavigate: string
providerSessions: string
refresh: string
refreshing: string
noResults: string
pinSession: string
unpinSession: string
exportSession: string
deleteSession: string
noSessions: string
gatewayRunning: string
gatewayStopped: string
hermesActiveSessions: (version: string, count: number) => string
restartMessaging: string
updateHermes: string
actionRunning: string
actionDone: string
actionFailed: string
actionStartedWaiting: string
loadingStatus: string
recentLogs: string
noLogs: string
days: (count: number) => string
statSessions: string
statApiCalls: string
statTokens: string
statCost: string
actualCost: (cost: string) => string
loadingUsage: string
noUsage: (period: number) => string
retry: string
dailyTokens: string
input: string
output: string
noDailyActivity: string
topModels: string
noModelUsage: string
topSkills: string
noSkillActivity: string
actions: (count: string) => string
}
messaging: {
search: string
loading: string
loadFailed: string
states: Record<string, string>
unknown: string
hintPendingRestart: string
hintGatewayStopped: string
credentialsSet: string
needsSetup: string
gatewayStopped: string
getCredentials: string
openSetupGuide: string
required: string
recommended: string
advanced: (count: number) => string
noTokenNeeded: string
enabled: string
disabled: string
unsavedChanges: string
saving: string
saveChanges: string
saved: string
replaceValue: string
openDocs: string
clearField: (key: string) => string
enableAria: (name: string) => string
disableAria: (name: string) => string
platformEnabled: (name: string) => string
platformDisabled: (name: string) => string
restartToApply: string
setupSaved: (name: string) => string
restartToReconnect: string
keyCleared: (key: string) => string
setupUpdated: (name: string) => string
failedUpdate: (name: string) => string
failedSave: (name: string) => string
failedClear: (key: string) => string
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
platformIntro: Record<string, string>
}
profiles: {
close: string
nameHint: string
title: string
count: (count: number) => string
loading: string
newProfile: string
noProfiles: string
selectPrompt: string
refresh: string
refreshing: string
default: string
skills: (count: number) => string
env: string
defaultBadge: string
rename: string
copySetup: string
copying: string
modelLabel: string
skillsLabel: string
notSet: string
soulDesc: string
unsavedChanges: string
loadingSoul: string
emptySoul: string
saving: string
saveSoul: string
deleteTitle: string
deleteDescPrefix: string
deleteDescMid: string
deleteDescSuffix: string
deleting: string
createDesc: string
nameLabel: string
cloneFromDefault: string
cloneFromDefaultDesc: string
invalidName: (hint: string) => string
nameRequired: string
creating: string
createAction: string
renameTitle: string
renameDescPrefix: string
renameDescSuffix: string
newNameLabel: string
renaming: string
created: string
renamed: string
deleted: string
setupCopied: string
soulSaved: string
failedLoad: string
failedDelete: string
failedCopy: string
failedLoadSoul: string
failedSaveSoul: string
failedCreate: string
failedRename: string
}
cron: {
close: string
search: string
refresh: string
refreshing: string
loading: string
states: Record<string, string>
deliveryLabels: Record<string, string>
scheduleLabels: Record<string, string>
scheduleHints: Record<string, string>
days: Record<string, string>
dayFallback: (value: string) => string
everyDayAt: (time: string) => string
weekdaysAt: (time: string) => string
everyDayOfWeekAt: (day: string, time: string) => string
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
topOfHour: string
everyHourAt: (minute: string) => string
active: (enabled: number, total: number) => string
newCron: string
createFirst: string
emptyDescNew: string
emptyDescSearch: string
emptyTitleNew: string
emptyTitleSearch: string
last: string
next: string
actionsFor: (title: string) => string
actionsTitle: string
resume: string
pause: string
resumeTitle: string
pauseTitle: string
triggerNow: string
edit: string
deleteTitle: string
deleteDescPrefix: string
deleteDescSuffix: string
deleting: string
resumed: string
paused: string
triggered: string
deleted: string
created: string
updated: string
failedLoad: string
failedUpdate: string
failedTrigger: string
failedDelete: string
failedSave: string
editTitle: string
createTitle: string
editDesc: string
createDesc: string
nameLabel: string
namePlaceholder: string
promptLabel: string
promptPlaceholder: string
frequencyLabel: string
deliverLabel: string
customScheduleLabel: string
customPlaceholder: string
customHint: string
optional: string
promptScheduleRequired: string
saveChanges: string
createAction: string
}
artifacts: {
search: string
refresh: string
refreshing: string
indexing: string
tabAll: string
tabImages: string
tabFiles: string
tabLinks: string
noArtifactsTitle: string
noArtifactsDesc: string
failedLoad: string
openFailed: string
itemsImage: string
itemsLink: string
itemsFile: string
itemsGeneric: string
zero: string
rangeOf: (start: number, end: number, total: number) => string
goToPage: (itemLabel: string, page: number) => string
colTitleLink: string
colTitleFile: string
colTitleDefault: string
colLocationLink: string
colLocationFile: string
colLocationDefault: string
colSession: string
kindImage: string
kindFile: string
kindLink: string
chat: string
copyUrl: string
copyPath: string
}
sidebar: {
nav: Record<string, string>
searchAria: string
searchPlaceholder: string
clearSearch: string
noMatch: (query: string) => string
results: string
pinned: string
sessions: string
groupAriaGrouped: string
groupAriaUngrouped: string
groupTitleGrouped: string
groupTitleUngrouped: string
allPinned: string
shiftClickHint: string
noWorkspace: string
newSessionIn: (label: string) => string
reorderWorkspace: (label: string) => string
showMoreIn: (count: number, label: string) => string
loading: string
loadMore: string
loadCount: (step: number) => string
row: {
pin: string
unpin: string
copyId: string
export: string
rename: string
archive: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
sessionRunning: string
needsInput: string
waitingForAnswer: string
renamed: string
renameFailed: string
renameTitle: string
renameDesc: string
untitledPlaceholder: string
ageNow: string
ageDay: string
ageHour: string
ageMin: string
}
}
composer: {
message: string
placeholderStarting: string
placeholderReconnecting: string
placeholderFollowUp: string
newSessionPlaceholders: readonly string[]
followUpPlaceholders: readonly string[]
startVoice: string
queueMessage: string
stop: string
send: string
speaking: string
transcribing: string
thinking: string
muted: string
listening: string
muteMic: string
unmuteMic: string
stopListening: string
stopShort: string
endConversation: string
endShort: string
stopDictation: string
transcribingDictation: string
voiceDictation: string
commonCommands: string
hotkeys: string
helpFooter: string
commandDescs: Record<string, string>
hotkeyDescs: Record<string, string>
attachUrlTitle: string
attachUrlDesc: string
urlPlaceholder: string
urlHintPre: string
attach: string
queued: (count: number) => string
attachmentOnly: string
emptyTurn: string
attachments: (count: number) => string
editingInComposer: string
editQueued: string
sendQueuedNext: string
sendQueuedNow: string
deleteQueued: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string
removeAttachment: (label: string) => string
dictating: string
preparingAudio: string
speakingResponse: string
readingAloud: string
themeSuggestions: string
noMatchingThemes: string
themeTryPre: string
themeTryPost: string
attachLabel: string
files: string
folder: string
images: string
pasteImage: string
url: string
promptSnippets: string
tipPre: string
tipPost: string
snippetsTitle: string
snippetsDesc: string
snippets: Record<string, { label: string; description: string; text: string }>
}
}
+874
View File
@@ -0,0 +1,874 @@
import type { Translations } from './types'
export const zh: Translations = {
common: {
save: '保存',
saving: '保存中…',
cancel: '取消',
close: '关闭',
confirm: '确认',
delete: '删除',
refresh: '刷新',
retry: '重试',
on: '开',
off: '关'
},
boot: {
ready: 'Hermes Desktop 已就绪',
desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
steps: {
connectingGateway: '正在连接实时桌面网关',
loadingSettings: '正在加载 Hermes 设置',
loadingSessions: '正在加载最近会话',
startingDesktopConnection: '正在启动桌面连接',
startingHermesDesktop: '正在启动 Hermes Desktop…'
},
errors: {
backgroundExited: 'Hermes 后台进程已退出。',
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
backendStopped: '后端已停止',
desktopBootFailed: '桌面启动失败',
gatewaySignInRequired: '需要登录网关',
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
},
failure: {
title: 'Hermes 无法启动',
description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。',
remoteTitle: '需要重新登录远程网关',
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
retry: '重试',
repairInstall: '修复安装',
useLocalGateway: '使用本地网关',
openLogs: '打开日志',
repairHint: '修复会重新运行安装器。在新机器上可能需要几分钟。',
remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
hideRecentLogs: '隐藏最近日志',
showRecentLogs: '显示最近日志',
signedInTitle: '已登录',
signedInMessage: '正在重新连接远程网关…',
signInIncompleteTitle: '登录未完成',
signInIncompleteMessage: '登录窗口在认证完成前关闭。',
signInFailed: '登录失败',
signInToRemoteGateway: '登录远程网关',
signInWithProvider: provider => `使用 ${provider} 登录`,
identityProvider: '你的身份提供方'
}
},
notifications: {
region: '通知',
hide: '隐藏',
show: '显示',
more: count => `另外 ${count} 条通知`,
clearAll: '全部清除',
dismiss: '关闭通知',
details: '详情',
copyDetail: '复制详情',
copyDetailFailed: '无法复制通知详情',
backendOutOfDateTitle: '后端版本过旧',
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
updateHermes: '更新 Hermes',
updateReadyTitle: '有可用更新',
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
seeWhatsNew: '查看更新内容',
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
methodNotAllowed: '桌面后端拒绝了该请求(405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
microphonePermission: '麦克风权限已被拒绝。',
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
}
},
titlebar: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',
search: '搜索',
searchTitle: '搜索会话、视图与操作',
swapSidebarSides: '交换侧边栏位置',
swapSidebarSidesTitle: '交换会话栏和文件浏览器的位置',
hideRightSidebar: '隐藏右侧栏',
showRightSidebar: '显示右侧栏',
muteHaptics: '关闭触感反馈',
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置'
},
language: {
label: '语言',
description: '选择桌面界面的语言。',
saving: '正在保存语言…',
saveError: '语言更新失败'
},
settings: {
closeSettings: '关闭设置',
exportConfig: '导出配置',
importConfig: '导入配置',
resetToDefaults: '恢复默认',
resetConfirm: '将所有设置恢复为 Hermes 默认值?',
exportFailed: '导出失败',
resetFailed: '重置失败',
nav: {
gateway: '网关',
apiKeys: '工具与密钥',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于'
},
sections: {
model: '模型',
chat: '对话',
appearance: '外观',
workspace: '工作区',
safety: '安全',
memory: '记忆与上下文',
voice: '语音',
advanced: '高级'
},
searchPlaceholder: {
about: '关于 Hermes Desktop',
config: '搜索设置…',
gateway: '网关连接…',
keys: '搜索 API 密钥…',
mcp: '搜索 MCP 服务器…',
sessions: '搜索已归档会话…'
},
modeOptions: {
light: { label: '明亮', description: '明亮的桌面界面' },
dark: { label: '暗色', description: '低眩光工作区' },
system: { label: '跟随系统', description: '跟随系统外观' }
},
appearance: {
title: '外观',
intro: '这些是仅桌面端的显示偏好。模式控制明暗;主题控制强调色与对话界面样式。',
colorMode: '颜色模式',
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
toolViewTitle: '工具调用显示',
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
product: '产品',
productDesc: '易读的工具活动与简洁摘要。',
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
},
fieldLabels: {
model: '默认模型',
model_context_length: '上下文窗口',
fallback_providers: '备用模型',
toolsets: '启用的工具集',
timezone: '时区',
'display.personality': '人格',
'display.show_reasoning': '推理过程块',
'agent.max_turns': '最大智能体步数',
'agent.image_input_mode': '图片附件',
'terminal.cwd': '工作目录',
'terminal.backend': '执行后端',
'terminal.timeout': '命令超时',
'terminal.persistent_shell': '持久化 Shell',
'terminal.env_passthrough': '环境变量透传',
file_read_max_chars: '文件读取上限',
'tool_output.max_bytes': '终端输出上限',
'tool_output.max_lines': '文件分页上限',
'tool_output.max_line_length': '行长度上限',
'code_execution.mode': '代码执行模式',
'approvals.mode': '审批模式',
'approvals.timeout': '审批超时',
'approvals.mcp_reload_confirm': '确认 MCP 重载',
command_allowlist: '命令白名单',
'security.redact_secrets': '隐去密钥',
'security.allow_private_urls': '允许私有 URL',
'browser.allow_private_urls': '浏览器私有 URL',
'browser.auto_local_for_private_urls': '私有 URL 使用本地浏览器',
'checkpoints.enabled': '文件检查点',
'checkpoints.max_snapshots': '检查点上限',
'voice.record_key': '语音快捷键',
'voice.max_recording_seconds': '最长录音时长',
'voice.auto_tts': '朗读回复',
'stt.enabled': '语音转文字',
'stt.provider': '语音转文字提供方',
'stt.local.model': '本地转写模型',
'stt.local.language': '转写语言',
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
'stt.elevenlabs.language_code': 'ElevenLabs 语言',
'stt.elevenlabs.tag_audio_events': '标记音频事件',
'stt.elevenlabs.diarize': '说话人区分',
'tts.provider': '文字转语音提供方',
'tts.edge.voice': 'Edge 语音',
'tts.openai.model': 'OpenAI TTS 模型',
'tts.openai.voice': 'OpenAI 语音',
'tts.elevenlabs.voice_id': 'ElevenLabs 语音',
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
'memory.memory_enabled': '持久记忆',
'memory.user_profile_enabled': '用户画像',
'memory.memory_char_limit': '记忆预算',
'memory.user_char_limit': '画像预算',
'memory.provider': '记忆提供方',
'context.engine': '上下文引擎',
'compression.enabled': '自动压缩',
'compression.threshold': '压缩阈值',
'compression.target_ratio': '压缩目标',
'compression.protect_last_n': '保护最近消息',
'agent.api_max_retries': 'API 重试次数',
'agent.service_tier': '服务等级',
'agent.tool_use_enforcement': '工具调用强制',
'delegation.model': '子智能体模型',
'delegation.provider': '子智能体提供方',
'delegation.max_iterations': '子智能体轮次上限',
'delegation.max_concurrent_children': '并行子智能体',
'delegation.child_timeout_seconds': '子智能体超时',
'delegation.reasoning_effort': '子智能体推理强度'
},
fieldDescriptions: {
model: '用于新对话,除非你在输入框中选择其他模型。',
model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
'display.personality': '新会话的默认助手风格。',
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
'display.show_reasoning': '当后端提供推理内容时予以显示。',
'agent.image_input_mode': '控制图片附件如何发送给模型。',
'terminal.cwd': '工具与终端操作的默认项目目录。',
'code_execution.mode': '代码执行被限定到当前项目的严格程度。',
'terminal.persistent_shell': '当后端支持时,在命令之间保留 Shell 状态。',
'terminal.env_passthrough': '传入工具执行的环境变量。',
file_read_max_chars: 'Hermes 单次文件读取可读取的最大字符数。',
'approvals.mode': 'Hermes 如何处理需要显式审批的命令。',
'approvals.timeout': '审批提示在超时前等待的时长。',
'security.redact_secrets': '尽可能从模型可见内容中隐藏检测到的密钥。',
'checkpoints.enabled': '在文件编辑前创建可回滚的快照。',
'memory.memory_enabled': '保存有助于未来会话的持久记忆。',
'memory.user_profile_enabled': '维护一份精简的用户偏好画像。',
'context.engine': '在接近上下文上限时管理长对话的策略。',
'compression.enabled': '当对话变大时对较早的上下文进行摘要。',
'voice.auto_tts': '自动朗读助手回复。',
'stt.enabled': '启用本地或提供方支持的语音转写。',
'stt.elevenlabs.language_code': '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。',
'agent.max_turns': 'Hermes 停止一次运行前工具调用轮次的上限。'
},
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
versionUnavailable: '版本不可用',
updates: '更新',
checkNow: '立即检查',
checking: '检查中…',
seeWhatsNew: '查看新增内容',
releaseNotes: '发行说明',
onLatest: '你已是最新版本。',
installing: '正在安装更新。',
cantUpdate: '此版本无法在应用内自我更新。',
cantReach: '无法连接更新服务器。',
tapCheck: '点击"立即检查"以查找更新。',
updateReady: count => `已准备好新更新(包含 ${count} 项更改)。`,
lastChecked: age => `上次检查:${age}`,
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '从未',
justNow: '刚刚',
minAgo: count => `${count} 分钟前`,
hoursAgo: count => `${count} 小时前`,
daysAgo: count => `${count} 天前`
}
},
skills: {
tabSkills: '技能',
tabToolsets: '工具集',
all: '全部',
searchSkills: '搜索技能…',
searchToolsets: '搜索工具集…',
refresh: '刷新技能',
refreshing: '正在刷新技能',
loading: '正在加载能力…',
noSkillsTitle: '未找到技能',
noSkillsDesc: '尝试更宽泛的搜索或其他分类。',
noToolsetsTitle: '未找到工具集',
noToolsetsDesc: '尝试更宽泛的搜索词。',
noDescription: '暂无描述。',
configured: '已配置',
needsKeys: '需要密钥',
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
configureToolset: label => `配置 ${label}`,
toggleToolset: label => `切换 ${label} 工具集`,
skillsLoadFailed: '技能加载失败',
toolsetsRefreshFailed: '工具集刷新失败',
skillEnabled: '技能已启用',
skillDisabled: '技能已禁用',
toolsetEnabled: '工具集已启用',
toolsetDisabled: '工具集已禁用',
appliesToNewSessions: name => `${name} 将应用于新会话。`,
failedToUpdate: name => `更新 ${name} 失败`
},
agents: {
close: '关闭代理',
title: '派生树',
subtitle: '当前回合的子代理实时活动。',
emptyTitle: '暂无活跃子代理',
emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
running: '运行中',
failed: '失败',
done: '完成',
streaming: '流式传输',
files: '文件',
moreFiles: count => `还有 ${count} 个文件`,
delegation: index => `派发 ${index}`,
workers: count => `${count} 个工作单元`,
workersActive: count => `${count} 个活跃`,
agentsCount: count => `${count} 个代理`,
activeCount: count => `${count} 个活跃`,
failedCount: count => `${count} 个失败`,
toolsCount: count => `${count} 个工具`,
filesCount: count => `${count} 个文件`,
updatedAgo: age => `更新于 ${age}`,
ageNow: '刚刚',
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分钟前`,
ageHours: hours => `${hours} 小时前`,
durationSeconds: seconds => `${seconds} 秒`,
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
tokensK: k => `${k}k 词元`,
tokens: value => `${value} 词元`
},
commandCenter: {
close: '关闭命令中心',
searchPlaceholder: '搜索会话、视图与操作',
sections: { sessions: '会话', system: '系统', usage: '用量' },
sectionDescriptions: {
sessions: '搜索与管理会话',
system: '状态、日志与系统操作',
usage: '一段时间内的词元、成本与技能活动'
},
nav: {
newChat: { title: '新建会话', detail: '开始一个新会话' },
settings: { title: '设置', detail: '配置 Hermes 桌面端' },
skills: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
messaging: { title: '消息平台', detail: '配置 Telegram、Slack、Discord 等' },
artifacts: { title: '产物', detail: '浏览生成的输出' }
},
sectionEntries: {
sessions: { title: '会话面板', detail: '搜索、置顶与管理会话' },
system: { title: '系统面板', detail: '网关状态、日志、重启/更新' },
usage: { title: '用量面板', detail: '词元、成本与技能活动' }
},
providerNavigate: '导航',
providerSessions: '会话',
refresh: '刷新',
refreshing: '刷新中…',
noResults: '未找到匹配结果。',
pinSession: '置顶会话',
unpinSession: '取消置顶',
exportSession: '导出会话',
deleteSession: '删除会话',
noSessions: '暂无会话。',
gatewayRunning: '消息网关运行中',
gatewayStopped: '消息网关已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
restartMessaging: '重启消息服务',
updateHermes: '更新 Hermes',
actionRunning: '运行中',
actionDone: '完成',
actionFailed: '失败',
actionStartedWaiting: '操作已启动,等待状态…',
loadingStatus: '正在加载状态…',
recentLogs: '最近日志',
noLogs: '尚未加载日志。',
days: count => `${count} 天`,
statSessions: '会话',
statApiCalls: 'API 调用',
statTokens: '输入/输出词元',
statCost: '预估成本',
actualCost: cost => `实际 ${cost}`,
loadingUsage: '正在加载用量…',
noUsage: period => `最近 ${period} 天暂无用量。`,
retry: '重试',
dailyTokens: '每日词元',
input: '输入',
output: '输出',
noDailyActivity: '暂无每日活动。',
topModels: '常用模型',
noModelUsage: '暂无模型用量。',
topSkills: '常用技能',
noSkillActivity: '暂无技能活动。',
actions: count => `${count} 次操作`
},
messaging: {
search: '搜索消息平台…',
loading: '正在加载消息平台…',
loadFailed: '消息平台加载失败',
states: {
connected: '已连接',
connecting: '连接中',
disabled: '已禁用',
fatal: '错误',
gateway_stopped: '消息网关已停止',
not_configured: '需要设置',
pending_restart: '需要重启',
retrying: '重试中',
startup_failed: '启动失败'
},
unknown: '未知',
hintPendingRestart: '在状态栏重启网关以应用此更改。',
hintGatewayStopped: '在状态栏启动网关以建立连接。',
credentialsSet: '凭据已设置',
needsSetup: '需要设置',
gatewayStopped: '消息网关已停止',
getCredentials: '获取你的凭据',
openSetupGuide: '打开设置指南',
required: '必填',
recommended: '推荐',
advanced: count => `高级 (${count})`,
noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
enabled: '已启用',
disabled: '已禁用',
unsavedChanges: '有未保存的更改',
saving: '保存中…',
saveChanges: '保存更改',
saved: '已保存',
replaceValue: '替换当前值',
openDocs: '打开文档',
clearField: key => `清除 ${key}`,
enableAria: name => `启用 ${name}`,
disableAria: name => `禁用 ${name}`,
platformEnabled: name => `${name} 已启用`,
platformDisabled: name => `${name} 已禁用`,
restartToApply: '重启网关后此更改才会生效。',
setupSaved: name => `${name} 设置已保存`,
restartToReconnect: '重启网关以使用新凭据重新连接。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 设置已更新。`,
failedUpdate: name => `更新 ${name} 失败`,
failedSave: name => `保存 ${name} 失败`,
failedClear: key => `清除 ${key} 失败`,
fieldCopy: {
TELEGRAM_BOT_TOKEN: { label: 'Bot 令牌', help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。' },
TELEGRAM_ALLOWED_USERS: {
label: '允许的 Telegram 用户 ID',
help: '推荐。来自 @userinfobot 的逗号分隔数字 ID。不设置则任何人都能私信你的机器人。'
},
TELEGRAM_PROXY: { label: '代理 URL', help: '仅在 Telegram 被屏蔽的网络中需要。' },
DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
DISCORD_ALLOWED_USERS: { label: '允许的 Discord 用户 ID', help: '推荐。逗号分隔的 Discord 用户 ID。' },
DISCORD_REPLY_TO_MODE: { label: '回复方式', help: 'first、all 或 off。' },
SLACK_BOT_TOKEN: { label: 'Slack bot 令牌', help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。' },
SLACK_APP_TOKEN: { label: 'Slack app 令牌', help: 'Socket Mode 需要 app 级令牌。' },
SLACK_ALLOWED_USERS: { label: '允许的 Slack 用户 ID', help: '推荐。逗号分隔的 Slack 用户 ID。' },
MATTERMOST_URL: { label: '服务器 URL' },
MATTERMOST_TOKEN: { label: 'Bot 令牌' },
MATTERMOST_ALLOWED_USERS: { label: '允许的用户 ID', help: '推荐。逗号分隔的 Mattermost 用户 ID。' },
MATRIX_HOMESERVER: { label: 'Homeserver URL' },
MATRIX_ACCESS_TOKEN: { label: '访问令牌' },
MATRIX_USER_ID: { label: 'Bot 用户 ID' },
MATRIX_ALLOWED_USERS: { label: '允许的 Matrix 用户 ID', help: '推荐。@user:server 格式的逗号分隔用户 ID。' },
SIGNAL_HTTP_URL: { label: 'Signal 桥接 URL', help: '运行中的 signal-cli REST 桥接的 URL。' },
SIGNAL_ACCOUNT: { label: '电话号码', help: '在 signal-cli 桥接中注册的号码。' },
SIGNAL_ALLOWED_USERS: { label: '允许的 Signal 用户', help: '推荐。逗号分隔的 Signal 标识符。' },
WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
WHATSAPP_MODE: { label: '桥接模式' },
WHATSAPP_ALLOWED_USERS: { label: '允许的 WhatsApp 用户', help: '推荐。逗号分隔的电话号码或 WhatsApp ID。' }
},
platformIntro: {
telegram:
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
discord:
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key)和 Client Secret。',
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信(应用)选项。',
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
}
},
profiles: {
close: '关闭配置档案',
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
title: '配置档案',
count: count => `${count} 个配置档案`,
loading: '正在加载配置档案…',
newProfile: '新建配置档案',
noProfiles: '暂无配置档案。',
selectPrompt: '选择一个配置档案以查看其详情。',
refresh: '刷新配置档案',
refreshing: '正在刷新配置档案',
default: '默认',
skills: count => `${count} 个技能`,
env: 'env',
defaultBadge: '默认',
rename: '重命名',
copySetup: '复制安装命令',
copying: '复制中…',
modelLabel: '模型',
skillsLabel: '技能',
notSet: '未设置',
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
unsavedChanges: '有未保存的更改',
loadingSoul: '正在加载 SOUL.md…',
emptySoul: '空的 SOUL.md —— 开始撰写人格设定…',
saving: '保存中…',
saveSoul: '保存 SOUL.md',
deleteTitle: '删除配置档案?',
deleteDescPrefix: '这将删除 ',
deleteDescMid: ' 并移除其 ',
deleteDescSuffix: ' 目录。此操作无法撤销。',
deleting: '删除中…',
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
invalidName: hint => `名称无效。${hint}`,
nameRequired: '名称为必填项。',
creating: '创建中…',
createAction: '创建配置档案',
renameTitle: '重命名配置档案',
renameDescPrefix: '重命名会更新配置档案目录以及 ',
renameDescSuffix: ' 中的所有包装脚本。',
newNameLabel: '新名称',
renaming: '重命名中…',
created: '配置档案已创建',
renamed: '配置档案已重命名',
deleted: '配置档案已删除',
setupCopied: '安装命令已复制',
soulSaved: 'SOUL.md 已保存',
failedLoad: '加载配置档案失败',
failedDelete: '删除配置档案失败',
failedCopy: '复制安装命令失败',
failedLoadSoul: '加载 SOUL.md 失败',
failedSaveSoul: '保存 SOUL.md 失败',
failedCreate: '创建配置档案失败',
failedRename: '重命名配置档案失败'
},
cron: {
close: '关闭定时任务',
search: '搜索定时任务…',
refresh: '刷新定时任务',
refreshing: '正在刷新定时任务',
loading: '正在加载定时任务…',
states: {
enabled: '已启用',
scheduled: '已排程',
running: '运行中',
paused: '已暂停',
disabled: '已禁用',
error: '错误',
completed: '已完成'
},
deliveryLabels: {
local: '此桌面',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: '电子邮件'
},
scheduleLabels: {
daily: '每天',
weekdays: '工作日',
weekly: '每周',
monthly: '每月',
hourly: '每小时',
'every-15-minutes': '每 15 分钟',
custom: '自定义'
},
scheduleHints: {
daily: '每天上午 9:00',
weekdays: '周一至周五上午 9:00',
weekly: '每周一上午 9:00',
monthly: '每月第一天上午 9:00',
hourly: '每个整点',
'every-15-minutes': '每 15 分钟',
custom: 'Cron 语法或自然语言'
},
days: {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日'
},
dayFallback: value => `第 ${value} 天`,
everyDayAt: time => `每天 ${time}`,
weekdaysAt: time => `工作日 ${time}`,
everyDayOfWeekAt: (day, time) => `每${day} ${time}`,
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth} 日 ${time}`,
topOfHour: '每个整点',
everyHourAt: minute => `每小时的 :${minute}`,
active: (enabled, total) => `${enabled}/${total} 个启用`,
newCron: '新建定时任务',
createFirst: '创建第一个定时任务',
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
emptyDescSearch: '尝试更宽泛的搜索词。',
emptyTitleNew: '暂无排程任务',
emptyTitleSearch: '无匹配项',
last: '上次:',
next: '下次:',
actionsFor: title => `${title} 的操作`,
actionsTitle: '定时任务操作',
resume: '恢复定时任务',
pause: '暂停定时任务',
resumeTitle: '恢复',
pauseTitle: '暂停',
triggerNow: '立即触发',
edit: '编辑定时任务',
deleteTitle: '删除定时任务?',
deleteDescPrefix: '这将永久移除 ',
deleteDescSuffix: '。它会立即停止触发。',
deleting: '删除中…',
resumed: '定时任务已恢复',
paused: '定时任务已暂停',
triggered: '定时任务已触发',
deleted: '定时任务已删除',
created: '定时任务已创建',
updated: '定时任务已更新',
failedLoad: '加载定时任务失败',
failedUpdate: '更新定时任务失败',
failedTrigger: '触发定时任务失败',
failedDelete: '删除定时任务失败',
failedSave: '保存定时任务失败',
editTitle: '编辑定时任务',
createTitle: '新建定时任务',
editDesc: '更新排程、提示词或投递目标。更改将在下次运行时生效。',
createDesc: '排程一个提示词以自动运行。使用 cron 语法或类似"每 15 分钟"的自然语言。',
nameLabel: '名称',
namePlaceholder: '晨间简报',
promptLabel: '提示词',
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
frequencyLabel: '频率',
deliverLabel: '投递至',
customScheduleLabel: '自定义排程',
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
optional: '可选',
promptScheduleRequired: '提示词和排程为必填项。',
saveChanges: '保存更改',
createAction: '创建定时任务'
},
artifacts: {
search: '搜索产物…',
refresh: '刷新产物',
refreshing: '正在刷新产物',
indexing: '正在索引最近会话的产物',
tabAll: '全部',
tabImages: '图片',
tabFiles: '文件',
tabLinks: '链接',
noArtifactsTitle: '未找到产物',
noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
failedLoad: '产物加载失败',
openFailed: '打开失败',
itemsImage: '张图片',
itemsLink: '个链接',
itemsFile: '个文件',
itemsGeneric: '项',
zero: '0',
rangeOf: (start, end, total) => `${start}-${end},共 ${total}`,
goToPage: (itemLabel, page) => `前往${itemLabel}第 ${page} 页`,
colTitleLink: '链接标题',
colTitleFile: '名称',
colTitleDefault: '标题 / 名称',
colLocationLink: 'URL',
colLocationFile: '路径',
colLocationDefault: '位置',
colSession: '会话',
kindImage: '图片',
kindFile: '文件',
kindLink: '链接',
chat: '对话',
copyUrl: '复制 URL',
copyPath: '复制路径'
},
sidebar: {
nav: {
'new-session': '新建会话',
skills: '技能与工具',
messaging: '消息平台',
artifacts: '产物'
},
searchAria: '搜索会话',
searchPlaceholder: '搜索会话…',
clearSearch: '清除搜索',
noMatch: query => `没有会话匹配"${query}"。`,
results: '结果',
pinned: '已置顶',
sessions: '会话',
groupAriaGrouped: '以单一列表显示会话',
groupAriaUngrouped: '按工作区分组会话',
groupTitleGrouped: '取消分组',
groupTitleUngrouped: '按工作区分组',
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
shiftClickHint: 'Shift+单击对话以置顶 · 拖动以重新排序',
noWorkspace: '无工作区',
newSessionIn: label => `在 ${label} 中新建会话`,
reorderWorkspace: label => `重新排序工作区 ${label}`,
showMoreIn: (count, label) => `在 ${label} 中再显示 ${count} 个`,
loading: '加载中…',
loadMore: '加载更多',
loadCount: step => `再加载 ${step} 个`,
row: {
pin: '置顶',
unpin: '取消置顶',
copyId: '复制 ID',
export: '导出',
rename: '重命名',
archive: '归档',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
renameDesc: '给这个对话起一个好记的标题。留空则清除。',
untitledPlaceholder: '无标题会话',
ageNow: '刚刚',
ageDay: '天',
ageHour: '时',
ageMin: '分'
}
},
composer: {
message: '消息',
placeholderStarting: '正在启动 Hermes…',
placeholderReconnecting: '正在重新连接 Hermes…',
placeholderFollowUp: '发送后续消息',
newSessionPlaceholders: [
'我们要构建什么?',
'给 Hermes 一个任务',
'你在想什么?',
'描述你需要什么',
'我们该处理什么?',
'随便问点什么',
'从一个目标开始'
],
followUpPlaceholders: [
'发送后续消息',
'补充更多上下文',
'细化这个请求',
'下一步是什么?',
'继续推进',
'再深入一点',
'调整或继续'
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
stop: '停止',
send: '发送',
speaking: '讲话中',
transcribing: '转写中',
thinking: '思考中',
muted: '已静音',
listening: '聆听中',
muteMic: '麦克风静音',
unmuteMic: '取消麦克风静音',
stopListening: '停止聆听并发送',
stopShort: '停止',
endConversation: '结束语音对话',
endShort: '结束',
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
voiceDictation: '语音听写',
commonCommands: '常用命令',
hotkeys: '快捷键',
helpFooter: '打开完整面板 · 退格键关闭',
commandDescs: {
'/help': '命令与快捷键的完整列表',
'/clear': '开始新会话',
'/resume': '恢复之前的会话',
'/details': '控制对话记录的详细程度',
'/copy': '复制所选内容或最后一条助手消息',
'/quit': '退出 hermes'
},
hotkeyDescs: {
'@': '引用文件、文件夹、URL、git',
'/': '斜杠命令面板',
'?': '此快速帮助(删除以关闭)',
Enter: '发送 · Shift+Enter 换行',
'Cmd/Ctrl+K': '发送下一条排队的回合',
'Cmd/Ctrl+L': '重绘',
Esc: '关闭弹窗 · 取消运行',
'↑ / ↓': '循环弹窗 / 历史'
},
attachUrlTitle: '附加 URL',
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
urlPlaceholder: 'https://example.com/post',
urlHintPre: '请包含完整 URL,例如 ',
attach: '附加',
queued: count => `${count} 条排队`,
attachmentOnly: '仅附件回合',
emptyTurn: '空回合',
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
editQueued: '编辑排队回合',
sendQueuedNext: '下一个发送排队回合',
sendQueuedNow: '立即发送排队回合',
deleteQueued: '删除排队回合',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,
removeAttachment: label => `移除 ${label}`,
dictating: '听写中',
preparingAudio: '正在准备音频',
speakingResponse: '正在朗读回复',
readingAloud: '朗读中',
themeSuggestions: '桌面主题建议',
noMatchingThemes: '没有匹配的主题。',
themeTryPre: '试试 ',
themeTryPost: '。',
attachLabel: '附加',
files: '文件…',
folder: '文件夹…',
images: '图片…',
pasteImage: '粘贴图片',
url: 'URL…',
promptSnippets: '提示词片段…',
tipPre: '提示:输入 ',
tipPost: ' 以内联引用文件。',
snippetsTitle: '提示词片段',
snippetsDesc: '选择一个起始提示词放入输入框。',
snippets: {
codeReview: {
label: '代码审查',
description: '审查当前更改是否存在回归、遗漏的边界情况和缺失的测试。',
text: '请审查这部分是否存在缺陷、回归和缺失的测试。'
},
implementationPlan: {
label: '实现计划',
description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
text: '请在修改代码前制定一个简洁的实现计划。'
},
explainThis: {
label: '解释这段',
description: '讲解所选代码的工作方式,并链接到关键文件。',
text: '请解释这是如何工作的,并指给我关键文件。'
}
}
}
}
-1
View File
@@ -7,7 +7,6 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } fro
import type { ComposerAttachment } from '@/store/composer'
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
export const INTERRUPTED_MARKER = '\n\n_[interrupted]_'
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
export const BUILTIN_PERSONALITIES = [
'helpful',
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { resolveRemotePathPicker } from '@/store/remote-path-picker'
import { $connection } from '@/store/session'
import { fsGitRoot, fsReadDir, fsReadFileDataUrl, isRemoteBackend, selectPaths } from './desktop-fs'
const request = vi.fn()
const readDir = vi.fn()
const readFileDataUrl = vi.fn()
const gitRoot = vi.fn()
const desktopSelectPaths = vi.fn()
function setRemote(remote: boolean) {
$connection.set(remote ? ({ mode: 'remote' } as never) : null)
}
beforeEach(() => {
request.mockReset()
readDir.mockReset()
readFileDataUrl.mockReset()
gitRoot.mockReset()
desktopSelectPaths.mockReset()
$gateway.set({ request } as unknown as HermesGateway)
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
readDir,
readFileDataUrl,
gitRoot,
selectPaths: desktopSelectPaths
}
})
afterEach(() => {
$connection.set(null)
$gateway.set(null)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('desktop-fs facade', () => {
it('routes reads to local IPC when not remote', async () => {
setRemote(false)
readDir.mockResolvedValue({ entries: [] })
await fsReadDir('/p')
expect(readDir).toHaveBeenCalledWith('/p')
expect(request).not.toHaveBeenCalled()
expect(isRemoteBackend()).toBe(false)
})
it('routes directory listing to fs.list when remote', async () => {
setRemote(true)
request.mockResolvedValue({ entries: [], path: '/srv' })
const result = await fsReadDir('/srv')
expect(request).toHaveBeenCalledWith('fs.list', { path: '/srv' })
expect(result.path).toBe('/srv')
expect(readDir).not.toHaveBeenCalled()
})
it('unwraps the data url from fs.read_data_url when remote', async () => {
setRemote(true)
request.mockResolvedValue({ dataUrl: 'data:image/png;base64,AAAA' })
expect(await fsReadFileDataUrl('/srv/x.png')).toBe('data:image/png;base64,AAAA')
expect(request).toHaveBeenCalledWith('fs.read_data_url', { path: '/srv/x.png' })
})
it('returns gateway git root when remote', async () => {
setRemote(true)
request.mockResolvedValue({ root: '/srv/repo' })
expect(await fsGitRoot('/srv/repo/a')).toBe('/srv/repo')
})
it('uses the native picker locally and the remote picker when remote', async () => {
setRemote(false)
desktopSelectPaths.mockResolvedValue(['/local/a.png'])
expect(await selectPaths({ title: 'pick' })).toEqual(['/local/a.png'])
setRemote(true)
const pending = selectPaths({ title: 'pick' })
resolveRemotePathPicker(['/srv/a.png'])
expect(await pending).toEqual(['/srv/a.png'])
// Remote selection never touches the native dialog.
expect(desktopSelectPaths).toHaveBeenCalledTimes(1)
})
})
+64
View File
@@ -0,0 +1,64 @@
import type { HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
import { $gateway } from '@/store/gateway'
import { openRemotePathPicker } from '@/store/remote-path-picker'
import { $connection } from '@/store/session'
// On a remote gateway (e.g. a VPS over tailscale) the agent's filesystem lives
// on the server, but the Electron IPC helpers only see the client machine. This
// facade routes reads + path selection through gateway `fs.*` RPCs when remote,
// and falls back to local Electron IPC against a locally-spawned backend.
export const isRemoteBackend = (): boolean => $connection.get()?.mode === 'remote'
function gw<T>(method: string, params: Record<string, unknown>): Promise<T> {
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<T>(method, params)
}
const unavailable = (): never => {
throw new Error('File reading is unavailable')
}
export function fsReadDir(path: string): Promise<HermesReadDirResult> {
if (isRemoteBackend()) {
return gw('fs.list', { path })
}
return window.hermesDesktop?.readDir?.(path) ?? Promise.resolve({ entries: [], error: 'no-bridge' })
}
export function fsReadFileText(path: string): Promise<HermesReadFileTextResult> {
if (isRemoteBackend()) {
return gw('fs.read_text', { path })
}
return window.hermesDesktop?.readFileText?.(path) ?? unavailable()
}
export async function fsReadFileDataUrl(path: string): Promise<string> {
if (isRemoteBackend()) {
return (await gw<{ dataUrl?: string }>('fs.read_data_url', { path })).dataUrl ?? unavailable()
}
return window.hermesDesktop?.readFileDataUrl?.(path) ?? unavailable()
}
export async function fsGitRoot(path: string): Promise<string | null> {
if (isRemoteBackend()) {
return (await gw<{ root?: string | null }>('fs.git_root', { path })).root ?? null
}
return window.hermesDesktop?.gitRoot?.(path) ?? null
}
export async function selectPaths(options: HermesSelectPathsOptions = {}): Promise<string[]> {
if (isRemoteBackend()) {
return openRemotePathPicker(options)
}
return (await window.hermesDesktop?.selectPaths?.(options)) ?? []
}
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
expect(isDesktopSlashSuggestion('/version')).toBe(true)
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
expect(isDesktopSlashCommand('/yolo')).toBe(true)
})
@@ -43,6 +43,7 @@ const DESKTOP_COMMAND_META = [
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
+10 -7
View File
@@ -8,6 +8,7 @@ import { HashRouter } from 'react-router-dom'
import App from './app'
import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { I18nProvider } from './i18n'
import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client'
import { ThemeProvider } from './themes/context'
@@ -27,13 +28,15 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
+4 -3
View File
@@ -1,6 +1,7 @@
import { atom } from 'nanostores'
import type { DesktopBootProgress } from '@/global'
import { translateNow } from '@/i18n'
export interface DesktopBootState extends DesktopBootProgress {
visible: boolean
@@ -9,7 +10,7 @@ export interface DesktopBootState extends DesktopBootProgress {
const INITIAL_BOOT_STATE: DesktopBootState = {
error: null,
fakeMode: false,
message: 'Starting Hermes Desktop…',
message: translateNow('boot.steps.startingHermesDesktop'),
phase: 'renderer.init',
progress: 2,
running: true,
@@ -61,7 +62,7 @@ export function setDesktopBootStep(step: {
})
}
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
export function completeDesktopBoot(message = translateNow('boot.ready')) {
const current = $desktopBoot.get()
$desktopBoot.set({
...current,
@@ -80,7 +81,7 @@ export function failDesktopBoot(message: string) {
$desktopBoot.set({
...current,
error: message,
message: `Desktop boot failed: ${message}`,
message: translateNow('boot.desktopBootFailedWithMessage', message),
phase: 'renderer.error',
progress: clampProgress(current.progress),
running: false,
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$perSessionBrowse,
browseBackward,
browseForward,
deriveUserHistory,
isBrowsingHistory,
resetBrowseState
} from './composer-input-history'
const SESSION_A = 'session-a'
const SESSION_B = 'session-b'
// Newest-first user text ring, what the caller passes to browse*.
const HISTORY = ['third', 'second', 'first']
const MSG = (role: string, text: string) => ({ id: '', role, text })
beforeEach(() => {
$perSessionBrowse.set({})
})
describe('deriveUserHistory', () => {
it('returns user messages newest-first with empty/whitespace skipped', () => {
const messages = [
MSG('user', ' '),
MSG('assistant', 'hi'),
MSG('user', 'first'),
MSG('user', 'second')
]
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
})
})
describe('browseBackward', () => {
it('returns null when history is empty', () => {
expect(browseBackward(SESSION_A, '', [])).toBeNull()
})
it('returns the most recent entry on first press and saves the draft', () => {
const result = browseBackward(SESSION_A, 'unsent draft', HISTORY)
expect(result).toBe('third')
expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft')
})
it('moves to older entries on subsequent presses and stops at the oldest', () => {
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third')
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second')
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first')
expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull()
})
it('uses caller-provided history, not a mirrored ring', () => {
// The store never owns the ring — the caller passes it every press.
// If the ring changes between presses (e.g. a new message was sent),
// the next press sees the updated ring and the cursor continues
// from where it was within it.
expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest')
// Caller added a new message; ring is now [brand-new, youngest, older].
// Cursor was at 0, next press advances to 1 -> "youngest".
expect(
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
).toBe('youngest')
// One more press -> "older".
expect(
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
).toBe('older')
})
})
describe('browseForward', () => {
it('returns null when not browsing', () => {
expect(browseForward(SESSION_A, HISTORY)).toBeNull()
})
it('moves toward the present', () => {
browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third'
browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second'
expect(browseForward(SESSION_A, HISTORY)).toEqual({
text: 'third',
returnedToPresent: false
})
})
it('restores the saved draft and resets when reaching the present', () => {
browseBackward(SESSION_A, 'my original draft', HISTORY)
const result = browseForward(SESSION_A, HISTORY)
expect(result).toEqual({ text: 'my original draft', returnedToPresent: true })
expect(isBrowsingHistory(SESSION_A)).toBe(false)
})
})
describe('per-session isolation', () => {
it('tracks cursor and draft independently per session', () => {
browseBackward(SESSION_A, 'draft-a', HISTORY)
browseBackward(SESSION_A, '', HISTORY) // older
browseBackward(SESSION_B, 'draft-b', HISTORY)
const a = $perSessionBrowse.get()[SESSION_A]!
const b = $perSessionBrowse.get()[SESSION_B]!
expect(a.cursor).toBe(1)
expect(a.draftSnapshot).toBe('draft-a')
expect(b.cursor).toBe(0)
expect(b.draftSnapshot).toBe('draft-b')
})
})
describe('resetBrowseState', () => {
it('clears cursor and draft snapshot', () => {
browseBackward(SESSION_A, 'draft', HISTORY)
resetBrowseState(SESSION_A)
const s = $perSessionBrowse.get()[SESSION_A]!
expect(s.cursor).toBe(-1)
expect(s.draftSnapshot).toBe('')
})
})
describe('session switch behavior', () => {
it('resets the previous session cursor and lets the new session derive its own ring', () => {
// Session A: user browsed into the past
browseBackward(SESSION_A, '', HISTORY)
expect(isBrowsingHistory(SESSION_A)).toBe(true)
// Caller switches to session B; resets A's browse state
resetBrowseState(SESSION_A)
// Session B's ring is derived from B's messages, not A's
const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')]
const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text)
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b')
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b')
expect(isBrowsingHistory(SESSION_A)).toBe(false)
})
})
@@ -0,0 +1,158 @@
import { atom } from 'nanostores'
/**
* Per-session input history browse state.
*
* The user-text ring is **derived from the live session messages** on each
* keypress — it is not mirrored anywhere. This keeps a single source of truth
* and avoids the entire class of seeding/dedup bugs that come from trying to
* keep a parallel ring in sync with submit/queue/voice paths.
*
* We only persist the cursor and the saved draft:
* - `cursor` — index into the derived user-text ring (0 = newest, larger = older).
* `-1` means "not browsing".
* - `draftSnapshot` — the composer text at the moment the user started
* browsing, so ArrowDown back to the "present" restores it.
*/
export interface SessionBrowseState {
cursor: number
draftSnapshot: string
}
const $perSessionBrowse = atom<Record<string, SessionBrowseState>>({})
function ensure(sessionId: string): SessionBrowseState {
const all = { ...$perSessionBrowse.get() }
let s = all[sessionId]
if (!s) {
s = { cursor: -1, draftSnapshot: '' }
all[sessionId] = s
$perSessionBrowse.set(all)
}
return s
}
function persist() {
$perSessionBrowse.set({ ...$perSessionBrowse.get() })
}
function valid(sessionId: string | null | undefined): sessionId is string {
return typeof sessionId === 'string' && sessionId.length > 0
}
/**
* Derive the user-text ring (newest first) from session messages.
* The caller is responsible for providing already-session-scoped messages.
*/
export function deriveUserHistory<T extends { role: string }>(
messages: readonly T[],
getText: (m: T) => string
): string[] {
const out: string[] = []
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.role !== 'user') {continue}
const t = getText(m).trim()
if (t) {out.push(t)}
}
return out
}
/**
* Start browsing backward, or step to the next older entry.
* Returns the text to place in the composer, or null if already at the oldest
* entry (or the ring is empty).
*/
export function browseBackward(
sessionId: string | null | undefined,
currentDraft: string,
history: readonly string[]
): string | null {
if (!valid(sessionId) || history.length === 0) {
return null
}
const s = ensure(sessionId)
if (s.cursor === -1) {
s.draftSnapshot = currentDraft
s.cursor = 0
} else if (s.cursor < history.length - 1) {
s.cursor += 1
} else {
return null
}
persist()
return history[s.cursor]!
}
/**
* Browse forward toward the present. When reaching the "newest" entry the
* saved draft is restored and the cursor resets.
*/
export function browseForward(
sessionId: string | null | undefined,
history: readonly string[]
): { text: string; returnedToPresent: boolean } | null {
if (!valid(sessionId)) {
return null
}
const s = ensure(sessionId)
if (s.cursor === -1) {
return null
}
if (s.cursor > 0) {
s.cursor -= 1
persist()
return { text: history[s.cursor]!, returnedToPresent: false }
}
// At newest; moving forward restores the saved draft.
const text = s.draftSnapshot
s.cursor = -1
s.draftSnapshot = ''
persist()
return { text, returnedToPresent: true }
}
/** Clear browse state for a session (e.g. on session switch or new submit). */
export function resetBrowseState(sessionId: string | null | undefined) {
if (!valid(sessionId)) {
return
}
const all = { ...$perSessionBrowse.get() }
const existing = all[sessionId]
if (!existing) {return}
all[sessionId] = { cursor: -1, draftSnapshot: '' }
$perSessionBrowse.set(all)
}
/** True if the user is currently browsing history for this session. */
export function isBrowsingHistory(sessionId: string | null | undefined): boolean {
if (!valid(sessionId)) {
return false
}
const s = $perSessionBrowse.get()[sessionId]
return s ? s.cursor >= 0 : false
}
export { $perSessionBrowse }
+21 -10
View File
@@ -7,6 +7,7 @@ import {
dequeueQueuedPrompt,
enqueueQueuedPrompt,
getQueuedPrompts,
promoteQueuedPrompt,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt,
@@ -63,6 +64,20 @@ describe('composer queue store', () => {
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
})
it('promotes a queued entry to the front', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(third).not.toBeNull()
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
})
it('updates queued text and attachment snapshot', () => {
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
@@ -103,26 +118,22 @@ describe('composer queue store', () => {
})
describe('shouldAutoDrainOnSettle', () => {
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
const base = { isBusy: false, queueLength: 1, wasBusy: true }
it('drains the next queued prompt when a turn completes naturally', () => {
it('drains the next queued prompt when a turn settles', () => {
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
// Regression: previously the Stop button "never worked" because cancelling
// a turn flipped busy → false and the queue immediately re-fired its head.
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
it('drains after an interrupt — the settle edge is the same', () => {
// Interrupting to reach a queued message is the point of the queue; the
// gateway emits the same settle whether the turn finished or was stopped.
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
it('does not drain when the queue is empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
})
it('does not drain when interrupted even if the queue is also empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
})
it('ignores steady busy state (no true → false transition)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
})
+27 -14
View File
@@ -137,6 +137,26 @@ export const removeQueuedPrompt = (key: string | null | undefined, id: string):
return true
}
export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
const sid = sidOf(key)
if (!sid) {
return false
}
const queue = queueFor(sid)
const index = queue.findIndex(e => e.id === id)
if (index <= 0) {
return false
}
const entry = queue[index]!
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
return true
}
export const updateQueuedPrompt = (
key: string | null | undefined,
id: string,
@@ -194,33 +214,26 @@ export interface AutoDrainSettleInput {
wasBusy: boolean
isBusy: boolean
queueLength: number
userInterrupted: boolean
}
/**
* Decide whether the composer should auto-drain the next queued prompt when a
* turn settles (busy transitions true → false).
*
* The queue auto-advances when a turn *completes naturally*, but must NOT
* advance when the user *explicitly interrupted* the turn via the Stop button.
* Conflating the two made the Stop button appear to "never work": cancelling a
* turn flipped busy → false, the queue immediately re-fired its head, and the
* agent kept running. An explicit interrupt means stop — the queued turns are
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
* per-row "send now" arrow).
* Queued turns always advance once the session is idle again, whether the turn
* finished naturally or the user interrupted it. Interrupting to reach a queued
* message is the whole point of the queue, so we never suppress the drain. The
* gateway guarantees a settle (message.complete + session.info running:false)
* even after an interrupt, so this single edge reliably advances the queue. To
* cancel queued turns the user deletes them from the panel.
*/
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
const { isBusy, queueLength, userInterrupted, wasBusy } = params
const { isBusy, queueLength, wasBusy } = params
// Only react to a true → false transition; ignore steady state and entry.
if (isBusy || !wasBusy) {
return false
}
// An explicit Stop suppresses exactly one auto-drain.
if (userInterrupted) {
return false
}
return queueLength > 0
}
+10 -7
View File
@@ -1,5 +1,7 @@
import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
export interface NotificationAction {
@@ -52,28 +54,29 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string
summarize: msg => {
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
return status
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
: translateNow('notifications.errors.openaiRejectedApiKey')
}
},
{
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
},
{
test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg),
summarize: msg =>
/ELEVENLABS_API_KEY not set/i.test(msg)
? 'ElevenLabs STT needs ELEVENLABS_API_KEY.'
: 'ElevenLabs rejected the API key (401).'
? translateNow('notifications.errors.elevenLabsNeedsKey')
: translateNow('notifications.errors.elevenLabsRejectedKey')
},
{
test: msg => /method not allowed/i.test(msg),
summarize: () =>
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.'
summarize: () => translateNow('notifications.errors.methodNotAllowed')
},
{
test: msg => /microphone permission/i.test(msg),
summarize: () => 'Microphone permission was denied.'
summarize: () => translateNow('notifications.errors.microphonePermission')
}
]
@@ -0,0 +1,41 @@
import { atom } from 'nanostores'
import type { HermesSelectPathsOptions } from '@/global'
export interface RemotePathPickerRequest {
id: number
options: HermesSelectPathsOptions
resolve: (paths: string[]) => void
}
// Holds the currently open remote path-picker request, if any. The picker
// modal subscribes and resolves the promise when the user confirms or cancels.
// Used only when the desktop is connected to a remote gateway, where the native
// OS dialog (which browses the client machine) is the wrong filesystem.
export const $remotePathPicker = atom<RemotePathPickerRequest | null>(null)
let nextRequestId = 0
export function openRemotePathPicker(options: HermesSelectPathsOptions = {}): Promise<string[]> {
// Only one picker at a time; cancel any prior request.
const previous = $remotePathPicker.get()
if (previous) {
previous.resolve([])
}
return new Promise<string[]>(resolve => {
$remotePathPicker.set({ id: (nextRequestId += 1), options, resolve })
})
}
export function resolveRemotePathPicker(paths: string[]): void {
const request = $remotePathPicker.get()
if (!request) {
return
}
$remotePathPicker.set(null)
request.resolve(paths)
}
+7 -6
View File
@@ -13,6 +13,7 @@ import type {
DesktopUpdateStatus,
DesktopVersionInfo
} from '@/global'
import { translateNow } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
@@ -85,12 +86,12 @@ export function reportBackendContract(contract: number | undefined): void {
}
notify({
action: { label: 'Update Hermes', onClick: () => void applyUpdates() },
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
title: 'Backend out of date'
message: translateNow('notifications.backendOutOfDateMessage'),
title: translateNow('notifications.backendOutOfDateTitle')
})
}
@@ -121,7 +122,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
notify({
action: {
label: "See what's new",
label: translateNow('notifications.seeWhatsNew'),
onClick: () => {
snoozeUpdateToast()
openUpdatesWindow()
@@ -130,9 +131,9 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
durationMs: 0,
id: UPDATE_TOAST_ID,
kind: 'info',
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
message: translateNow('notifications.updateReadyMessage', behind),
onDismiss: () => snoozeUpdateToast(),
title: 'Update ready'
title: translateNow('notifications.updateReadyTitle')
})
}
+4
View File
@@ -466,6 +466,10 @@ export interface ProfileInfo {
skill_count: number
}
export interface ProfileSetupCommand {
command: string
}
export interface ProfileSoul {
content: string
exists: boolean
+1 -1
View File
@@ -871,7 +871,7 @@ delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
# WARNING: values above 10 multiply API cost linearly.
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
# Raise to 2 to allow workers to spawn their own subagents.
# Requires role="orchestrator" on intermediate agents.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
+33 -5
View File
@@ -3194,6 +3194,18 @@ class HermesCLI:
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
_DEFAULT_CONFIG_MODEL = ""
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
# Read max_tokens from config (env var override: HERMES_MAX_TOKENS)
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
if _env_mt:
try:
self.max_tokens = int(_env_mt)
except (ValueError, TypeError):
self.max_tokens = None
elif isinstance(_model_config, dict):
_mt = _model_config.get("max_tokens")
self.max_tokens = _mt if isinstance(_mt, int) else None
else:
self.max_tokens = None
# Auto-detect model from local server if still on default
if self.model == _DEFAULT_CONFIG_MODEL:
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
@@ -5097,9 +5109,9 @@ class HermesCLI:
resolved_id = self.session_id
if resolved_id and resolved_id != self.session_id:
ChatConsole().print(
f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
f"[dim]Session {_escape(self.session_id)} was compressed into "
f"{_escape(resolved_id)}; resuming the descendant with your "
f"transcript.[/]"
f"transcript.[/dim]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
@@ -5168,6 +5180,7 @@ class HermesCLI:
acp_command=runtime.get("command"),
acp_args=runtime.get("args"),
credential_pool=runtime.get("credential_pool"),
max_tokens=self.max_tokens,
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
disabled_toolsets=self.disabled_toolsets,
@@ -5378,7 +5391,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
self._console_print(f"[dim]{_escape(msg)}[/dim]")
return
try:
@@ -5388,7 +5401,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
self._console_print(f"[dim]{_escape(msg)}[/dim]")
return
# Retarget the terminal/code-exec tools to match the process cwd.
@@ -5398,7 +5411,7 @@ class HermesCLI:
if quiet:
print(msg, file=sys.stderr)
else:
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
self._console_print(f"[dim]{_escape(msg)}[/dim]")
def _preload_resumed_session(self) -> bool:
"""Load a resumed session's history from the DB early (before first chat).
@@ -9002,6 +9015,10 @@ class HermesCLI:
elif canonical == "update":
if self._handle_update_command():
return False
elif canonical == "version":
from hermes_cli.main import _print_version_info
_print_version_info(check_updates=True)
elif canonical == "paste":
self._handle_paste_command()
elif canonical == "image":
@@ -9284,6 +9301,7 @@ class HermesCLI:
api_mode=turn_route["runtime"].get("api_mode"),
acp_command=turn_route["runtime"].get("command"),
acp_args=turn_route["runtime"].get("args"),
max_tokens=turn_route["runtime"].get("max_tokens"),
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
quiet_mode=True,
@@ -13094,6 +13112,16 @@ class HermesCLI:
_welcome_color = "#FFF8DC"
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
# Warm the /model picker's provider-models cache off-thread during this
# idle window (banner shown, user about to type). The no-args picker
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
# it's opened in a session. Fire-and-forget, guarded once-per-process.
try:
from hermes_cli.model_switch import prewarm_picker_cache_async
prewarm_picker_cache_async()
except Exception:
pass
# Redaction opt-out warning (#17691): ON by default, loud when off.
# The redactor snapshots its state at import time so any toggle now
# won't affect the running process — we just want the operator to
+11
View File
@@ -3016,6 +3016,17 @@ class BasePlatformAdapter(ABC):
expanded = os.path.expanduser(raw)
if os.path.isfile(expanded):
found.append((raw, expanded))
else:
# The reply mentions a deliverable-looking path that does not
# exist on disk, so it is silently dropped from native delivery.
# This is the most common reason a promised file never arrives
# (the model said "here's your file" but never wrote it, or
# referenced the wrong path). Log it so the gap is visible in
# gateway.log rather than vanishing without a trace.
logger.info(
"Skipping bare file path in reply (no file on disk): %s",
_log_safe_path(raw),
)
# Deduplicate by expanded path, preserving discovery order
seen: set = set()
+2 -2
View File
@@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send document: {e}")
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
async def send_video(
@@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
print(f"[{self.name}] Failed to send video: {e}")
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
async def send_image(
+94 -2
View File
@@ -1179,6 +1179,7 @@ def _resolve_runtime_agent_kwargs() -> dict:
from hermes_cli.runtime_provider import (
resolve_runtime_provider,
format_runtime_provider_error,
_get_model_config,
)
from hermes_cli.auth import AuthError, is_rate_limited_auth_error
@@ -1200,6 +1201,26 @@ def _resolve_runtime_agent_kwargs() -> dict:
except Exception as exc:
raise RuntimeError(format_runtime_provider_error(exc)) from exc
model_cfg = _get_model_config()
max_tokens = None
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
if _env_mt:
try:
max_tokens = int(_env_mt)
except (ValueError, TypeError):
max_tokens = None
elif isinstance(model_cfg, dict):
mt = model_cfg.get("max_tokens")
if isinstance(mt, int):
max_tokens = mt
# Fall back to a per-provider output cap (custom_providers max_output_tokens)
# only when the documented global model.max_tokens isn't set, so the global
# key always wins.
if max_tokens is None:
_runtime_mot = runtime.get("max_output_tokens")
if isinstance(_runtime_mot, int) and _runtime_mot > 0:
max_tokens = _runtime_mot
return {
"api_key": runtime.get("api_key"),
"base_url": runtime.get("base_url"),
@@ -1208,6 +1229,7 @@ def _resolve_runtime_agent_kwargs() -> dict:
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
"credential_pool": runtime.get("credential_pool"),
"max_tokens": max_tokens,
}
@@ -2596,6 +2618,7 @@ class GatewayRunner:
"api_key": override.get("api_key"),
"base_url": override.get("base_url"),
"api_mode": override.get("api_mode"),
"max_tokens": override.get("max_tokens"),
}
if override_runtime.get("api_key"):
logger.debug(
@@ -2693,6 +2716,7 @@ class GatewayRunner:
"command": runtime_kwargs.get("command"),
"args": list(runtime_kwargs.get("args") or []),
"credential_pool": runtime_kwargs.get("credential_pool"),
"max_tokens": runtime_kwargs.get("max_tokens"),
}
route = {
"model": model,
@@ -7908,6 +7932,8 @@ class GatewayRunner:
return await self._handle_profile_command(event)
if _cmd_def_inner.name == "update":
return await self._handle_update_command(event)
if _cmd_def_inner.name == "version":
return await self._handle_version_command(event)
# Catch-all: any other recognized slash command reached the
# running-agent guard. Reject gracefully rather than falling
@@ -8264,6 +8290,9 @@ class GatewayRunner:
if canonical == "update":
return await self._handle_update_command(event)
if canonical == "version":
return await self._handle_version_command(event)
if canonical == "debug":
return await self._handle_debug_command(event)
@@ -10889,6 +10918,12 @@ class GatewayRunner:
return event.platform_update_id <= recorded_uid
async def _handle_version_command(self, event: MessageEvent) -> str:
"""Handle /version — show the running Hermes Agent version."""
from hermes_cli.banner import format_banner_version_label
return format_banner_version_label()
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
from hermes_cli.commands import gateway_help_lines
@@ -11998,13 +12033,24 @@ class GatewayRunner:
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
return t("gateway.voice.enabled_short")
toggle_line = t("gateway.voice.enabled_short")
else:
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
return t("gateway.voice.disabled_short")
toggle_line = t("gateway.voice.disabled_short")
# Bare /voice still toggles, but append an explainer so users
# discover the on/off/tts/status subcommands (and, on Discord,
# live voice-channel join/leave). The toggle result is shown
# first via the {toggle} placeholder.
supports_voice_channels = adapter is not None and hasattr(
adapter, "join_voice_channel"
)
channels = (
t("gateway.voice.help_channels") if supports_voice_channels else ""
)
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
"""Join the user's current Discord voice channel."""
@@ -17024,6 +17070,47 @@ class GatewayRunner:
last_progress_msg = [None] # Track last message for dedup
repeat_count = [0] # How many times the same message repeated
# ── Discord voice "verbal ack before tool calls" ────────────────
# When the bot is in a voice channel with the continuous mixer
# installed (discord.voice_fx.enabled), speak a short phrase ("let me
# look into that") over the ambient idle bed on the FIRST tool call of
# the turn. Fires from tool_start_callback (independent of the
# tool-progress text gate), at most once per turn. No-op on every
# other platform / when not in a voice channel.
_voice_ack_fired = [False]
_voice_ack_guild: List[Optional[int]] = [None]
if source.platform == Platform.DISCORD:
_va = self.adapters.get(Platform.DISCORD)
# source.chat_id is the linked text channel; resolve the guild whose
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
_vtc = getattr(_va, "_voice_text_channels", None)
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
for _gid, _tc in _vtc.items():
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
_voice_ack_guild[0] = _gid
break
_voice_ack_loop = asyncio.get_running_loop()
def voice_ack_callback(call_id, tool_name, args):
"""tool_start_callback: speak a one-time ack in the voice channel."""
if _voice_ack_fired[0] or _voice_ack_guild[0] is None:
return
if not _run_still_current():
return
_voice_ack_fired[0] = True
_adapter = self.adapters.get(Platform.DISCORD)
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
return
try:
safe_schedule_threadsafe(
_adapter.play_ack_in_voice(_voice_ack_guild[0]),
_voice_ack_loop,
logger=logger,
log_message="voice ack scheduling error",
)
except Exception as _ack_err:
logger.debug("voice ack schedule failed: %s", _ack_err)
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
# that implements ``delete_message``). When enabled via
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
@@ -17834,6 +17921,11 @@ class GatewayRunner:
# Per-message state — callbacks and reasoning config change every
# turn and must not be baked into the cached agent constructor.
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
# Discord voice verbal-ack hook (fires once per turn on first tool
# call; armed only when in a voice channel with the mixer running).
agent.tool_start_callback = (
voice_ack_callback if _voice_ack_guild[0] is not None else None
)
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
agent.stream_delta_callback = _stream_delta_cb
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
+7 -3
View File
@@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
return None
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError:
except (OSError, UnicodeDecodeError):
# OSError: file vanished or permission flipped between exists() and
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
# (a truncated or clobbered status file). Either way it's unusable.
return None
if not raw:
return None
@@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
try:
raw = pid_path.read_text().strip()
except OSError:
# File was deleted between exists() and read_text(), or permission flipped.
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.
return None
if not raw:
return None
+2 -2
View File
@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.15.1"
__release_date__ = "2026.5.29"
__version__ = "0.16.0"
__release_date__ = "2026.6.5"
def _ensure_utf8():

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