Compare commits

..

159 Commits

Author SHA1 Message Date
Brooklyn Nicholson
350f4a6448 fix(update): use subprocess hand-off instead of os.execve (cross-platform)
The exec-based re-exec was sketchy (replaces the running interpreter
mid-update on the common CLI path that already works) and, worse, had to
disable itself on Windows because os.exec* there spawns a new PID and
breaks the desktop installer's exit-code wait — so it didn't fix the
"do it again" trap for Windows users at all.

Replace it with a plain subprocess hand-off: after a successful pull + dep
install, spawn `hermes update` again with HERMES_UPDATE_FINALIZE=1, inherit
stdio, and forward the child's exit code via sys.exit(). The parent PID
stays intact everywhere, so the hand-off now stays on for Windows too. This
is the same `[sys.executable, "-m", "hermes_cli.main", ...]` subprocess
pattern the updater already uses for the desktop build.

Renames: _should_reexec_after_pull -> _should_handoff_after_pull (drops the
Windows gate), _reexec_into_updated_code -> _handoff_update_to_refreshed_code
(returns child rc / None), HERMES_UPDATE_NO_REEXEC -> HERMES_UPDATE_NO_HANDOFF.
On spawn failure we still fall through and finish in-process.
2026-06-05 06:11:17 -05:00
Brooklyn Nicholson
a6c60a2902 fix(update): re-exec into pulled code so update finishes in one run
`hermes update` runs from the *old* install, but its post-pull steps
(dependency install, config migration, skills sync, gateway restart) ran
from the modules this process imported at startup — even though the new
source was already on disk after the git pull. Any bug fixed in the pulled
version that lived in an already-imported post-pull path still crashed the
first run, so users had to run `hermes update` a second time (the retry
loaded the fresh code and succeeded). The desktop installer drives the same
old `hermes update`, so it hit the same "do it again" trap.

After a successful pull AND dependency install (so new source and its deps
are present), re-exec into the refreshed code via POSIX `execve`, which
keeps the same PID/fds so the installer's stdout/stderr streaming and
exit-code wait are unaffected. The re-exec'd run sets
`HERMES_UPDATE_FINALIZE=1`, skips the fetch/pull/snapshot/backup work and
the re-exec itself, and runs only the post-pull finalize steps with new
code.

Gated off on the finalize pass (loop-breaker), on Windows (`os.exec*`
spawns a new PID and would break the installer's exit-code wait), under
pytest (never replace the test interpreter mid-suite), and via the
`HERMES_UPDATE_NO_REEXEC` escape hatch. On any `execve` failure we fall
through and finish in-process — the historical behavior.
2026-06-05 06:07:05 -05:00
ethernet
7f016f5f33 change(desktop): show up to 50 models in list per provider by default 2026-06-05 00:00:19 -04:00
shannonsands
ab706a3346 Clear stale desktop onboarding errors (#38844) 2026-06-04 22:59:55 -05:00
ethernet
4eca569bf4 fix: temp for update 2026-06-04 23:32:48 -04:00
Ben Barclay
7c00ffd92c fix(google-workspace): fall back to uv when venv has no pip (#39516)
The Hermes Docker image's venv is built with `uv sync`, which does not
bootstrap pip into the venv. When the google-workspace setup script needs
to install its deps and the running interpreter has no pip,
`sys.executable -m pip install` dead-ends with "No module named pip"
(reported via Discord support).

install_deps() now falls back to `uv pip install --python <interpreter>`
when the pip path fails and uv is on PATH. uv installs into the exact
interpreter the script is running under without needing pip present, so
the pip-less venv self-heals (e.g. a dep evicted on image update, or a
build without the [google]/[all] extra). On environments with neither
pip nor uv, the [google] extra hint is printed as before.

Verified E2E against nousresearch/hermes-agent:latest: under the venv
python with a missing dep, --install-deps now prints "Dependencies
installed." and exits 0 instead of failing.

Adds TestInstallDeps regression coverage: pip path, uv fallback,
uv-not-consulted-when-pip-works control, and both no-installer-available
and uv-also-fails failure cases.
2026-06-05 13:30:02 +10:00
ethernet
fb853a1783 fix(install): scrap rebuild venv 2026-06-04 23:20:29 -04:00
Ben
96cd37e212 fix(dashboard): reap orphaned embedded-chat sessions to stop slash_worker leak
Since #38591 made the dashboard's embedded chat unconditional, every
browser refresh of /chat spins up a fresh session.create (new sid + a
fresh _SlashWorker via _deferred_build) over /api/ws, but the old tab's
WS disconnect only DETACHES the transport (ws.py) — it never closes the
old session or its slash_worker. The dashboard's in-process gateway is
long-lived, so the detached _SlashWorker subprocess's stdin pipe stays
open forever and the worker never reaches EOF: one leaked python process
per refresh.

Fix at the session-lifecycle layer (not PTY signal timing — verified that
a process whose owning gateway dies is always reaped via stdin-EOF; the
leak is specifically the long-lived dashboard process keeping detached
sessions parked). On WS disconnect, schedule a grace-delayed reap of any
session left orphaned (transport detached to stdio, not mid-turn). A quick
reconnect / session.resume / prompt.submit rebinds a live transport and
cancels the reap, preserving the intentional detach-for-reconnect window.

- server.py: extract _teardown_session() (shared with session.close),
  add _ws_session_is_orphaned() + _schedule_ws_orphan_reap(), gated by
  HERMES_TUI_WS_ORPHAN_REAP_GRACE_S (default 20s, 0 disables).
- ws.py: schedule the reap for each detached session on disconnect.
- tests: reap-closes-worker, spares-reattached/mid-turn/finalized,
  disabled-when-grace-zero.
2026-06-04 19:50:33 -07:00
teknium1
bcb024ad48 fix(desktop): fail remote test when OAuth ws-ticket mint fails
Youssef's review caught a residual false-positive: resolveTestWsUrl
swallowed an OAuth ticket-mint failure and returned null, so the caller
skipped the WS probe and reported the remote test as reachable. But the
real boot path (resolveRemoteBackend) treats a mint failure as a hard
'session expired' auth error and refuses to connect — so an expired OAuth
session passed the test then failed boot, the exact false-positive this
PR exists to kill.

Extract resolveTestWsUrl into the electron-free connection-config.cjs
(injectable mintTicket) so it's unit-testable, and make OAuth mint
failure throw an actionable needsOauthLogin error instead of skipping.
Adds the three cases Youssef requested plus a mintTicket-required guard.
2026-06-04 19:49:06 -07:00
xxxigm
500cf537b7 fix(desktop): validate live WebSocket in remote gateway connection test
The "Test remote" button only checked HTTP GET /api/status, but the chat
surface depends on the renderer opening a live WebSocket to /api/ws — a
separate transport with separate server-side guards (Host/Origin checks,
ws-ticket/token auth, peer-IP checks). A gateway could pass the HTTP check yet
reject the WebSocket, so the test reported "reachable" while boot still failed
with the opaque "Could not connect to Hermes gateway".

testDesktopConnectionConfig now mirrors the renderer's connect: after the
status check it opens the WS URL (token/local) or a freshly minted ws-ticket
(OAuth) and confirms the upgrade is accepted and not immediately torn down by
a post-handshake auth rejection. Failures surface an actionable message instead
of a false-positive. The WS leg is skipped when the runtime lacks a global
WebSocket so it never fails spuriously.
2026-06-04 19:49:06 -07:00
xxxigm
10c78bf625 test(desktop): add injectable gateway WebSocket probe + unit tests
Adds electron/gateway-ws-probe.cjs: a small helper that opens a gateway
WebSocket URL and classifies the handshake (open/frame → ok; error or close
before open → fail; open-then-early-close → credential rejected; never-opens →
timeout). The WebSocket implementation is injected so it can be unit-tested
without a real socket.

Wires gateway-ws-probe.test.cjs into test:desktop:platforms, covering every
handshake outcome plus constructor-throw and missing-impl.
2026-06-04 19:49:06 -07:00
Teknium
9cc47b20cb feat(desktop): add 'choose provider later' skip to first-run onboarding (#39483)
The first-run provider picker was a hard gate — the only way out was
connecting a provider. Add an 'I'll choose a provider later' link that
dismisses the overlay and persists the skip to localStorage so it never
re-nags on subsequent launches. Users connect a provider any time from
Settings -> Providers (manual onboarding already bypasses the skip gate).

- onboarding.ts: firstRunSkipped state seeded from localStorage
  (hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action;
  completeDesktopOnboarding clears the flag once a provider connects.
- overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink
  rendered in both the OAuth picker footer and the API-key fallback, first-run only.
- tests: skip persists + hidden in manual mode; full-state fixtures updated.
2026-06-04 19:40:54 -07:00
asill-livestream
5bcb63e400 fix(tui): add thread-safety locks for _sessions and prompt dicts
C1: Add _sessions_lock to protect all compound mutations and iterations
    on the global _sessions dict across 5+ concurrent execution contexts
    (main dispatcher, pool workers, daemon threads, notification poller,
    atexit handler).

C2: Add _prompt_lock to protect _pending/_pending_prompt_payloads/_answers
    dicts from races between _block() (agent callback thread) and
    _respond() (pool worker).  Lock scope is kept tight — _block() only
    holds the lock during registration/cleanup, releasing it before
    _emit() and ev.wait() to avoid blocking other prompts for 300s.

All 187 existing TUI tests pass with no regressions.
2026-06-04 19:40:52 -07:00
teknium
2069e78b88 chore: add HeLLGURD to release AUTHOR_MAP for PR #39453 salvage 2026-06-04 19:40:46 -07:00
YapBi
1bcfe9c58a fix(cli): widen _run_cleanup MCP shutdown guard to BaseException 2026-06-04 19:40:46 -07:00
YapBi
e9529578d5 fix(mcp): widen shutdown_mcp_servers exception guard to BaseException 2026-06-04 19:40:46 -07:00
kyssta-exe
25742372eb fix(approval): check is_approved in execute_code guard (#39275)
check_execute_code_guard() never called is_approved() before entering the
approval flow, and never persisted session/permanent approvals from the
gateway response. This meant 'Approve session' and 'Always' buttons had
no effect — every execute_code call re-prompted the user.

- Add is_approved() check after get_current_session_key(), matching
  check_all_command_guards()
- Persist session ('approve_session') and permanent ('approve_permanent')
  approvals based on the gateway choice, same as terminal command guard
- Add 3 regression tests for session persistence, permanent persistence,
  and short-circuit on pre-existing approval
2026-06-04 19:40:30 -07:00
teknium1
facd011b63 chore(release): map youngstar-eth in AUTHOR_MAP for salvage PR #39134 2026-06-04 19:39:07 -07:00
youngstar-eth
338f0b2234 fix(desktop): recover from corrupt Electron cache in bootstrap install (Windows)
Windows counterpart of #39127: scripts/install.ps1 `Install-Desktop` runs
`npm run pack` once and throws on the opaque ENOENT a corrupt cached Electron
download produces, with no recovery. Add `Clear-ElectronBuildCache` plus a
purge-and-retry-once on pack failure, mirroring the install.sh fix: remove the
cached electron-*.zip (%LOCALAPPDATA%\electron\Cache + ELECTRON_CACHE /
electron_config_cache overrides) and stale *-unpacked output, then retry so
@electron/get re-downloads with its own SHASUM verification.

Refs #37544.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:39:07 -07:00
brooklyn!
ff5652d0f6 Merge pull request #39330 from NousResearch/bb/desktop-profile-support
feat(desktop): concurrent multi-profile sessions, cross-profile @session links
2026-06-04 20:50:34 -05:00
Brooklyn Nicholson
7b4acadfe7 feat(desktop): per-profile "+" to start a session in the all-profiles view
Mirror the workspace-group "+": each profile header in the all-profiles
session list gets a new-session button. Unlike selecting the profile, it
leaves the browse scope untouched (newSessionInProfile keeps
$showAllProfiles), so creating a chat doesn't collapse the unified view.
2026-06-04 20:44:22 -05:00
Brooklyn Nicholson
4891f9ae78 feat(desktop): concurrent multi-profile gateway sockets
Keep one persistent socket per profile with live work instead of closing
the single socket on every profile swap, so background sessions across
profiles keep streaming at once. A gateway registry owns the primary
(window) socket plus lazy secondaries (own backoff/reconnect); all feed
the same session-keyed event handler. Secondaries are pruned to profiles
with a working/needs-input session, the keepalive pings every open
backend, and LRU eviction spares freshly-touched backends so the soft cap
can't abort a running agent. Approval/sudo/secret prompts are parked
per-session (surfaced via the needs-input badge) so a background turn can
block without hijacking the foreground. Single-profile users only ever
have the primary, so their path is unchanged.
2026-06-04 20:44:19 -05:00
Brooklyn Nicholson
89baf02919 Merge origin/main into bb/desktop-profile-support
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's
ListRow + actions-menu refactors for credential rows; keep our profileColor
import on the sidebar. Drop the now-orphaned Tip-based helpers.
2026-06-04 20:17:07 -05:00
Brooklyn Nicholson
1b01fa3acf feat(desktop): long-press a rail profile to pick its color
Hold (~450ms) a profile square — or right-click → Color… — to open a
shadcn Popover of swatches and override its rail color, with Auto to fall
back to the deterministic hue. The hold timer rides alongside the dnd
pointer listener (a real drag cancels it, the trailing click is
suppressed), so reorder/select/recolor stay distinct gestures.

Overrides persist in localStorage ($profileColors), resolved via
resolveProfileColor (override wins, else the name-hashed hue). Cosmetic
and gated on the multi-profile rail, so single-profile users are
unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella).
2026-06-04 20:12:37 -05:00
Brooklyn Nicholson
86371e6cd8 style(desktop): drop border + radius from the profile-swap overlay 2026-06-04 20:12:37 -05:00
ethernet
80672754a8 fix(docs): update all install instructions everywhere 2026-06-04 21:07:45 -04:00
kewe63
dfe6fbb0b3 fix(ssh): narrow symlink fallback to WinError 1314 only
The previous catch-all except OSError would silently swallow real
errors (disk full, bad path, permission issues unrelated to symlink
privilege). Narrow the handler to winerror == 1314 — the specific
Windows error code for "A required privilege is not held by the
client" — and re-raise every other OSError so genuine failures are
not hidden.
2026-06-04 18:06:21 -07:00
kewe63
46abf04012 fix(ssh): handle WinError 1314 symlink failure with shutil.copy2 fallback
On Windows, os.symlink() raises OSError (WinError 1314) unless the
process has Administrator rights or Developer Mode is enabled. The SSH
bulk-upload staging logic used symlinks to mirror the remote layout
before piping through tar; this caused all ssh_bulk_upload tests to
fail on Windows.

- ssh.py: wrap os.symlink() in try/except OSError and fall back to
  shutil.copy2() so staging works on every platform. shutil was already
  imported, no new dependency introduced.
- file_sync.py: replace str(Path(remote).parent) with
  posixpath.dirname(remote) in unique_parent_dirs(). pathlib.Path uses
  the host separator (\ on Windows), but these paths are sent to a
  remote Linux host over SSH and must always use forward slashes.
- test_ssh_bulk_upload.py: make test_staging_symlinks_mirror_remote_layout
  platform-agnostic — assert file existence and content instead of
  os.path.islink() + os.readlink(), since the staged entry may be a
  copy on Windows.
2026-06-04 18:06:21 -07:00
asill-livestream
ea44011d15 fix(desktop): prevent thinking block from closing mid-streaming
When reasoning text grows during streaming, new parts can be appended
beyond endIndex.  The pending check used slice(startIndex, endIndex)
which excluded these new parts — if the original part completed, the
block would close while new reasoning was still streaming.

Fix: remove the endIndex cap from slice() so all parts from startIndex
onward are checked.  During non-streaming, the array is stable and
all parts are within range anyway.
2026-06-04 21:05:45 -04:00
teknium1
93b5df3189 fix(test): patch async_is_safe_url in web-provider SSRF mocks
web_tools.is_safe_url was replaced by async_is_safe_url, but three
web-provider test files still monkeypatched the old sync name, raising
AttributeError. Patch the async variant with an async lambda.
2026-06-04 18:04:47 -07:00
kewe63
c60952ba94 fix(web): run URL SSRF checks off the event loop in async paths
Add async_is_safe_url() wrapping is_safe_url via asyncio.to_thread, and route
all async SSRF call sites through it: web_extract_tool, the vision/video
preflight checks, and both download redirect guards. socket.getaddrinfo blocks;
calling it inline from async tool paths froze the event loop for the duration of
DNS resolution.

vision_tools: split _validate_image_url into _image_url_shape_ok (no DNS) +
sync _validate_image_url (for sync callers/tests) + async _validate_image_url_async.

Widened beyond the original PR #3691 to sibling async sites that also blocked
the loop (second redirect guard, video preflight).

Salvage of #3691 by @Kewe63 — surgically re-applied onto current main because
the original branch was too stale to cherry-pick cleanly (would have reverted
the web_crawl_tool refactor).

Co-authored-by: Kewe63 <kewe.3217@gmail.com>
2026-06-04 18:04:47 -07:00
Kewe63
46b2afc56b fix(state): use TRUNCATE WAL checkpoint to prevent unbounded WAL growth
PASSIVE checkpoint never shrinks the WAL file, causing state.db-wal to
grow without bound. Change to TRUNCATE in _try_wal_checkpoint() and
close() so the WAL is truncated regularly.

Fixes #24034
2026-06-04 17:56:35 -07:00
teknium1
76c7512dbf chore: add Kewe63 gmail to release AUTHOR_MAP 2026-06-04 17:54:59 -07:00
kewe63
19db9cd076 fix(acp): replace direct db._lock/_conn access with public update_session_meta()
session.py _persist() bypassed SessionDB's thread-safe write path by
accessing private internals db._lock and db._conn directly:

    with db._lock:
        db._conn.execute("UPDATE sessions SET model_config = ? ...")
        db._conn.commit()

This was fragile for three reasons:
1. It bypassed _execute_write()'s BEGIN IMMEDIATE + jitter-retry logic,
   so concurrent writes could hit SQLite BUSY without retrying.
2. It called db._conn.commit() manually, breaking the transactional
   contract that _execute_write() enforces.
3. Any internal rename of _lock or _conn would silently break this
   call site with an AttributeError at runtime.

Fix:
- Add SessionDB.update_session_meta(session_id, model_config_json, model)
  to hermes_state.py. Routes through _execute_write() for the standard
  BEGIN IMMEDIATE + lock + jitter-retry guarantee. Uses COALESCE so
  passing model=None leaves the stored model column unchanged.
- Replace the db._lock / db._conn block in session.py _persist() with
  a single db.update_session_meta() call.

Tests (tests/acp/test_session_db_private_access.py, 11 tests):
- Unit tests for update_session_meta: updates model_config, updates
  model, preserves existing model on None, routes through _execute_write,
  no-op on non-existent session.
- AST checks: db._lock and db._conn not referenced in session.py;
  _persist() calls update_session_meta().
- Integration round-trips: cwd and model persisted correctly; COALESCE
  prevents overwriting an existing model with NULL.
2026-06-04 17:54:59 -07:00
teknium1
d33d23c852 fix(vision): drop models.dev catalog fallback, keep explicit profile flag
The models.dev supports_vision field reflects model IMAGE-INPUT capability,
which is not the same contract as 'provider API accepts images inside
tool-result messages' — the looser heuristic could re-introduce the exact
HTTP 400 'text is not set' it aims to fix. Keep only the explicit, opt-in
ProviderProfile.supports_vision flag (set on xiaomi); add catalog-based
detection later if a concrete provider needs it.
2026-06-04 17:53:49 -07:00
Kewe63
f736d2be86 fix(vision): detect vision-capable custom providers via ProviderProfile flag
_supports_media_in_tool_results() had a hardcoded provider allowlist
that missed custom providers and newer vision-capable providers like
xiaomi. Added ProviderProfile.supports_vision flag and made the
function check:

1. Registered provider profile (supports_vision flag)
2. Model capabilities from models.dev catalog (supports_vision)
3. Existing hardcoded allowlist (unchanged)

This fixes HTTP 400 "text is not set" errors when vision-capable
custom providers receive text-only tool results instead of
multipart image content.

Related: #25594
2026-06-04 17:53:49 -07:00
Kewe63
4a4b9bd2dc fix(test): add platform guard for grp import
Tests in test_gateway_service.py imported grp inline without a
platform guard, causing ImportError on systems where grp is
unavailable (e.g. macOS, WSL without grp module).

Added pytest.importorskip('grp') at module level alongside the
existing pwd guard, and removed three redundant inline import grp
statements.

Fixes #24531
2026-06-04 17:52:50 -07:00
bedirhancode
99cee124dc docs(install): warn that VPS browser consoles mangle special chars (#36279) (#38811)
Some VPS providers (Hetzner Cloud and others) offer a browser-based
console for managing hosts. These consoles transmit special characters
incorrectly — ':' may arrive as ';', '@' may be mis-rendered, and
non-English keyboard layouts fare worse — which silently corrupts
'docker run' arguments like '-v ~/.hermes:/opt/data', '-e KEY=value',
and pasted API keys / tokens.

Adds a :::caution admonition above the Quick start 'docker run' block
in website/docs/user-guide/docker.md recommending SSH for copy-paste-
safe command entry, with manual-typing guidance as a fallback.

Pure docs change, no code touched.

Closes #36279

Co-authored-by: Bedirhan Celayir <bedirhancode@users.noreply.github.com>
2026-06-05 10:49:55 +10:00
ethernet
36f1cd7dea feat(installer): do shallow clones
no need to get the whole repo history :)
2026-06-04 17:49:16 -07:00
Brooklyn Nicholson
f764b0400a fix(desktop): deleting the active profile reliably falls back to default
Centralize the fallback in DeleteProfileDialog (the single delete choke
point) so both the rail and the Profiles view inherit it. Reset *after*
the host's onDeleted refresh so a refreshActiveProfile racing the dying
backend can't clobber the pill back to the deleted profile, and set
$activeProfile too (selectProfile only moved the gateway, leaving the
statusbar pill stranded on the dead profile).
2026-06-04 19:49:11 -05:00
teknium1
0538c5ed19 chore: add dirtyren to AUTHOR_MAP for PR #38177 salvage 2026-06-04 17:42:10 -07:00
dirtyren
74e845c000 fix(slack): pass thread_ts in standalone send_message tool path
The standalone `_send_slack()` function used by the send_message tool
and cron delivery fallback was not passing `thread_ts` to the Slack API,
causing messages to post to the top-level channel instead of inside
threads.

- Add `thread_ts` parameter to `_send_slack()`
- Include `thread_ts` in the chat.postMessage payload when present
- Pass `thread_id` from `_send_to_platform()` to `_send_slack()`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:42:10 -07:00
Brooklyn Nicholson
9dbd3c57d7 feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id>
chip the agent resolves via session_search. New READ shape dumps a whole
session by id (head+tail when large); a `profile` param reads another
profile's DB read-only, and a cross-profile locate scan resolves bare ids
when the model drops the owning profile from the link.

Also: ASCII "waking up <profile>" overlay during lazy gateway swaps,
global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and
reauth toasts surfaced once per disconnect instead of every backoff tick.
2026-06-04 19:41:51 -05:00
Teknium
fe4e327bb5 chore: add Kewe63 to release AUTHOR_MAP 2026-06-04 17:40:33 -07:00
Kewe63
c14c37d46b fix(openviking): add missing /agent/{agent}/ segment to memory URI — fixes #36969
_build_memory_uri produced URIs of the form:
  viking://user/{user}/memories/{subdir}/mem_{slug}.md

The /agent/{agent}/ segment was missing, causing every agent under
the same user to write into the same flat namespace. In multi-agent
deployments agents silently overwrite each other's memories and
vector retrieval cross-pollinates results.

self._agent was already populated correctly (from OPENVIKING_AGENT
env var, default 'hermes') and sent via X-OpenViking-Agent header —
it was simply not interpolated into the URI.

Fix: add the missing segment so URIs follow the documented shape:
  viking://user/{user}/agent/{agent}/memories/{subdir}/mem_{slug}.md

Tests: 4 new regression tests in TestOpenVikingMemoryUriBuilder,
13/13 passed (9 existing + 4 new).
2026-06-04 17:40:33 -07:00
Teknium
b20fcffa54 docs: make dashboard/gateway prerequisites explicit for remote-backend connection (#39128)
Both the desktop and web-dashboard remote-backend sections now state up front
that the 'remote backend' is a running 'hermes dashboard' process the desktop
app attaches to (it does not start it for you), and that the gateway is a
separate process needed only for messaging channels.
2026-06-04 17:38:49 -07:00
Ben Barclay
8a888441d7 fix(docker): recover from out-of-band container removal in persistent mode (salvage #36631) (#39415)
Salvage of #36631 (@annguyenNous), rebased onto current main with
regression tests added. Fixes #36266.

When a persistent Docker sandbox container is removed out-of-band (idle
reaper, `docker prune`, OOM kill, daemon restart), the gateway kept
issuing `docker exec` against the dead container ID, returning
"No such container" on every subsequent tool call — the agent was
permanently blocked until the gateway process restarted.

DockerEnvironment.execute() now detects the "No such container" /
"is not running" error after a non-zero exit (gated on
persist_across_processes) and calls _recreate_container(): it tries
label-based reuse first, falls back to a fresh container replaying the
same image + full all_run_args set, re-runs init_session(), and retries
the command once. A genuine non-zero exit is NOT misclassified as
container-gone.

Differs from #36631 as submitted: adds the tests the original lacked.
tests/tools/test_docker_environment.py covers _is_container_gone pattern
matching (incl. the negative/control case), the recover-and-retry path,
the persist_across_processes=False opt-out (no recovery), and the
ordinary-failure passthrough (no spurious recreation). _make_dummy_env
now forwards persist_across_processes.

Verified:
- Unit: 67/67 in test_docker_environment.py (4 new + existing).
- Live E2E against the real docker daemon: started a persistent
  container, `docker rm -f`'d it out-of-band, and the next execute()
  transparently recreated a fresh container and succeeded; a follow-up
  command worked in the recovered container; a real `exit N` passed
  through without triggering recovery.

Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
2026-06-05 10:33:44 +10:00
Ben Barclay
c54b935873 fix(desktop): rename session via session.title RPC so /title works (#39410)
The desktop `/title <name>` command 404s with "Session not found" on
every platform (reported on Windows in #38508).

Root cause: `session.create` returns two distinct ids — a *runtime*
session id (held in `activeSessionIdRef`) and a `stored_session_id` (the
DB `sessions.id`) — and deliberately does NOT persist a DB row until the
first turn. Routing `/title` through the REST `PATCH /api/sessions/{id}`
endpoint (as #38576 proposed) resolves the id against the `sessions`
table, so the runtime id — or any brand-new, not-yet-persisted session —
never resolves and returns 404. This is an id-type mismatch, not a
Windows file-locking quirk, so it fails on macOS and Linux too.

Fix: route `/title <name>` through the gateway's `session.title` RPC —
the exact path the TUI already uses (`ui-tui/.../slash/commands/core.ts`).
The RPC maps the runtime id to the in-memory session, writes through the
gateway's own DB connection, and queues the title (`pending: true`) when
the row isn't persisted yet, so it works for a fresh chat. The sidebar is
then refreshed via the existing `refreshSessions()` plumbing.

Keeps the sidebar-refresh wiring and `refreshSessions` threading from
#38576; replaces only the broken REST/slash-worker write path. A bare
`/title` (no arg) still falls through to the worker to show the current
title.

Tests rewritten to assert `session.title` routing with the runtime-vs-
stored id distinction (which the original mock collapsed), plus the
queued/`pending` fresh-chat case and the error path.

Supersedes #38576. Fixes #38508.

Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
2026-06-04 19:32:24 -05:00
Teknium
fd87c61078 feat(models): add qwen/qwen3.7-plus to nous + openrouter catalogs (#39409)
Adds qwen/qwen3.7-plus directly under qwen/qwen3.7-max in both the
OpenRouter curated catalog (OPENROUTER_MODELS) and the Nous portal
catalog (_PROVIDER_MODELS['nous']), then regenerates the docs-hosted
model-catalog.json manifest from those source lists.
2026-06-04 17:29:45 -07:00
rob-maron
54cae7d1cb switch model order 2026-06-04 17:29:31 -07:00
Teknium
2c98dc0a96 fix(desktop): offer remote sign-in on a gated-gateway boot failure (#39402)
When a remote gateway with username/password (or OAuth) auth restarts, its
session cookie lapses and Desktop boots into the recovery overlay with a
session-expired error. That overlay only exposed local-recovery actions —
Retry (resets the local bootstrap latch) and Repair (re-runs the installer) —
neither of which can re-establish a remote session, so the user is stuck in a
no-op Retry loop with no way to sign in again.

The overlay now detects a remote-reauth boot failure from the saved connection
config (remote + gated + not currently connected + has a URL) and surfaces a
primary 'Sign in to remote gateway' button that opens the gateway login window
(the username/password form for a basic gateway, the OAuth redirect otherwise)
and reloads on success. Button copy is driven by a best-effort provider probe,
matching the gateway-settings page. Detection and copy logic live in a pure
helper module with unit coverage.
2026-06-04 17:28:29 -07:00
Ben Barclay
82c157b267 fix(docker): clean up orphaned container when docker run fails (salvage #7440) (#39412)
When `docker run -d` fails after Docker has already created the container
object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image
pull), the code raised before `self._container_id` was set — so the
container leaked permanently in "Created" state. Reported in #7439:
110+ orphaned containers accumulated over 3 days from hourly cron-
scheduled gateway sessions hitting a Docker Desktop startup race.

The orphan reaper added in #33645 (reap_orphan_containers) does NOT cover
this case: it filters `status=exited`, but a failed-create container is in
`Created` state, so it slips through and is never reaped.

Wrap the `docker run -d` call in try/except and `docker rm -f` the
container by its known name before re-raising.

Salvages #7440 by @Tranquil-Flow. Their branch predated the cross-process
reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed
the same intent (plus their two regression tests, adapted to mock the new
reuse `docker ps` probe) against current `main`.

Verified adversarially: reverted just the product change to origin/main's
`docker.py`, ran the two new tests -> both FAIL with
`assert 0 == 1 ("docker rm should be called once")`. With the fix applied,
both pass; full test_docker_environment.py is 65/65 green.

Closes #7440. Fixes #7439.

Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
2026-06-05 10:19:08 +10:00
Evi Nova
4690bbc363 fix(local): recognize unqualified hostnames as local endpoints (#9248)
Docker Compose service names (e.g. ollama, litellm, hermes-litellm)
are unqualified hostnames with no dots. These are always local — they
resolve via Docker DNS, /etc/hosts, or mDNS. Without this fix, the
stale stream timeout fires on local LLM proxies, causing infinite
reconnect loops.

Closes #7905
2026-06-05 10:18:10 +10:00
annguyenNous
751b91446e fix(mcp): ensure server.shutdown() on probe iteration failure
Wrap the _tools iteration in _probe_single_server() in try/finally
so that server.shutdown() is called even if iterating tool metadata
raises. Without this, the MCP server connection leaks until the
event loop is torn down by _stop_mcp_loop().
2026-06-04 17:11:17 -07:00
Ali Zakaee
454d6cbe52 fix(telegram): finalize sealed overflow chunk so split streamed replies render formatting
The existing-message overflow split path in stream_consumer.run() sealed the
first chunk via _send_or_edit(chunk) (finalize=False) then reset _message_id
to None — so that chunk was never edited again and never received the adapter's
final rich-text pass. On Telegram, MarkdownV2 formatting is applied on the
finalize edit, so early split messages of a long multi-part streamed reply
rendered raw markdown (##, **bold**, code fences) while only the last chunk
rendered correctly.

Fix: seal the overflow chunk with finalize=True so it gets its final
formatting pass before _message_id is cleared.

Salvaged from #32609 (the streaming-format portion only; the PR's send_draft
parse_mode change is already superseded on main, and its media-roots change
conflicts with the current denylist + recency-window delivery model).
2026-06-04 17:11:12 -07:00
flooryyyy
e7a7872a87 fix(tui_gateway): dedup re-queued process notifications flooding TUI
_ notification_poller_loop_ re-emits status.update every cycle
when a background process completes while the session is busy.
The same completion event gets re-queued and re-emitted to the
TUI every few ms, flooding the transcript with duplicate lines.

Add _notification_event_dedup_key(evt) that returns a tuple
identity for each notification event. Only emit status.update
on first sight per identity:
- completions: (sid, type) — one-shot per process session
- watch_match: (sid, type, command, pattern, output, ...)
- watch_overflow/disabled: (sid, type, command, message, ...)

The dedup key design was refined from an initial sid:type approach
after @lordbuffcloud identified that distinct watch_match events
(READY vs DONE) for the same process would be incorrectly collapsed.
Tests from @tymrtn cover distinct watch matches, exact replay
dedup, and completion one-shot behavior.

Co-authored-by: tymrtn <ty@tmrtn.com>
2026-06-04 16:56:34 -07:00
Shannon Sands
2f0c8e90e6 Add Telegram QR onboarding to dashboard 2026-06-04 16:55:27 -07:00
Teknium
5300727a08 revert: keep Google Chat OAuth secret + active_provider profile-scoped (#39398)
* Revert "fix(gateway): anchor Google Chat OAuth client secret to default Hermes root"

This reverts commit fff0561441.

* Revert "fix(cli): honor global-root active_provider fallback for named profiles"

This reverts commit 3858cf4307.

* docs(google_chat): describe OAuth client secret as profile-scoped, not host-wide

The setup docs, oauth docstring, and the adapter's 'no credentials'
error message all described the Google Chat OAuth client secret as
host-wide shared infrastructure. That contradicts profile isolation:
profiles are separate auth boundaries, so two profiles can point at
different Google OAuth apps / accounts. Reword all three to say the
secret is profile-scoped and each profile registers its own.
2026-06-04 16:54:40 -07:00
bluefishs
6ad015255d chore: enforce LF line endings for container entrypoints (#12181)
Windows contributors checking out on NTFS with git's default core.autocrlf
will end up with CRLF in docker/entrypoint.sh. When COPY'd into the image
and invoked as ENTRYPOINT, the kernel interprets the trailing \r as part of
the interpreter path, producing a confusing 'no such file or directory'
despite the file being present and executable.

Lock LF for the usual suspects (*.sh, Dockerfile, *.dockerfile, and the
specific docker/entrypoint.sh). The existing tree is already LF; this is
preventive against future Windows regressions only.
2026-06-05 09:54:01 +10:00
zer0 spirits
eb43a5b5d8 chore: improve .dockerignore with Python and common patterns (#6092)
Co-authored-by: 欧阳 <archer@ouyangdeMac-mini.local>
2026-06-05 09:53:42 +10:00
Ben Barclay
b434f8c3e0 fix(deps): promote markdown to a core dependency so rich delivery works out of the box (#32486) (#38649)
`markdown` was declared only in the `matrix` optional extra, and the
official Docker image installs `--extra all --extra messaging --extra
anthropic --extra bedrock --extra azure-identity --extra hindsight` —
notably NOT `--extra matrix` (the matrix extra is deliberately routed to
lazy-install because `mautrix[encryption]`/`python-olm` can't build on
Windows/macOS — see the 2026-05-12 policy comment in `[all]`).

Result: `markdown` never lands in the image venv, so the Markdown->HTML
conversion on the DEFAULT delivery path silently falls back to plain
text. Cron/agent deliveries render raw `##`/`**`/tables in clients like
Element (no `formatted_body`). The conversion is now used by BOTH
`gateway/platforms/matrix.py` and `tools/send_message_tool.py`, so it is
no longer matrix-specific.

`markdown` is a pure-Python `py3-none-any` wheel (~108KB, no compiled
extensions, no platform constraints), so none of the reasons the matrix
extra was lazy-routed apply to it. Promote it to a core dependency so it
ships in the wheel, the Docker image, and every install; drop the now
redundant copies from the `matrix` extra and the `platform.matrix`
lazy-deps group; refresh the stale "installed with the matrix extra"
docstring.

Verified against a real build: ran the image's exact `uv sync` command
(same extras, no `--extra matrix`) in a clean container off the new
lockfile -> `import markdown` succeeds (3.10.2). On `origin/main` the
same command leaves markdown absent. 223 targeted tests pass
(test_matrix.py + test_lazy_deps.py).

Closes #32486.
2026-06-04 16:46:36 -07:00
Dusk
495c3733d8 fix(config): bridge docker_volumes and docker_forward_env in config set (#38611)
Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-06-05 09:31:01 +10:00
Ben
825629424d fix(tui): persist timed-out/cancelled clarify prompts in transcript
When a clarify prompt times out (backend _block returns an empty answer
after the configured timeout) or is dismissed with Esc/Ctrl+C, the live
ClarifyPrompt overlay was torn down by turnController.idle() ->
resetFlowOverlays() with no persistent transcript record. The question and
options vanished from the screen while the agent's follow-up still referred
to "the options above".

The answered path already persists the question + answer; only the
unanswered exits left no trace. This asymmetry is the bug.

Fix (TUI layer only, no Python/protocol change):
- formatAbandonedClarify() in lib/text.ts renders the question + the same
  1-based numbered option list shown by ClarifyPrompt, plus a reason
  ('timed out' / 'cancelled').
- Timeout: createGatewayEventHandler flushes a still-live clarify into the
  transcript as a plain system line when the clarify tool's own tool.complete
  fires. A live capture of the event stream confirmed this is the only point
  where the overlay is still set after a timeout: the sequence is
  clarify.request -> (timeout) -> tool.complete -> message.complete, with NO
  intervening message.start/tool.start. On a real answer, answerClarify()
  clears the overlay before tool.complete arrives, so the hook no-ops there
  (no double-write); a per-requestId guard set is belt-and-braces.
- Explicit cancel: answerClarify('') persists the prompt as a system line
  instead of a transient 'prompt cancelled' flash.

System lines always render (unlike trail lines, which /details can hide),
so the record reliably survives on screen as standard output.

Verified live in the TUI: an Esc-cancelled clarify now leaves the question +
options + '(cancelled - no selection)' in the transcript after the turn ends.

Tests: formatAbandonedClarify unit cases + gateway-handler behavioral cases
(persist on clarify tool.complete, no flush on a non-clarify tool.complete,
no double-persist on repeat tool.complete, no-op when the overlay was already
cleared by an answer).
2026-06-04 16:25:54 -07:00
Brooklyn Nicholson
a40e20e136 feat(desktop): profile rail rename/delete + context-switch polish
- right-click a profile square to rename or delete it, via shared
  self-contained dialogs (also reused by the profiles page)
- switching or creating a profile now resets to a fresh new-session
  draft so the prior session doesn't stay sticky across contexts
- deleting the profile you're currently in falls back to default
  instead of stranding the gateway on a dead profile
- shared ConfirmDialog: Enter/Space confirm from anywhere in the dialog;
  profile-delete and cron-delete both route through it
2026-06-04 18:24:39 -05:00
Brooklyn Nicholson
cf9dc366dd refactor(desktop): drop per-session icons, read-only cross-profile reads
The per-session icon picker added more noise than value — rip it out end
to end (sessions.icon column, set_session_icon, the PATCH field, the
picker UI, and the SessionInfo.icon type).

The cross-profile session aggregator now opens each profile's state.db
read-only (mode=ro, no schema init), so listing other profiles on every
sidebar refresh never DDLs or takes a write lock on their live DBs. The
single-profile hot path stays on par with /api/sessions.
2026-06-04 18:24:35 -05:00
Austin Pickett
dfd6bcf1ff fix(desktop): restore accordion expand for credential settings rows (#39327)
* fix(desktop): restore accordion expand for credential settings rows

Reintroduce collapsible provider and tool key rows so descriptions, docs
links, and advanced fields stay hidden until a row is expanded.

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

* docs(desktop): add credential settings accordion screenshots for PR 39327

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 19:10:44 -04:00
Brooklyn Nicholson
48d8d80771 feat(desktop): single-profile rail shows default icon + create
Left-align the default's home icon next to the create "+" in the
single-profile state (toggle/squares/Manage still appear only once a
second profile exists).
2026-06-04 17:40:35 -05:00
Brooklyn Nicholson
0c7def31aa feat(desktop): show "+" in the rail for single-profile users
Always mount the profile rail, but when only the default profile exists
render just the create-profile "+" (hide the default/all toggle, the
draggable squares, and Manage). Gives a first-profile affordance without
the full switcher chrome; everything else appears once a 2nd profile exists.
2026-06-04 17:38:10 -05:00
Brooklyn Nicholson
76b98f43ca fix(desktop): gate ALL-profiles grouping on multiProfile
If a user drops back to a single profile while scope is still ALL
(persisted), the rail is hidden — they'd be stuck in the grouped view
with no toggle out. Fall back to the scoped view when only one profile.
2026-06-04 17:35:34 -05:00
Brooklyn Nicholson
fb18bde897 feat(desktop): fluid, haptic profile-rail reordering
- Wheel maps vertical scroll → horizontal so the rail is navigable with a
  plain mouse (trackpad x-scroll still passes through).
- Springy easeOutBack reflow; dragged square glides between snapped cells
  (no scale — overflow-x strip would clip it) with a subtle lift.
- Haptic 'selection' tick per crossed cell + 'success' on a committed reorder.
2026-06-04 17:33:44 -05:00
Brooklyn Nicholson
9915665e4c fix(desktop): step profile-rail drags cell-by-cell, clamp to strip
Snap the drag transform to whole cells (no free glide) and clamp it to the
occupied squares strip via a relative wrapper as offsetParent, so a square
can't float past the last profile onto the "+" and break the layout.
2026-06-04 17:10:05 -05:00
Brooklyn Nicholson
3e4fa8ca9c fix(desktop): lock profile-rail drag to the x-axis
overflow-x-auto makes overflow-y compute to auto, so a vertical drag
translate faulted in a cross-axis scrollbar. Pin the drag transform to
y:0 with a modifier — squares only slide horizontally now.
2026-06-04 17:07:48 -05:00
Brooklyn Nicholson
cfbc47d893 feat(desktop): open command palette with Cmd/Ctrl+P too
Bind Cmd/Ctrl+P to the command palette alongside Cmd+K (VS Code quick-open
muscle memory); Cmd+. stays the command center. No Print accelerator
competes, so the renderer preventDefault is enough.
2026-06-04 17:01:44 -05:00
Brooklyn Nicholson
e0121c59d3 feat(desktop): drag-sort profiles in the rail
Make the named-profile squares reorderable via dnd-kit (horizontal sort,
4px activation so a tap still selects). Order persists in localStorage
($profileOrder); unordered/new profiles alphabetize at the tail.
2026-06-04 16:59:39 -05:00
helix4u
d29caf3828 fix(desktop): satisfy slash metadata typecheck 2026-06-04 17:56:36 -04:00
Brooklyn Nicholson
5df732a355 feat(desktop): quick-create profile from rail + pin rail on empty sidebar
- Add a "+" in the profile rail that opens a self-contained CreateProfileDialog
  (name + clone toggle + optional SOUL.md); extract it and ActionStatus from
  the profiles view so both surfaces share one flow.
- Keep the profile rail pinned to the bottom when a profile has no sessions by
  rendering a flex-1 spacer (previously the rail floated up to the nav).
2026-06-04 16:55:16 -05:00
Brooklyn Nicholson
b94b3622b5 feat(desktop): per-session profile switching + cross-profile sessions
Add first-class profile support to the desktop app without app reloads.

- Swap the single live gateway onto a session's profile lazily (spawned on
  demand by the Electron backend pool), so one backend serves the active
  profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
  read-only; unified "All profiles" view groups sessions per profile with
  per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
  pinned left, colored named-profile squares scrolling between, Manage pinned
  right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
  gateway profile and invalidate React Query caches on swap. Single-profile
  users never trigger a swap, so their path is unchanged.

Backend:
- web_server: profile-aware active/list endpoints + per-profile session
  totals; hermes_state: session_count(exclude_children); main.py: honor
  --profile over HERMES_HOME env for pooled backends.

UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
  title=, and strip redundant tooltips from self-descriptive chrome.
2026-06-04 16:35:34 -05:00
ethernet
1eeb7da2e6 fix(desktop): slash commands bypass queue when busy and chip id suffix leak (#39289)
Two fixes for desktop app slash command handling:

1. Slash commands submitted while the agent is busy now execute
   immediately instead of being queued. Previously submitDraft()
   unconditionally queued any draft when busy, but slash commands
   are client-side operations or self-contained gateway RPCs that
   should run regardless of busy state (matching TUI behavior).
   executeSlashCommand already has its own per-command busy guard
   for commands that genuinely need an idle session.

2. Slash command trigger items no longer leak the "|index" suffix
   from their item.id into the serialized chip text. The
   toItem callback now sets rawText in metadata so
   hermesDirectiveFormatter.serialize takes the direct-insertion
   path instead of the legacy @type:id fallback. This also means
   slash commands enter the composer as plain text (not chips),
   matching selectSkinSlashCommand and TUI behavior.
2026-06-04 16:06:45 -05:00
Austin Pickett
acce1a2452 feat(desktop): polish credentials settings and messaging env routing (#39217)
* feat(desktop): polish credentials settings and messaging env routing

Align Provider API Keys and Tools & Keys with Advanced ListRow inputs,
add Tools & Keys sidebar subnav, move platform env vars to Messaging via
channel_managed discovery, strip toolset emojis, and condense cron actions.

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

* fix(desktop): align Messaging credential inputs with settings ListRow style

Remove monospace inputs and use CREDENTIAL_CONTROL_CLASS + ListRow layout
to match Provider API Keys and Tools & Keys.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:01:15 -04:00
liuhao1024
a3fb48b2ce fix(state): keep /branch sessions visible after parent reopen
/branch (aka /fork) sessions vanished from /resume and /sessions. Both
surfaces funnel through list_sessions_rich(include_children=False), which
hid any session with a parent_session_id unless identified as a branch via a
heuristic — parent.end_reason == 'branched' AND child.started_at >=
parent.ended_at.

Two ways that heuristic failed:
1. CLI/gateway branches: once the parent was reopened (e.g. resumed) and
   re-ended with a different end_reason (tui_shutdown overwriting 'branched'),
   the heuristic stopped matching and the branch was hidden permanently.
2. TUI branches (tui_gateway session.branch): the TUI never ends the parent
   as 'branched' — it creates the child while the parent is still live — so
   the heuristic NEVER matched and TUI branches were hidden from the moment
   they were created (this is the macOS desktop app's primary symptom).

Fix: persist a stable '_branched_from' marker in the branch session's
model_config at creation time across ALL THREE branch paths (CLI cli.py,
gateway gateway/run.py, and TUI tui_gateway/server.py), and OR a
json_extract(model_config, '$._branched_from') IS NOT NULL check into the
list_sessions_rich filter. The marker is immutable across the parent's
lifecycle, so the branch stays visible regardless of how/whether the parent
is ended. The legacy end_reason heuristic is kept (OR'd) so pre-existing
branches remain visible. Subagent/compression children (no marker, parent
not 'branched') stay correctly hidden. Fixes #20856.

Approach by liuhao1024 (PR #20864); reimplemented on current main, extended
to the TUI branch path (which the original missed), with regression tests for
the reopen+re-end scenario and the TUI marker persistence.
2026-06-04 10:07:20 -07:00
teknium1
d1367355d5 chore(release): map jeffrobodie@gmail.com -> jeffrobodie-glitch for salvage 2026-06-04 12:18:38 -04:00
Jeff
1f347ee543 fix(uv): move venv aside instead of gutting it in place on Windows rebuild
hermes update can brick a Windows install. When 'hermes update --force' runs
past the concurrent-process guard, rebuild_venv runs while the venv is still in
use: shutil.rmtree(ignore_errors=True) deletes site-packages + certifi's cert
bundle but can't remove the locked python.exe, leaving a half-gutted venv that
uv venv then refuses to overwrite. Every later HTTPS call dies with
FileNotFoundError for the missing cacert and there is no recovery.

--clear alone (the c136eb4de retry path) does not fix the real lock case: when
the locked interpreter is *inside* the venv being rebuilt, neither rmtree nor
uv venv --clear can delete it. os.replace of the parent directory *is* allowed
on Windows (a running .exe is tracked by handle, not path), so we move the old
venv aside atomically to <venv>.old, rebuild with --clear in its place, and the
still-running gateway/desktop keep using the moved-aside copy until they
restart. If the venv genuinely can't be moved, we abort cleanly and leave it
fully intact; if the rebuild fails, we restore the moved-aside copy.

Folds in the call-site guards from #38511 (@f3rs3n):
- rebuild_venv() returns False (and restores the backup) if uv exits 0 without
  producing an interpreter.
- both hermes update venv-rebuild call sites abort with RuntimeError instead of
  continuing into dependency install when rebuild_venv() returns False.

Also gitignore /venv.old/ so the update autostash (git stash --include-untracked)
doesn't sweep the moved-aside venv on every run.

Root-cause fix for #37881. Supersedes the --clear-only retry from c136eb4de.

Co-authored-by: f3rs3n <32328813+f3rs3n@users.noreply.github.com>
2026-06-04 12:18:38 -04:00
rexdotsh
ee7948ea6e fix(deps): exclude dev tooling from all extra 2026-06-04 08:54:38 -07:00
kshitijk4poor
8077e7d2fb fix(tui): narrow resume lock to avoid blocking session.close
The salvaged fix held _session_resume_lock across _make_agent (MCP discovery
+ AIAgent construction, seconds), serializing it against session.close. Since
session.close runs on the main RPC dispatch thread (not a _LONG_HANDLER), a
close racing a mid-build resume would stall all fast-path RPCs (approval.respond,
session.interrupt).

Restructure to double-checked locking: build the agent outside the lock, then
re-check _find_live_session_by_key under the lock before _init_session. A losing
concurrent resume discards its just-built agent (no worker/poller wired yet) and
reuses the winner. Updated the concurrent-resume regression test to assert the
real invariant (one surviving live session + loser agent closed) rather than the
implementation detail of a single _make_agent call.
2026-06-04 08:18:26 -07:00
rexdotsh
bd6d098762 fix(tui): keep resumed live history current 2026-06-04 08:18:26 -07:00
rexdotsh
98903d0313 fix(tui): reuse live session on resume 2026-06-04 08:18:26 -07:00
kyssta-exe
30412a9771 fix(cron): re-validate stale cron-output entries before deletion (#37721)
quick() and dry_run() previously trusted the stored category from
tracked.json without re-validating at delete time. Stale entries from
before #34840 could carry category="cron-output" for cron control-plane
paths (e.g. cron/jobs.json), causing quick() to delete the live
scheduler registry.

Fix:
- Fix guess_category() to only classify cron/output/** as cron-output
  (was classifying ALL cron/* paths, missing the #34840 fix).
- Re-validate cron-output entries via guess_category() at delete time
  in quick() and dry_run(); stale entries that are no longer classified
  as cron-output are skipped and removed from tracked.json.
- Add _is_protected_cron_path() as a hard defense-in-depth guard that
  blocks deletion of cron/cronjobs directories and known control-plane
  files (jobs.json, .tick.lock) regardless of stored category.
- Update test_cron_subtree_categorised to match fixed guess_category
  (only cron/output/* is cron-output, not all of cron/).

Tests: add 5 regression tests in TestStaleCronEntryMigration.
2026-06-04 07:52:04 -07:00
CryptoByz
693f4c7e9c fix(gateway): clear zombie agent slot when session_reset races in-flight run
A session_reset (/new, /cc) that bumps the run generation while an agent
turn is in flight left the dead agent in the _running_agents slot: the
in-flight run's own release is generation-guarded and correctly returns
False, and the outer finally's sentinel-only check also missed the
leftover real agent. The session then silently dropped every subsequent
message as 'agent busy' until a full gateway restart. (#28686)

- _process_message_or_command outer finally now calls the unconditional,
  idempotent _release_running_agent_state(key) on all exit paths instead
  of the sentinel-vs-else branch that could strand a dead agent.
- _handle_reset_command evicts the slot right after bumping the
  generation, so the zombie is cleared at reset time regardless of how
  the in-flight run unwinds.

Co-authored-by: CryptoByz <cryptobyz.airdrop@gmail.com>
2026-06-04 07:50:45 -07:00
teknium1
2982122be7 fix(gateway): deliver $HOME deliverables on root-run gateways
Root-run gateways have $HOME=/root, which is on the MEDIA system-path
denylist, so the gateway silently dropped agent-generated deliverables
under /root (e.g. /root/work/proposal.docx) — the user got a 'here is
your file' reply with nothing attached.

_path_under_denied_prefix now treats the running user's own home as
deliverable: the home tree itself is no longer denied, while the
more-specific denied paths inside it (~/.ssh, ~/.aws, ~/.hermes/.env,
auth.json, config.yaml) stay blocked because they are separate denylist
entries. The exception only matches when the denied prefix IS $HOME, so
a non-root gateway still can't deliver another user's home.

Diagnosis, reproduction, and the failing-case analysis are from
@GodsBoy (#38108 / #38106). Implemented here as the minimal denylist
fix rather than a staging/copy subsystem.

Co-authored-by: GodsBoy <dhuysamen@gmail.com>
2026-06-04 07:50:22 -07:00
Teknium
580d924097 perf(desktop): make session-id search SQL-bounded, not O(n)
search_sessions_by_id previously fetched up to 10k sessions via
list_sessions_rich and filtered them in Python — O(n) per keystroke.
Push the id match into SQL instead.

- list_sessions_rich gains an optional id_query param: a case-insensitive
  LIKE pushed into the outer WHERE, matched against each surfaced row's id
  AND every id in its forward compression chain (via the existing chain
  CTE). Searching a compression root id or a tip id both resolve to the
  same projected conversation. LIKE wildcards in the needle are escaped.
- search_sessions_by_id now fetches only matching rows (limit*4) and ranks
  exact > prefix > substring in Python over that small set.
- web_server /api/sessions/search: route ID matches and content matches
  through one lineage-keyed dedup helper so an id-hit and a content-hit on
  the same conversation collapse to a single result (the contributor's
  version keyed ID hits by raw sid and content hits by root, which could
  double-list a compression tip).
- command-center haystack also matches _lineage_root_id for parity.

E2E verified against a real DB: exact match over 3000+ sessions
materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip;
LIKE-wildcard escaping holds.

Follow-up to @0xharryriddle's feat(desktop): search sessions by id.
2026-06-04 07:49:34 -07:00
Harry Riddle
9ecc331be8 feat(desktop): search sessions by id 2026-06-04 07:49:34 -07:00
teknium
62f0cfd902 fix(kanban-dashboard): use context-local board pin in specify/decompose endpoints
The dashboard specify and decompose endpoints run as sync FastAPI threadpool
handlers and pinned the active board by mutating the process-global
HERMES_KANBAN_BOARD env var. Two concurrent requests for different boards
race on that shared global and cross-write — the same bug class as the CLI
path (#38323), now using the scoped_current_board() contextvar introduced by
the CLI fix.
2026-06-04 07:39:53 -07:00
worlldz
081694c111 fix(kanban): isolate board override per concurrent call 2026-06-04 07:39:53 -07:00
AhmetArif0
de370fd10f fix(dashboard): prevent stale desc-save indicator when requests overlap
handleSaveDesc and handleAutoDescribe both set their loading flag in a
try block but always cleared it unconditionally in finally. When a user
opened profile A's description editor, clicked Save, then quickly
switched to profile B's editor and saved, profile A's resolving request
would clear descSaving/describing while profile B's request was still
in-flight, making the "Saving…" indicator disappear prematurely.

Track concurrent in-flight counts with descSavingCount and
describingCount refs (mirrors the existing activeDescRequest guard
pattern). The loading flag is cleared only when the counter reaches
zero, i.e. all overlapping requests have settled.
2026-06-04 07:23:22 -07:00
AhmetArif0
c2d11cc95d fix(dashboard): surface model-write failure when creating a profile
POST /api/profiles returns model_set: false when the model assignment
step fails (e.g. filesystem error) while the profile itself was created
successfully. handleCreate discarded the response, so the user received
a "Profile created" success toast with no indication that their chosen
model was not persisted.

Capture the response and show an error toast when a model was selected
but model_set is explicitly false, directing the user to set it from
the profile editor.
2026-06-04 07:23:22 -07:00
AhmetArif0
6feb40e702 fix(desktop): wait for backend exit before reloading on connection-config apply
The apply handler sent SIGTERM then fired a 150 ms setTimeout to reload
the renderer. If the backend took longer to shut down the port was still
bound when startHermes() ran after reload, causing an "address already
in use" failure.

Capture the process reference before resetHermesConnection() nulls it,
then await the actual exit event. A 5 s SIGKILL fallback ensures the
wait never hangs if the backend ignores SIGTERM.
2026-06-04 07:23:22 -07:00
Teknium
fef04a197e fix(desktop): purge electron cache unconditionally, not via stdlib zipfile gate
The salvaged detector validated each cached electron-*.zip with
zipfile.testzip() and only purged ones it judged corrupt. But stdlib
zipfile reads from the end-of-central-directory backward, so it silently
tolerates prepended/concatenated junk — which is exactly the corruption
the bug report names ('86257938 extra bytes at beginning or within
zipfile', a partial download resumed into the same file). testzip()
returns clean on those zips, so the self-heal never fired for the
reported failure mode.

Drop the self-rolled validator: on any packaged-build failure, purge the
version's cached zips AND the half-written unpacked dir, then retry once.
@electron/get re-downloads with its own SHASUM verification — the real
source of truth, which catches prepend/concat/truncate alike. An
unrelated failure just costs one clean re-download and fails the same way.

Verified empirically: zipfile.testzip() returns None (clean) on a
prepended-junk zip; the unconditional purge removes it correctly.
2026-06-04 07:17:33 -07:00
Harry Riddle
f583c6ebd5 fix(desktop): recover from corrupt cached Electron download on build
hermes desktop failed on Linux with an ENOENT renaming
release/linux-unpacked/electron -> Hermes. Root cause is a corrupt
cached Electron zip (~/.cache/electron/electron-*.zip): app-builder
unpack-electron extracts a partial tree from the bad zip that is
missing the electron binary, so electron-builder dies on the final
rename. Re-running repeats the broken extraction, leaving the desktop
app permanently unlaunchable until the cache is manually purged.

- Add _electron_download_cache_dirs() + _purge_corrupt_electron_cache()
  to hermes_cli/main.py: validate every electron-*.zip via
  zipfile.testzip() and delete corrupt ones; honor electron_config_cache
  / ELECTRON_CACHE overrides with per-OS defaults.
- Wire purge + single retry into cmd_gui packaged-build failure path so
  a poisoned download self-heals (electron re-downloads clean).
- Add beforePack hook (apps/desktop/scripts/before-pack.cjs) to wipe the
  target unpacked dir before staging, making packaging idempotent across
  interrupted runs. Cross-platform, best-effort.
- Tests: corrupt-zip detector, cmd_gui purge/retry/launch path,
  no-retry-when-clean path, and node --test for the cleanup helper.
2026-06-04 07:17:33 -07:00
brooklyn!
e003c53b06 chore(desktop): zero eslint/typecheck debt + prettier pass (#39100)
- eslint --fix across src/ and electron/ (unused imports, import/prop sort, padding)
- flatten empty catch blocks in electron CJS; drop unused applyUpdatesPosixInApp arg
- add setMutableRef helper for imperative ref writes (react-compiler clean)
- move sidebar cookie persistence into an effect; extract scrollElementToBottom helper
2026-06-04 14:10:38 +00:00
Frowtek
3858cf4307 fix(cli): honor global-root active_provider fallback for named profiles 2026-06-04 07:08:30 -07:00
Frowtek
b7169f9bbb fix(gateway): keep pending /update completion notifications until the target platform reconnects 2026-06-04 06:56:28 -07:00
ethernet
a6a0a5b1b0 fix(desktop): detect linux arm64 binary 2026-06-04 09:51:26 -04:00
Frowtek
fff0561441 fix(gateway): anchor Google Chat OAuth client secret to default Hermes root 2026-06-04 06:45:32 -07:00
Frowtek
07f5382675 fix(gateway): don't treat dm_policy: pairing as open access on own-policy adapters 2026-06-04 06:31:28 -07:00
annguyenNous
4cca7f569d fix(tools): add raise_for_status for MiniMax t2a_v2 TTS path
The MiniMax t2a_v2 code path calls response.json() without first
checking the HTTP status code. If the API returns HTTP 4xx/5xx with
non-JSON content (e.g. HTML error page), response.json() raises an
opaque JSONDecodeError instead of a clear HTTPError.

The non-t2a_v2 path already has response.raise_for_status() at line
1299. Add the same check before response.json() in the t2a_v2 path
for consistent error handling.
2026-06-04 06:17:11 -07:00
teknium1
dd4ba4c2c4 fix(vision): cap pixel dimensions proactively at embed time + declare Pillow
Follow-up to the salvaged #37727. That PR fixed the reactive recovery path
(classifier + post-failure shrinker) but left the PROACTIVE embed-time guard
in vision_tools byte-only — a tall small-byte screenshot (e.g. 1200x12000 at
0.06 MB) still baked into immutable history un-resized, relying on a failed
round-trip to trigger reactive shrink.

- vision_tools: add _image_exceeds_dimension() + _EMBED_MAX_DIMENSION (7900px);
  the embed-time cap now fires on bytes OR pixels and passes max_dimension to
  the resizer, so tall small-byte images are shrunk before they're embedded.
- vision_tools: best-effort lazy-install of Pillow (tool.vision) in the resize
  ImportError fallback so the soft dep self-heals (respects allow_lazy_installs).
- error_classifier: add two more Anthropic dimension-cap wording variants.
- pyproject + lazy_deps: declare Pillow as the [vision] extra / tool.vision
  lazy dep (it was undeclared everywhere; without it ALL resize recovery no-ops).
- tests: cover _image_exceeds_dimension (tall/small/edge/no-Pillow/corrupt).

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-04 06:16:45 -07:00
kyssta-exe
6bdbe30763 fix(vision): guard image pixel dimensions, not just bytes (#37677)
Anthropic enforces two independent ceilings per image:
1. 5 MB encoded byte size
2. 8000 px longest side

Hermes only guarded #1. A tall screenshot (e.g. 1200x12000 at 0.06 MB)
passes every byte check but fails the pixel check, returning a
non-retryable HTTP 400 that permanently bricks the conversation thread.

Fixes:
- error_classifier: add 'image dimensions exceed' pattern to
  _IMAGE_TOO_LARGE_PATTERNS so the 400 is classified as image_too_large
  and triggers the shrink/retry path instead of falling through to
  non-retryable error.
- conversation_compression: check pixel dimensions (via Pillow) even
  when byte size is under the 4 MB target. If max(dims) > 8000, force
  shrink.
- vision_tools._resize_image_for_vision: add optional max_dimension param.
  When set, images exceeding the pixel cap are downscaled even if they're
  under the byte budget. The resize loop now checks both byte AND pixel
  limits before accepting a candidate.

Closes #37677
2026-06-04 06:16:45 -07:00
annguyenNous
f7dabd3019 fix(api-server): guard json.loads against corrupted SQLite data in response cache
The ResponseStore.get() method calls json.loads(row[0]) without any
error handling. If the SQLite responses table contains corrupted JSON
data (e.g. from a crash mid-write or disk corruption), this raises
an unhandled JSONDecodeError that propagates to the caller.

Fix: wrap in try/except (json.JSONDecodeError, TypeError). On parse
failure, log a warning, evict the corrupted entry from the cache, and
return None (consistent with the function's Optional return type).
2026-06-04 06:15:29 -07:00
teknium1
7314757876 refactor(feishu): slim meeting-invite parser; add AUTHOR_MAP entry
Collapse the payload-shape normalization helpers into one _as_dict and
drop unused dataclass fields (user_type/user_role, duplicate id, bot) on
the meeting-invite handler. Module 274->212 LOC, behavior unchanged.

Add zhaolei.vc@bytedance.com -> zhaoleibd to release.py AUTHOR_MAP.
2026-06-04 06:15:23 -07:00
zhaolei.vc
f3bbfda6d1 feat(gateway): handle Feishu meeting invitations
Change-Id: I8cf5638393dd9adb1d7be5e170ce5082b41f77fa
2026-06-04 06:15:23 -07:00
kyssta-exe
86c64cfb5b fix(gateway): visually expire Discord interactive views on timeout
All Discord interactive views (ExecApprovalView, SlashConfirmView,
UpdatePromptView, ModelPickerView, ClarifyChoiceView) now edit their
message when the view times out, disabling buttons and updating the
embed to show a 'Prompt expired' footer. Previously, timed-out buttons
remained visually clickable in the UI, causing Discord's generic
'Interaction failed' error when clicked.

Fixes #38022
2026-06-04 06:14:54 -07:00
Teknium
38d3c49aaf refactor(skills): clean up bundled skill set + add environments: relevance gate (#39028)
* refactor(skills): clean up bundled skill set + add environments: relevance gate

Bundled skills cleanup pass plus a new offer-time relevance gate.

Removals (redundant / dead):
- spotify (covered by the spotify plugin's 7 native tools)
- linear (covered by `hermes mcp install linear`)
- kanban-codex-lane, debugging-hermes-tui-commands
- empty category markers: diagramming, gifs, inference-sh,
  mlops/training, mlops/vector-databases
- domain (stale orphan dup of optional/research/domain-intel)

Bundled -> optional:
- baoyu-article-illustrator, baoyu-comic, creative-ideation, pixel-art
- dspy, subagent-driven-development
- minecraft-modpack-server, pokemon-player
- hermes-s6-container-supervision (-> optional/devops)

Consolidation:
- webhook-subscriptions + native-mcp folded into the hermes-agent skill
  as references/webhooks.md + references/native-mcp.md with SKILL.md pointers
- writing-plans merged into plan (v2.0.0); related_skills + prose refs updated

New: environments: frontmatter gate (agent/skill_utils.skill_matches_environment)
- Offer-time relevance filter (kanban / docker / s6), parallel to platforms:.
- Wired into the 3 OFFER surfaces only (prompt_builder skills index,
  skills_tool.list_skills, skill_commands slash discovery).
- Explicit loads (skill_view, --skills preload) intentionally BYPASS it, so
  load-bearing force-loads like the kanban dispatcher's `--skills kanban-worker`
  always resolve. Verified via E2E.
- kanban-orchestrator/kanban-worker tagged environments: [kanban];
  hermes-s6-container-supervision tagged environments: [s6] + platforms: [linux].

Validation: 8/8 E2E gating assertions (incl force-load invariant);
442 targeted tests green (agent, skills_tool, skill_commands, kanban worker).

* docs: regenerate skill catalogs + pages for the bundled cleanup

Regenerated per-skill doc pages, catalogs, and sidebar to match the skill
moves/removals in the parent commit. Moved skills' pages relocate
bundled -> optional (history preserved); removed skills' pages deleted;
edited skills' pages refreshed (hermes-agent now embeds the webhook +
native-mcp reference pointers). zh-Hans i18n mirror: stale bundled pages
and catalog rows for moved/removed skills pruned (new optional translations
land via the translation pipeline).

* test: drop regression test for removed kanban-codex-lane skill

The kanban-codex-lane skill was removed in the bundled-skills cleanup;
its dedicated regression test read the now-deleted SKILL.md and failed
with FileNotFoundError on CI shard 6.
2026-06-04 06:11:22 -07:00
teknium1
c136eb4de1 fix(update): harden venv rebuild + verify core deps after install
Two complementary fixes for a silent partial-install failure that bit
``hermes update`` in the wild: a fresh checkout pulled 145 commits,
``rebuild_venv`` failed to recreate the venv on Windows because
``shutil.rmtree(ignore_errors=True)`` couldn't delete files held open by
the running ``hermes.exe`` shim. ``uv venv`` then refused with
"A directory already exists at: venv" and the update fell back to
installing on top of the stale venv. The resulting partial install
missed exactly one newly-added base dep — ``pathspec==1.1.1`` — which
``hermes desktop --build-only`` imports at the top of its content-hash
check. The desktop rebuild died with ModuleNotFoundError and the parent
update only logged "⚠ Desktop build failed (non-fatal)". Same root cause
made the "default: sync failed" line in the skill-sync stage, because
that sync subprocess hit the same missing import.

Fix 1: ``rebuild_venv`` retries with ``--clear``
------------------------------------------------
If ``uv venv`` fails with "already exists" in stderr (which is what uv
prints, and what uv's own hint tells you to fix with --clear), retry
once with ``--clear``. Only this specific failure pattern triggers the
retry — disk-full / interpreter-download failures still surface as
before so we don't mask real problems.

Fix 2: post-install dep verification
------------------------------------
Belt-and-suspenders so future uv resolver quirks (or any other cause of
partial installs) surface immediately instead of hours later in a
downstream subprocess. After ``_install_python_dependencies_with_optional_fallback``
runs, ``_verify_core_dependencies_installed``:

  1. Reads ``[project.dependencies]`` straight from pyproject.toml
     (so we don't trust the venv's stale metadata).
  2. Filters by environment markers via ``packaging.requirements.Requirement``
     so cross-platform exclusions (``ptyprocess ; sys_platform != 'win32'``)
     don't false-positive on Windows.
  3. Runs ``importlib.metadata.version()`` for each remaining dep inside
     the *target* venv interpreter (resolved from ``VIRTUAL_ENV``, not
     ``sys.executable``).
  4. If anything is missing, reinstalls the base group with
     ``--reinstall`` to force re-resolution. If a second probe still
     reports missing deps, force-installs each one with its pinned spec.
  5. Treats final failure as a warning rather than a hard error — a
     single broken-on-PyPI dep shouldn't block an otherwise-successful
     update — but the message points at ``hermes update --force`` and
     names the missing packages so the user knows what's wrong.

Tests
-----
- ``TestRebuildVenv::test_retries_with_clear_when_dir_already_exists`` —
  simulates the rmtree-couldn't-delete-it failure mode and asserts the
  ``--clear`` retry path is taken and succeeds.
- ``TestRebuildVenv::test_does_not_retry_when_first_failure_is_not_dir_exists``
  — guards against masking real failures (disk full, etc.).
- ``test_verify_core_dependencies.py`` — 7 tests covering the happy
  path, the regression (missing pathspec triggers --reinstall), the
  per-package fallback when --reinstall doesn't help, the platform-
  marker filter so Windows doesn't try to install ptyprocess, the
  missing-pyproject noop, and the VIRTUAL_ENV resolver.

Co-authored-by: Kyssta <218078013+kyssta-exe@users.noreply.github.com>
2026-06-04 06:05:41 -07:00
annguyenNous
28ca4460a1 fix(gateway): guard kanban dispatcher against malformed config and empty summaries
Two error handling gaps in the gateway kanban dispatcher:

1. float() on dispatch_interval_seconds crashes with ValueError if the
   config value is a non-numeric string. Wrap in try/except and fall
   back to the default 60-second interval with a warning log.

2. splitlines()[0] on payload_summary and task.result raises IndexError
   when the string is whitespace-only (truthy but strip() produces empty
   string, splitlines() returns []). Guard with a check on the lines
   list before indexing.
2026-06-04 06:03:05 -07:00
brooklyn!
cbfe1d21d1 docs(guides): Run Nemotron 3 Ultra free in Hermes Agent (launch guide) (#38769)
* docs(guides): add "Run Nemotron 3 Ultra free in Hermes Agent" launch guide

Day-0 NVIDIA Nemotron 3 Ultra availability on Nous Portal (free June 4-18,
in partnership with NVIDIA + Nebius). Quick Setup walkthrough for selecting
the nvidia/nemotron-3-ultra:free tier, plus switching/troubleshooting notes.
Registered at the top of Guides & Tutorials.

* docs(guides): reword Nemotron lead-in to match launch copy

Frame as Nemotron Coalition induction (working with NVIDIA) + Nebius
partnership for the free tier, rather than a direct NVIDIA partnership,
to avoid overstating the relationship.

* docs(guides): lead Nemotron guide with desktop app, CLI second

Add a one-click desktop-app install track (download → Nous Portal
recommended sign-in → pick the Free-tier nemotron-3-ultra model) as the
recommended path for non-terminal users, and keep the CLI curl flow as
Option B. Update switching/troubleshooting to cover both surfaces.
2026-06-04 09:00:29 -04:00
AhmetArif0
cd68b8f0e8 fix(auth): set active_provider after hermes auth add qwen-oauth
hermes auth add qwen-oauth called pool.add_entry() but never wrote to
providers["qwen-oauth"] or set active_provider in auth.json.
_model_section_has_credentials() checks get_active_provider() first; with
active_provider unset and no api_key_env_vars configured for oauth_external
providers, the setup wizard reported "No inference provider configured" even
after a successful Qwen CLI OAuth login.

Add _mark_qwen_oauth_active() in auth.py: writes a minimal provider state
entry (base_url for display only) and calls _save_provider_state() to set
active_provider. The function deliberately does not copy the api_key — that
lives in the Qwen CLI credential file managed by _save_qwen_cli_tokens /
resolve_qwen_runtime_credentials and must not be duplicated in auth.json
where it would become stale.

pool.add_entry() is retained so "hermes auth list" continues to show the entry.
Runtime credential resolution continues to use resolve_qwen_runtime_credentials.

Mirrors the fix applied to openai-codex (#37517) and xai-oauth (#37576).
2026-06-04 05:58:33 -07:00
Teknium
d12c233378 docs(wecom): stop implying live streaming and typing support (#38990)
The WeCom adapter delivers each response as a single complete message
via aibot_respond_msg / aibot_send_msg — it does not stream tokens
incrementally (no edit_message override) and send_typing is a no-op.
Reword the 'Reply-mode streaming' feature bullet to 'Reply correlation',
retitle the section to 'Reply-Mode Responses', and add a note clarifying
that neither token streaming nor typing indicators are supported.
2026-06-04 05:57:01 -07:00
Frowtek
71a9f44e80 fix(gateway): retry startup auto-resume when a failed platform reconnects 2026-06-04 05:56:45 -07:00
Fearvox
fa8e2f935b polish(minimax): address Copilot review comments on M3 default-aux fix
Three Copilot inline review comments on #37664, two worth landing
in a polish pass before merge:

1. auxiliary_client.py:270 — Copilot suggested keeping the
   minimax-* entries in _API_KEY_PROVIDER_AUX_MODELS_FALLBACK as
   a safety net for environments where the profile-based
   resolution can't import or run plugin discovery. **Declined.**
   The deepseek precedent (commit 773a0faca) explicitly removed
   deepseek from the same dict for the same reason — the profile
   layer is the source of truth and the dict is a legacy
   pre-profiles-system fallback. We do not want to fragment the
   codebase by provider: either the profile layer is authoritative
   or the dict is. The minimax PR picks profile (matching deepseek)
   and the dict stays cleaned up. The risk Copilot raises is
   real but theoretical — plugin discovery runs at import time of
   the providers module, which is the first thing any modern
   Hermes entrypoint imports.

2. tests/agent/test_minimax_provider.py:162 — Copilot flagged
   that the test class relies on _get_aux_model_for_provider()
   resolving via provider profiles but doesn't explicitly trigger
   plugin discovery. **Fixed.** Added 'import model_tools  # noqa:
   F401' at the top of both test_minimax_aux_is_standard and
   test_minimax_aux_not_highspeed. The fixtures in the parallel
   test_minimax_profile.py already did this; the legacy test in
   test_minimax_provider.py was order-dependent and would silently
   break if anyone reorganised the test ordering. Pinned the
   dependency explicitly so the test is order-independent.

3. tests/plugins/model_providers/test_minimax_profile.py:46 —
   Copilot flagged that the docstring referenced a hard-coded
   line number 'hermes_cli/models.py:298' that would go stale.
   **Fixed.** Replaced with the symbol reference
   'hermes_cli.models._PROVIDER_MODELS[\'minimax\']' which is
   stable under file edits and grep-friendly. The new docstring
   also reads more naturally — readers don't have to look up
   'what's at line 298' to follow the reasoning.

All 221 minimax-related tests still pass.
2026-06-04 05:53:35 -07:00
Fearvox
b531b5d12a fix(minimax): update AUTHOR_MAP entry + test_minimax_oauth_aux_model_registered
Two follow-ups to the M3 default-aux-model PR (#37664):

1. AUTHOR_MAP entry: add fearvox1015@gmail.com -> Fearvox so the
   check-attribution CI job recognises Nolan's real contributor
   email. The previous run of the attribution check on #37664
   failed because the commit was authored as nolan@0xvox.com
   (wrong local git config) which isn't in AUTHOR_MAP. The
   commit itself is now re-authored to fearvox1015@gmail.com
   so both the per-commit check and the AUTHOR_MAP lookup pass.

2. tests/hermes_cli/test_api_key_providers.py::TestMinimaxOAuthProvider
   ::test_minimax_oauth_aux_model_registered was pinning the aux
   model in the legacy _API_KEY_PROVIDER_AUX_MODELS dict, which
   the PR correctly removed (mirrors the deepseek cleanup in
   773a0faca). The test now asserts the new world order: the
   aux model comes from ProviderProfile.default_aux_model on
   the minimax-oauth profile, not the fallback dict. This is
   the same pattern that the profile-layer deepseek fix
   introduced.
2026-06-04 05:53:35 -07:00
Fearvox
3d1d0a49fe fix(minimax): align default_aux_model with M3 frontier on minimax + minimax-cn
The minimax / minimax-cn / minimax-oauth profiles still advertised
M2.7 (and M2.7-highspeed for OAuth) as their default_aux_model,
predating the M3 release (2026-06-01). The user-facing
_PROVIDER_MODELS['minimax'] catalog top entry is M3, and the
recommended config for a Token-Plan install now sets
model.default: MiniMax-M3, so the aux default was the only
remaining drift.

Updates:

  * minimax        default_aux_model: M2.7        -> M3
  * minimax-cn     default_aux_model: M2.7        -> M3
  * minimax-oauth  default_aux_model: M2.7-highspeed -> M2.7
                    (M3 is not on the OAuth / Coding Plan tier per
                    platform docs as of this PR; the highspeed
                    variant was the 2x-cost regression from #4082
                    that PR #6082 collapsed to plain M2.7 for
                    minimax / minimax-cn but missed OAuth)

  * agent/auxiliary_client.py: drop the three legacy
    _API_KEY_PROVIDER_AUX_MODELS_FALLBACK entries for the minimax
    family. _get_aux_model_for_provider() reads from
    ProviderProfile.default_aux_model first (line 250) and only
    falls back to the dict when the profile has no aux model or
    the profile import fails. With the profile now set, the dict
    entries are dead code and a drift hazard. Mirrors the deepseek
    cleanup in 773a0faca.

  * tests/agent/test_minimax_provider.py: update the existing
    TestMinimaxAuxModel assertions from MiniMax-M2.7 to MiniMax-M3
    (the intent — 'standard, not highspeed' — is unchanged; the
    pin value is).

  * tests/plugins/model_providers/test_minimax_profile.py: new
    file mirroring tests/plugins/model_providers/test_deepseek_profile.py.
    Pins each of the three profiles' default_aux_model and
    asserts _get_aux_model_for_provider() returns it. A second
    class guards against the highspeed regression coming back.

Refs:
  - Closes #36196 in spirit (M3 support — the catalog half of
    that issue is #36212; this PR covers the profile half)
  - Related: #4082 (M2.7-highspeed 2x-cost), #6082 (previous
    M2.7-highspeed -> M2.7 fix that missed OAuth + the
    auxiliary_client.py fallback dict)
  - Pattern: 773a0faca (same profile-layer fix for deepseek)
2026-06-04 05:53:35 -07:00
AhmetArif0
5f62ba8e4b fix(auth): use _save_xai_oauth_tokens in auth_commands to set active_provider
hermes auth add xai-oauth called pool.add_entry() directly, writing only the
credential-pool entry (source "manual:xai_pkce") without touching
providers["xai-oauth"] or setting active_provider in auth.json.

_model_section_has_credentials() checks get_active_provider() first; with
active_provider unset and no api_key_env_vars configured for oauth_external
providers, the setup wizard reported "No inference provider configured" even
after a successful OAuth login.

Use _save_xai_oauth_tokens() — the canonical path already called from the
hermes model xAI login flow — which writes providers["xai-oauth"]["tokens"]
(setting active_provider) and lets _seed_from_singletons seed the pool with
a "loopback_pkce" entry on the next load_pool() call.

Mirrors the fix applied to openai-codex in #37517.
2026-06-04 05:48:50 -07:00
teknium1
643181b346 chore: add scubamount to AUTHOR_MAP for salvaged PR #37616 2026-06-04 05:46:13 -07:00
scubamount
b6206020d3 fix(desktop): remove session search aux model 2026-06-04 05:46:13 -07:00
AhmetArif0
34a2903527 fix(auth): set active_provider after hermes auth add google-gemini-cli
hermes auth add google-gemini-cli called pool.add_entry() but never wrote
to providers["google-gemini-cli"] or set active_provider in auth.json.
_model_section_has_credentials() checks get_active_provider() first; with
active_provider unset and no api_key_env_vars configured for oauth_external
providers, the setup wizard reported "No inference provider configured" even
after a successful OAuth login.

Add _mark_google_gemini_cli_active() in auth.py: writes a minimal provider
state entry (email for display only) and calls _save_provider_state() to set
active_provider. The function deliberately does not copy access_token or
refresh_token — those are managed by agent.google_oauth in the Google
credential file and must not be duplicated in auth.json where they would
become stale.

pool.add_entry() is retained so "hermes auth list" continues to show the entry.
Runtime credential resolution continues to use agent.google_oauth directly.

Mirrors the fix applied to openai-codex (#37517) and xai-oauth (#37576).
2026-06-04 05:44:22 -07:00
Teknium
9fbfeb31b9 fix(cron): make sequential jobs non-blocking too + sweep MCP after jobs finish
Follow-up on the parallel-dispatch decoupling: the sequential pass for
workdir/profile jobs still ran inline in the ticker thread, so a long
workdir/profile job reintroduced the exact starvation #37312 describes,
just for env-mutating jobs. And the MCP orphan sweep ran immediately
after dispatch in sync=False mode — before jobs finished — defeating its
own 'runs after every job' contract and racing jobs still spawning MCP
children.

- Sequential jobs now queue to a persistent single-thread cron-seq pool
  (preserves one-at-a-time ordering across ticks, never blocks the tick).
- Same in-flight dedup guard now covers sequential jobs.
- MCP orphan sweep runs via a done-callback after the LAST dispatched job
  completes in async mode; inline after as_completed in sync mode.

Verified E2E: tick(sync=False) returns in ~1ms with a 1.5s sequential job
in flight; sweep fires only after that job ends.
2026-06-04 05:40:13 -07:00
Vynxe Vainglory
eb9cde7346 fix(cron): decouple job dispatch from completion in tick()
PR #13021 fixed serial starvation by adding ThreadPoolExecutor to tick(),
but kept as_completed(timeout=600) which still blocks the ticker thread
until the slowest job finishes. This causes the same starvation pattern:
when one job runs long (15+ min), other jobs' next_run_at expires past the
grace window and they get perpetually fast-forwarded instead of running.

This PR decouples dispatch from completion:
- Persistent ThreadPoolExecutor (reused across ticks, no auto-join)
- Fire-and-forget dispatch: tick submits and returns immediately
- Running-job guard: prevents re-dispatching active jobs
- sync parameter: defaults to True (backward compatible), callers opt
  into sync=False for non-blocking behavior
- atexit shutdown handler for clean pool teardown
- gateway/run.py: production ticker opts into sync=False

Refs #33315 (complementary — that issue's PRs fix grace handling in
jobs.py; this PR prevents the grace from expiring in the first place)
2026-06-04 05:40:13 -07:00
teknium1
c14e6b4edf chore(release): map ashishpatel26 author email for salvage 2026-06-04 05:38:12 -07:00
ashishpatel26
c9b62061d4 fix(cli): launchd KeepAlive unconditional restart (#37388)
Replace KeepAlive.SuccessfulExit=false dict with <key>KeepAlive</key><true/>
so launchd restarts hermes-gateway on any exit, matching the documented
drain-then-exit restart protocol used by --graceful-restart.
2026-06-04 05:38:12 -07:00
teknium
153fe28474 fix(vision): use MiniMax type="video" block (not input_video) + tests
The salvaged conversion emitted type:"input_video", which MiniMax M3 rejects
just like the original video_url block. Per MiniMax's Anthropic-compat docs,
the video content block is type:"video" with an image-style source (base64 or
url). Fixes the block type, converts URL-based videos too, and adds 4 video
conversion tests (none shipped with the original PR).
2026-06-04 05:38:11 -07:00
kyssta-exe
0b46c4163a fix(vision): convert video_url blocks to Anthropic input_video format for MiniMax providers
The video_analyze tool sends OpenAI-style 'video_url' content blocks, which
breaks Anthropic-protocol providers (minimax, minimax-cn). These providers
expect 'input_video' blocks with base64 data instead of data: URLs.

Extends _convert_openai_images_to_anthropic() to also handle video_url
blocks, converting them to Anthropic's input_video format when targeting
Anthropic-compatible endpoints.

Fixes #37219
2026-06-04 05:38:11 -07:00
AhmetArif0
9756dff5fd fix(model_metadata): drop stale ≤256,000 cache entries for Grok-4.3
The ``grok-4.3`` (1M context) catalog entry was added on 2026-05-15
(ce0e189d3).  Between 2026-04-10 (when ``grok-4`` at 256,000 was first
added by b57769718) and 2026-05-15, grok-4.3 slugs resolved via the
generic ``grok-4`` substring catch-all and that 256,000 value was
persisted to context_length_cache.yaml.  Users who first queried
grok-4.3 in that 35-day window are stuck at 256K forever — the cache
is read at step 1 before the hardcoded defaults in step 8, so the
correct 1M entry is never reached.

Mirror the existing Kimi/Codex/MiniMax-M3 stale-cache guards: add
_model_name_suggests_grok_4_3() and an elif branch that drops any
cached value ≤ 256,000 for a grok-4.3 slug so the next lookup falls
through to the 1M hardcoded default.

Adds 4 regression tests: helper unit test, stale-drop-and-re-resolve,
correct-cache-preserved, and no-clobber for plain grok-4 (256K correct).
2026-06-04 05:36:34 -07:00
Teknium
b04c6e95f6 fix(approval): catch perl/ruby -i as a separate flag token
The salvaged pattern matched -i only inside the first flag token, so
`perl -p -i -e '...' config.yaml` (the -i split out after -p) slipped
through. Widen to match a -...i flag token anywhere in the args; still
no false positive on `perl -e` code eval or config reads. Adds tests
for the separate-token, backup-suffix, and read-safe forms.
2026-06-04 05:36:30 -07:00
AhmetArif0
a6a4e6f9d7 fix(approval): gate perl/ruby -i in-place edits of Hermes config/env
sed -i coverage for ~/.hermes/config.yaml and .env was added in #14639,
but perl -i and ruby -i — which perform the same direct file mutation —
were not covered. The existing perl/ruby pattern only catches -e/-c (code
evaluation), not -i (file mutation), so:

  perl -i -pe 's/approvals.mode: on/approvals.mode: off/' ~/.hermes/config.yaml

bypasses the approval gate entirely, letting the agent flip approvals.mode
off mid-session via the mtime-keyed config cache reload.

Add a single pattern mirroring the sed -i lines: `\b(?:perl|ruby)\s+-[^\s]*i`
against both _HERMES_CONFIG_PATH and _HERMES_ENV_PATH. Three regression
tests pin the new coverage.
2026-06-04 05:36:30 -07:00
teknium1
5f199e610b chore(release): add AUTHOR_MAP entry for solaitken 2026-06-04 05:35:43 -07:00
Sol Aitken
de60bf40c6 fix(memory): register parent packages for user-installed provider imports
User-installed memory providers load under the synthetic
_hermes_user_memory.<name> package, but the loader never registered that
parent namespace in sys.modules (it only registers "plugins" and
"plugins.memory" for bundled providers). As a result any external provider
using a relative import failed to load:

    from . import config
    ModuleNotFoundError: No module named '_hermes_user_memory'

The same gap in discover_plugin_cli_commands() meant an external provider's
cli.py with a relative import could never be discovered, so the documented
"hermes <plugin>" CLI integration did not work for standalone plugins.

Register the synthetic parent namespace before loading user-installed
providers, mirror it for cli.py discovery (including the per-provider parent
package, without executing the plugin's __init__.py), and make
_load_provider_from_dir() reuse only modules actually loaded from disk so a
parent shell registered by CLI discovery is never mistaken for the loaded
provider.

Regressions cover: a flat provider with a sibling relative import, a provider
with its implementation in a nested subpackage (including a namespace
intermediate directory), cli.py discovery with a relative import, and
provider load after CLI discovery ran first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:35:43 -07:00
AhmetArif0
4ae3c988b5 fix(gateway): bridge shared-key loop to nested platform config blocks
The shared-key bridging loop (allow_from, require_mention,
free_response_channels, …) read only the top-level yaml platform block
(yaml_cfg.get(plat.value)).  When a user configured a platform solely
under ``platforms:`` or ``gateway.platforms:`` with no top-level block,
the loop skipped that platform entirely and all bridged keys were silently
dropped into PlatformConfig.extra — making allow_from, require_mention,
etc. ineffective for nested-only configs.

The apply_yaml_config_fn dispatch already received this same fallback in
44f3e51 to handle plugin adapters (e.g. Discord allow_from).  The
shared-key loop now mirrors it: if yaml_cfg.get(plat.value) is absent,
fall back to gateway.platforms.<name> then platforms.<name>.

The enabled field is deliberately excluded from the nested fallback
(guarded by _cfg_toplevel): _merge_platform_map already merged it with
the correct precedence, so re-applying it from a single nested source
would overwrite the correctly-merged value.

Two new regression tests assert that allow_from and require_mention
configured under platforms.telegram and gateway.platforms.telegram are
bridged into PlatformConfig.extra.  All 54 existing config tests pass.
2026-06-04 05:31:47 -07:00
Teknium
d3fab54933 fix(cli): clear screen on exit so live chrome isn't stranded in scrollback (#38928)
The classic CLI left its live bottom chrome — the status bar, input box,
and separator rules — frozen in terminal scrollback after exit, on every
exit path (/exit, /quit, Ctrl+C, EOF) and on both Linux and Windows. The
prior erase_when_done=True fix (bf82a7f1c) routes prompt_toolkit's teardown
through renderer.erase(), but that walks back by the renderer's internal
cursor model and does not reliably wipe the chrome in practice — users still
saw a dead status bar + the rest of the session sitting above the resume
summary.

Clear the screen + scrollback directly at the single exit funnel instead.
All exit paths converge on _print_exit_summary() (called from the run-loop
finally block after app.run() returns and prompt_toolkit has restored
terminal modes), so a new _clear_terminal_on_exit() helper runs there before
the summary prints. It writes ESC[3J ESC[2J ESC[H (erase scrollback, erase
screen, home cursor) on a real TTY, no-ops silently when stdout is not a
terminal (pipes/redirects), and falls back to the platform clear command if
the escape write fails. Works on Linux, macOS, and modern Windows terminals
(Terminal/conhost with VT processing, already enabled by prompt_toolkit).

The resume/goodbye summary now prints at a clean top-left with nothing
stranded above it.

Fixes #38252.
2026-06-04 04:38:35 -07:00
Teknium
c0435f4fef docs: remote desktop connect uses username/password, not --insecure + session token (#38926)
The documented path for connecting Hermes Desktop to a remote backend was
`--insecure` + a pinned HERMES_DASHBOARD_SESSION_TOKEN — an unauthenticated
bind plus a copy-pasted token. Replace it everywhere with the bundled
username/password dashboard-auth provider: set HERMES_DASHBOARD_BASIC_AUTH_*,
run `hermes dashboard --host 0.0.0.0` (the non-loopback bind engages the auth
gate), and Sign in from the app.

- desktop.md: rewrite 'Connecting to a remote backend' for the user/pass + Sign in flow
- web-dashboard.md: rewrite both remote-backend sections (overview + dedicated);
  reframe the auth-gate section so --insecure is a discouraged escape hatch, not a
  co-equal use case; drop the removed --tui flag from the systemd example
- environment-variables.md: lead with HERMES_DASHBOARD_BASIC_AUTH_*; drop the
  session-token / HERMES_DESKTOP_REMOTE_TOKEN remote-connect entries
- docker.md: mention the username/password provider as the simplest gate provider
2026-06-04 21:23:59 +10:00
Teknium
df9fb8e5e6 fix(tools): stop hermes tools reporting kanban as removed (#38918)
The hermes tools save summary printed '- kanban' (and would print
'+ kanban') for a platform even though kanban is never offered as a
checklist option. kanban is a check_fn-gated toolset whose tools are a
subset of the platform composite, so _get_platform_tools resolves it as
enabled, but _prompt_toolset_checklist only renders CONFIGURABLE_TOOLSETS
— so it can never survive into the returned selection. The added/removed
diff (current_enabled - new_enabled) then surfaced kanban as removed.

Scope the printed diff to the checklist's actual universe via the new
_checklist_toolset_keys() helper at all three diff sites (first-install,
all-platforms, per-platform). The persisted config is unaffected —
_save_platform_tools already preserves non-configurable entries; this was
purely a false-signal in the UI.
2026-06-04 03:31:43 -07:00
Ben
616c0a36b6 fix(dashboard-auth): don't abort verify chain on one provider's ProviderError
The gated dashboard verifies a session cookie by trying each registered
DashboardAuthProvider's verify_session in turn (the session cookie stores
only the access token, not which provider issued it). A provider that
doesn't recognise a token returns None; a provider whose IDP/JWKS is
unreachable raises ProviderError.

The loop used to return HTTP 503 on the FIRST ProviderError, before any
later provider got a turn. With multiple providers stacked, that means an
unreachable IDP for a session you didn't even use blocks login through a
different, reachable provider.

Concrete repro: a self-hosted-OIDC session hits the 'nous' provider first
(registered earlier); nous tries to reach Nous Portal's JWKS, which is
unreachable in a self-hosted deployment, so it raises — and the gate
503s before the 'self-hosted' provider can verify the token. Hit live
while testing the new self-hosted OIDC plugin against a local Keycloak.

Fix: a ProviderError from one provider is logged and the loop continues
to the next. A 503 is returned only if NO provider verified the token
AND at least one was unreachable — distinguishing a transient IDP outage
(don't force a needless re-login) from a token that's genuinely invalid
(fall through to refresh/relogin). Single-provider behaviour is
unchanged.

Tests: adds an _UnreachableProvider stub and three cases — unreachable
provider first must not block a working second; all-unreachable still
503s; reachable-but-unrecognised falls through to 401/relogin (not 503).
Mutation-tested: reverting the fix makes the first case fail with the
exact 503 bug.
2026-06-04 03:23:45 -07:00
Ben
f57ce341dc feat(dashboard-auth): add generic self-hosted OIDC provider
Adds a bundled dashboard-auth provider plugin that authenticates the
web dashboard against any conformant self-hosted OpenID Connect server
(Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …) using
standard OIDC — no per-IDP code.

It's a pure drop-in plugin implementing the DashboardAuthProvider
protocol; it touches no core auth/runtime/login paths. Mechanics:

- OIDC discovery from {issuer}/.well-known/openid-configuration
  (cached; issuer pinned; endpoints required HTTPS, loopback http
  allowed for local-dev IDPs)
- authorization-code + PKCE (S256), public client
- verifies the OIDC ID token (RS256/ES256) against the discovered
  jwks_uri with iss/aud pinned to the configured issuer/client_id, and
  maps standard claims (sub/email/name/preferred_username, groups→org)
  onto a Session
- standard refresh_token grant for silent re-auth; RFC 7009 revocation
  on logout when advertised

Verifies the ID token (not the access token) because OIDC guarantees the
ID token is a signed JWT carrying identity, while access-token format is
opaque to the client per spec — the only universally-correct choice
across self-hosted IDPs.

Config via dashboard.oauth.self_hosted.{issuer,client_id,scopes} in
config.yaml or HERMES_DASHBOARD_OIDC_{ISSUER,CLIENT_ID,SCOPES} env vars
(env-wins-config, empty-is-unset — same convention as the nous plugin).
Confidential clients (client_secret) left as a documented TODO seam.

Docs: adds a Self-hosted OIDC section to the web-dashboard guide,
including a copy-paste Keycloak worked example (realm import + docker
run + dashboard wiring + login walkthrough).

Tests: 65 cases covering construction, discovery (incl. issuer
mismatch + https enforcement), start_login/PKCE, complete_login, ID
token verification, refresh/revoke, and env/config precedence.
2026-06-04 03:23:45 -07:00
Ben
cae6b5486f feat(dashboard): always enable embedded chat; remove dashboard --tui flag
The dashboard's embedded Chat surface (/chat, /api/ws, /api/pty) was gated
behind `hermes dashboard --tui` / HERMES_DASHBOARD_TUI=1. The desktop app and
the dashboard's own Chat tab both drive the agent over the /api/ws + /api/pty
WebSockets, so a dashboard started without the flag would pass the /api/status
health check but slam the chat WebSocket shut with WS code 4403 — the app
connects, reports "ready", and chat stays dead. This was the root cause behind
multiple user reports of the desktop app failing to connect to a self-hosted
gateway/dashboard, and it bit Docker and host installs alike.

Make the embedded chat unconditional:

- web_server.py: _DASHBOARD_EMBEDDED_CHAT_ENABLED defaults to True; drop the
  embedded_chat parameter and the runtime reassignment from start_server().
  The WS gates still read the constant (now always true) so the seam — and its
  "rejects when disabled" contract test — stays meaningful.
- main.py: remove the `--tui` argument from the dashboard subparser and the
  `embedded_chat = args.tui or HERMES_DASHBOARD_TUI==1` derivation.
- web/: isDashboardEmbeddedChatEnabled() returns true unconditionally; drop the
  deprecated __HERMES_DASHBOARD_TUI__ alias and the dead LEGACY_TUI_RE scrape in
  the vite dev-token plugin.
- apps/desktop/electron/main.cjs: drop `--tui` from the spawned dashboardArgs
  (it would now error with "unrecognized arguments: --tui") and the redundant
  HERMES_DASHBOARD_TUI env injection.
- Docker: no s6 run-script change needed — the script never passed --tui; the
  HERMES_DASHBOARD_TUI env var is now simply a no-op, so the image works out of
  the box with no extra var.
- Docs: remove every dashboard --tui / HERMES_DASHBOARD_TUI reference across the
  CLI reference, env-var reference, docker/desktop/web-dashboard guides, in-app
  tips, and the zh-Hans translations. The terminal `hermes --tui` / HERMES_TUI
  references are intentionally left untouched.

Tests: 270 passing across web_server, dashboard lifecycle, host-header,
auth-gate, and docker-override-scripts suites.
2026-06-04 03:03:35 -07:00
Teknium
bf82a7f1cc fix(cli): erase live chrome on exit so it isn't stranded above the session summary
Sets erase_when_done=True on the classic CLI's prompt_toolkit Application so the
live bottom chrome (status bar, input box, separator rules) is wiped on exit
instead of frozen into scrollback.

Previously prompt_toolkit's render_as_done teardown repainted the chrome one
final time and left it on screen (ESC[J only erases below the cursor, not the
chrome above), so a dead status bar + empty prompt + rules were stranded
between the conversation transcript and the 'Resume this session' summary, and
stacked with the next session's UI on resume. erase_when_done routes teardown
through renderer.erase() which wipes exactly the managed chrome region; the
conversation transcript prints through patch_stdout into normal scrollback and
is untouched. Applies to every exit path (/exit, /quit, EOF, Ctrl+C).

Fixes #38252.
2026-06-04 03:03:23 -07:00
alt-glitch
aeec88c77f fix(installer): symlink bundled node/npm into command bin dir for FHS root installs
Root installs on Linux (FHS layout, #15608) put the `hermes` command in
`/usr/local/bin` (on PATH) but symlinked the bundled node/npm/npx into
`~/.local/bin`, which isn't on PATH for a stock root shell. `node`/`npm`
were 'command not found' and `hermes dashboard` failed with 'npm is not
available' because its build-on-demand fallback couldn't find npm.

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

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

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

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

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

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

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

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

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

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

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

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

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

Local and token modes are unchanged.

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

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

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

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

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

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

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

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

* style(desktop): flatten providers settings UI chrome

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

* feat(desktop): rework providers settings UI

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

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

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

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

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

---------

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

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

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

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

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

  - base.py: add the optional supports_password flag and
    complete_password_login(username, password) -> Session (default
    raises NotImplementedError so an OAuth-only provider that forgets the
    flag fails loudly). Add InvalidCredentialsError. OAuth providers are
    unaffected (flag defaults False; the method is never called).
  - routes.py: add POST /auth/password-login, mirroring the cookie-minting
    tail of /auth/callback but skipping PKCE/state/code. Returns JSON
    {ok, next} (the form POSTs via fetch). Generic 401 for both unknown
    user and wrong password (no enumeration oracle); 404 hides whether a
    provider exists or supports passwords; per-IP sliding-window rate
    limit (10/min -> 429). /api/auth/providers now reports
    supports_password so the login page can branch.
  - middleware.py: allowlist /auth/password-login (a bootstrap route).
    verify/refresh/revoke/ws-tickets/logout need zero changes — a password
    session is just a Session with provider-minted opaque tokens.
  - login_page.py: render a credential form (instead of a redirect button)
    for supports_password providers, wired by a small inline script that
    POSTs to /auth/password-login and navigates on success. OAuth-only
    pages stay script-free.
2026-06-04 01:02:25 -07:00
506 changed files with 23327 additions and 12001 deletions

View File

@@ -3,6 +3,21 @@
.gitignore
.gitmodules
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
# Dependencies
node_modules
**/node_modules
@@ -24,7 +39,20 @@ ui-tui/packages/hermes-ink/dist/
# Environment files
.env
.env.*
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md
# Runtime data (bind-mounted at /opt/data; must not leak into build context)

8
.gitattributes vendored
View File

@@ -1,2 +1,10 @@
# Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true
# Enforce LF for scripts that run inside Linux containers.
# Without this, Windows checkout converts to CRLF and breaks `exec` in the
# container entrypoint with "no such file or directory".
*.sh text eol=lf
Dockerfile text eol=lf
*.dockerfile text eol=lf
docker/entrypoint.sh text eol=lf

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.DS_Store
/venv/
/venv.old/
/_pycache/
*.pyc*
__pycache__/

View File

@@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
### Linux, macOS, WSL2, Termux
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
### Windows (native, PowerShell)
@@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
Run this in PowerShell:
```powershell
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
@@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
>
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
After installation:

View File

@@ -31,7 +31,7 @@
## 快速安装
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。

View File

@@ -457,12 +457,7 @@ class SessionManager:
else:
# Update model_config (contains cwd) if changed.
try:
with db._lock:
db._conn.execute(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(cwd_json, model_str, state.session_id),
)
db._conn.commit()
db.update_session_meta(state.session_id, cwd_json, model_str)
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)

View File

@@ -265,9 +265,6 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"stepfun": "step-3.5-flash",
"kimi-coding-cn": "kimi-k2-turbo-preview",
"gmi": "google/gemini-3.1-flash-lite-preview",
"minimax": "MiniMax-M2.7",
"minimax-oauth": "MiniMax-M2.7-highspeed",
"minimax-cn": "MiniMax-M2.7",
"anthropic": "claude-haiku-4-5-20251001",
"opencode-zen": "gemini-3-flash",
"opencode-go": "glm-5",
@@ -4756,10 +4753,14 @@ def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
"""Convert OpenAI ``image_url``/``video_url`` blocks to Anthropic format.
Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
Converts:
- ``image_url`` blocks to Anthropic ``image`` blocks
- ``video_url`` blocks to Anthropic ``video`` blocks (MiniMax M3 compat)
Only touches messages that have list-type content with ``image_url`` or
``video_url`` blocks; plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
@@ -4796,6 +4797,39 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
},
})
changed = True
elif block.get("type") == "video_url":
# MiniMax's Anthropic-compatible endpoint expects a "video"
# block (not OpenAI's "video_url", and not "input_video").
# See https://platform.minimax.io/docs/api-reference/text-anthropic-api
# — the Messages-field table lists type="video" (M3 only,
# URL/base64/mm_file://). The source shape mirrors the "image"
# block: base64 → {type:"base64", media_type, data}, URL →
# {type:"url", url}.
video_url_val = (block.get("video_url") or {}).get("url", "")
if video_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = video_url_val.partition(",")
media_type = "video/mp4"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "video",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based video
new_content.append({
"type": "video",
"source": {
"type": "url",
"url": video_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)

View File

@@ -646,6 +646,11 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# much larger; shrinking to 4 MB here loses quality but only fires
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
# Anthropic enforces an 8000px per-side dimension cap independently of
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
# even when the byte budget is fine.
max_dimension = 8000
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
@@ -658,9 +663,30 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
"""Return a smaller data URL, or None if shrink can't help."""
if not isinstance(url, str) or not url.startswith("data:"):
return None
if len(url) <= target_bytes:
# This specific image wasn't the oversized one.
return None
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
if not needs_shrink:
# Even if bytes are fine, check pixel dimensions against
# Anthropic's 8000px cap. A tall image can be tiny in bytes
# yet huge in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
if not data_d:
return None
raw_d = _b64_dim.b64decode(data_d)
from PIL import Image as _PILImage
import io as _io_dim
with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
if max(_img.size) <= max_dimension:
return None # both bytes and pixels are fine
needs_shrink = True # pixels exceed limit, force shrink
except Exception:
# If we can't check dimensions (Pillow unavailable, corrupt
# image, etc.), fall back to byte-only check.
return None
try:
header, _, data = url.partition(",")
mime = "image/jpeg"
@@ -684,6 +710,7 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
Path(tmp.name),
mime_type=mime,
max_base64_bytes=target_bytes,
max_dimension=max_dimension,
)
finally:
try:

View File

@@ -171,6 +171,9 @@ _IMAGE_TOO_LARGE_PATTERNS = [
"image too large", # generic
"image_too_large", # error_code variant
"image size exceeds", # variant
"image dimensions exceed", # Anthropic: "image dimensions exceed max allowed size: 8000 pixels"
"dimensions exceed max allowed size", # Anthropic dimension-cap (wording variant)
"max allowed size: 8000", # Anthropic dimension-cap (explicit pixel ceiling)
# "request_too_large" on a request known to contain an image → image is
# the likely culprit; we still try the shrink path before giving up.
]

View File

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

View File

@@ -441,6 +441,10 @@ def is_local_endpoint(base_url: str) -> bool:
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
return True
# Unqualified hostnames (no dots) are local by definition — Docker
# Compose service names, /etc/hosts entries, or mDNS names.
if host and "." not in host:
return True
# RFC-1918 private ranges, link-local, and Tailscale CGNAT
try:
addr = ipaddress.ip_address(host)
@@ -1140,6 +1144,18 @@ def _model_name_suggests_minimax_m3(model: str) -> bool:
return "minimax-m3" in model.lower()
def _model_name_suggests_grok_4_3(model: str) -> bool:
"""Return True if the model name looks like a Grok 4.3 variant.
Catches ``grok-4.3``, ``grok-4.3-latest``, and similar slugs.
Used as a guard against stale cache entries seeded by pre-catalog builds
that resolved grok-4.3 via the generic ``grok-4`` catch-all (256,000)
before the ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS
on 2026-05-15.
"""
return "grok-4.3" in model.lower()
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@@ -1564,6 +1580,19 @@ def get_model_context_length(
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale ≤256,000 cache entries for Grok-4.3. The
# ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS on
# 2026-05-15; prior to that, grok-4.3 slugs resolved via the
# ``grok-4`` catch-all (256,000) and that value was persisted.
# grok-4.3 is 1M, so any sub-262K cached value is a pre-catalog
# leftover — drop it and fall through to the hardcoded default.
elif cached <= 256_000 and _model_name_suggests_grok_4_3(model):
logger.info(
"Dropping stale Grok-4.3 cache entry %s@%s -> %s (pre-catalog value); "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Nous Portal: the portal /v1/models endpoint is authoritative.
# Bypass the persistent cache so step 5b can always reconcile
# against it — this corrects pre-fix entries seeded from the

View File

@@ -22,6 +22,7 @@ from agent.skill_utils import (
get_disabled_skill_names,
iter_skill_index_files,
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
)
from utils import atomic_json_write
@@ -1005,6 +1006,13 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
if not skill_matches_platform(frontmatter):
return False, frontmatter, ""
# Environment relevance gate (offer-time only): hide skills tagged for
# a runtime environment that isn't active (e.g. kanban-only skills for
# non-kanban users, s6-only skills outside the container). Explicit
# loads (skill_view / --skills) bypass this — see skill_matches_environment.
if not skill_matches_environment(frontmatter):
return False, frontmatter, ""
return True, frontmatter, extract_skill_description(frontmatter)
except Exception as e:
logger.warning("Failed to parse skill file %s: %s", skill_file, e)

View File

@@ -270,7 +270,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
_skill_commands_platform = _resolve_skill_commands_platform()
_skill_commands = {}
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
disabled = _get_disabled_skill_names()
seen_names: set = set()
@@ -291,6 +291,10 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
# Skip skills incompatible with the current OS platform
if not skill_matches_platform(frontmatter):
continue
# Skip skills not relevant to the current runtime env
# (kanban/docker/s6). Offer-time only; explicit load bypasses.
if not skill_matches_environment(frontmatter):
continue
name = frontmatter.get('name', skill_md.parent.name)
if name in seen_names:
continue

View File

@@ -169,6 +169,106 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
# a *relevance* gate, not a hard-compatibility gate (that is what ``platforms:``
# is for). A skill tagged for an environment it isn't relevant to is hidden from
# the skills index / offer surfaces so it does not add noise for users who will
# never need it — but it can ALWAYS still be loaded explicitly (``skill_view``,
# ``--skills``), because an explicit request is explicit consent.
#
# Detection is cached for the process lifetime via ``_ENV_DETECT_CACHE``.
_KNOWN_ENVIRONMENTS = frozenset({"kanban", "docker", "s6"})
_ENV_DETECT_CACHE: Dict[str, bool] = {}
def _detect_environment(env: str) -> bool:
"""Return True when the named runtime environment is currently active.
Cached per process. Unknown env names return True (fail-open: never hide a
skill because of a tag we don't understand).
"""
if env in _ENV_DETECT_CACHE:
return _ENV_DETECT_CACHE[env]
result = True
if env == "kanban":
# Kanban is "active" either as a dispatcher-spawned worker (the
# dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the
# worker env) or as an orchestrator profile that has opted into the
# kanban toolset. Mirror the same signals the kanban tools themselves
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
# tool availability.
if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"):
result = True
else:
try:
from tools.kanban_tools import _profile_has_kanban_toolset
result = bool(_profile_has_kanban_toolset())
except Exception:
result = False
elif env == "docker":
try:
from hermes_constants import is_container
result = is_container()
except Exception:
result = False
elif env == "s6":
# The Hermes Docker image runs s6-overlay as PID 1 (/init). s6 plants
# its runtime scaffolding under /run/s6 and ships its admin tree under
# /package/admin/s6-overlay. Either marker means we're inside an
# s6-supervised container.
result = os.path.isdir("/run/s6") or os.path.isdir(
"/package/admin/s6-overlay"
)
_ENV_DETECT_CACHE[env] = result
return result
def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is relevant to the current runtime environment.
Skills may declare an ``environments`` list in their YAML frontmatter::
environments: [kanban] # only relevant when kanban is active
environments: [s6] # only relevant inside the s6 Docker image
environments: [docker] # only relevant inside any container
If the field is absent or empty the skill is relevant in **all**
environments (backward-compatible default).
This is an OFFER-time filter: it controls whether a skill shows up in the
skills index / autocomplete / slash-command list. It is intentionally NOT
enforced by ``skill_view`` or ``--skills`` preloading — an explicit load is
explicit consent, and load-bearing force-loads (e.g. the kanban dispatcher
injecting ``--skills kanban-worker``) must always succeed regardless of how
the offer surfaces filter the skill.
A skill matches when ANY of its declared environments is currently active
(OR semantics, mirroring ``platforms``). Unknown env tags fail open.
"""
environments = frontmatter.get("environments")
if not environments:
return True
if not isinstance(environments, list):
environments = [environments]
for env in environments:
normalized = str(env).lower().strip()
if not normalized:
continue
if normalized not in _KNOWN_ENVIRONMENTS:
# Tag we don't understand — don't hide the skill over it.
return True
if _detect_environment(normalized):
return True
return False
# ── Disabled skills ───────────────────────────────────────────────────────

View File

@@ -27,7 +27,7 @@
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://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
```
Already have the Hermes CLI? Just run:
@@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest.
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
---
@@ -56,10 +56,7 @@ hermes update
## Requirements
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing:
- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required.
- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing).
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
---
@@ -94,7 +91,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard --tui` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
### Verification

View File

@@ -67,7 +67,9 @@ test('verifyHermesCli returns true when --version exits 0', () => {
} finally {
try {
fs.unlinkSync(scriptPath)
} catch {}
} catch {
void 0
}
}
})

View File

@@ -52,7 +52,9 @@ function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null

View File

@@ -45,11 +45,17 @@ test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
null
)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
assert.equal(
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
'ssh-session'
)
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})

View File

@@ -101,7 +101,9 @@ function downloadInstallScript(commit, destPath) {
.get(res.headers.location, res2 => {
if (res2.statusCode !== 200) {
reject(
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
new Error(
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
)
return
}
@@ -121,7 +123,9 @@ function downloadInstallScript(commit, destPath) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {}
} catch {
void 0
}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
@@ -134,14 +138,18 @@ function downloadInstallScript(commit, destPath) {
out.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {}
} catch {
void 0
}
reject(err)
})
})
.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {}
} catch {
void 0
}
reject(err)
})
})
@@ -168,13 +176,19 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
}
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` })
emit({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
@@ -207,7 +221,9 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
killed = true
try {
child.kill('SIGTERM')
} catch {}
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
@@ -278,7 +294,9 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
killed = true
try {
child.kill('SIGTERM')
} catch {}
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
@@ -369,7 +387,9 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
hermesHome
})
if (result.code !== 0) {
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`)
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
@@ -381,9 +401,13 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
} catch {}
} catch {
void 0
}
}
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
}
// Parse the JSON result frame from a stage run. The protocol guarantees
@@ -397,7 +421,9 @@ function parseStageResult(stdout) {
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
} catch {}
} catch {
void 0
}
}
return null
}
@@ -408,13 +434,20 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
const isPosix = installerKind === 'posix'
const args = isPosix
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
? [
'--stage',
stage.name,
'--non-interactive',
'--json',
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(
scriptPath,
args,
{ emit, stageName: stage.name, abortSignal, hermesHome }
)
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
abortSignal,
hermesHome
})
const durationMs = Date.now() - startedAt
@@ -449,7 +482,14 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit(ev)
return ev
}
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
const ev = {
type: 'stage',
name: stage.name,
state: 'failed',
durationMs,
json,
error: json.reason || `exit code ${result.code}`
}
emit(ev)
return ev
}
@@ -489,7 +529,9 @@ async function runBootstrap(opts) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {}
} catch {
void 0
}
}
return { ok: false, cancelled: true }
}
@@ -501,7 +543,9 @@ async function runBootstrap(opts) {
const emit = ev => {
try {
runLog.stream.write(JSON.stringify(ev) + '\n')
} catch {}
} catch {
void 0
}
try {
if (typeof onEvent === 'function') onEvent(ev)
} catch (err) {
@@ -578,7 +622,9 @@ async function runBootstrap(opts) {
} finally {
try {
runLog.stream.end()
} catch {}
} catch {
void 0
}
}
}

View File

@@ -0,0 +1,172 @@
/**
* connection-config.cjs
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
* `X-Hermes-Session-Token` header; WS uses `?token=`.
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
* by an HttpOnly session cookie; WS upgrades require a single-use
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
if (!value) {
throw new Error('Remote gateway URL is required.')
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return parsed.toString().replace(/\/+$/, '')
}
function buildGatewayWsUrl(baseUrl, token) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
}
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
/**
* Build the WS URL the renderer would connect with, so the connection test can
* exercise the same transport the app actually uses.
*
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
* so this stays electron-free and unit-testable; main.cjs passes the real
* `mintGatewayWsTicket`.
*
* Return semantics:
* - token mode + token → ws(s)://…/api/ws?token=…
* - token mode, no token → null (genuine skip; nothing to authenticate with)
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
* - oauth, mint fails → THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch —
* HTTP /api/status passes, the test reports "reachable", then the renderer
* can't authenticate /api/ws and boot dies with "Could not connect".
*
* @param {string} baseUrl
* @param {'token'|'oauth'} authMode
* @param {string|null} token
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
* @returns {Promise<string|null>}
*/
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
if (authMode === 'oauth') {
const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
}
let ticket
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
const err = new Error(
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
err.needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
if (!token) {
return null
}
return buildGatewayWsUrl(baseUrl, token)
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
}
/**
* Classify a gateway's auth mode from its public /api/status body.
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
* Returns 'oauth' | 'token'.
*/
function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}
/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
return 'token'
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
module.exports = {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
}

View File

@@ -0,0 +1,211 @@
/**
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
* classification from /api/status, the coerce-time auth-mode resolution rules,
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
})
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
})
test('normalizeRemoteBaseUrl rejects empty input', () => {
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
})
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
})
test('normalizeRemoteBaseUrl rejects garbage', () => {
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
})
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(buildGatewayWsUrl('https://gw.example.com', 'tok123'), 'wss://gw.example.com/api/ws?token=tok123')
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'), 'ws://127.0.0.1:9119/api/ws?token=abc')
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(buildGatewayWsUrl('https://host/hermes', 't'), 'wss://host/hermes/api/ws?token=t')
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
assert.ok(!url.includes('token='))
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
})
// --- authModeFromStatus ---
test('authModeFromStatus returns oauth when auth_required is true', () => {
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
})
test('authModeFromStatus returns token when auth_required is false/missing', () => {
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
assert.equal(authModeFromStatus({}), 'token')
assert.equal(authModeFromStatus(null), 'token')
assert.equal(authModeFromStatus(undefined), 'token')
})
// --- resolveAuthMode ---
test('resolveAuthMode: explicit input wins over existing', () => {
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
})
test('resolveAuthMode: falls back to existing when input absent', () => {
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
})
test('resolveAuthMode: defaults to token when nothing is set', () => {
assert.equal(resolveAuthMode(undefined, undefined), 'token')
assert.equal(resolveAuthMode(null, null), 'token')
})
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
})
// --- cookiesHaveSession ---
test('cookiesHaveSession detects the bare access-token cookie', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
test('cookiesHaveSession handles non-arrays', () => {
assert.equal(cookiesHaveSession(null), false)
assert.equal(cookiesHaveSession(undefined), false)
assert.equal(cookiesHaveSession([]), false)
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
assert.equal(tokenPreview(''), null)
assert.equal(tokenPreview(null), null)
})
test('tokenPreview returns set for short tokens', () => {
assert.equal(tokenPreview('12345678'), 'set')
})
test('tokenPreview returns a masked suffix for long tokens', () => {
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
})
// --- resolveTestWsUrl ---
//
// The "Test remote" button must exercise the same WS transport the app uses,
// and must FAIL (not skip) when an OAuth session can't mint a ws-ticket — that
// is the exact false-positive PR #39098 set out to eliminate.
test('resolveTestWsUrl (token mode) builds a ?token= URL the WS probe can use', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'token', 'tok123')
assert.equal(url, 'wss://gw.example.com/api/ws?token=tok123')
})
test('resolveTestWsUrl (token mode, no token) returns null — genuine skip', async () => {
assert.equal(await resolveTestWsUrl('https://gw.example.com', 'token', null), null)
})
test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => 'tkt-9'
})
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw new Error('401 ticket mint failed')
}
}),
err => {
// Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error)
return true
}
)
})
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
await assert.rejects(
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
/mintTicket function is required/
)
})

View File

@@ -0,0 +1,188 @@
/**
* Live WebSocket validation for the remote-gateway "Test remote" button.
*
* Background: the desktop boot does two independent things to a remote gateway:
*
* 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header)
* to confirm the backend is up. This is what "Test remote" historically
* checked, and what the boot logs print as "Remote Hermes backend is
* ready".
* 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a
* query param) via ``gateway.connect()``. The chat surface only works once
* THIS succeeds.
*
* Those two paths use different processes, transports, and credentials, and the
* server applies extra guards to the WS upgrade that the HTTP status route never
* sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway
* can pass the HTTP status check yet reject the WebSocket — which surfaces to
* the user as a green "Test remote" followed by an opaque "Could not connect to
* Hermes gateway" on the boot overlay.
*
* This module performs the second half of the check: it actually opens the WS
* URL and confirms the upgrade is accepted (and isn't immediately torn down by
* a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the
* unit tests can drive the handshake without a real socket; in production the
* caller passes the Node/Electron global ``WebSocket``.
*/
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
// After the upgrade is accepted, a gateway that rejects the credential
// post-handshake closes the socket almost immediately. Wait a short grace
// window: a frame (gateway.ready) or a still-open socket means success; an
// early close means the upgrade was accepted but the session was refused.
const DEFAULT_READY_GRACE_MS = 750
/**
* Attempt a live WebSocket connection and classify the outcome.
*
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
* @param {object} [options]
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
* @param {number} [options.connectTimeoutMs]
* @param {number} [options.readyGraceMs]
* @returns {Promise<{ ok: boolean, reason?: string }>}
*/
function probeGatewayWebSocket(wsUrl, options = {}) {
const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
if (typeof WebSocketImpl !== 'function') {
return Promise.resolve({
ok: false,
reason: 'WebSocket is not available in this runtime.'
})
}
return new Promise(resolve => {
let settled = false
let opened = false
let connectTimer = null
let graceTimer = null
let socket
const clearTimers = () => {
if (connectTimer !== null) {
clearTimeout(connectTimer)
connectTimer = null
}
if (graceTimer !== null) {
clearTimeout(graceTimer)
graceTimer = null
}
}
const finish = result => {
if (settled) return
settled = true
clearTimers()
try {
socket?.close?.()
} catch {
// ignore — best effort teardown
}
resolve(result)
}
try {
socket = new WebSocketImpl(wsUrl)
} catch (error) {
finish({
ok: false,
reason: error instanceof Error ? error.message : String(error)
})
return
}
const onOpen = () => {
if (settled) return
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
graceTimer = setTimeout(() => {
finish({ ok: true })
}, readyGraceMs)
}
const onMessage = () => {
// Any frame means the gateway accepted us and is talking — unambiguous
// success, no need to wait out the grace window.
finish({ ok: true })
}
const onError = event => {
finish({
ok: false,
reason: extractErrorReason(event) || 'WebSocket connection failed.'
})
}
const onClose = event => {
if (settled) return
if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a
// server-side Host/Origin guard tripped after accept).
finish({
ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
})
return
}
finish({
ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
})
}
addListener(socket, 'open', onOpen)
addListener(socket, 'message', onMessage)
addListener(socket, 'error', onError)
addListener(socket, 'close', onClose)
if (connectTimeoutMs > 0) {
connectTimer = setTimeout(() => {
finish({
ok: false,
reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.`
})
}, connectTimeoutMs)
}
})
}
function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
// Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') {
socket.on(type, handler)
}
}
function extractErrorReason(event) {
if (!event) return ''
if (event instanceof Error) return event.message
const err = event.error || event.message
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
return ''
}
function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
if (code && reason) return `${fallback} (code ${code}: ${reason})`
if (code) return `${fallback} (code ${code})`
if (reason) return `${fallback} (${reason})`
return fallback
}
module.exports = {
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READY_GRACE_MS,
probeGatewayWebSocket
}

View File

@@ -0,0 +1,122 @@
/**
* Tests for electron/gateway-ws-probe.cjs.
*
* Run with: node --test electron/gateway-ws-probe.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* The probe drives a real WebSocket handshake for the "Test remote" button.
* Here we inject a fake socket so we can deterministically replay each handshake
* outcome (open, frame, error, early close, never-opens) without a network.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
// Minimal WebSocket double: records listeners synchronously (the probe attaches
// them in its executor) and exposes emit() so the test can replay events.
function makeFakeWs() {
const instances = []
class FakeWs {
constructor(url) {
this.url = url
this.listeners = {}
this.closed = false
instances.push(this)
}
addEventListener(type, fn) {
;(this.listeners[type] ||= []).push(fn)
}
close() {
this.closed = true
}
emit(type, event) {
for (const fn of this.listeners[type] || []) fn(event)
}
}
return { FakeWs, instances }
}
const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 }
test('probe resolves ok when the socket opens and stays open', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('open')
const result = await promise
assert.deepEqual(result, { ok: true })
assert.equal(instances[0].closed, true)
})
test('probe resolves ok immediately when a frame arrives', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
})
instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
const result = await promise
assert.deepEqual(result, { ok: true })
})
test('probe fails when the socket errors before opening', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('error', { message: 'ECONNREFUSED' })
const result = await promise
assert.equal(result.ok, false)
assert.match(result.reason, /ECONNREFUSED/)
})
test('probe fails when the gateway closes before opening', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('close', { code: 1006 })
const result = await promise
assert.equal(result.ok, false)
assert.match(result.reason, /before it opened/)
assert.match(result.reason, /1006/)
})
test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('open')
instances[0].emit('close', { code: 4403, reason: 'forbidden' })
const result = await promise
assert.equal(result.ok, false)
assert.match(result.reason, /credential rejected/)
assert.match(result.reason, /4403/)
assert.match(result.reason, /forbidden/)
})
test('probe times out when the socket never opens', async () => {
const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 20,
readyGraceMs: 10
})
assert.equal(result.ok, false)
assert.match(result.reason, /Timed out/)
})
test('probe fails gracefully when the constructor throws', async () => {
class ThrowingWs {
constructor() {
throw new Error('bad url')
}
}
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST })
assert.equal(result.ok, false)
assert.match(result.reason, /bad url/)
})
test('probe reports unavailable when no WebSocket implementation is provided', async () => {
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined })
assert.equal(result.ok, false)
assert.match(result.reason, /not available/)
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,21 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
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'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
profile: {
get: () => ipcRenderer.invoke('hermes:profile:get'),
set: name => ipcRenderer.invoke('hermes:profile:set', name)
},
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),

View File

@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -146,6 +146,7 @@
"package.json"
],
"beforeBuild": "scripts/before-build.cjs",
"beforePack": "scripts/before-pack.cjs",
"afterPack": "scripts/after-pack.cjs",
"extraResources": [
{

View File

@@ -0,0 +1,78 @@
'use strict'
/**
* before-pack.cjs — electron-builder beforePack hook.
*
* Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
*
* WHY THIS EXISTS
* ---------------
* electron-builder's final packaging step copies the stock `electron`
* binary into `release/<platform>-unpacked/` and then renames it to the
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
* the `electron` binary itself.
*
* On the next run, electron-builder sees the destination directory already
* populated, skips re-copying the binary it thinks is present, then tries to
* rename a `electron` file that no longer exists. The build dies with:
*
* ENOENT: no such file or directory, rename
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
*
* This is a hard failure with no obvious cause for the user — `hermes desktop`
* just prints "Desktop GUI build failed" and the only fix is to manually
* `rm -rf` the release directory, which a normal user has no way to know.
*
* The packaging step is not idempotent across an interrupted run, so we make
* it idempotent ourselves: wipe the target unpacked directory up front so
* electron-builder always stages into a clean tree. This is safe — the
* directory is a pure build artifact that electron-builder fully recreates
* on every pack; nothing else depends on its prior contents.
*
* Cross-platform: the same partial-state trap exists on macOS
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
*
* Best-effort: a cleanup failure must never mask the real build. We log and
* resolve rather than throw — worst case electron-builder hits the original
* ENOENT, which is no worse than not having this hook at all.
*
* electron-builder passes a context with:
* - appOutDir: the unpacked app directory about to be staged
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
*/
const fs = require('node:fs')
function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') {
return false
}
if (!fs.existsSync(appOutDir)) {
return false
}
// Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
// Windows where an AV/indexer may briefly hold a handle.
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
return true
}
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
exports.default = async function beforePack(context) {
const appOutDir = context && context.appOutDir
try {
if (cleanStaleAppOutDir(appOutDir)) {
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
}
} catch (err) {
// Never fail the build over cleanup; surface why so a genuinely stuck
// directory (permissions, mount) is still diagnosable.
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
}
}

View File

@@ -0,0 +1,53 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'linux-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
// Reproduce the corrupted partial state: license + payload present,
// electron binary missing — exactly what trips the ENOENT rename.
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
const removed = cleanStaleAppOutDir(appOutDir)
assert.equal(removed, true)
assert.equal(fs.existsSync(appOutDir), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const missing = path.join(tempRoot, 'does-not-exist')
assert.equal(cleanStaleAppOutDir(missing), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
assert.equal(cleanStaleAppOutDir(''), false)
assert.equal(cleanStaleAppOutDir(undefined), false)
assert.equal(cleanStaleAppOutDir(null), false)
assert.equal(cleanStaleAppOutDir(42), false)
})
test('beforePack default export resolves even when cleanup throws', async () => {
const { default: beforePack } = require('../scripts/before-pack.cjs')
// A directory path that rmSync can't remove is simulated by passing a
// context whose appOutDir is a file the hook will try (and be allowed) to
// remove; the contract under test is that the hook never rejects.
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
})

View File

@@ -16,6 +16,7 @@ import {
PaginationPrevious
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -736,7 +737,6 @@ function ArtifactCellAction({
<button
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
onClick={onClick}
title={title}
type="button"
>
{children}
@@ -774,15 +774,16 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
title={artifact.value}
>
{value}
</div>
<Tip label={artifact.value}>
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
>
{value}
</div>
</Tip>
<CopyButton
appearance="icon"
buttonSize="icon-xs"

View File

@@ -1,26 +1,43 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
}
/**
* Full-bleed affordance shown while files are dragged over the chat area. Always
* `pointer-events-none` so the drop lands on the real element underneath and the
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
* composer surface so the two read as one family.
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it — the overlay is purely visual.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
*/
export function ChatDropOverlay({ active }: { active: boolean }) {
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const lastKind = useRef<'files' | 'session'>('files')
if (kind) {
lastKind.current = kind
}
const { icon, label } = COPY[kind ?? lastKind.current]
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
active ? 'opacity-100' : 'opacity-0'
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
Drop files to attach
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
{label}
</div>
</div>
)

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
// Shown over the conversation while the live gateway swaps to another profile's
// backend (lazily spawned). Keeps the last profile name through the fade-out so
// the label doesn't blank. Purely visual — pointer-events-none.
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
const [frame, setFrame] = useState(0)
const [label, setLabel] = useState<null | string>(profile)
useEffect(() => {
if (profile) {
setLabel(profile)
}
}, [profile])
useEffect(() => {
if (!profile) {
return
}
const id = window.setInterval(() => setFrame(value => (value + 1) % FRAMES.length), 80)
return () => window.clearInterval(id)
}, [profile])
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
profile ? 'opacity-100' : 'opacity-0'
)}
>
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
Waking up {label}
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
@@ -62,49 +63,49 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
}
return (
<div
className="group/attachment relative min-w-0 shrink-0"
title={attachment.path || attachment.detail || attachment.label}
>
<button
aria-label={canPreview ? `Preview ${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()}
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
)}
</span>
</button>
{onRemove && (
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-label={`Remove ${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)}
aria-label={canPreview ? `Preview ${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()}
type="button"
>
<Codicon name="close" size="0.625rem" />
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
{detail}
</span>
)}
</span>
</button>
)}
</div>
{onRemove && (
<button
aria-label={`Remove ${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"
>
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>
</Tip>
)
}

View File

@@ -2,13 +2,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -104,8 +98,8 @@ export function ContextMenu({
<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.
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -120,12 +114,7 @@ export function ContextMenu({
)
}
function PromptSnippetsDialog({
onInsertText,
onOpenChange,
open,
snippets
}: PromptSnippetsDialogProps) {
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
@@ -160,12 +149,7 @@ function PromptSnippetsDialog({
)
}
export function ContextMenuItem({
children,
disabled,
icon: Icon,
onSelect
}: ContextMenuItemProps) {
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -64,38 +65,40 @@ 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 ? (
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
title="Start voice conversation"
type="button"
>
<AudioLines size={17} />
</Button>
<Tip label="Start voice conversation">
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
type="button"
>
<AudioLines size={17} />
</Button>
</Tip>
) : (
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
type="submit"
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
) : (
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
</Tip>
)}
</div>
)
@@ -126,22 +129,23 @@ function ConversationPill({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
title={muted ? 'Unmute microphone' : 'Mute microphone'}
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
</Tip>
{listening && (
<Button
aria-label="Stop listening and send"
@@ -151,7 +155,6 @@ function ConversationPill({
triggerHaptic('submit')
onStopTurn()
}}
title="Stop listening and send"
type="button"
variant="ghost"
>
@@ -167,7 +170,6 @@ function ConversationPill({
triggerHaptic('close')
onEnd()
}}
title="End voice conversation"
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
@@ -224,34 +226,35 @@ function DictationButton({
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
title={aria}
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Codicon name="mic" size="1rem" />
)}
</Button>
<Tip label={aria}>
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Codicon name="mic" size="1rem" />
)}
</Button>
</Tip>
)
}

View File

@@ -10,6 +10,8 @@
* steal focus from the composer effect.
*/
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@@ -23,8 +25,14 @@ interface InsertDetail {
text: string
}
interface InsertRefsDetail {
refs: InlineRefInput[]
target: ComposerTarget
}
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
let activeTarget: ComposerTarget = 'main'
@@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
/** Insert typed ref chips (carrying a display label) into a composer — the
* structured cousin of {@link requestComposerInsert}, used for session links. */
export const requestComposerInsertRefs = (
refs: InlineRefInput[],
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
if (refs.length) {
dispatch<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
}
}
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/**
* Focus a composer input across React commit + browser focus restore.
*

View File

@@ -16,6 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
rawText: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -91,7 +92,13 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const metadata: SlashItemMetadata = {
command,
display,
meta
meta,
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
// trigger-adapter uniqueness) leaks into the serialized chip text
// and the submitted command.
rawText: command
}
return {

View File

@@ -18,14 +18,11 @@ import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
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 { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@@ -48,6 +45,7 @@ import {
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
@@ -55,7 +53,12 @@ import { useAtCompletions } from './hooks/use-at-completions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
import {
dragHasAttachments,
droppedFileInlineRef,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
@@ -172,7 +175,7 @@ export function ChatBar({
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
@@ -435,7 +438,7 @@ export function ChatBar({
requestMainFocus()
}
const insertInlineRefs = (refs: string[]) => {
const insertInlineRefs = (refs: InlineRefInput[]) => {
const editor = editorRef.current
if (!editor) {
@@ -455,6 +458,19 @@ export function ChatBar({
return true
}
// Latest-closure ref so the (once-only) subscription always calls the current
// insertInlineRefs without re-subscribing every render.
const insertInlineRefsRef = useRef(insertInlineRefs)
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target }) => {
if (target === 'main') {
insertInlineRefsRef.current(refs)
}
})
}, [])
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
@@ -1041,7 +1057,19 @@ export function ChatBar({
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
if (hasComposerPayload) {
// Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
// routes them to executeSlashCommand, which has its own per-command
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button: an explicit interrupt must actually halt the running
@@ -1253,9 +1281,11 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}

View File

@@ -5,6 +5,49 @@ import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
/** MIME for an in-app session drag (sidebar row → composer). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
export interface SessionDragPayload {
id: string
profile: string
title: string
}
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
transfer.effectAllowed = 'copy'
}
export function dragHasSession(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
}
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
const raw = transfer?.getData(HERMES_SESSION_MIME)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
} catch {
return null
}
}
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
if (!transfer) {
return false
@@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null
}
const refsHtml = refs
.map(ref => {
if (typeof ref !== 'string') {
return refChipHtml(ref.kind, ref.value, ref.label)
}
const match = ref.match(/^@([^:]+):(.+)$/)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)

View File

@@ -2,6 +2,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 { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@@ -80,41 +81,44 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)}
>
<Button
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
title="Edit queued turn"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
<Button
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
title="Send queued turn now"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
<Button
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
title="Delete queued turn"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
<Tip label="Edit queued turn">
<Button
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label="Send queued turn now">
<Button
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label="Delete queued turn">
<Button
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
</div>
</div>
)

View File

@@ -15,7 +15,7 @@ import {
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
@@ -52,14 +52,14 @@ export function quoteRefValue(value: string) {
return formatRefValue(value)
}
export function refChipHtml(kind: string, rawValue: string) {
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
}
export function refChipElement(kind: string, rawValue: string) {
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
@@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) {
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = refLabel(id)
label.textContent = displayLabel || refLabel(id)
chip.append(directiveIconElement(kind), label)
return chip

View File

@@ -37,7 +37,10 @@ function Harness({
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {return}
if (!editor) {
return
}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {

View File

@@ -1,50 +1,71 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import {
dragHasAttachments,
dragHasSession,
readSessionDrag,
type SessionDragPayload
} from '@/app/chat/composer/inline-refs'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
export type DragKind = 'files' | 'session' | null
const dragKindOf = (event: ReactDragEvent): DragKind => {
if (dragHasSession(event.dataTransfer)) {
return 'session'
}
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return 'files'
}
return null
}
interface FileDropZoneOptions {
/** When false the zone ignores drags entirely. */
enabled?: boolean
onDropFiles: (files: DroppedFile[]) => void
onDropSession?: (session: SessionDragPayload) => void
}
/**
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
* keeps nested children from flickering the active state; `onDropCapture` clears
* it even when a nested target (the composer) handles the drop and stops
* propagation before our bubble-phase `onDrop` would fire.
* "Drop anywhere in this region" affordance for files *and* in-app session
* links. An enter/leave depth counter keeps nested children from flickering the
* active state; `onDropCapture` clears it even when a nested target (the
* composer) handles the drop and stops propagation before our bubble-phase
* `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
*/
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragActive, setDragActive] = useState(false)
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
const [dragKind, setDragKind] = useState<DragKind>(null)
const depth = useRef(0)
const reset = useCallback(() => {
depth.current = 0
setDragActive(false)
setDragKind(null)
}, [])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
const kind = enabled ? dragKindOf(event) : null
if (!kind) {
return
}
event.preventDefault()
depth.current += 1
setDragActive(true)
setDragKind(kind)
},
[enabled]
)
const onDragOver = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
if (!enabled || !dragKindOf(event)) {
return
}
@@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
const onDrop = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
const kind = enabled ? dragKindOf(event) : null
if (!kind) {
return
}
event.preventDefault()
reset()
if (kind === 'session') {
const session = readSessionDrag(event.dataTransfer)
if (session) {
onDropSession?.(session)
}
return
}
const files = extractDroppedFiles(event.dataTransfer)
if (files.length) {
onDropFiles(files)
}
},
[enabled, onDropFiles, reset]
[enabled, onDropFiles, onDropSession, reset]
)
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
return {
dragKind,
dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset }
}
}

View File

@@ -12,7 +12,6 @@ import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { NotificationStack } from '@/components/notifications'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -23,6 +22,7 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$activeSessionId,
$awaitingResponse,
@@ -46,9 +46,10 @@ import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRef } from './composer/inline-refs'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
@@ -179,6 +180,7 @@ export function ChatView({
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
@@ -307,7 +309,13 @@ export function ChatView({
[currentCwd]
)
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
// Dropping a sidebar session inserts an @session link the agent can resolve
// via session_search (carries the source profile, so cross-profile works).
const onDropSession = useCallback((session: SessionDragPayload) => {
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
}, [])
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
return (
<div
@@ -325,7 +333,6 @@ export function ChatView({
selectedSessionId={selectedSessionId}
/>
<NotificationStack />
<PromptOverlays />
<div
@@ -372,7 +379,8 @@ export function ChatView({
</Suspense>
)}
</AssistantRuntimeProvider>
<ChatDropOverlay active={dragActive} />
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
import { $messages, setMessages, setBusy } from '@/store/session'
import { $messages, setBusy, setMessages } from '@/store/session'
type Sample = {
id: string
@@ -40,13 +40,16 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
},
summary: () => {
const byId = new Map<string, number[]>()
for (const s of samples) {
const k = `${s.id}:${s.phase}`
const arr = byId.get(k) ?? []
arr.push(s.actualDuration)
byId.set(k, arr)
}
const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {}
for (const [k, arr] of byId) {
arr.sort((a, b) => a - b)
const total = arr.reduce((a, b) => a + b, 0)
@@ -55,19 +58,27 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
total: Math.round(total * 100) / 100,
max: Math.round(arr[arr.length - 1] * 100) / 100,
p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100,
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100,
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100
}
}
return out
},
}
}
}
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined
if (!probe || !probe.enabled) return
if (!probe || !probe.enabled) {
return
}
probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime })
if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000)
if (probe.samples.length > 5000) {
probe.samples.splice(0, probe.samples.length - 5000)
}
}
if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
@@ -86,7 +97,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
snapshotMsgs: () => $messages.get().length,
reset: () => {
activeHandle?.stop()
if (baseline) setMessages(baseline)
if (baseline) {
setMessages(baseline)
}
baseline = null
setBusy(false)
},
@@ -104,7 +119,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => {
activeHandle?.stop()
const current = $messages.get()
if (!baseline) baseline = current
if (!baseline) {
baseline = current
}
const msgId = `synthetic-${Date.now()}`
// Seed an empty assistant message — assistant-ui will see it grow.
setMessages([
@@ -126,13 +145,20 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
let flushHandle: number | null = null
const applyDelta = (delta: string) => {
if (!delta) return
if (!delta) {
return
}
setMessages(prev =>
prev.map(m => {
if (m.id !== msgId) return m
if (m.id !== msgId) {
return m
}
const head = m.parts.slice(0, -1)
const last = m.parts.at(-1)
const lastText = last && last.type === 'text' ? last.text : ''
return {
...m,
parts: [...head, { type: 'text', text: lastText + delta }]
@@ -150,8 +176,16 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}
const scheduleFlush = () => {
if (flushHandle !== null) return
if (flushMinMs <= 0) { flushNow(); return }
if (flushHandle !== null) {
return
}
if (flushMinMs <= 0) {
flushNow()
return
}
const since = performance.now() - lastFlushAt
const wait = Math.max(0, flushMinMs - since)
flushHandle =
@@ -162,48 +196,62 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
const handle: SyntheticDriverHandle = {
stop: () => {
if (timer) clearTimeout(timer)
if (timer) {
clearTimeout(timer)
}
timer = null
if (flushHandle !== null) {
clearTimeout(flushHandle)
cancelAnimationFrame?.(flushHandle)
}
flushHandle = null
if (pendingDelta) {
applyDelta(pendingDelta)
pendingDelta = ''
}
activeHandle = null
// Mark message finalized.
setMessages(prev =>
prev.map(m =>
m.id === msgId
? { ...m, pending: false }
: m
)
)
setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, pending: false } : m)))
setBusy(false)
}
}
activeHandle = handle
const tick = () => {
if (activeHandle !== handle) return
if (pushed >= totalTokens) {
if (pendingDelta) flushNow()
handle.stop()
if (activeHandle !== handle) {
return
}
if (pushed >= totalTokens) {
if (pendingDelta) {
flushNow()
}
handle.stop()
return
}
pushed += 1
if (flushMinMs > 0) {
pendingDelta += chunk
scheduleFlush()
} else {
applyDelta(chunk)
}
timer = setTimeout(tick, intervalMs)
}
timer = setTimeout(tick, intervalMs)
return handle
}
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { CopyButton } from '@/components/ui/copy-button'
import { Tip } from '@/components/ui/tooltip'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify } from '@/store/notifications'
@@ -80,17 +81,18 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
<button
className={cn(
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
</Tip>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
@@ -112,14 +114,15 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
showLabel={false}
text={copyText}
/>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
<Tip label="Send this entry to chat">
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
type="button"
>
<Send className="size-3" />
</button>
</Tip>
</span>
</div>
)
@@ -225,11 +228,6 @@ export function PreviewConsolePanel({
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
@@ -250,7 +248,6 @@ export function PreviewConsolePanel({
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={consoleState.clear}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />

View File

@@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -607,15 +608,16 @@ export function PreviewPane({
{!embedded && (
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
<Tip label={`Open ${currentUrl}`}>
<a
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
>
{previewLabel || 'Preview'}
</a>
</Tip>
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
$rightRailActiveTabId,
@@ -101,27 +102,33 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) return
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) event.preventDefault()
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
@@ -130,7 +137,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
aria-label={`Close ${tab.label}`}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<Codicon name="close" size="0.75rem" />
@@ -143,7 +149,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
aria-label="Close preview pane"
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
onClick={closeRightRail}
title="Close preview pane"
type="button"
>
<Codicon name="close" size="0.75rem" />

View File

@@ -17,7 +17,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -34,7 +34,10 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
@@ -51,8 +54,17 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
import {
$newChatProfile,
$profiles,
$profileScope,
ALL_PROFILES,
newSessionInProfile,
normalizeProfileKey
} from '@/store/profile'
import {
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
$sessionsTotal,
@@ -64,6 +76,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
@@ -93,6 +106,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
]
const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
@@ -200,6 +216,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -210,6 +227,7 @@ export function ChatSidebar({
currentView,
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -225,12 +243,23 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
const workingSessionIds = useStore($workingSessionIds)
const profiles = useStore($profiles)
const profileScope = useStore($profileScope)
// Only surface the profile switcher when more than one profile exists, so
// single-profile users see the unchanged sidebar.
const multiProfile = profiles.length > 1
// Gate ALL-profiles grouping on multiProfile too: if a user drops back to one
// profile while scope is still ALL (persisted), the rail is hidden and they'd
// otherwise be stuck in the grouped view with no way out.
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const trimmedQuery = searchQuery.trim()
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
@@ -259,7 +288,19 @@ export function ChatSidebar({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
// Profile scope = the "workspace switcher" context. Concrete scope shows only
// that profile's sessions (clean rows, no per-row tags); ALL fans every
// profile in, grouped by profile below. Single-profile users land here with
// scope === their only profile, so nothing is filtered out.
const visibleSessions = useMemo(
() => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)),
[sessions, showAllProfiles, profileScope]
)
const sortedSessions = useMemo(
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[visibleSessions]
)
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
@@ -268,7 +309,7 @@ export function ChatSidebar({
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
for (const s of sessions) {
for (const s of visibleSessions) {
map.set(s.id, s)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
@@ -277,7 +318,7 @@ export function ChatSidebar({
}
return map
}, [sessions])
}, [visibleSessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@@ -330,11 +371,10 @@ export function ChatSidebar({
return []
}
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
if (sessionMatchesSearch(s, trimmedQuery)) {
out.set(s.id, s)
}
}
@@ -366,11 +406,87 @@ export function ChatSidebar({
[agentSessions, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
(profile: string) => {
if (!onLoadMoreProfileSessions) {
return
}
setProfileLoadMorePending(prev => ({ ...prev, [profile]: true }))
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
},
[onLoadMoreProfileSessions]
)
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
if (!showAllProfiles) {
return undefined
}
const groups = new Map<string, SidebarSessionGroup>()
for (const session of agentSessions) {
const key = normalizeProfileKey(session.profile)
const group = groups.get(key) ?? {
color: profileColor(key),
id: key,
label: key,
mode: 'profile',
path: null,
sessions: []
}
group.sessions.push(session)
groups.set(key, group)
}
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
}, [
showAllProfiles,
agentSessions,
loadMoreForProfileGroup,
onLoadMoreProfileSessions,
profileLoadMorePending,
sessionProfileTotals
])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
const hasMoreSessions = knownSessionTotal > sortedSessions.length
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
@@ -449,6 +565,8 @@ export function ChatSidebar({
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
const isNewSession = item.id === 'new-session'
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
@@ -460,7 +578,17 @@ export function ChatSidebar({
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => onNavigate(item)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={item.label}
type="button"
>
@@ -468,7 +596,7 @@ export function ChatSidebar({
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
@@ -544,11 +672,19 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px'
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
@@ -557,37 +693,43 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
headerAction={
// Grouping operates on unpinned recents; if everything is
// pinned the toggle does nothing visible, so hide it to avoid
// a phantom click target.
agentSessions.length > 0 ? (
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
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'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
) : null
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// 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'}>
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
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'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorder={handleAgentDragEnd}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
@@ -595,10 +737,18 @@ export function ChatSidebar({
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sortable={agentSessions.length > 1}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
</SidebarContent>
</Sidebar>
)
@@ -667,6 +817,12 @@ interface SidebarSessionGroup {
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'workspace'
onLoadMore?: () => void
totalCount?: number
}
interface SidebarSessionsSectionProps {
@@ -850,38 +1006,65 @@ function SidebarWorkspaceGroup({
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const isProfileGroup = group.mode === 'profile'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
const [visibleCount, setVisibleCount] = useState(pageStep)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
const nextCount = Math.min(pageStep, hiddenCount)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + pageStep
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
group.onLoadMore?.()
}
}
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
{group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<SidebarCount>
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{onNewSession && (
<button
aria-label={`New session in ${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"
onClick={() => onNewSession(group.path)}
title={`New session in ${group.label}`}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
{(onNewSession || isProfileGroup) && (
<Tip label={`New session in ${group.label}`}>
<button
aria-label={`New session in ${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
// alone); workspace groups seed the new session's cwd from the path.
onClick={() => (isProfileGroup ? newSessionInProfile(group.id) : onNewSession?.(group.path))}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
)}
{reorderable && (
<span
@@ -904,17 +1087,21 @@ function SidebarWorkspaceGroup({
{open && (
<>
{renderRows(visibleSessions)}
{hiddenCount > 0 && (
<button
aria-label={`Show ${nextCount} more in ${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)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={`Show ${nextCount} more in ${group.label}`}>
<button
aria-label={`Show ${nextCount} more in ${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"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
</Tip>
))}
</>
)}
</div>
@@ -961,12 +1148,16 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
return (
<button
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
{/* Seat the icon in the same w-3.5 column session rows use for their dot
so the chevron + label line up with the rows above. */}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)

View File

@@ -0,0 +1,491 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
KeyboardSensor,
type Modifier,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import {
$activeGatewayProfile,
$profileColors,
$profileOrder,
$profiles,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
refreshActiveProfile,
selectProfile,
setProfileColor,
setProfileOrder,
setShowAllProfiles,
sortByProfileOrder
} from '@/store/profile'
import type { ProfileInfo } from '@/types/hermes'
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
// instead of gliding, and clamp to the occupied strip so it can't float past the
// last profile onto the "+".
const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerNodeRect) {
return { ...transform, y: 0 }
}
const pitch = draggingNodeRect.width + RAIL_GAP
const minX = containerNodeRect.left - draggingNodeRect.left
const maxX = containerNodeRect.right - draggingNodeRect.right
const snapped = Math.round(transform.x / pitch) * pitch
return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 }
}
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
export function ProfileRail() {
const profiles = useStore($profiles)
const scope = useStore($profileScope)
const gatewayProfile = useStore($activeGatewayProfile)
const order = useStore($profileOrder)
const colors = useStore($profileColors)
const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
// rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes
// through. Native + non-passive so we can preventDefault and not bleed the
// gesture into the sessions list above.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const onWheel = (event: WheelEvent) => {
if (el.scrollWidth <= el.clientWidth || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
return
}
el.scrollLeft += event.deltaY
event.preventDefault()
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const isAll = scope === ALL_PROFILES
const activeKey = normalizeProfileKey(gatewayProfile)
const defaultProfile = profiles.find(profile => profile.is_default)
const onDefault = !isAll && activeKey === 'default'
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
const multiProfile = profiles.length > 1
// distance constraint: a small drag reorders, a tap still selects the profile.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
// Tick a haptic each time the drag crosses into a new cell, and a satisfying
// confirm on a committed reorder.
const lastOverRef = useRef<string | null>(null)
const handleDragStart = ({ active }: DragStartEvent) => {
lastOverRef.current = String(active.id)
}
const handleDragOver = ({ over }: DragOverEvent) => {
const id = over ? String(over.id) : null
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
triggerHaptic('selection')
}
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
lastOverRef.current = null
if (!over || active.id === over.id) {
return
}
const ids = named.map(profile => profile.name)
const from = ids.indexOf(String(active.id))
const to = ids.indexOf(String(over.id))
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
triggerHaptic('success')
}
}
// Re-pull the running profile + list on mount so a profile created elsewhere
// shows up; cheap and best-effort.
useEffect(() => {
void refreshActiveProfile()
}, [])
return (
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
{/* One button toggles default ↔ all: home face when scoped to a profile,
layers face when showing everything. Pinned left like Manage is right.
Hidden until a second profile exists. */}
{multiProfile &&
(defaultProfile ? (
// On default → toggle to all. Anywhere else (all view or a named
// profile) → return to default. So leaving a profile never lands on all.
<ProfilePill
active={isAll || onDefault}
glyph={isAll ? 'layers' : 'home'}
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
/>
) : (
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
))}
{/* Single-profile: the active default's home icon next to the create +. */}
{!multiProfile && defaultProfile && (
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
)}
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref={scrollRef}
>
{multiProfile && (
<DndContext
collisionDetection={closestCenter}
modifiers={[stepThroughCells]}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
{/* relative → the strip is the dragged square's offsetParent, so the
clamp modifier bounds drags to the occupied cells (not the +). */}
<div className="relative flex items-center gap-1">
{named.map(profile => (
<ProfileSquare
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<Tip label="New profile">
<button
aria-label="New profile"
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
onClick={() => setCreateOpen(true)}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
</div>
{multiProfile && (
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
)}
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreated={async name => {
await refreshActiveProfile()
selectProfile(name)
}}
open={createOpen}
/>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={refreshActiveProfile}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={refreshActiveProfile}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</div>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).
glyph: string
label: string
onSelect: () => void
}
function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) {
return (
<Tip label={label}>
<Button
aria-label={label}
aria-pressed={active}
className={cn(
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
active && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={onSelect}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name={glyph} size="0.875rem" />
</Button>
</Tip>
)
}
interface ProfileSquareProps {
active: boolean
color: null | string
label: string
onSelect: () => void
onRecolor: (color: null | string) => void
onRename: () => void
onDelete: () => void
}
// Hold this long without moving (a drag would have started first) to open the
// color picker — the "hard press" gesture, distinct from tap-to-select.
const LONG_PRESS_MS = 450
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
// fill + the initial in the full color; the active one pops to full opacity with
// a color ring. These pack tightly so the rail reads as a strip of profiles,
// drag-sort to reorder (a tap below the drag threshold still selects), and
// right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
const hue = color ?? 'var(--ui-text-quaternary)'
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label,
transition: RAIL_TRANSITION
})
const clearPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
// A real drag (movement past the dnd threshold) cancels the pending hold, so a
// reorder never doubles as a color pick. Also tidy up on unmount.
useEffect(() => {
if (isDragging) {
clearPress()
}
}, [isDragging])
useEffect(() => clearPress, [])
const base = CSS.Transform.toString(transform)
const ring = active ? `inset 0 0 0 1.5px ${hue}` : ''
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
const pickColor = (next: null | string) => {
onRecolor(next)
setPickerOpen(false)
triggerHaptic('selection')
}
return (
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<ContextMenu>
<TooltipProvider delayDuration={0}>
<Tooltip>
<PopoverAnchor asChild>
<ContextMenuTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little
// overshoot (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
// Hold-to-recolor rides alongside the dnd pointer listener (call
// it first so drag tracking still arms), then a timer opens the
// picker and flags the trailing click so it doesn't also select.
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false
return
}
onSelect()
}}
onPointerCancel={clearPress}
onPointerDown={event => {
listeners?.onPointerDown?.(event)
if (event.button !== 0) {
return
}
suppressClick.current = false
clearPress()
pressTimer.current = window.setTimeout(() => {
suppressClick.current = true
triggerHaptic('success')
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerLeave={clearPress}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</TooltipTrigger>
</ContextMenuTrigger>
</PopoverAnchor>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
statusbar) — Radix then flips the menu up instead of squishing it. */}
<ContextMenuContent
aria-label={`Actions for ${label}`}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
>
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>Color</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<PopoverContent
aria-label={`Color for ${label}`}
className="w-auto p-2"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={`Set color ${swatch}`}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
Auto
</button>
</PopoverContent>
</Popover>
)
}

View File

@@ -25,6 +25,7 @@ interface SessionActions {
sessionId: string
title: string
pinned?: boolean
profile?: string
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
@@ -41,7 +42,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
@@ -113,7 +114,13 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
))
const renameDialog = (
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
<RenameSessionDialog
currentTitle={title}
onOpenChange={setRenameOpen}
open={renameOpen}
profile={profile}
sessionId={sessionId}
/>
)
return { renameDialog, renderItems }
@@ -170,9 +177,10 @@ interface RenameSessionDialogProps {
onOpenChange: (open: boolean) => void
sessionId: string
currentTitle: string
profile?: string
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -200,7 +208,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
setSubmitting(true)
try {
const result = await renameSession(sessionId, next)
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' })

View File

@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
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'
@@ -74,6 +75,7 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
@@ -86,6 +88,22 @@ export function SidebarSessionRow({
className
)}
data-working={isWorking ? 'true' : undefined}
draggable
onDragStart={event => {
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
// native drag so the two DnD systems don't fight.
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
event.preventDefault()
return
}
writeSessionDrag(event.dataTransfer, {
id: session.id,
profile: session.profile || 'default',
title
})
}}
ref={ref}
style={style}
{...rest}
@@ -123,12 +141,15 @@ export function SidebarSessionRow({
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row.
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// anywhere on the row. Width MUST match the non-reorderable dot
// column (w-3.5) so rows don't shift horizontally when reorder is
// toggled (e.g. scoped → ALL-profiles view).
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
@@ -146,11 +167,16 @@ export function SidebarSessionRow({
/>
</span>
) : (
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<span
className={cn(
'grid w-3.5 shrink-0 place-items-center',
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
@@ -165,6 +191,7 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>

View File

@@ -6,14 +6,8 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
getActionStatus,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
updateHermes
} from '@/hermes'
import { Tip } from '@/components/ui/tooltip'
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, type IconComponent, Pin } from '@/lib/icons'
@@ -100,17 +94,18 @@ function RowIconButton({
title: string
}) {
return (
<Button
aria-label={title}
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
onClick={onClick}
size="icon-xs"
title={title}
type="button"
variant="ghost"
>
{children}
</Button>
<Tip label={title}>
<Button
aria-label={title}
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
onClick={onClick}
size="icon-xs"
type="button"
variant="ghost"
>
{children}
</Button>
</Tip>
)
}
@@ -128,12 +123,7 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
)
}
export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onOpenSession
}: CommandCenterViewProps) {
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
@@ -168,7 +158,7 @@ export function CommandCenterView({
}
return sorted.filter(session => {
const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase()
const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase()
return haystack.includes(needle)
})
@@ -586,20 +576,21 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96)
return (
<div
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
<Tip
key={entry.day}
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
label={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
>
<div
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/60"
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
/>
</div>
<div className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end">
<div
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/60"
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
/>
</div>
</Tip>
)
})}
</div>

View File

@@ -4,14 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -34,9 +27,11 @@ import {
Palette,
Plus,
Settings,
Settings2,
Sun,
Users,
Wrench
Wrench,
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
@@ -98,8 +93,31 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
})
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
{
icon: Zap,
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
label: 'Providers',
tab: 'providers&pview=accounts'
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
label: 'Provider API keys',
tab: 'providers&pview=keys'
},
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
{
icon: KeyRound,
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
label: 'Tools & Keys',
tab: 'keys&kview=tools'
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
label: 'Tools & Keys settings',
tab: 'keys&kview=settings'
},
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
@@ -124,7 +142,11 @@ export function CommandPalette() {
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
queryFn: getHermesConfigRecord,
enabled: open
})
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
@@ -141,7 +163,9 @@ export function CommandPalette() {
const mcpServers = useMemo(() => {
const raw = configQuery.data?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
return raw && typeof raw === 'object' && !Array.isArray(raw)
? Object.keys(raw as Record<string, unknown>).sort()
: []
}, [configQuery.data])
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
@@ -169,7 +193,7 @@ export function CommandPalette() {
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets', 'providers'],
keywords: ['tools', 'toolsets'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
@@ -207,25 +231,9 @@ export function CommandPalette() {
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
},
{
// Declared before Settings: cmdk keeps group order, so this keeps the
// theme/mode pickers on top for "theme"/"color" queries instead of
// buried under a fuzzy Settings match.
heading: 'Appearance',
items: [
{
@@ -243,6 +251,25 @@ export function CommandPalette() {
to: 'color-mode'
}
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
}
]
}, [go])

View File

@@ -0,0 +1,108 @@
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 { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
busy?: boolean
isPaused: boolean
title: string
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}
interface CronJobActionsMenuProps
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${title}`}
className={className}
size="icon-sm"
title="Cron job actions"
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@@ -4,6 +4,7 @@ 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,
@@ -27,12 +28,13 @@ import {
updateCronJob
} from '@/hermes'
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 { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
@@ -310,7 +312,6 @@ export function CronView({ onClose }: CronViewProps) {
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
try {
@@ -371,25 +372,6 @@ export function CronView({ onClose }: CronViewProps) {
}
}
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: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
notifyError(err, 'Failed to delete cron job')
} finally {
setDeleting(false)
}
}
async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
@@ -432,20 +414,20 @@ export function CronView({ onClose }: CronViewProps) {
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// 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.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
// 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.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
@@ -479,30 +461,33 @@ export function CronView({ onClose }: CronViewProps) {
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete cron job?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
permanently. It will stop firing immediately.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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?"
/>
</OverlayView>
)
}
@@ -563,47 +548,27 @@ function CronJobRow({
)}
</button>
<div className="flex shrink-0 items-center gap-0.5">
<IconAction
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
disabled={busy}
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
<div className="flex shrink-0 items-center">
<CronJobActionsMenu
busy={busy}
isPaused={isPaused}
onDelete={onDelete}
onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Codicon name="zap" size="0.875rem" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Codicon name="edit" size="0.875rem" />
</IconAction>
<IconAction
aria-label="Delete cron"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
>
<Codicon name="trash" size="0.875rem" />
</IconAction>
<CronJobActionsTrigger
className="text-muted-foreground hover:text-foreground"
onClick={event => event.stopPropagation()}
title={jobTitle(job)}
/>
</CronJobActionsMenu>
</div>
</div>
)
}
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Button
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
variant="ghost"
{...props}
>
{children}
</Button>
)
}
function EmptyState({
actionLabel,
description,

View File

@@ -11,7 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { toggleCommandPalette } from '../store/command-palette'
import {
@@ -25,9 +25,11 @@ import {
pinSession,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -45,6 +47,7 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal
@@ -98,6 +101,26 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const active = $selectedStoredSessionId.get()
if (active) {
const session = scope ? $sessions.get().find(s => s.id === active) : null
if (!scope || !session || normalizeProfileKey(session.profile) === scope) {
keep.add(active)
}
}
return keep
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
@@ -201,9 +224,9 @@ export function DesktopController() {
}
}, [])
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → command
// palette (the composer's "drain next queued" moved to Cmd+Shift+K), Cmd+. →
// command center (sessions / system / usage).
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
// Cmd+. → command center (sessions / system / usage).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
@@ -212,7 +235,7 @@ export function DesktopController() {
const key = event.key.toLowerCase()
if (key === 'k') {
if (key === 'k' || key === 'p') {
event.preventDefault()
toggleCommandPalette()
} else if (key === '.') {
@@ -236,17 +259,15 @@ export function DesktopController() {
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// clutter the sidebar.
const result = await listSessions(limit, 1)
// Unified cross-profile list (served read-only off each profile's
// state.db; no per-profile backend is spawned). Single-profile users get
// the same rows tagged profile="default".
const result = await listAllProfileSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
// Don't hard-replace. Two kinds of rows must survive a refresh the
// server didn't return: (1) sessions whose first turn is still in
// flight (message_count 0, so min_messages=1 omits them) and (2)
// pinned sessions that have aged off the most-recent page — otherwise
// the pin "disappears until you refresh". mergeSessionPage keeps both.
const keepIds = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
setSessions(prev => mergeSessionPage(prev, result.sessions, keepIds))
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionProfileTotals(result.profile_totals ?? {})
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
@@ -260,6 +281,21 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
const keep = sessionsToKeep(key)
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
}, [])
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@@ -316,7 +352,7 @@ export function DesktopController() {
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=keys`)
navigate(`${SETTINGS_ROUTE}?tab=providers`)
}, [navigate])
const modelMenuContent = useMemo(
@@ -349,9 +385,11 @@ export function DesktopController() {
return
}
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId)
const latest = await getSessionMessages(storedSessionId, storedProfile)
updateSessionState(
runtimeSessionId,
state => ({
@@ -454,6 +492,20 @@ export function DesktopController() {
return () => window.removeEventListener('keydown', onKeyDown)
}, [startFreshSessionDraft])
// A profile switch/create drops to a fresh new-session draft so the previously
// open session doesn't bleed across contexts. Skip the initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}
lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -506,6 +558,7 @@ export function DesktopController() {
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
@@ -528,6 +581,7 @@ export function DesktopController() {
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
@@ -570,6 +624,7 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
applyDesktopBootProgress,
@@ -9,9 +10,27 @@ import {
failDesktopBoot,
setDesktopBootStep
} from '@/store/boot'
import { setGateway } from '@/store/gateway'
import {
$gateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForProfile,
pruneSecondaryGateways,
reconnectSecondaryGateways,
reportPrimaryGatewayState,
setPrimaryGateway,
touchSecondaryGateways
} from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$attentionSessionIds,
$connection,
$sessions,
$workingSessionIds,
setConnection,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
@@ -75,6 +94,10 @@ export function useGatewayBoot({
let reconnecting = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
// Surface "sign in again" once per disconnect episode, not on every backoff
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
// identical error toasts (and their haptics). Reset on the next clean open.
let reauthNotified = false
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
// `connectionState` to a constant across the early-return guards (the state
@@ -96,14 +119,22 @@ export function useGatewayBoot({
reconnecting = true
try {
const conn = await desktop.getConnection()
const conn = await desktop.getConnection($activeGatewayProfile.get())
if (cancelled) {
return
}
publish(conn)
await gateway.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
// dead on every reconnect after the initial boot — reusing it surfaces
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
// than connecting with a stale one). For local/token gateways the URL
// carries a long-lived token and the re-mint is a cheap no-op.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
@@ -113,8 +144,15 @@ export function useGatewayBoot({
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
} catch {
// Fall through to scheduleReconnect's backoff below.
} catch (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
// ticket that can never succeed. Transport failures fall through to the
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, 'Gateway sign-in required')
}
} finally {
reconnecting = false
@@ -145,6 +183,7 @@ export function useGatewayBoot({
clearReconnectTimer()
reconnectAttempt = 0
reconnectSecondaryGateways()
if (!gatewayOpen()) {
void attemptReconnect()
@@ -165,13 +204,18 @@ export function useGatewayBoot({
const gateway = new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setGateway(gateway)
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
// Secondary (background-profile) sockets funnel into the same handler.
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
const offState = gateway.onState(st => {
setGatewayState(st)
// Mirror to the composer only while the primary is the active profile —
// a background secondary reconnect mustn't flip the foreground state.
reportPrimaryGatewayState(st)
if (st === 'open') {
reconnectAttempt = 0
reauthNotified = false
clearReconnectTimer()
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
@@ -179,6 +223,7 @@ export function useGatewayBoot({
scheduleReconnect()
}
})
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
// Wake signals: power resume (macOS/Windows), network coming back, and the
@@ -186,6 +231,7 @@ export function useGatewayBoot({
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
const onOnline = () => reconnectNow()
const onVisible = () => {
if (document.visibilityState === 'visible') {
reconnectNow()
@@ -195,6 +241,34 @@ export function useGatewayBoot({
window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisible)
// Keep live pool backends alive while this window is open (the main process
// can't observe the direct renderer↔backend WS). No-op for the primary.
const keepaliveTimer = setInterval(() => {
touchActiveGatewayBackend()
touchSecondaryGateways()
}, 60_000)
// Bound concurrency cost to live work: keep a background socket only while
// its profile has a running (working) or blocked (needs-input) session.
// Once that profile goes idle its socket is dropped and its backend is free
// to idle-reap. The active profile is always spared.
const recomputeKeptGateways = () => {
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
const keep = new Set<string>()
for (const session of $sessions.get()) {
if (live.has(session.id)) {
keep.add(normalizeProfileKey(session.profile))
}
}
pruneSecondaryGateways(keep)
}
const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways())
const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways())
const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways())
const offWindowState = desktop.onWindowStateChanged?.(payload => {
const current = $connection.get()
@@ -230,12 +304,31 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
await gateway.connect(conn.wsUrl)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
// failure, throws a reauth error rather than connecting with a dead
// ticket (which would surface as an opaque "connection closed").
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
if (cancelled) {
return
}
// Record which profile the primary (window) backend booted as, so
// same-profile resumes are no-op swaps and any reconnect targets the
// right backend. Best-effort: a missing preference means "default".
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
setDesktopBootStep({
phase: 'renderer.config',
message: 'Loading Hermes settings',
@@ -270,6 +363,10 @@ export function useGatewayBoot({
return () => {
cancelled = true
clearReconnectTimer()
clearInterval(keepaliveTimer)
offWorking()
offAttention()
offActiveProfile()
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
@@ -278,10 +375,12 @@ export function useGatewayBoot({
offExit()
offWindowState?.()
offBootProgress()
closeSecondaryGateways()
gateway.close()
publish(null)
callbacksRef.current.onGatewayReady(null)
setGateway(null)
setPrimaryGateway(null)
$gateway.set(null)
}
}, [])
}

View File

@@ -2,6 +2,9 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@@ -14,11 +17,25 @@ export function useGatewayRequest() {
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
// Holds the reauth error from the most recent failed reconnect so
// requestGateway can surface the gateway's "session expired, sign in again"
// message instead of the opaque "connection closed" that triggered the retry.
const reauthErrorRef = useRef<unknown>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
}, [gatewayState])
// Track the active gateway (primary or a background profile's socket) so
// outbound requests and overlay props always target the focused profile.
useEffect(
() =>
$gateway.subscribe(gateway => {
gatewayRef.current = gateway as HermesGateway | null
}),
[]
)
const ensureGatewayOpen = useCallback(async () => {
const existing = gatewayRef.current
@@ -41,14 +58,29 @@ export function useGatewayRequest() {
return null
}
reauthErrorRef.current = null
try {
const conn = await desktop.getConnection()
// Reconnect to whichever profile the gateway is currently routed to (not
// always the primary), so a sleep/wake reconnect keeps the user on the
// profile they were chatting in.
const conn = await desktop.getConnection($activeGatewayProfile.get())
connectionRef.current = conn
setConnection(conn)
await existing.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is dead here;
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
// connecting with a stale ticket. Stash it so requestGateway can show
// the actionable "sign in again" message.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await existing.connect(wsUrl)
return existing
} catch {
} catch (error) {
if (isGatewayReauthRequired(error)) {
reauthErrorRef.current = error
}
connectionRef.current = null
setConnection(null)
@@ -78,9 +110,21 @@ export function useGatewayRequest() {
throw error
}
const recovered = await ensureGatewayOpen()
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
// single-use ticket); background profiles are always local pool
// backends, so the registry handles their reconnect with no reauth.
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session
// expired) over the generic transport error that triggered the retry.
const reauthError = reauthErrorRef.current
reauthErrorRef.current = null
if (reauthError) {
throw reauthError
}
throw error
}

View File

@@ -21,6 +21,8 @@ import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'
@@ -108,6 +110,47 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
help: 'first, all, or off.',
advanced: true
},
DISCORD_ALLOW_ALL_USERS: {
label: 'Allow all Discord users',
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
advanced: true
},
DISCORD_HOME_CHANNEL: {
label: 'Home channel ID',
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
advanced: true
},
DISCORD_HOME_CHANNEL_NAME: {
label: 'Home channel name',
help: 'Display name for the home channel in logs and status output.',
advanced: true
},
BLUEBUBBLES_ALLOW_ALL_USERS: {
label: 'Allow all iMessage users',
help: 'When true, skip the BlueBubbles allowlist.',
advanced: true
},
MATTERMOST_ALLOW_ALL_USERS: {
label: 'Allow all Mattermost users',
advanced: true
},
MATTERMOST_HOME_CHANNEL: {
label: 'Home channel',
advanced: true
},
QQ_ALLOW_ALL_USERS: {
label: 'Allow all QQ users',
advanced: true
},
QQBOT_HOME_CHANNEL: {
label: 'QQ home channel',
help: 'Default channel or group for cron delivery.',
advanced: true
},
QQBOT_HOME_CHANNEL_NAME: {
label: 'QQ home channel name',
advanced: true
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
@@ -497,7 +540,7 @@ function PlatformDetail({
<section>
<SectionTitle>Required</SectionTitle>
<div className="mt-3 space-y-4">
<div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
@@ -520,7 +563,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<div className="mt-3 space-y-4">
<div className="mt-3 grid gap-1">
{optionalFields.map(field => (
<MessagingField
edits={edits}
@@ -546,7 +589,7 @@ function PlatformDetail({
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 space-y-4">
<div className="mt-3 grid gap-1">
{advancedFields.map(field => (
<MessagingField
edits={edits}
@@ -640,45 +683,48 @@ function MessagingField({
saving: string | null
}) {
const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return (
<div className="space-y-1.5">
<div className="flex flex-wrap items-baseline gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
{copy.label}
</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</div>
<div className="flex items-center gap-2">
<Input
className="font-mono"
id={`messaging-field-${field.key}`}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
size="icon-sm"
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
</div>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
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}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
}
description={copy.help}
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>}
</span>
}
/>
)
}

View File

@@ -1,5 +1,3 @@
import type { ComponentType, SVGProps } from 'react'
import {
SiApple,
SiBilibili,
@@ -14,6 +12,7 @@ import {
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -69,10 +68,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
if (!spec) {
return (
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
>
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
{platformName.charAt(0).toUpperCase()}
</span>
)

View File

@@ -24,6 +24,9 @@ interface OverlayNavItemProps {
active: boolean
icon: IconComponent
label: string
// Renders as an indented child of another nav item: smaller icon and a
// lighter active state so it never competes with the boxed parent item.
nested?: boolean
onClick: () => void
trailing?: ReactNode
}
@@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
nested
? active
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
: active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<Icon
className={cn(
'shrink-0',
nested ? 'size-3.5' : 'size-4',
active ? 'text-foreground/80' : 'text-muted-foreground/80'
)}
/>
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>

View File

@@ -51,9 +51,7 @@ export function PageSearchShell({
<div className="shrink-0">
{(tabs || !searchHidden) && (
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
{tabs ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
) : null}
{tabs ? <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div> : null}
{!searchHidden && (
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
<SearchField
@@ -66,9 +64,7 @@ export function PageSearchShell({
)}
</div>
)}
{filters ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
) : null}
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View File

@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
export const PROFILE_NAME_HINT =
'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
export function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
// Self-contained create flow (name + clone toggle + optional SOUL.md). Owns the
// createProfile/updateProfileSoul calls so every caller just refreshes/selects
// via onCreated. SOUL left blank keeps the cloned/blank persona untouched.
export function CreateProfileDialog({
onClose,
onCreated,
open
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setSoul('')
setError(null)
setStatus('idle')
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
}
await onCreated?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to create profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">Clone from default</span>
<span className="text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground"> optional</span>
</label>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={`The system prompt / persona for this profile.\nLeave blank to keep the ${cloneFromDefault ? 'cloned' : 'empty'} default.`}
value={soul}
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || !trimmed || invalid} type="submit">
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,58 @@
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { deleteProfile } from '@/hermes'
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
// Enter-to-confirm + busy/done/error from the shared dialog. The single choke
// point for every delete entry point (rail + Profiles view).
export function DeleteProfileDialog({
profile,
onClose,
onDeleted,
open
}: {
profile: { name: string; path: string } | null
onClose: () => void
onDeleted?: () => Promise<void> | void
open: boolean
}) {
return (
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
profile ? (
<>
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={onClose}
onConfirm={async () => {
if (!profile) {
return
}
// Deleting the profile the live gateway is on strands it on a dead
// backend. Capture that before the delete; reset *after* the host's
// onDeleted refresh so our reset is the last write — a refreshActiveProfile
// racing the (still-dying) backend can't clobber the pill back to it.
const wasActive = normalizeProfileKey(profile.name) === normalizeProfileKey($activeGatewayProfile.get())
await deleteProfile(profile.name)
await onDeleted?.()
if (wasActive) {
// Swap gateway/sidebar to default and set the pill now — the primary
// backend is always default, so this is correct, not just optimistic.
selectProfile('default')
setActiveProfile('default')
}
}}
open={open}
title="Delete profile?"
/>
)
}

View File

@@ -1,45 +1,57 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { ActionStatus } from '@/components/ui/action-status'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
deleteProfile,
getProfiles,
getProfileSetupCommand,
getProfileSoul,
type ProfileInfo,
renameProfile,
updateProfileSoul
} from '@/hermes'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { Tip } from '@/components/ui/tooltip'
import { createProfile, getProfiles, getProfileSoul, type ProfileInfo, updateProfileSoul } from '@/hermes'
import { AlertTriangle, Save, Users } from '@/lib/icons'
import { profileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeProfile, switchProfile } from '@/store/profile'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
import { CreateProfileDialog } from './create-profile-dialog'
import { DeleteProfileDialog } from './delete-profile-dialog'
import { RenameProfileDialog } from './rename-profile-dialog'
const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
// Pick a free "<source>-copy" name for a duplicated profile, appending a numeric
// suffix when the base is taken. Source is truncated to leave room for the
// suffix and to stay within the 64-char profile-name limit.
function uniqueCloneName(source: string, existing: Set<string>): string {
const base = `${source}-copy`.slice(0, 58)
function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
if (!existing.has(base)) {
return base
}
for (let i = 2; i < 1000; i++) {
const candidate = `${base}-${i}`
if (!existing.has(candidate)) {
return candidate
}
}
return `${base}-${Date.now()}`
}
// Three-state affordance shared by every save/create/rename/delete button:
// spinner while pending, a check on success, then back to the idle icon+label.
interface ProfilesViewProps {
onClose: () => void
}
@@ -48,13 +60,15 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const [loadError, setLoadError] = useState<null | string>(null)
const refresh = useCallback(async () => {
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
setLoadError(null)
setSelectedName(current => {
if (current && list.some(p => p.name === current)) {
return current
@@ -63,7 +77,8 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null
})
} catch (err) {
notifyError(err, 'Failed to load profiles')
setLoadError(err instanceof Error ? err.message : 'Failed to load profiles')
setProfiles(prev => prev ?? [])
}
}, [])
@@ -81,61 +96,31 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
const trimmed = name.trim()
const handleClone = useCallback(
async (source: ProfileInfo) => {
const existing = new Set((profiles ?? []).map(p => p.name))
const target = uniqueCloneName(source.name, existing)
if (!isValidProfileName(trimmed)) {
throw new Error(PROFILE_NAME_HINT)
try {
await createProfile({ name: target, clone_from: source.name })
setSelectedName(target)
await refresh()
} catch (err) {
setLoadError(err instanceof Error ? err.message : `Failed to duplicate ${source.name}`)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
notify({ kind: 'success', title: 'Profile created', message: trimmed })
setSelectedName(trimmed)
await refresh()
},
[refresh]
[profiles, refresh]
)
const handleRename = useCallback(
async (from: string, to: string): Promise<void> => {
const target = to.trim()
if (target === from) {
return
}
if (!isValidProfileName(target)) {
throw new Error(PROFILE_NAME_HINT)
}
await renameProfile(from, target)
notify({ kind: 'success', title: 'Profile renamed', message: `${from}${target}` })
setSelectedName(target)
await refresh()
},
[refresh]
)
const handleConfirmDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
setDeleting(true)
const handleMakeDefault = useCallback(async (profile: ProfileInfo) => {
try {
await deleteProfile(pendingDelete.name)
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
setPendingDelete(null)
setSelectedName(null)
await refresh()
// Relaunches the backend under this profile's HERMES_HOME and reloads the
// window, so control normally doesn't return here.
await switchProfile(profile.name)
} catch (err) {
notifyError(err, 'Failed to delete profile')
} finally {
setDeleting(false)
setLoadError(err instanceof Error ? err.message : `Failed to switch to ${profile.name}`)
}
}, [pendingDelete, refresh])
}, [])
return (
<OverlayView closeLabel="Close profiles" onClose={onClose}>
@@ -158,27 +143,30 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<Codicon name="add" size="0.875rem" />
</Button>
</div>
{loadError && (
<div className="mb-1 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-[0.66rem] text-destructive">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span>{loadError}</span>
</div>
)}
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onClone={() => void handleClone(profile)}
onDelete={() => setPendingDelete(profile)}
onMakeDefault={() => void handleMakeDefault(profile)}
onRename={() => setPendingRename(profile)}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>
)}
{profiles.length === 0 && <p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>}
</OverlaySidebar>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
<ProfileDetail key={selected.name} profile={selected} />
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
@@ -193,126 +181,181 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
onCreated={async name => {
setSelectedName(name)
await refresh()
}}
open={createOpen}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete profile?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove
its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={async name => {
setSelectedName(name)
await refresh()
}}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={async () => {
setSelectedName(null)
await refresh()
}}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</OverlayView>
)
}
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
function ProfileRow({
active,
onClone,
onDelete,
onMakeDefault,
onRename,
onSelect,
profile
}: {
active: boolean
onClone: () => void
onDelete: () => void
onMakeDefault: () => void
onRename: () => void
onSelect: () => void
profile: ProfileInfo
}) {
const running = useStore($activeProfile)
const isRunning = profile.name === running
return (
<button
<div
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
'group relative flex items-center rounded-md border transition-colors',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)'
: 'border-transparent hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.is_default && <span className="text-[0.6rem] text-primary">default</span>}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
{profile.has_env ? ' · env' : ''}
</span>
</button>
<button
className={cn(
'flex min-w-0 flex-1 flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-[length:var(--conversation-text-font-size)] transition-colors',
active ? 'text-foreground' : 'text-(--ui-text-secondary) group-hover:text-foreground'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center gap-1.5 pr-6">
{profile.is_default ? null : (
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: profileColor(profile.name) ?? 'var(--ui-text-quaternary)' }}
/>
)}
<span className="truncate text-sm font-medium">{profile.name}</span>
{isRunning && (
<Tip label="Current default profile">
<Codicon className="shrink-0 text-(--ui-accent)" name="pass-filled" size="0.75rem" />
</Tip>
)}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{isRunning ? 'default · ' : ''}
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
</span>
</button>
<ProfileActionsMenu
isRunning={isRunning}
onClone={onClone}
onDelete={onDelete}
onMakeDefault={onMakeDefault}
onRename={onRename}
profile={profile}
>
<Button
aria-label={`Actions for ${profile.name}`}
className="absolute right-1 top-1 size-6 bg-transparent text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100"
size="icon-xs"
title="Profile actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</ProfileActionsMenu>
</div>
)
}
function ProfileDetail({
function ProfileActionsMenu({
children,
isRunning,
onClone,
onDelete,
onMakeDefault,
onRename,
profile
}: {
children: React.ReactNode
isRunning: boolean
onClone: () => void
onDelete: () => void
onRename: (newName: string) => Promise<void>
onMakeDefault: () => void
onRename: () => void
profile: ProfileInfo
}) {
const [renameOpen, setRenameOpen] = useState(false)
const [copying, setCopying] = useState(false)
const handleCopySetup = useCallback(async () => {
setCopying(true)
try {
const { command } = await getProfileSetupCommand(profile.name)
await navigator.clipboard.writeText(command)
notify({ kind: 'success', title: 'Setup command copied', message: command })
} catch (err) {
notifyError(err, 'Failed to copy setup command')
} finally {
setCopying(false)
}
}, [profile.name])
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align="end" aria-label={`Actions for ${profile.name}`} className="w-44" sideOffset={6}>
<DropdownMenuItem disabled={isRunning} onSelect={onMakeDefault}>
<Codicon name="pass" size="0.875rem" />
<span>{isRunning ? 'Current default' : 'Make default'}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!profile.is_default && (
<DropdownMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onClone}>
<Codicon name="copy" size="0.875rem" />
<span>Duplicate</span>
</DropdownMenuItem>
{!profile.is_default && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
function ProfileDetail({ profile }: { profile: ProfileInfo }) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && <Badge>Default</Badge>}
{profile.has_env && <Badge variant="muted">.env</Badge>}
</div>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
{profile.path}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
<Pencil />
Rename
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
<Terminal />
{copying ? 'Copying...' : 'Copy setup'}
</Button>
{!profile.is_default && (
<Button
className="hover:text-destructive hover:no-underline"
onClick={onDelete}
size="sm"
variant="text"
>
<Trash2 />
Delete
</Button>
)}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && <Badge>Default</Badge>}
</div>
<Tip label={profile.path}>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground">{profile.path}</p>
</Tip>
</div>
<dl className="grid gap-2 text-xs sm:grid-cols-2">
@@ -333,16 +376,6 @@ function ProfileDetail({
<SoulEditor profileName={profile.name} />
</div>
</div>
<RenameProfileDialog
currentName={profile.name}
onClose={() => setRenameOpen(false)}
onRename={async newName => {
await onRename(newName)
setRenameOpen(false)
}}
open={renameOpen}
/>
</div>
)
}
@@ -360,14 +393,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<'idle' | 'saved' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
const requestRef = useRef<string>(profileName)
const savedTimerRef = useRef<null | number>(null)
useEffect(() => {
requestRef.current = profileName
setLoading(true)
setError(null)
setStatus('idle')
setContent('')
setOriginal('')
@@ -391,21 +426,37 @@ function SoulEditor({ profileName }: { profileName: string }) {
})()
}, [profileName])
useEffect(
() => () => {
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current)
}
},
[]
)
const dirty = content !== original
const isEmpty = !content.trim()
const saving = status === 'saving'
async function handleSave() {
setSaving(true)
setStatus('saving')
setError(null)
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current)
}
try {
await updateProfileSoul(profileName, content)
setOriginal(content)
notify({ kind: 'success', title: 'SOUL.md saved', message: profileName })
setStatus('saved')
savedTimerRef.current = window.setTimeout(() => {
setStatus(current => (current === 'saved' ? 'idle' : current))
}, 2200)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to save SOUL.md')
} finally {
setSaving(false)
}
}
@@ -440,230 +491,17 @@ function SoulEditor({ profileName }: { profileName: string }) {
)}
<div className="flex justify-end">
<Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm">
<Save />
{saving ? 'Saving...' : 'Save SOUL.md'}
<Button disabled={loading || saving || !dirty} onClick={() => void handleSave()} size="sm">
<ActionStatus
busy="Saving…"
done="Saved"
idle="Save SOUL.md"
idleIcon={<Save />}
state={saving ? 'saving' : status === 'saved' && !dirty ? 'done' : 'idle'}
/>
</Button>
</div>
</section>
)
}
function CreateProfileDialog({
onClose,
onCreate,
open
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setError(null)
setSaving(false)
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">Clone from default</span>
<span className="ml-2 text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || !trimmed || invalid} type="submit">
{saving ? 'Creating...' : 'Create profile'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function RenameProfileDialog({
currentName,
onClose,
onRename,
open
}: {
currentName: string
onClose: () => void
onRename: (newName: string) => Promise<void>
open: boolean
}) {
const [name, setName] = useState(currentName)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setSaving(false)
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onRename(trimmed)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || invalid || unchanged} type="submit">
{saving ? 'Renaming...' : 'Rename'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
// Self-contained rename (owns the renameProfile call) so every caller just
// reacts via onRenamed. Unchanged name is a no-op close.
export function RenameProfileDialog({
currentName,
onClose,
onRenamed,
open
}: {
currentName: string
onClose: () => void
onRenamed?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState(currentName)
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setStatus('idle')
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await renameProfile(currentName, trimmed)
await onRenamed?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to rename profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || invalid || unchanged} type="submit">
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,6 +5,7 @@ import { ErrorBoundary } from '@/components/error-boundary'
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 { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -148,21 +149,21 @@ function RightSidebarChrome({
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{tabs.map(tab => (
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
title={tab.label}
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
<Tip key={tab.id} label={tab.label}>
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
))}
</nav>
@@ -216,21 +217,21 @@ function FilesystemTab({
return (
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Tip label={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
</Tip>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
@@ -240,7 +241,6 @@ function FilesystemTab({
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
@@ -251,7 +251,6 @@ function FilesystemTab({
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />

View File

@@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
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 { SidebarPanelLabel } from '../../shell/sidebar-label'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
@@ -31,6 +32,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}
@@ -38,17 +40,18 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
title={label}
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</Tip>
</div>
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
{status === 'starting' && (

View File

@@ -1,9 +1,10 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { TerminalTab } from './index'
import { TERMINAL_BG } from './selection'
/**
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
@@ -21,11 +22,17 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
useEffect(() => {
const el = ref.current
if (!el) return
if (!el) {
return
}
$slot.set(el)
return () => {
if ($slot.get() === el) $slot.set(null)
if ($slot.get() === el) {
$slot.set(null)
}
}
}, [])
@@ -55,6 +62,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
useLayoutEffect(() => {
if (!slot) {
setRect(null)
return
}
@@ -72,13 +80,17 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
if (!sameRect(prev, next)) {
prev = next
setRect(next)
if (next.width > 0 && next.height > 0) setReady(true)
if (next.width > 0 && next.height > 0) {
setReady(true)
}
}
frame = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(frame)
}, [slot])

View File

@@ -96,11 +96,18 @@ interface UseTerminalSessionOptions {
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) return true
if ((t.files?.length ?? 0) > 0) return true
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
}
if ((t.files?.length ?? 0) > 0) {
return true
}
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
if (t.items[i]?.kind === 'file') return true
if (t.items[i]?.kind === 'file') {
return true
}
}
return false
@@ -108,22 +115,38 @@ function transferHasDropCandidates(t: DataTransfer): boolean {
function collectDroppedPaths(t: DataTransfer): string[] {
const seen = new Set<string>()
const push = (value: unknown) => {
if (typeof value !== 'string') return
if (typeof value !== 'string') {
return
}
const path = value.trim()
if (path) seen.add(path)
if (path) {
seen.add(path)
}
}
try {
const raw = t.getData(HERMES_PATHS_MIME)
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
if (raw) {
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
push(entry?.path)
}
}
} catch {
// Malformed in-app drag payload — fall through to OS files.
}
const getPath = window.hermesDesktop?.getPathForFile
const addFile = (file: File | null) => {
if (!file || !getPath) return
if (!file || !getPath) {
return
}
try {
push(getPath(file))
} catch {
@@ -131,10 +154,16 @@ function collectDroppedPaths(t: DataTransfer): string[] {
}
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
addFile(t.files.item(i))
}
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
const item = t.items[i]
if (item?.kind === 'file') addFile(item.getAsFile())
if (item?.kind === 'file') {
addFile(item.getAsFile())
}
}
return [...seen]
@@ -142,8 +171,15 @@ function collectDroppedPaths(t: DataTransfer): string[] {
function quotePathForShell(path: string, shellName: string): string {
const shell = shellName.toLowerCase()
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
if (shell.includes('powershell') || shell.includes('pwsh')) {
return `'${path.replace(/'/g, "''")}'`
}
if (shell.includes('cmd')) {
return `"${path.replace(/"/g, '""')}"`
}
return `'${path.replace(/'/g, "'\\''")}'`
}
@@ -250,12 +286,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
@@ -263,11 +301,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const onDrop = (e: DragEvent) => {
const id = sessionIdRef.current
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
e.preventDefault()
e.stopPropagation()
const paths = collectDroppedPaths(e.dataTransfer)
if (!paths.length) return
if (!paths.length) {
return
}
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
term.focus()
triggerHaptic('selection')
@@ -305,11 +351,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
// synchronously while sibling panes are mid-transition (e.g. file browser
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
let pendingFrame = 0
const scheduleResize = () => {
if (pendingFrame) return
if (pendingFrame) {
return
}
pendingFrame = window.requestAnimationFrame(() => {
pendingFrame = 0
if (!disposed) fitAndResize()
if (!disposed) {
fitAndResize()
}
})
}
@@ -317,7 +370,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
resizeObserver.observe(host)
cleanup.push(() => {
resizeObserver.disconnect()
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
if (pendingFrame) {
window.cancelAnimationFrame(pendingFrame)
}
})
const dataDisposable = term.onData(data => {

View File

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

View File

@@ -326,10 +326,7 @@ export function useMessageStream({
return
}
flushHandleRef.current = window.setTimeout(
runFlush,
Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)
)
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
}, [flushQueuedDeltas])
const queueDelta = useCallback(
@@ -755,12 +752,11 @@ export function useMessageStream({
return
}
// Turn ended — drop any blocking prompt that's still open (e.g. the
// agent was interrupted, or the approval already resolved). Prevents a
// stale overlay from outliving the turn that raised it.
if (isActiveEvent) {
clearAllPrompts()
}
// Turn ended — drop any blocking prompt still open for THIS session
// (e.g. interrupted, or the approval already resolved). Scoped to the
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
flushQueuedDeltas(sessionId)
@@ -845,37 +841,34 @@ export function useMessageStream({
}
}
} else if (event.type === 'approval.request') {
if (!isActiveEvent) {
return
}
// Dangerous-command / execute_code approval. The Python side is
// blocked in _await_gateway_decision() until approval.respond lands;
// without this the agent stalls until its 5-min timeout and the tool
// is BLOCKED. Approval is session-keyed (no request_id) — the overlay
// sends back {choice, session_id}.
// Dangerous-command / execute_code approval. The Python side is blocked
// in _await_gateway_decision() until approval.respond lands; without
// this the agent stalls until its 5-min timeout and the tool is BLOCKED.
// Park it per-session (like clarify) so a *background* profile's turn can
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
})
} else if (event.type === 'sudo.request') {
if (!isActiveEvent) {
return
}
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
} else if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
setSudoRequest({ requestId })
setSudoRequest({ requestId, sessionId: sessionId ?? null })
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'secret.request') {
if (!isActiveEvent) {
return
}
// Skill credential capture (tools/skills_tool.py). Blocked on
// secret.respond {request_id, value}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
@@ -884,18 +877,23 @@ export function useMessageStream({
setSecretRequest({
requestId,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
// A turn that errors out has also ended — drop any open blocking
// prompt so an approval/sudo/secret overlay can't linger past the
// failed turn (same intent as the message.complete clear).
if (isActiveEvent) {
clearAllPrompts()
// A turn that errors out has also ended — drop any open blocking prompt
// for this session so an approval/sudo/secret overlay can't linger past
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
}
if (looksLikeProviderSetup) {

View File

@@ -0,0 +1,166 @@
import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
transcribeAudio: vi.fn()
}))
// The active id the desktop holds is the *runtime* session id from
// session.create — deliberately distinct from the stored DB id here, because
// that mismatch is the bug: the REST renameSession endpoint resolves against
// the stored sessions table and 404s on a runtime id. session.title accepts
// the runtime id directly.
const RUNTIME_SESSION_ID = 'rt-abc123'
function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
ended_at: null,
id: RUNTIME_SESSION_ID,
input_tokens: 0,
is_active: true,
last_active: 0,
message_count: 3,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: 'Old title',
tool_call_count: 0,
...overrides
}
}
interface HarnessHandle {
submitText: (text: string) => Promise<boolean>
}
function Harness({
onReady,
refreshSessions,
requestGateway
}: {
onReady: (handle: HarnessHandle) => 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 actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
activeSessionIdRef,
branchCurrentSession: async () => true,
busyRef,
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
handleSkinCommand: () => '',
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) =>
updater({ messages: [], busy: false, awaitingResponse: false } as never)
})
useEffect(() => {
onReady({ submitText: actions.submitText })
}, [actions.submitText, onReady])
return null
}
describe('usePromptActions /title', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title New title')
// Routes through session.title with the runtime session id — NOT the slash
// worker (slash.exec) and NOT the REST endpoint. This is the path that
// resolves the runtime id and persists reliably across platforms.
expect(requestGateway).toHaveBeenCalledWith('session.title', {
session_id: RUNTIME_SESSION_ID,
title: 'New title'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect($sessions.get()[0]?.title).toBe('New title')
})
it('reports the queued state when the session row is not persisted yet', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title Fresh chat')
expect(requestGateway).toHaveBeenCalledWith('session.title', {
session_id: RUNTIME_SESSION_ID,
title: 'Fresh chat'
})
// Even when queued, the sidebar reflects the chosen title optimistically.
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect($sessions.get()[0]?.title).toBe('Fresh chat')
})
it('falls through to the slash worker for a bare /title (show current title)', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title')
expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything())
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' }))
})
it('surfaces a rename error without touching the sidebar store', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.title') {
throw new Error('Title too long')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/title way too long title')
expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' }))
expect(refreshSessions).not.toHaveBeenCalled()
expect($sessions.get()[0]?.title).toBe('Old title')
})
})

View File

@@ -1,7 +1,7 @@
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { transcribeAudio } from '@/hermes'
import { getProfiles, transcribeAudio } from '@/hermes'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
@@ -18,6 +18,7 @@ import {
isDesktopSlashCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
@@ -29,6 +30,7 @@ import {
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
$messages,
@@ -36,10 +38,11 @@ import {
setAwaitingResponse,
setBusy,
setMessages,
setSessions,
setYoloActive
} from '@/store/session'
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -76,6 +79,7 @@ interface PromptActionsOptions {
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
@@ -140,6 +144,7 @@ export function usePromptActions({
branchCurrentSession,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
@@ -246,7 +251,7 @@ export function usePromptActions({
}
const releaseBusy = () => {
busyRef.current = false
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
}
@@ -290,7 +295,7 @@ export function usePromptActions({
)
}
busyRef.current = true
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
@@ -439,6 +444,51 @@ export function usePromptActions({
return
}
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` instead points the next new chat
// (and the current empty draft) at that profile's backend.
if (normalizedName === 'profile') {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({
kind: 'success',
message: `Profile: ${current}. Use /profile <name> or the "New session" picker to start a chat in another profile.`
})
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: 'Unknown profile',
message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}`
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
// Swap the live gateway now so an empty draft sends into this
// profile immediately; an existing thread keeps its own profile.
await ensureGatewayProfile(key)
notify({ kind: 'success', message: `New chats will use profile ${match.name}.` })
} catch (err) {
notifyError(err, 'Failed to set profile')
}
return
}
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sessionId) {
@@ -454,6 +504,50 @@ export function usePromptActions({
const renderSlashOutput = (text: string) =>
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
// /title <name> renames the session. Route through the gateway's
// `session.title` RPC — the same path the TUI uses — NOT the REST
// renameSession endpoint and NOT the slash worker.
//
// Why not the slash worker: it's a separate HermesCLI subprocess whose
// SQLite write to the shared state.db can silently fail (notably on
// Windows), and it never refreshes the sidebar.
//
// Why not REST renameSession: `sessionId` here is the *runtime* session
// id returned by session.create — it is NOT the stored DB `sessions.id`,
// and session.create deliberately does not persist a DB row until the
// first turn. The REST PATCH endpoint resolves against the sessions
// table, so a runtime id (or a brand-new, not-yet-persisted session)
// 404s with "Session not found" on every platform. See #38508 / #38576.
//
// session.title maps the runtime id to the in-memory session, writes
// through the gateway's own DB connection, and QUEUES the title
// (`pending: true`) when the row isn't persisted yet — so it works for a
// fresh chat too. refreshSessions() then pulls the authoritative title
// back into the sidebar. A bare `/title` (no arg) still falls through to
// the worker to display the current title.
if (normalizedName === 'title' && arg) {
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
if (normalizedName === 'skin') {
renderSlashOutput(handleSkinCommand(arg))
@@ -554,6 +648,7 @@ export function usePromptActions({
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
startFreshSessionDraft,
submitPromptText
@@ -594,7 +689,7 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
busyRef.current = false
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
@@ -753,7 +848,7 @@ export function usePromptActions({
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
clearNotifications()
busyRef.current = true
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
@@ -791,7 +886,7 @@ export function usePromptActions({
}
}
busyRef.current = false
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))

View File

@@ -12,6 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$messages,
@@ -36,8 +37,8 @@ import {
setMessages,
setSelectedStoredSessionId,
setSessions,
setSessionsTotal,
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@@ -173,6 +174,10 @@ function upsertOptimisticSession(
preview: string | null = null
) {
const now = Date.now() / 1000
// Stamp the profile the session was just created on (= the live gateway's
// profile) so the scoped sidebar shows the new row immediately instead of
// filtering it out as "default" until the aggregator re-fetches.
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
const session: SessionInfo = {
cwd: created.info?.cwd ?? null,
@@ -180,11 +185,13 @@ function upsertOptimisticSession(
id,
input_tokens: 0,
is_active: true,
is_default_profile: profileKey === 'default',
last_active: now,
message_count: created.message_count ?? created.messages?.length ?? 0,
model: created.info?.model ?? null,
output_tokens: 0,
preview,
profile: profileKey,
source: 'tui',
started_at: now,
title,
@@ -311,74 +318,80 @@ export function useSessionActions({
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
const createBackendSessionForSend = useCallback(
async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
creatingSessionRef.current = true
creatingSessionRef.current = true
try {
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
try {
// Route the new chat to the chosen profile's backend (null = primary,
// 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 }) })
const stored = created.stored_session_id ?? null
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
return null
return null
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
const yoloArmed = $yoloActive.get()
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
// User may have armed YOLO on the new-chat draft before the runtime
// session existed — apply it to the freshly created session.
if (yoloArmed) {
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
}
return created.session_id
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
const yoloArmed = $yoloActive.get()
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
// User may have armed YOLO on the new-chat draft before the runtime
// session existed — apply it to the freshly created session.
if (yoloArmed) {
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
}
return created.session_id
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
}, [
activeSessionIdRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
},
[
activeSessionIdRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
)
const selectSidebarItem = useCallback(
(item: SidebarNavItem) => {
@@ -417,6 +430,12 @@ export function useSessionActions({
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const sessionProfile = storedForProfile?.profile
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
@@ -479,7 +498,7 @@ export function useSessionActions({
let localSnapshot = $messages.get()
try {
const storedMessages = await getSessionMessages(storedSessionId)
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
@@ -549,7 +568,7 @@ export function useSessionActions({
return
}
const fallback = await getSessionMessages(storedSessionId)
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) {
return

View File

@@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -38,7 +39,7 @@ export function useSessionStateCache({
}, [activeSessionId])
useEffect(() => {
busyRef.current = busy
setMutableRef(busyRef, busy)
}, [busy, busyRef])
useEffect(() => {
@@ -89,7 +90,7 @@ export function useSessionStateCache({
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])

View File

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

View File

@@ -0,0 +1,363 @@
import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { ListRow } from './primitives'
import type { EnvRowProps } from './types'
export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
export const isKeyVar = (key: string, info: EnvVarInfo) =>
info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
export function KeyField({
info,
placeholder,
rowProps,
varKey
}: {
info: EnvVarInfo
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
const editType = info.is_password ? 'password' : 'text'
if (info.is_set && !editing) {
return (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
/>
)
}
return (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
type={editType}
value={draft}
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
}
function CredentialDocsLink({ href }: { href: string }) {
return (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={href}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)
}
/** One credential row — collapsible; description and docs link expand on click. */
export function CredentialKeyCard({
expanded,
info,
label,
onExpand,
onToggle,
placeholder,
rowProps,
varKey
}: CredentialKeyCardProps) {
const docsUrl = info.url?.trim()
const description = info.description?.trim()
const expandable = Boolean(description || docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{label}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{group.name}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
export function credentialRowLabel(varKey: string, info: EnvVarInfo): string {
if (isKeyVar(varKey, info)) {
return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
}
return prettyName(varKey)
}
interface CredentialKeyCardProps {
expanded: boolean
info: EnvVarInfo
label: string
onExpand: () => void
onToggle: () => void
placeholder: string
rowProps: KeyRowProps
varKey: string
}
interface ProviderKeyRowsProps {
expanded: boolean
group: ProviderKeyRowGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}
export interface ProviderKeyRowGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { type IconComponent } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
import { Pill } from './primitives'
import type { EnvRowProps } from './types'
// Shared filter used by every credential surface (Providers + Keys pages):
// category gate first, then a free-text match across key name + description.
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{count && <Pill>{count}</Pill>}
</div>
)
}
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
// credential pages (Providers, Keys) share one source of truth and one set of
// mutation handlers instead of duplicating the plumbing.
export function useEnvCredentials(): UseEnvCredentials {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Best-effort cleanup of a retired localStorage flag (global "Show
// advanced" toggle) — everything in these views is configuration-level.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
// Direct save for a known value (no edit-state round-trip) — used by the
// onboarding-style key form, which owns its own input. Returns a result so
// the form can surface inline errors instead of only toasting.
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
const trimmed = value.trim()
if (!trimmed) {
return { message: 'Enter a value first.', ok: false }
}
setSaving(key)
try {
await setEnvVar(key, trimmed)
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
clearLocalState(key)
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
return { ok: true }
} catch (err) {
notifyError(err, `Failed to save ${key}`)
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
return {
saveValue,
vars,
rowProps: {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
}
}
interface CategoryHeadingProps {
count?: string
icon: IconComponent
title: string
}
interface UseEnvCredentials {
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
vars: Record<string, EnvVarInfo> | null
}

View File

@@ -0,0 +1,130 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
interface EnvVarActionsMenuProps
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
clearDisabled?: boolean
docsUrl?: string | null
isRevealed?: boolean
isSet: boolean
label: string
onClear?: () => void
onEdit: () => void
onReveal?: () => void
showReveal?: boolean
}
export function EnvVarActionsMenu({
align = 'end',
children,
clearDisabled = false,
docsUrl,
isRevealed = false,
isSet,
label,
onClear,
onEdit,
onReveal,
showReveal = true,
sideOffset = 6
}: EnvVarActionsMenuProps) {
const hasClear = isSet && onClear
const hasReveal = isSet && showReveal && onReveal
const hasDocs = Boolean(docsUrl?.trim())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${label}`}
className="w-44"
sideOffset={sideOffset}
>
{hasDocs && (
<DropdownMenuItem
onSelect={event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}}
>
<ExternalLink className="size-3.5" />
<span>Docs</span>
</DropdownMenuItem>
)}
{hasReveal && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onReveal()
}}
>
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
<span>{isRevealed ? 'Hide value' : 'Reveal value'}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{isSet ? 'Replace' : 'Set'}</span>
</DropdownMenuItem>
{hasClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={clearDisabled}
onSelect={() => {
triggerHaptic('warning')
onClear()
}}
variant="destructive"
>
<Trash2 className="size-3.5" />
<span>Clear</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface EnvVarActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
label: string
}
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${label}`}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
title="Credential actions"
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

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

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { getNested, setNested } from './helpers'
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
it('reads and writes nested config paths', () => {
@@ -20,4 +20,48 @@ describe('settings helpers', () => {
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
describe('stripToolsetLabel', () => {
it('removes leading emoji prefixes from registry labels', () => {
expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs')
expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution')
expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions')
expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation')
expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation')
})
it('leaves plain titles unchanged', () => {
expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes')
})
})
describe('toolsetDisplayLabel', () => {
it('strips emoji from toolset rows', () => {
expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs')
})
})
describe('providerGroup', () => {
it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
})
it('prefers the longest matching prefix so CN/regional buckets win', () => {
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
// KIMI_CN_ likewise must beat KIMI_.
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
})
it('falls back to "Other" for un-grouped env vars', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
})

View File

@@ -8,6 +8,13 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
export const stripToolsetLabel = (label: string): string =>
label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label
export const toolsetDisplayLabel = (toolset: Pick<ToolsetInfo, 'label' | 'name'>): string =>
stripToolsetLabel(asText(toolset.label || toolset.name))
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
@@ -19,9 +26,30 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
// "Other" used by the Keys settings view for un-grouped env vars.
export const providerGroup = (key: string) => {
let best: (typeof PROVIDER_GROUPS)[number] | undefined
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
for (const candidate of PROVIDER_GROUPS) {
if (!key.startsWith(candidate.prefix)) {
continue
}
if (!best || candidate.prefix.length > best.prefix.length) {
best = candidate
}
}
return best?.name ?? 'Other'
}
export const providerMeta = (name: string) =>
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
PROVIDER_GROUPS.find(g => g.name === name)
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])

View File

@@ -1,9 +1,10 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -16,13 +17,15 @@ import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
'keys',
'mcp',
@@ -32,6 +35,20 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
const openProviderView = (view: ProviderView) => {
setActiveView('providers')
setProviderView(view)
}
const openKeysView = (view: KeysView) => {
setActiveView('keys')
setKeysView(view)
}
const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -83,6 +100,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
)
})}
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label="Providers"
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
label="Accounts"
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label="API keys"
nested
onClick={() => openProviderView('keys')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
@@ -92,9 +133,27 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="API Keys"
label="Tools & Keys"
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={keysView === 'tools'}
icon={Wrench}
label="Tools"
nested
onClick={() => openKeysView('tools')}
/>
<OverlayNavItem
active={keysView === 'settings'}
icon={Settings2}
label="Settings"
nested
onClick={() => openKeysView('settings')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
@@ -115,28 +174,32 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
title="Import config"
>
<IconUpload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
title="Reset to defaults"
>
<IconRefresh className="size-3.5" />
</OverlayIconButton>
<Tip label="Export config">
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Import config">
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
>
<IconUpload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Reset to defaults">
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
>
<IconRefresh className="size-3.5" />
</OverlayIconButton>
</Tip>
</div>
</OverlaySidebar>
@@ -154,8 +217,10 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings />
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : (

View File

@@ -1,425 +1,94 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
export const KEYS_VIEWS = ['tools', 'settings'] as const
export type KeysView = (typeof KEYS_VIEWS)[number]
// Providers live on their own page; messaging-platform credentials live on the
// dedicated Messaging page (and are hidden here via `channel_managed`). This
// view covers tool API keys plus server/setting env vars (API server, webhook,
// gateway), which fold into the Settings subnav.
// Backend categories that surface under each subnav. Platform credentials use the
// `messaging` category but are flagged ``channel_managed`` and configured on
// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY)
// appear here alongside ``setting``.
const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
settings: ['setting', 'messaging'],
tools: ['tool']
}
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="textStrong">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
}
function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
Cancel
</Button>
</div>
)}
</div>
)
}
function EnvProviderGroup({
group,
rowProps,
forceExpand = false
}: {
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
forceExpand?: boolean
}) {
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
export function KeysSettings({ view }: KeysSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [openKey, setOpenKey] = useState<null | string>(null)
useEffect(() => {
if (forceExpand) {
setExpanded(true)
}
}, [forceExpand])
setOpenKey(null)
}, [view])
return (
<div className="overflow-hidden rounded-xl bg-background/60">
<button
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
onClick={() => setExpanded(e => !e)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<Zap className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{group.name === 'Other' ? 'Other providers' : group.name}
</span>
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
</span>
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
</button>
{expanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{group.entries.map(([key, info]) => (
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
</div>
))}
</div>
)}
</div>
)
}
export function KeysSettings() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
// matching provider group, scroll the row in, and flash it.
const highlightKey = useDeepLinkHighlight({
elementId: key => `env-var-${key}`,
param: 'key',
ready: key => Boolean(vars?.[key])
})
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
// default-collapsed-unless-set keep the surface manageable.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
const groups = useMemo(() => {
if (!vars) {
return []
}
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
return KEYS_VIEWS.flatMap(v => {
const cats = VIEW_CATEGORIES[v]
const groups = new Map<string, [string, EnvVarInfo][]>()
for (const entry of entries) {
const name = providerGroup(entry[0])
groups.set(name, [...(groups.get(name) ?? []), entry])
}
return Array.from(groups, ([name, entries]) => ({
name,
priority: providerPriority(name),
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
hasAnySet: entries.some(([, info]) => info.is_set)
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}, [vars])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
setting: 'Settings'
}
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([, info]) => asText(info.category) === cat)
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
return entries.length === 0 ? [] : [{ category: v, entries }]
})
}, [vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
if (!vars) {
return <LoadingState label="Loading API keys and credentials..." />
}
const rowProps = {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
const visible = groups.filter(g => g.category === view)
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading
icon={Zap}
meta={`${configuredCount} of ${providerGroups.length} configured`}
title="LLM providers"
/>
<div className="grid gap-2">
{providerGroups.map(group => (
<EnvProviderGroup
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
group={group}
key={group.name}
rowProps={rowProps}
/>
))}
</div>
</div>
{visible.map(group => (
<div className="grid gap-2" key={group.category}>
{group.entries.map(([key, info]: [string, EnvVarInfo]) => {
const label = credentialRowLabel(key, info)
{otherGroups.map(group => (
<div className="mb-6" key={group.category}>
<SectionHeading
icon={Settings2}
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
title={group.label}
/>
<div className="grid gap-2">
{group.entries.map(([key, info]) => (
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow info={info} varKey={key} {...rowProps} />
</div>
))}
</div>
return (
<CredentialKeyCard
expanded={openKey === key}
info={info}
key={key}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
)
})}
</div>
))}
{visible.length === 0 && (
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Nothing configured in this category yet.
</div>
)}
</SettingsContent>
)
}
interface KeysSettingsProps {
view: KeysView
}

View File

@@ -1,13 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2 } from '@/lib/icons'
@@ -29,7 +23,6 @@ const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
@@ -232,7 +225,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
))}
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
<Button
disabled={!selectedProvider || !selectedModel || applying}
onClick={() => void applyMainModel()}
size="sm"
>
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
@@ -333,7 +330,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
</span>
}
key={meta.key}

View File

@@ -0,0 +1,251 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import {
FEATURED_ID,
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { listOAuthProviders } from '@/hermes'
import { ChevronDown, KeyRound } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
// Group the env catalog by provider — one ListRow per vendor plus optional
// advanced overrides (base URL, region, etc.). Groups without a key field and
// the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
for (const [key, info] of Object.entries(vars)) {
if (info.category !== 'provider') {
continue
}
const name = providerGroup(key)
if (name === 'Other') {
continue
}
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
}
const groups: ProviderKeyGroup[] = []
for (const [name, entries] of buckets) {
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
if (!primary) {
continue
}
const meta = providerMeta(name)
groups.push({
// Advanced = the provider's non-key knobs (base URL, region, deployment).
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
// so we never render a second "Paste key" input — unless one is already
// set, in which case keep it visible so it stays clearable.
advanced: entries
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
.sort(([a], [b]) => a.localeCompare(b)),
description: meta?.description ?? primary[1].description,
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
hasAnySet: entries.some(([, i]) => i.is_set),
name,
primary,
priority: providerPriority(name)
})
}
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}
// Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
// are the exact shared components, so the two surfaces stay visually identical.
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
const [showAll, setShowAll] = useState(false)
const ordered = useMemo(() => sortProviders(providers), [providers])
if (ordered.length === 0) {
return null
}
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
const showOthers = !collapsible || showAll
return (
<section className="mb-5 grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
<Button
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
type="button"
variant="textStrong"
>
Have an API key instead?
</Button>
</div>
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Sign in with a subscription no API key to copy. Hermes runs the browser sign-in for you, right here in the
app.
</p>
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
Connected
</p>
{connected.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
</>
)}
{showOthers && (
<>
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={onWantApiKey} />
</>
)}
{collapsible && (
<Button
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
type="button"
variant="text"
>
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</Button>
)}
</section>
)
}
function NoProviderKeys() {
return (
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
No provider API keys available.
</div>
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
useEffect(() => {
if (onboardingActive) {
return
}
let cancelled = false
// OAuth providers are best-effort — a failure here just hides the panel.
void (async () => {
try {
const { providers } = await listOAuthProviders()
if (!cancelled) {
setOauthProviders(providers)
}
} catch {
// Ignore — the OAuth panel just won't render.
}
})()
return () => void (cancelled = true)
}, [onboardingActive])
if (!vars) {
return <LoadingState label="Loading providers..." />
}
const hasOauth = oauthProviders.length > 0
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
// providers there's nothing for the "Accounts" view to show, so fall to keys.
const showApiKeys = view === 'keys' || !hasOauth
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
return (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<NoProviderKeys />
)}
</SettingsContent>
)
}
return (
<SettingsContent>
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
</SettingsContent>
)
}
interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
priority: number
}
interface ProvidersSettingsProps {
onViewChange: (view: ProviderView) => void
view: ProviderView
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -134,18 +135,19 @@ export function SessionsSettings() {
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
</Button>
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
title="Delete permanently"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
<Tip label="Delete permanently">
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</Tip>
</div>
}
description={session.preview || undefined}
@@ -251,13 +253,7 @@ function DefaultProjectDirSetting() {
<ListRow
action={
<div className="flex items-center gap-3">
<Button
disabled={busy}
onClick={() => void choose()}
size="sm"
type="button"
variant="textStrong"
>
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>

View File

@@ -4,11 +4,12 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons'
import { Check, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives'
interface ToolsetConfigPanelProps {
@@ -108,33 +109,26 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{envVar.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={envVar.url} rel="noreferrer" target="_blank">
Docs
<ExternalLink className="size-3" />
</a>
</Button>
)}
{isSet && (
<Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost">
{revealed !== null ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost">
<Trash2 />
</Button>
)}
</div>
{!editing && (
<EnvVarActionsMenu
clearDisabled={busy}
docsUrl={envVar.url}
isRevealed={revealed !== null}
isSet={isSet}
label={envVar.key}
onClear={() => void handleClear()}
onEdit={() => setEditing(true)}
onReveal={() => void handleReveal()}
>
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
</EnvVarActionsMenu>
)}
</div>
{isSet && revealed !== null && (
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div>
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
{revealed || '---'}
</div>
)}
{editing && (

View File

@@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {

View File

@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import {
@@ -153,6 +154,10 @@ export function AppShell({
</main>
{overlays}
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay — not just the chat view. */}
<NotificationStack />
</SidebarProvider>
)
}

View File

@@ -2,6 +2,7 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { Activity, AlertCircle } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
@@ -76,16 +77,17 @@ export function GatewayMenuPanel({
</span>
</div>
<div className="flex items-center">
<Button
aria-label="Open system panel"
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
title="Open system panel"
variant="ghost"
>
<IconLayoutDashboard />
</Button>
<Tip label="Open system panel">
<Button
aria-label="Open system panel"
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
variant="ghost"
>
<IconLayoutDashboard />
</Button>
</Tip>
</div>
</div>
@@ -99,13 +101,11 @@ export function GatewayMenuPanel({
<SectionLabel>Recent activity</SectionLabel>
<ul className="mt-1.5 space-y-0.5">
{recentLogs.map((line, index) => (
<li
className="truncate font-mono text-[0.68rem] text-muted-foreground/85"
key={`${index}:${line}`}
title={line.trim()}
>
{trimLogLine(line) || '\u00A0'}
</li>
<Tip key={`${index}:${line}`} label={line.trim()}>
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
{trimLogLine(line) || '\u00A0'}
</li>
</Tip>
))}
</ul>
<button

View File

@@ -4,7 +4,18 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles, Zap, ZapFilled } from '@/lib/icons'
import {
Activity,
AlertCircle,
ChevronDown,
Clock,
Command,
Hash,
Loader2,
Sparkles,
Zap,
ZapFilled
} from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
@@ -311,7 +322,11 @@ export function useStatusbarItems({
{
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
hidden: !showYoloToggle,
icon: yoloActive ? <ZapFilled className="size-3.5 shrink-0" /> : <Zap className="size-3.5 shrink-0 opacity-70" />,
icon: yoloActive ? (
<ZapFilled className="size-3.5 shrink-0" />
) : (
<Zap className="size-3.5 shrink-0 opacity-70" />
),
id: 'yolo',
onSelect: () => void toggleYolo(),
title: yoloActive

View File

@@ -55,9 +55,7 @@ export function resolveFastControl(
// Only a toggle if there's a base to switch back to; otherwise it's a
// standalone fast model with no "off" state.
return providerModels.includes(baseId)
? { kind: 'variant', baseId, fastId: model, on: true }
: { kind: 'none' }
return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' }
}
const fastId = `${model}-fast`
@@ -182,24 +180,20 @@ export function ModelEditSubmenu({
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
onCheckedChange={checked =>
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
size="xs"
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
Fast
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
</DropdownMenuItem>

View File

@@ -157,7 +157,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
? [
fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
reasoningEffortLabel(currentReasoningEffort) || 'Med'
]
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId

View File

@@ -91,18 +91,11 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</>
)
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
title={title}
type="button"
>
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
{content}
</button>
</DropdownMenuTrigger>
@@ -135,7 +128,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
@@ -168,13 +160,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.href || item.variant === 'link') {
return (
<a
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
href={item.href}
rel="noreferrer"
target="_blank"
title={title}
>
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
{content}
</a>
)
@@ -191,7 +177,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
item.onSelect?.()
}}
title={title}
type="button"
>
{content}

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