Compare commits

...

101 Commits

Author SHA1 Message Date
Brooklyn Nicholson
ffe5558bd0 docs(desktop): clarify macOS Gatekeeper first-open (no Apple Developer account needed)
Users hitting macOS Gatekeeper's "unidentified developer" dialog on first
launch sometimes read it as a request for an Apple Developer account or
"developer password." The desktop builds are signed + notarized, so no such
account is required — but the docs had zero macOS first-open guidance to say so.

- Add a Troubleshooting subsection to the Desktop App page explaining that
  Gatekeeper is not an account requirement, with right-click->Open and
  Privacy & Security "Open Anyway" steps, and a note that any password macOS
  asks for is the user's own Mac login password, not a developer password.
- Add a macOS note to the Installation page's recommended-installer step
  cross-linking to that section.
2026-06-05 09:15:32 -05:00
adybag14-cyber
af8b917dab fix(termux): scope frontend npm installs 2026-06-05 06:56:51 -07:00
Teknium
9ca11b35d5 perf(/model): prewarm picker provider-models cache in background (#39847)
* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

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

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

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

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

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

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

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

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

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

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

Port from anomalyco/opencode#30749.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit c8e80cd0bf.

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

This reverts commit 8a19884bf3.

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

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

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

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

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

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

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

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

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

---------

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

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

Port from anomalyco/opencode#30749.

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

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

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

* feat(delegation): uncap max_spawn_depth to match max_concurrent_children

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

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

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

Port from anomalyco/opencode#30749.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds
and tsc typecheck all green on 7.17.0.
2026-06-04 21:30:23 -07:00
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
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
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
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
243 changed files with 11924 additions and 2857 deletions

6
.gitignore vendored
View File

@@ -108,6 +108,12 @@ docs/superpowers/*
# logs, and per-session caches are never artifacts of the codebase.
.hermes/
# Desktop/bootstrap install marker written into the managed checkout root by the
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/

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

@@ -2720,6 +2720,61 @@ def run_conversation(
# compress history and retry, not abort immediately.
status_code = getattr(api_error, "status_code", None)
# ── Respect disabled auto-compaction on overflow ──────
# Ported from anomalyco/opencode#30749. When the user has
# turned auto-compaction off (``compression.enabled: false``),
# NO automatic compaction trigger may fire — including the
# provider/request-size overflow recovery paths below
# (long-context-tier 429, 413 payload-too-large, and
# context-overflow). Without this guard the proactive
# threshold path correctly honours the setting (see the
# preflight check and the post-response ``should_compress``
# gate) but a provider overflow error would still silently
# compress + rotate the session, bypassing the user's
# explicit choice. Surface a terminal error instead so the
# user can compact manually (``/compress``), start fresh
# (``/new``), switch to a larger-context model, or reduce
# attachments. Forced compaction via ``/compress``
# (``force=True``) is unaffected — it never reaches this loop.
_overflow_reasons = {
FailoverReason.long_context_tier,
FailoverReason.payload_too_large,
FailoverReason.context_overflow,
}
if (
classified.reason in _overflow_reasons
and not getattr(agent, "compression_enabled", True)
):
agent._flush_status_buffer()
agent._vprint(
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
f"(compression.enabled: false).",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
f"switch to a larger-context model, or reduce attachments.",
force=True,
)
logger.error(
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
f"auto-compaction disabled — not compressing."
)
agent._persist_session(messages, conversation_history)
return {
"messages": messages,
"completed": False,
"api_calls": api_call_count,
"error": (
"Context overflow and auto-compaction is disabled "
"(compression.enabled: false). Run /compress to compact manually, "
"/new to start fresh, or switch to a larger-context model."
),
"partial": True,
"failed": True,
"compaction_disabled": True,
}
# ── Anthropic Sonnet long-context tier gate ───────────
# Anthropic returns HTTP 429 "Extra usage is required for
# long context requests" when a Claude Max (or similar)

View File

@@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
# passes max_tokens=None, because Gemini's native API otherwise applies a low
# internal default and truncates output (unlike OpenAI-compat endpoints where
# an omitted limit means full budget).
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
@@ -414,6 +421,18 @@ def build_gemini_request(
generation_config["temperature"] = temperature
if max_tokens is not None:
generation_config["maxOutputTokens"] = max_tokens
else:
# Gemini's native generateContent does NOT treat an omitted
# maxOutputTokens as "use the model's full output budget" — it applies
# a low internal default and the model stops early with
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
# then retries 3× and refuses the incomplete call). Every current
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
# 65,535 output tokens, so default to that ceiling when the caller
# passes None ("unlimited"). See the OpenAI-compat path where omitting
# the field genuinely means full budget — that assumption does not
# hold on the native API.
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
if top_p is not None:
generation_config["topP"] = top_p
if stop:

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)

View File

@@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs[k] = v
if extra_body:
api_kwargs["extra_body"] = extra_body
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
# keys (tags, reasoning, provider, plugins, …) are unknown fields
# there and Gemini rejects the whole request with a non-retryable
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
# This happens when a profile that emits extra_body (e.g. the Nous
# profile's portal `tags`) is active but the resolved endpoint is a
# Gemini base_url — typical when only Google credentials are set and
# a fallback/aux call lands on Gemini. The native client only reads
# thinking_config from extra_body, so drop everything else here.
try:
from agent.gemini_native_adapter import is_native_gemini_base_url
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
except Exception:
_native_gemini = False
if _native_gemini:
extra_body = {
k: v for k, v in extra_body.items()
if k in ("thinking_config", "thinkingConfig")
}
if extra_body:
api_kwargs["extra_body"] = extra_body
return api_kwargs

View File

@@ -171,12 +171,19 @@ async fn run_update(app: AppHandle) -> Result<()> {
let child_env = update_child_env(&install_root);
let mut update_args: Vec<String> =
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
update_args.push("--branch".into());
update_args.push(update_branch);
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
let update = run_streamed(
let mut update = run_streamed(
&app,
&hermes,
&update_args,
@@ -185,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("update"),
)
.await?;
// Retry-once for the update-boundary crash. `hermes update` lazily imports
// the FRESHLY PULLED modules, but the dependency-install step still runs the
// already-in-memory pre-pull code for one invocation. A release that changed
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
// whose `__iter__` injected a bool into the argv and crashed Windows
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
// bool found`, fixed in #39820) therefore kills the FIRST update on the
// parked population — even though the fix is already on disk by then. A
// second `hermes update` runs clean because the now-current module is loaded
// from the start. Rather than make the parked user click Update twice (and
// stare at a scary crash first), retry once automatically. Skip the retry
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
// a retry can't fix.
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
emit_log(
&app,
Some("update"),
LogStream::Stdout,
"[update] first update attempt failed; retrying once (the fix it just \
pulled loads on the second run)…",
);
update = run_streamed(
&app,
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
}
let update_ms = started.elapsed().as_millis() as u64;
match update.exit_code {
@@ -366,18 +405,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] timed out waiting for Hermes to exit; proceeding anyway",
"[update] Hermes still holding the venv shim; force-killing stragglers…",
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
);
}
return;
}
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
}
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
/// ne <self>` also spares this Tauri process, though it isn't named
/// hermes.exe.)
fn force_kill_other_hermes() {
if !cfg!(target_os = "windows") {
return;
}
#[cfg(target_os = "windows")]
{
let my_pid = std::process::id();
// /FI excludes our own PID; /T kills the tree; /F forces.
let _ = std::process::Command::new("taskkill")
.args([
"/F",
"/T",
"/IM",
"hermes.exe",
"/FI",
&format!("PID ne {my_pid}"),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
/// Best-effort lock probe: try to open the file for read+write. On Windows an
/// exclusively-held running .exe refuses the open with a sharing violation.
/// On Unix this almost always succeeds (no mandatory locking), which is fine —

View File

@@ -24,12 +24,6 @@
### Install with Hermes (recommended)
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop
```
Already have the Hermes CLI? Just run:
```bash
@@ -40,7 +34,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 +50,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).
---

View File

@@ -18,11 +18,24 @@
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// Bare + prefixed variants of the session cookies the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
//
// Two cookies are in play (see that module):
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
// instant the AT expires.
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
// has lapsed but the RT cookie is still present, the gateway middleware
// transparently rotates a fresh AT on the next authenticated request
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
// AT cookie. A liveness check that looked only at the AT cookie would
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@@ -65,6 +78,94 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
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)
}
// Normalize a profile name to a connection scope key, or null for the global
// (default) connection. Shared by the resolver and the IPC layer.
function connectionScopeKey(profile) {
return String(profile ?? '').trim() || null
}
// Coerce a remote auth mode to one of the two supported values ('token' default).
function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -97,22 +198,57 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* True if any cookie in `cookies` is a hermes session ACCESS-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
* check. `cookiesHaveSession` remains exported for callers that specifically
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
/**
* True if the cookie jar holds a credential that can yield an authenticated
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
* transparently rotates a fresh AT from the RT on the next authenticated
* request. Gating connectivity on the AT alone would force a full IDP
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
*
* This answers "should we even attempt to connect / show as signed in?", not
* "is the access token unexpired?". The authoritative liveness check is still
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
}

View File

@@ -15,15 +15,81 @@ const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- connectionScopeKey / normAuthMode ---
test('connectionScopeKey trims to a name or null for the global scope', () => {
assert.equal(connectionScopeKey(' coder '), 'coder')
assert.equal(connectionScopeKey(''), null)
assert.equal(connectionScopeKey(null), null)
assert.equal(connectionScopeKey(undefined), null)
})
test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('oauth'), 'oauth')
assert.equal(normAuthMode('token'), 'token')
assert.equal(normAuthMode(undefined), 'token')
assert.equal(normAuthMode('weird'), 'token')
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, ''), null)
assert.equal(profileRemoteOverride(config, null), null)
assert.equal(profileRemoteOverride(config, undefined), null)
})
test('profileRemoteOverride returns null when the profile has no entry', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, 'writer'), null)
})
test('profileRemoteOverride ignores local or url-less profile entries', () => {
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
})
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
const config = {
profiles: {
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
token: { value: 'sek' }
})
})
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
@@ -130,7 +196,10 @@ test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
// is NOT an access token, so this returns false. Connectivity callers must
// use cookiesHaveLiveSession instead (see below).
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
@@ -145,6 +214,56 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
})
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
// but the 24h RT cookie is still alive. The session is still connectable —
// the gateway rotates a fresh AT from the RT on the next request.
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
})
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: 'a' },
{ name: 'hermes_session_rt', value: 'r' }
]),
true
)
})
test('cookiesHaveLiveSession is false for empty values', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
assert.equal(
cookiesHaveLiveSession([
{ name: 'hermes_session_at', value: '' },
{ name: 'hermes_session_rt', value: '' }
]),
false
)
})
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHaveLiveSession(null), false)
assert.equal(cookiesHaveLiveSession(undefined), false)
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
@@ -159,3 +278,52 @@ test('tokenPreview returns set for short tokens', () => {
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,16 +1,21 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
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'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
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 electron/connection-config.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",
@@ -84,7 +84,7 @@
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"shiki": "^4.0.2",

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

@@ -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

@@ -45,6 +45,7 @@ import {
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
@@ -52,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,
@@ -432,7 +438,7 @@ export function ChatBar({
requestMainFocus()
}
const insertInlineRefs = (refs: string[]) => {
const insertInlineRefs = (refs: InlineRefInput[]) => {
const editor = editorRef.current
if (!editor) {
@@ -452,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)

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

@@ -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

@@ -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,
@@ -117,16 +118,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
{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"
@@ -135,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" />
@@ -148,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,9 @@ 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 {
@@ -52,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,
@@ -65,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'
@@ -94,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}`
@@ -201,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
@@ -211,6 +227,7 @@ export function ChatSidebar({
currentView,
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -226,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
@@ -260,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])
@@ -269,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)) {
@@ -278,7 +318,7 @@ export function ChatSidebar({
}
return map
}, [sessions])
}, [visibleSessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@@ -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
@@ -152,10 +173,10 @@ export function SidebarSessionRow({
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<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>
@@ -170,6 +191,7 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>

View File

@@ -6,6 +6,7 @@ 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 { 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'
@@ -93,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>
)
}
@@ -574,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,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,
@@ -311,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 {
@@ -372,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({
@@ -480,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>
)
}

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 { $activeGatewayProfile, $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()
@@ -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,39 @@ 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])
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill. Both are nanostores, so the blanket
// invalidateQueries() the profile store fires on swap doesn't touch them —
// without this the statusbar keeps showing the previous profile's model
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
// swap (background sockets persist), so the open→open effect won't re-run.
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
if (activeGatewayProfile === lastGatewayProfileRef.current) {
return
}
lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel()
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -506,6 +577,7 @@ export function DesktopController() {
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
@@ -528,6 +600,7 @@ export function DesktopController() {
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
@@ -570,6 +643,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

@@ -10,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 {
@@ -76,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
@@ -97,7 +119,7 @@ export function useGatewayBoot({
reconnecting = true
try {
const conn = await desktop.getConnection()
const conn = await desktop.getConnection($activeGatewayProfile.get())
if (cancelled) {
return
@@ -127,7 +149,8 @@ export function useGatewayBoot({
// 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)) {
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, 'Gateway sign-in required')
}
} finally {
@@ -160,6 +183,7 @@ export function useGatewayBoot({
clearReconnectTimer()
reconnectAttempt = 0
reconnectSecondaryGateways()
if (!gatewayOpen()) {
void attemptReconnect()
@@ -180,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
@@ -212,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()
@@ -259,6 +316,19 @@ export function useGatewayBoot({
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',
@@ -293,6 +363,10 @@ export function useGatewayBoot({
return () => {
cancelled = true
clearReconnectTimer()
clearInterval(keepaliveTimer)
offWorking()
offAttention()
offActiveProfile()
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
@@ -301,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

@@ -3,6 +3,8 @@ 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() {
@@ -24,6 +26,16 @@ export function useGatewayRequest() {
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
@@ -49,7 +61,10 @@ export function useGatewayRequest() {
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)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
@@ -95,7 +110,10 @@ 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

View File

@@ -18,11 +18,11 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
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'

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,10 +143,20 @@ 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}
/>
@@ -171,12 +166,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<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>
@@ -191,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">
@@ -331,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>
)
}
@@ -358,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('')
@@ -389,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)
}
}
@@ -438,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'
@@ -39,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

@@ -752,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)
@@ -842,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 : ''
@@ -881,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,
@@ -30,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,
@@ -37,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) => {
@@ -77,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
@@ -141,6 +144,7 @@ export function usePromptActions({
branchCurrentSession,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
@@ -440,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) {
@@ -455,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))
@@ -555,6 +648,7 @@ export function usePromptActions({
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
startFreshSessionDraft,
submitPromptText

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,
@@ -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,
@@ -320,6 +327,9 @@ export function useSessionActions({
creatingSessionRef.current = true
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
@@ -420,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)
@@ -437,15 +453,31 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
.then(usage => {
if (isCurrentResume() && usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
})
.catch(() => undefined)
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
return
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
}
}
setFreshDraftReady(false)
@@ -482,7 +514,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())
@@ -552,7 +584,7 @@ export function useSessionActions({
return
}
const fallback = await getSessionMessages(storedSessionId)
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) {
return

View File

@@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'memory.provider': ['', 'builtin', 'honcho'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
export const FIELD_LABELS: Record<string, string> = {
@@ -309,7 +310,8 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
'voice.auto_tts': 'Automatically speak assistant responses.',
'stt.enabled': 'Enable local or provider-backed speech transcription.',
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
'updates.non_interactive_local_changes':
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
}
// Curated desktop config surface: only fields a user might tune from the app.
@@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort'
'delegation.reasoning_effort',
'updates.non_interactive_local_changes'
]
}
]

View File

@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -6,6 +7,7 @@ import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
@@ -74,6 +76,23 @@ function ModeCard({
)
}
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
return (
<button
className={cn(
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
{label}
</button>
)
}
export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -83,6 +102,16 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
const [scope, setScope] = useState<null | string>(null)
const profiles = useStore($profiles)
useEffect(() => {
void refreshActiveProfile()
}, [])
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
@@ -100,8 +129,14 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}
setLoading(true)
// Clear scope-local entry state so a token from one scope can't leak into
// the next when switching profiles.
setRemoteToken('')
setLastTest(null)
desktop
.getConnectionConfig()
.getConnectionConfig(scope)
.then(config => {
if (cancelled) {
return
@@ -117,7 +152,7 @@ export function GatewaySettings() {
})
return () => void (cancelled = true)
}, [])
}, [scope])
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
@@ -223,6 +258,10 @@ export function GatewaySettings() {
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
// The 'default' profile uses the global ("All profiles") connection, so the
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@@ -239,6 +278,7 @@ export function GatewaySettings() {
const payload = () => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -296,6 +336,7 @@ export function GatewaySettings() {
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
@@ -305,7 +346,7 @@ export function GatewaySettings() {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
@@ -327,7 +368,7 @@ export function GatewaySettings() {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
@@ -357,6 +398,7 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@@ -395,10 +437,35 @@ export function GatewaySettings() {
</div>
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
its own remote host.
</p>
</div>
{namedProfiles.length > 0 ? (
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
Applies to
</div>
<div className="flex flex-wrap gap-1.5">
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
{namedProfiles.map(profile => (
<ScopeChip
active={scope === profile.name}
key={profile.name}
label={profile.name}
onSelect={() => setScope(profile.name)}
/>
))}
</div>
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{scope === null
? 'Default connection for every profile that has no override of its own.'
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
</p>
</div>
) : null}
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />

View File

@@ -1,6 +1,7 @@
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, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
@@ -173,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>

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}

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

@@ -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}

View File

@@ -4,14 +4,6 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
@@ -24,7 +16,7 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
import { appViewForPath, isOverlayView } from '../routes'
import { titlebarButtonClass } from './titlebar'
@@ -185,7 +177,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visibleSystemToolsBeforeSettings.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<ProfilesMenuButton navigate={navigate} />
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
@@ -193,47 +184,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
)
}
function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavigate> }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Profiles"
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title="Profiles"
type="button"
variant="ghost"
>
{/* Optical bump: the `account` glyph has more internal padding than
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
Nudge just this glyph to visually match its neighbours. */}
<Codicon name="account" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
<DropdownMenuLabel>
<div className="text-sm font-medium text-foreground">Profiles</div>
<div className="mt-1 text-xs font-normal leading-4 text-muted-foreground">
Advanced Hermes environments for separate personas, config, skills, and SOUL.md.
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
triggerHaptic('open')
navigate(PROFILES_ROUTE)
}}
>
<Codicon name="account" size="1rem" />
<span>Manage profiles</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
// Titlebar actions never show an active background — state reads from the
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
@@ -249,7 +199,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
@@ -272,7 +221,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>

View File

@@ -25,6 +25,14 @@ export interface SlashExecResponse {
warning?: string
}
export interface SessionTitleResponse {
title?: string
// True when the session row isn't persisted yet and the title was queued
// to be applied on the first turn (see tui_gateway session.title handler).
pending?: boolean
session_key?: string
}
export interface ExecCommandDispatchResponse {
type: 'exec' | 'plugin'
output?: string

View File

@@ -8,7 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
@@ -38,7 +38,12 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
],
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0']
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
session: [
'M8 9h8',
'M8 13h6',
'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z'
]
}
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
@@ -98,7 +103,7 @@ const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
* muted-foreground text so chips read as quiet tags on any bubble color. */
export const DIRECTIVE_CHIP_CLASS =
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
/**
* Parses our composer's `@type:value` references into directive segments
@@ -113,7 +118,7 @@ export const DIRECTIVE_CHIP_CLASS =
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = new RegExp(
'@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'@(file|folder|url|image|tool|line|terminal|session):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'g'
)
@@ -263,6 +268,14 @@ function shortLabel(type: HermesRefType, id: string): string {
}
}
// `@session:<profile>/<id>` — show a short id; the composer chip carries the
// friendly title, but once sent the wire form only has the id.
if (type === 'session') {
const sid = id.split('/').filter(Boolean).pop() || id
return sid.length > 10 ? `${sid.slice(0, 8)}` : sid
}
const tail = id.split(/[\\/]/).filter(Boolean).pop()
return tail || id

View File

@@ -438,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts
.slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex))
.slice(Math.max(0, startIndex))
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)

View File

@@ -2,7 +2,8 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime }
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $approvalRequest } from '@/store/prompts'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
@@ -120,13 +121,15 @@ function GroupHarness({ message }: { message: ThreadMessage }) {
}
beforeEach(() => {
$approvalRequest.set(null)
clearAllPrompts()
$activeSessionId.set('sess-1')
$toolDisclosureStates.set({})
})
afterEach(() => {
cleanup()
$approvalRequest.set(null)
clearAllPrompts()
$activeSessionId.set(null)
})
describe('ToolGroupSlot approval surfacing', () => {
@@ -143,7 +146,7 @@ describe('ToolGroupSlot approval surfacing', () => {
})
it('force-opens the group body so the approval surfaces without expanding', async () => {
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)

View File

@@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $approvalRequest } from '@/store/prompts'
import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
@@ -13,7 +14,8 @@ function part(toolName: string): ToolPart {
}
function setRequest(command = 'rm -rf /tmp/x') {
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
$activeSessionId.set('sess-1')
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@@ -25,7 +27,8 @@ function mockGateway() {
afterEach(() => {
cleanup()
$approvalRequest.set(null)
clearAllPrompts()
$activeSessionId.set(null)
$gateway.set(null)
})

View File

@@ -81,7 +81,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
session_id: request.sessionId ?? undefined
})
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest()
clearApprovalRequest(request.sessionId)
} catch (error) {
notifyError(error, 'Could not send approval response')
setSubmitting(null)

View File

@@ -3,6 +3,7 @@
import { type ComponentPropsWithRef, forwardRef } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> {
@@ -11,19 +12,20 @@ export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof But
}
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
return (
<Button
size="icon-xs"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon', className)}
ref={ref}
title={tooltip}
>
{children}
</Button>
<Tip label={tooltip} side={side}>
<Button
size="icon-xs"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon', className)}
ref={ref}
>
{children}
</Button>
</Tip>
)
}
)

View File

@@ -2,11 +2,23 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons'
import type { DesktopConnectionConfig } from '@/global'
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
type BusyAction = 'local' | 'repair' | 'retry' | null
import type { RemoteReauth } from './boot-failure-reauth'
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
// A remote gateway whose access cookie has lapsed (e.g. the dashboard
// restarted on the remote box) boots into this overlay with a reauth-shaped
// error. The local-recovery buttons (Retry resets the local bootstrap latch;
// Repair re-runs the installer) are no-ops for that case — the only fix is to
// re-establish the remote session. The detection + copy helpers live in
// ./boot-failure-reauth so they're unit-testable without a React render.
// Recovery surface for a hard boot failure (gateway never came up, backend
// exited during startup, bootstrap latched, …). Without this the app shell
@@ -18,6 +30,7 @@ export function BootFailureOverlay() {
const [busy, setBusy] = useState<BusyAction>(null)
const [logs, setLogs] = useState<string[]>([])
const [showLogs, setShowLogs] = useState(false)
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
const visible = Boolean(boot.error) && !boot.running
// While first-run onboarding owns the picker/flow we let it surface its own
@@ -36,6 +49,59 @@ export function BootFailureOverlay() {
.catch(() => undefined)
}, [visible])
// Resolve whether this boot failure is a remote-gateway reauth so we can
// offer the actionable "Sign in" path instead of the local-only recovery
// buttons. Runs whenever the overlay becomes visible.
useEffect(() => {
if (!visible) {
setRemoteReauth(null)
return
}
let cancelled = false
void (async () => {
const desktop = window.hermesDesktop
if (!desktop?.getConnectionConfig) {
return
}
let config: DesktopConnectionConfig
try {
config = await desktop.getConnectionConfig()
} catch {
return
}
if (cancelled || !isRemoteReauthFailure(config)) {
return
}
// Best-effort probe for the provider shape so the button copy matches
// what the user will see in the login window (password form vs OAuth
// redirect). Probe failure just keeps the generic copy.
let shape = deriveProviderShape(null)
try {
const probe = await desktop.probeConnectionConfig(config.remoteUrl)
shape = deriveProviderShape(probe?.providers)
} catch {
// Generic copy is fine.
}
if (!cancelled) {
setRemoteReauth({ url: config.remoteUrl, ...shape })
}
})()
return () => {
cancelled = true
}
}, [visible])
if (!visible || suppressed) {
return null
}
@@ -59,8 +125,44 @@ export function BootFailureOverlay() {
setBusy(null)
}
// Open the gateway's login window (renders the username/password form for a
// basic gateway, or the OAuth redirect otherwise — the desktop drives both
// through the same window). On a successful sign-in the session cookie is
// re-established in the persistent partition; reload so boot re-runs and the
// reconnect now mints a ticket against a live session.
const signInRemote = async () => {
if (!remoteReauth) {
return
}
setBusy('signin')
try {
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
if (result?.connected) {
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
window.location.reload()
return
}
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
} catch (err) {
notifyError(err, 'Sign-in failed')
} finally {
setBusy(null)
}
}
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const label = signInLabel(remoteReauth)
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
@@ -69,10 +171,13 @@ export function BootFailureOverlay() {
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The background gateway didn't come up. Try one of the recovery steps below nothing here deletes your
chats or settings.
{remoteReauth
? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
</p>
</div>
</div>
@@ -84,14 +189,23 @@ export function BootFailureOverlay() {
<div className="grid gap-2">
<div className="flex flex-wrap gap-2">
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
</Button>
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
</Button>
{remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
{busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{label}
</Button>
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway
@@ -102,7 +216,9 @@ export function BootFailureOverlay() {
</Button>
</div>
<p className="text-xs text-muted-foreground">
Repair re-runs the installer and can take a few minutes on a fresh machine.
{remoteReauth
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
</p>
</div>

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import type { DesktopConnectionConfig } from '@/global'
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
return {
envOverride: false,
mode: 'remote',
profile: null,
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
...overrides
}
}
describe('isRemoteReauthFailure', () => {
it('true for a remote, gated, disconnected gateway with a URL', () => {
expect(isRemoteReauthFailure(config())).toBe(true)
})
it('false when the oauth session is still connected', () => {
expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false)
})
it('false for a local gateway', () => {
expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false)
})
it('false for a token (non-gated) remote gateway', () => {
expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false)
})
it('false when there is no remote URL to sign in against', () => {
expect(isRemoteReauthFailure(config({ remoteUrl: '' }))).toBe(false)
})
it('false for null/undefined config', () => {
expect(isRemoteReauthFailure(null)).toBe(false)
expect(isRemoteReauthFailure(undefined)).toBe(false)
})
})
describe('deriveProviderShape', () => {
it('generic copy when there are no providers', () => {
expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
expect(deriveProviderShape(null)).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
})
it('password shape when the sole provider supports password', () => {
expect(
deriveProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
).toEqual({ isPassword: true, providerLabel: 'Username & Password' })
})
it('OAuth shape when the provider is a redirect IDP', () => {
expect(deriveProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])).toEqual({
isPassword: false,
providerLabel: 'Nous Research'
})
})
it('mixed deployment keeps generic OAuth copy (not every provider is password)', () => {
const shape = deriveProviderShape([
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
])
expect(shape.isPassword).toBe(false)
expect(shape.providerLabel).toBe('Username & Password / Nous Research')
})
it('falls back to name when displayName is empty', () => {
expect(deriveProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }]).providerLabel).toBe(
'basic'
)
})
})
describe('signInLabel', () => {
it('password gateway gets the plain "Sign in to remote gateway" copy', () => {
expect(signInLabel({ url: 'x', isPassword: true, providerLabel: 'Username & Password' })).toBe(
'Sign in to remote gateway'
)
})
it('OAuth gateway names the provider', () => {
expect(signInLabel({ url: 'x', isPassword: false, providerLabel: 'Nous Research' })).toBe(
'Sign in with Nous Research'
)
})
it('null reauth falls back to the generic provider phrase', () => {
expect(signInLabel(null)).toBe('Sign in with your identity provider')
})
})

View File

@@ -0,0 +1,67 @@
import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global'
// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out
// of the .tsx so they can be unit-tested without a React/jsdom render (the
// jsx-dev-runtime resolution in this repo's vitest setup is flaky for
// component renders, but these are plain functions).
export interface RemoteReauth {
url: string
// True when every advertised provider is username/password — drives the
// button copy ("Sign in to remote gateway" vs "Sign in with <provider>"),
// mirroring the gateway-settings page. Probe is best-effort.
isPassword: boolean
providerLabel: string
}
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
// fix it — only re-establishing the remote session can. A connected oauth
// session, or a token/local gateway, boots for some other reason the
// local-recovery buttons address, so those return false here.
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
if (!config) {
return false
}
return (
config.mode === 'remote' &&
config.remoteAuthMode === 'oauth' &&
!config.remoteOauthConnected &&
Boolean(config.remoteUrl)
)
}
// Derive the password flag + display label from the probed providers. A
// gateway is treated as password-style only when EVERY advertised provider
// supports password (a mixed deployment keeps the generic OAuth copy), so the
// button copy matches the login window the user is about to see.
export function deriveProviderShape(providers: DesktopAuthProvider[] | null | undefined): {
isPassword: boolean
providerLabel: string
} {
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: 'your identity provider' }
}
const isPassword = list.every(p => Boolean(p.supportsPassword))
const providerLabel =
list.length === 1
? list[0].displayName || list[0].name
: list.map(p => p.displayName || p.name).join(' / ')
return { isPassword, providerLabel }
}
// Button copy for the remote sign-in action.
export function signInLabel(reauth: RemoteReauth | null): string {
if (reauth?.isPassword) {
return 'Sign in to remote gateway'
}
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
}

View File

@@ -25,6 +25,7 @@ function setProviders(providers: OAuthProvider[]) {
providers,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
} satisfies DesktopOnboardingState)
}
@@ -33,6 +34,13 @@ const ctx: OnboardingContext = { requestGateway: async () => undefined as never
afterEach(() => {
cleanup()
try {
window.localStorage.clear()
} catch {
// jsdom localStorage should always be present; ignore if not.
}
$desktopOnboarding.set({
configured: null,
flow: { status: 'idle' },
@@ -40,6 +48,7 @@ afterEach(() => {
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
})
@@ -68,4 +77,24 @@ describe('onboarding Picker', () => {
expect(screen.queryByText('Other sign-in options')).toBeNull()
expect(screen.queryByText('Recommended')).toBeNull()
})
it('offers "choose later" on first run and persists the skip', () => {
setProviders([provider('nous', 'Nous Portal')])
render(<Picker ctx={ctx} />)
const skip = screen.getByRole('button', { name: "I'll choose a provider later" })
fireEvent.click(skip)
expect($desktopOnboarding.get().firstRunSkipped).toBe(true)
expect(window.localStorage.getItem('hermes-onboarding-skipped-v1')).toBe('1')
})
it('hides "choose later" in manual (add-provider) mode', () => {
setProviders([provider('nous', 'Nous Portal')])
$desktopOnboarding.set({ ...$desktopOnboarding.get(), manual: true })
render(<Picker ctx={ctx} />)
expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull()
})
})

View File

@@ -29,6 +29,7 @@ import {
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
dismissFirstRunOnboarding,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
@@ -189,6 +190,13 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return null
}
// The user chose "I'll choose a provider later" on first run. Stay out of the
// way on every subsequent launch — they re-enter via Settings → Providers
// (manual mode), which sets manual=true and bypasses this gate.
if (onboarding.firstRunSkipped && !onboarding.manual) {
return null
}
const { flow } = onboarding
const rawReason = onboarding.reason?.trim() || null
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
@@ -304,18 +312,25 @@ const persistShowAll = (value: boolean) => {
}
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { mode, providers } = useStore($desktopOnboarding)
const { manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
return (
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
{manual ? null : (
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
<ChooseLaterLink />
</div>
)}
</div>
)
}
@@ -352,7 +367,11 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</button>
) : null}
<div className="flex justify-end pt-1">
<div className="flex items-center justify-between gap-3 pt-1">
{/* First run only: let the user defer the choice and land in the app.
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant — hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('apikey')}
@@ -365,6 +384,21 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
// "I'll choose a provider later" — dismisses the first-run picker and persists
// the skip so it never re-nags. The user connects a provider any time from
// Settings → Providers. Rendered only on the unconfigured first-run flow.
function ChooseLaterLink() {
return (
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => dismissFirstRunOnboarding()}
type="button"
>
I'll choose a provider later
</button>
)
}
export function FeaturedProviderRow({
onSelect,
provider

View File

@@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Codicon } from '@/components/ui/codicon'
@@ -63,10 +64,16 @@ export function NotificationStack() {
const [latest, ...olderNotifications] = notifications
const overflowCount = olderNotifications.length
return (
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
// content z-[130]). Without the portal the stack lives inside the React root
// subtree, which any body-level dialog/overlay portal paints over — so a
// success toast fired while a dialog is open (or over an OverlayView page)
// was invisible. The titlebar-height var only exists inside the app shell
// scope, so fall back to its constant (34px) when mounted on <body>.
return createPortal(
<div
aria-label="Notifications"
className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
<NotificationItem notification={latest} />
@@ -81,7 +88,8 @@ export function NotificationStack() {
</button>
</div>
)}
</div>
</div>,
document.body
)
}

View File

@@ -64,7 +64,7 @@ function SudoDialog() {
request_id: request.requestId
})
triggerHaptic('submit')
clearSudoRequest(request.requestId)
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {
notifyError(error, 'Could not send sudo password')
setSubmitting(false)
@@ -163,7 +163,7 @@ function SecretDialog() {
value: secret
})
triggerHaptic('submit')
clearSecretRequest(request.requestId)
clearSecretRequest(request.sessionId, request.requestId)
} catch (error) {
notifyError(error, 'Could not send secret')
setSubmitting(false)

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { Check, Loader2 } from '@/lib/icons'
// idle → saving → done label+icon for action buttons (create / rename / delete…).
export function ActionStatus({
state,
idle,
busy,
done,
idleIcon = null
}: {
state: 'done' | 'idle' | 'saving'
idle: string
busy: string
done: string
idleIcon?: ReactNode
}) {
return (
<>
{state === 'saving' ? <Loader2 className="size-4 animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
{state === 'saving' ? busy : state === 'done' ? done : idle}
</>
)
}

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from 'react'
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 { AlertTriangle } from '@/lib/icons'
interface ConfirmDialogProps {
open: boolean
onClose: () => void
// Does the work. Throw to surface an inline error and keep the dialog open.
onConfirm: () => Promise<void> | void
title: ReactNode
description?: ReactNode
confirmLabel?: string
busyLabel?: string
doneLabel?: string
cancelLabel?: string
destructive?: boolean
}
// Shared confirmation dialog: Enter confirms (from anywhere in the dialog),
// Esc/Cancel/backdrop dismiss. Owns the pending → done → close beat and inline
// error, so callers pass only an async onConfirm that does the work.
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
busyLabel = 'Working…',
doneLabel = 'Done',
cancelLabel = 'Cancel',
destructive = false
}: ConfirmDialogProps) {
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
const busy = status === 'saving' || status === 'done'
useEffect(() => {
if (open) {
setStatus('idle')
setError(null)
}
}, [open])
async function run() {
if (busy) {
return
}
setStatus('saving')
setError(null)
try {
await onConfirm()
setStatus('done')
window.setTimeout(onClose, 600)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Something went wrong')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent
className="max-w-md"
onKeyDown={event => {
// Enter/Space confirm regardless of which button holds focus
// (preventDefault stops a focused Cancel from swallowing it).
if ((event.key === 'Enter' || event.key === ' ') && !busy) {
event.preventDefault()
void run()
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{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">
{cancelLabel}
</Button>
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
<ActionStatus busy={busyLabel} done={doneLabel} idle={confirmLabel} state={status} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,6 +2,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -178,7 +179,6 @@ export function CopyButton({
)}
disabled={disabled}
onClick={event => void copy(event)}
title={feedbackLabel}
type="button"
>
{content}
@@ -188,34 +188,37 @@ export function CopyButton({
if (appearance === 'tool-row') {
return (
<button
aria-label={ariaLabel}
className={cn(
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
className
)}
disabled={disabled}
onClick={event => void copy(event)}
title={feedbackLabel}
type="button"
>
{icon}
</button>
<Tip label={feedbackLabel}>
<button
aria-label={ariaLabel}
className={cn(
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
className
)}
disabled={disabled}
onClick={event => void copy(event)}
type="button"
>
{icon}
</button>
</Tip>
)
}
return (
const button = (
<Button
aria-label={ariaLabel}
className={className}
disabled={disabled}
onClick={event => void copy(event)}
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
title={feedbackLabel}
type="button"
variant={buttonVariant}
>
{content}
</Button>
)
// Only icon-only buttons need a tooltip; the text variant already shows its label.
return appearance === 'icon' ? <Tip label={feedbackLabel}>{button}</Tip> : button
}

View File

@@ -1,6 +1,7 @@
import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
@@ -57,12 +58,16 @@ function DialogContent({
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
data-slot="dialog-close-button"
>
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label="Close"
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>

View File

@@ -0,0 +1,44 @@
import { Popover as PopoverPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverContent({
align = 'center',
className,
collisionPadding = 8,
sideOffset = 6,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
// (Radix flips/shifts off edges), with the standard open/close motion.
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
collisionPadding={collisionPadding}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View File

@@ -17,15 +17,18 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
function TooltipContent({
className,
sideOffset = 0,
sideOffset = 6,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
// Instant, no transition (the Provider's delayDuration=0 + no animate-*
// classes). bg-foreground/text-background auto-inverts per theme: white
// on near-black in light mode, black on white in dark.
className={cn(
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]',
className
)}
data-slot="tooltip-content"
@@ -33,10 +36,34 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Content>, 'content'> {
label: React.ReactNode
children: React.ReactNode
delayDuration?: number
}
// Drop-in replacement for native `title=`: wrap any single element. Instant,
// position-aware, themed. Self-contained (carries its own Provider) so it works
// anywhere without a provider ancestor. Renders the child untouched when label
// is falsy.
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
if (!label) {
return <>{children}</>
}
return (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent {...props}>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -3,16 +3,29 @@ export {}
declare global {
interface Window {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getGatewayWsUrl: () => Promise<string>
// Resolve a backend connection. Omit `profile` (or pass the primary) for
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
getConnection: (profile?: string | null) => Promise<HermesConnection>
// Keepalive: mark a pool profile backend as recently used so the idle
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
profile: {
get: () => Promise<DesktopActiveProfile>
// Persists the desktop's profile choice and relaunches the local
// backend under the new HERMES_HOME (reloads the window). Pass null to
// clear the preference.
set: (name: string | null) => Promise<DesktopActiveProfile>
}
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@@ -151,6 +164,9 @@ export interface HermesConnection {
token: string
wsUrl: string
logs: string[]
// Set for pool (non-primary) backends so the renderer knows which profile a
// connection belongs to.
profile?: string
windowButtonPosition: { x: number; y: number } | null
}
@@ -165,9 +181,18 @@ export interface HermesWindowState {
windowButtonPosition: { x: number; y: number } | null
}
export interface DesktopActiveProfile {
// The desktop's stored profile preference, or null when unset (legacy launch
// that defers to the sticky active_profile / default).
profile: string | null
}
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
@@ -177,6 +202,9 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
@@ -293,6 +321,10 @@ export interface HermesApiRequest {
method?: string
body?: unknown
timeoutMs?: number
// Route this REST call to a specific profile's backend. Omit for the primary
// (window) backend. Read-only cross-profile data is served by the primary, so
// this is only needed for profile-scoped live/settings calls.
profile?: string | null
}
export interface HermesNotification {

View File

@@ -29,7 +29,6 @@ import type {
OAuthSubmitResponse,
PaginatedSessions,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
SessionMessagesResponse,
@@ -81,7 +80,6 @@ export type {
PaginatedSessions,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
RpcEvent,
@@ -111,6 +109,22 @@ export class HermesGateway extends JsonRpcGatewayClient {
}
}
// Profile that profile-scoped REST settings (config/env/skills/tools/model/…)
// should target. Mirrors $activeGatewayProfile, pushed in from the store via
// setApiRequestProfile so this module needs no store import (avoids a cycle).
// Electron main consumes request.profile to pick which backend *process* serves
// the call; each pooled backend already has its own HERMES_HOME, so no backend
// change is needed. Null → primary, so single-profile users are unaffected.
let _apiProfile: null | string = null
export function setApiRequestProfile(profile: null | string): void {
_apiProfile = profile || null
}
function profileScoped(): { profile?: string } {
return _apiProfile ? { profile: _apiProfile } : {}
}
export async function listSessions(
limit = 40,
minMessages = 0,
@@ -128,6 +142,30 @@ export async function listSessions(
}
}
// Unified, read-only session list aggregated across ALL profiles. Served by the
// primary backend straight off each profile's state.db — no per-profile backend
// is spawned. Single-profile users get the same rows as listSessions(), tagged
// profile="default".
export async function listAllProfileSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent',
profile: 'all' | (string & {}) = 'all'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}`
})
return {
...result,
sessions: result.sessions.slice(0, limit),
offset: 0
}
}
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/sessions/${encodeURIComponent(id)}`,
@@ -142,9 +180,13 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
})
}
export function getSessionMessages(id: string): Promise<SessionMessagesResponse> {
// `profile` reads another profile's transcript straight off its state.db via the
// primary backend (no spawn). Omit for the current/default profile.
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
path: `/api/sessions/${encodeURIComponent(id)}/messages`
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@@ -155,16 +197,21 @@ export function deleteSession(id: string): Promise<{ ok: boolean }> {
})
}
export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> {
export function renameSession(
id: string,
title: string,
profile?: string | null
): Promise<{ ok: boolean; title: string }> {
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title }
body: { title, ...(profile ? { profile } : {}) }
})
}
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
return window.hermesDesktop.api<ModelInfoResponse>({
...profileScoped(),
path: '/api/model/info'
})
}
@@ -202,36 +249,42 @@ export function getLogs(params: {
const suffix = query.toString()
return window.hermesDesktop.api<LogsResponse>({
...profileScoped(),
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
})
}
export function getHermesConfig(): Promise<HermesConfig> {
return window.hermesDesktop.api<HermesConfig>({
...profileScoped(),
path: '/api/config'
})
}
export function getHermesConfigRecord(): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...profileScoped(),
path: '/api/config'
})
}
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...profileScoped(),
path: '/api/config/defaults'
})
}
export function getHermesConfigSchema(): Promise<ConfigSchemaResponse> {
return window.hermesDesktop.api<ConfigSchemaResponse>({
...profileScoped(),
path: '/api/config/schema'
})
}
export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/config',
method: 'PUT',
body: { config }
@@ -240,12 +293,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...profileScoped(),
path: '/api/env'
})
}
export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/env',
method: 'PUT',
body: { key, value }
@@ -257,6 +312,7 @@ export function validateProviderCredential(
value: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value }
@@ -265,6 +321,7 @@ export function validateProviderCredential(
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/env',
method: 'DELETE',
body: { key }
@@ -273,6 +330,7 @@ export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
export function revealEnvVar(key: string): Promise<{ key: string; value: string }> {
return window.hermesDesktop.api<{ key: string; value: string }>({
...profileScoped(),
path: '/api/env/reveal',
method: 'POST',
body: { key }
@@ -281,12 +339,14 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
return window.hermesDesktop.api<OAuthProvidersResponse>({
...profileScoped(),
path: '/api/providers/oauth'
})
}
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
@@ -295,6 +355,7 @@ export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse>
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
return window.hermesDesktop.api<OAuthSubmitResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
@@ -303,12 +364,14 @@ export function submitOAuthCode(providerId: string, sessionId: string, code: str
export function pollOAuthSession(providerId: string, sessionId: string): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
})
}
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
method: 'DELETE'
})
@@ -316,12 +379,14 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }>
export function getSkills(): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...profileScoped(),
path: '/api/skills'
})
}
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),
path: '/api/skills/toggle',
method: 'PUT',
body: { name, enabled }
@@ -330,6 +395,7 @@ export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boole
export function getToolsets(): Promise<ToolsetInfo[]> {
return window.hermesDesktop.api<ToolsetInfo[]>({
...profileScoped(),
path: '/api/tools/toolsets'
})
}
@@ -339,6 +405,7 @@ export function toggleToolset(
enabled: boolean
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
method: 'PUT',
body: { enabled }
@@ -347,6 +414,7 @@ export function toggleToolset(
export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
return window.hermesDesktop.api<ToolsetConfig>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
})
}
@@ -356,6 +424,7 @@ export function selectToolsetProvider(
provider: string
): Promise<{ ok: boolean; name: string; provider: string }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
body: { provider }
@@ -485,20 +554,16 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return window.hermesDesktop.api<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
})
}
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: '/api/model/options'
})
}
@@ -515,6 +580,7 @@ export interface RecommendedDefaultModel {
// free user gets a free model instead of a paid default.
export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> {
return window.hermesDesktop.api<RecommendedDefaultModel>({
...profileScoped(),
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
})
}
@@ -524,6 +590,7 @@ export function setGlobalModel(
model: string
): Promise<{ ok: boolean; provider: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body: {
@@ -536,12 +603,14 @@ export function setGlobalModel(
export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> {
return window.hermesDesktop.api<AuxiliaryModelsResponse>({
...profileScoped(),
path: '/api/model/auxiliary'
})
}
export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> {
return window.hermesDesktop.api<ModelAssignmentResponse>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body

View File

@@ -31,6 +31,7 @@ const DESKTOP_COMMAND_META = [
['/goal', 'Manage the standing goal for this session'],
['/help', 'Show desktop slash commands'],
['/new', 'Start a new desktop chat'],
['/profile', 'Switch the active Hermes profile'],
['/queue', 'Queue a prompt for the next turn'],
['/resume', 'Resume a saved session'],
['/retry', 'Retry the last user message'],
@@ -111,7 +112,6 @@ const ADVANCED_COMMANDS = new Set([
'/insights',
'/kanban',
'/personality',
'/profile',
'/reasoning',
'/reload-mcp',
'/reload-skills',

View File

@@ -25,8 +25,10 @@ import type { HermesConnection } from '@/global'
* transport failure.
*/
export interface ResolveGatewayWsUrlDeps {
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */
getGatewayWsUrl?: () => Promise<string>
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The
* optional profile selects which backend to mint for — critical when swapping
* to a pooled profile, since the default mint resolves the primary backend. */
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
}
export class GatewayReauthRequiredError extends Error {
@@ -47,9 +49,13 @@ export function isGatewayReauthRequired(error: unknown): error is GatewayReauthR
export async function resolveGatewayWsUrl(
desktop: ResolveGatewayWsUrlDeps,
conn: Pick<HermesConnection, 'authMode' | 'wsUrl'>
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
): Promise<string> {
const mint = desktop.getGatewayWsUrl
// Mint for THIS connection's profile, not the primary. Without it a pooled
// profile swap re-mints the default backend's URL and connects to the wrong
// backend.
const profile = conn.profile ?? null
if (conn.authMode === 'oauth') {
if (!mint) {
@@ -62,7 +68,7 @@ export async function resolveGatewayWsUrl(
}
try {
return await mint()
return await mint(profile)
} catch (error) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
@@ -74,7 +80,7 @@ export async function resolveGatewayWsUrl(
// token / local: the URL carries a long-lived token. Re-mint when available
// (cheap, keeps parity), but the cached URL is a safe fallback.
if (mint) {
const fresh = await mint().catch(() => null)
const fresh = await mint(profile).catch(() => null)
if (fresh) {
return fresh

View File

@@ -87,6 +87,15 @@ export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => P
let registeredTrigger: HapticTrigger | null = null
let lastSelectionAt = 0
// Global rolling rate-limit. A runaway upstream loop (auth-expiry error-toast
// storms, reconnect flaps) can request dozens of haptics a second, which the
// trackpad actuator renders as a frantic "clickity" buzz. Cap firings to
// RATE_LIMIT per RATE_WINDOW so no source can machine-gun the actuator;
// intentional UI haptics are human-paced and never approach the ceiling.
const RATE_WINDOW = 1000
const RATE_LIMIT = 5
let recentFires: number[] = []
export function registerHapticTrigger(trigger: HapticTrigger | null) {
registeredTrigger = trigger
}
@@ -106,6 +115,14 @@ export function triggerHaptic(intent: HapticIntent = 'selection') {
lastSelectionAt = now
}
recentFires = recentFires.filter(t => now - t < RATE_WINDOW)
if (recentFires.length >= RATE_LIMIT) {
return
}
recentFires.push(now)
const config = HAPTIC_INTENTS[intent]
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)

View File

@@ -0,0 +1,58 @@
// Deterministic per-profile color so a profile is glanceable across the app
// (the sidebar profile rail). The default/root profile has no color — named
// profiles get a stable hue derived from the name, so the same profile always
// reads the same color without persisting anything.
const PROFILE_TAG_SATURATION = 68
const PROFILE_TAG_LIGHTNESS = 58
function hashString(value: string): number {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0
}
return hash
}
// Returns an hsl() string for a named profile, or null for default/empty
// (rendered neutral / untagged).
export function profileColor(name: null | string | undefined): null | string {
const key = (name ?? '').trim()
if (!key || key === 'default') {
return null
}
const hue = hashString(key) % 360
return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)`
}
// A profile's effective color: a user-picked override wins, else the
// deterministic hue. Default/empty stays neutral (null) regardless.
export function resolveProfileColor(
name: null | string | undefined,
overrides: Record<string, string>
): null | string {
const key = (name ?? '').trim()
if (!key || key === 'default') {
return null
}
return overrides[key] ?? profileColor(key)
}
// Curated swatches for the rail color picker — evenly spaced hues at the same
// saturation/lightness as the deterministic palette, so picks stay cohesive.
export const PROFILE_SWATCHES: readonly string[] = Array.from(
{ length: 12 },
(_, index) => `hsl(${index * 30} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)`
)
// Translucent fill derived from a profile color, for tag backgrounds.
export function profileColorSoft(color: string, percent = 16): string {
return `color-mix(in srgb, ${color} ${percent}%, transparent)`
}

View File

@@ -0,0 +1,13 @@
import { QueryClient } from '@tanstack/react-query'
// Shared React Query client. Lives in its own module (not main.tsx) so non-React
// code — e.g. the profile store on a gateway swap — can invalidate cached,
// profile-scoped settings without importing the app entry point.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 60_000
}
}
})

View File

@@ -64,6 +64,36 @@ export function persistStringArray(key: string, value: string[]) {
}
}
export function storedStringRecord(key: string): Record<string, string> {
try {
const value = window.localStorage.getItem(key)
if (!value) {
return {}
}
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
} catch {
return {}
}
}
export function persistStringRecord(key: string, value: Record<string, string>) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// Local preference; restricted storage should not break the app.
}
}
export function arraysEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}

View File

@@ -1,6 +1,6 @@
import './styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
@@ -9,6 +9,7 @@ import App from './app'
import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client'
import { ThemeProvider } from './themes/context'
installClipboardShim()
@@ -22,15 +23,6 @@ if (import.meta.env.MODE !== 'production') {
import('./app/chat/perf-probe')
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 60_000
}
}
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary label="root">

View File

@@ -1,16 +1,290 @@
import type { ConnectionState, GatewayEvent } from '@hermes/shared'
import { atom } from 'nanostores'
import type { HermesGateway } from '@/hermes'
import { HermesGateway } from '@/hermes'
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { setGatewayState } from '@/store/session'
// ── Multi-profile gateway routing ──────────────────────────────────────────
// Concurrent sessions across profiles need concurrent sockets: the renderer's
// event handler is already session-keyed, so the only thing stopping two
// profiles streaming at once was the single swapping socket. We keep that one
// socket as the PRIMARY (window) backend — owned by use-gateway-boot, with all
// its boot-progress / sleep-wake machinery — and add one persistent SECONDARY
// socket per *other* profile that has live work. Every socket feeds the same
// handleGatewayEvent, so background sessions keep painting. Single-profile users
// only ever have the primary, so their path is byte-for-byte unchanged.
const normKey = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default'
// Read connection state through a call so TS control-flow analysis doesn't
// narrow the getter to a constant across guards (it genuinely changes).
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
// The active gateway instance, exposed for inline message-stream components
// (e.g. inline ClarifyTool) that need to call gateway methods without having
// the instance threaded down through props from `ChatView`.
// (e.g. inline ClarifyTool, model overlays) that call gateway methods without
// the instance threaded down through props.
export const $gateway = atom<HermesGateway | null>(null)
export function setGateway(gateway: HermesGateway | null): void {
if ($gateway.get() === gateway) {
interface RegistryConfig {
onEvent: (event: GatewayEvent) => void
}
let config: RegistryConfig | null = null
export function configureGatewayRegistry(cfg: RegistryConfig): void {
config = cfg
}
// ── Primary (window) backend ───────────────────────────────────────────────
let primaryGateway: HermesGateway | null = null
let primaryProfile = 'default'
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
primaryGateway = gateway
primaryProfile = normKey(profile)
}
// ── Secondary (pool) backends ──────────────────────────────────────────────
interface Secondary {
profile: string
gateway: HermesGateway
offEvent: () => void
offState: () => void
reconnectTimer: ReturnType<typeof setTimeout> | null
reconnectAttempt: number
reconnecting: boolean
// While true the entry auto-reconnects on drop; pruning flips it off so a
// deliberate close doesn't trigger the backoff loop.
wantOpen: boolean
}
const secondaries = new Map<string, Secondary>()
let activeKey = 'default'
export function isActivePrimary(): boolean {
return activeKey === primaryProfile
}
export function activeGateway(): HermesGateway | null {
if (activeKey === primaryProfile) {
return primaryGateway
}
return secondaries.get(activeKey)?.gateway ?? primaryGateway
}
// Mirror a backend's connection state into the global composer state, but only
// when that backend is the one the user is currently looking at. Lets the
// composer reflect the active profile's socket without a background reconnect
// flipping the foreground enabled/disabled state.
function reportGatewayState(profile: string, state: ConnectionState): void {
if (normKey(profile) === activeKey) {
setGatewayState(state)
}
}
export function reportPrimaryGatewayState(state: ConnectionState): void {
reportGatewayState(primaryProfile, state)
}
function setActive(profile: string): void {
activeKey = normKey(profile)
const gateway = activeGateway()
$gateway.set(gateway)
setGatewayState(gateway?.connectionState ?? 'closed')
}
function clearTimer(entry: Secondary): void {
if (entry.reconnectTimer !== null) {
clearTimeout(entry.reconnectTimer)
entry.reconnectTimer = null
}
}
async function openSecondary(entry: Secondary): Promise<void> {
const desktop = window.hermesDesktop
if (!desktop) {
return
}
$gateway.set(gateway)
const conn = await desktop.getConnection(entry.profile)
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await entry.gateway.connect(wsUrl)
void desktop.touchBackend?.(entry.profile).catch(() => undefined)
}
function scheduleReconnect(entry: Secondary): void {
if (entry.reconnecting || entry.reconnectTimer !== null || !entry.wantOpen) {
return
}
// 1s, 2s, 4s … capped at 15s — same backoff shape as the primary.
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(entry.reconnectAttempt, 4))
entry.reconnectAttempt += 1
entry.reconnectTimer = setTimeout(() => {
entry.reconnectTimer = null
void reconnectSecondary(entry)
}, delay)
}
async function reconnectSecondary(entry: Secondary): Promise<void> {
if (entry.reconnecting || !entry.wantOpen || isOpen(entry.gateway)) {
return
}
entry.reconnecting = true
try {
await openSecondary(entry)
entry.reconnectAttempt = 0
} catch {
// Transport failure → fall through to the backoff below.
} finally {
entry.reconnecting = false
if (entry.wantOpen && !isOpen(entry.gateway)) {
scheduleReconnect(entry)
}
}
}
function createSecondary(profile: string): Secondary {
const gateway = new HermesGateway()
const entry: Secondary = {
profile,
gateway,
offEvent: () => {},
offState: () => {},
reconnectTimer: null,
reconnectAttempt: 0,
reconnecting: false,
wantOpen: true
}
entry.offEvent = gateway.onEvent(event => config?.onEvent(event))
entry.offState = gateway.onState(state => {
reportGatewayState(profile, state)
if (state === 'open') {
entry.reconnectAttempt = 0
clearTimer(entry)
} else if ((state === 'closed' || state === 'error') && entry.wantOpen) {
scheduleReconnect(entry)
}
})
secondaries.set(profile, entry)
return entry
}
// Make `profile` the active gateway, lazily opening its socket if needed. The
// primary is a no-op fast path. Background sockets are never closed here.
export async function ensureGatewayForProfile(profile: string): Promise<void> {
const key = normKey(profile)
if (key === primaryProfile) {
setActive(key)
return
}
let entry = secondaries.get(key)
if (!entry) {
entry = createSecondary(key)
}
entry.wantOpen = true
if (!isOpen(entry.gateway)) {
clearTimer(entry)
entry.reconnectAttempt = 0
try {
await openSecondary(entry)
} catch {
scheduleReconnect(entry)
}
}
setActive(key)
}
// Reconnect the active gateway after a transient request failure. Primary
// reconnects are owned by use-gateway-boot, so we only drive secondaries here.
export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
if (activeKey === primaryProfile) {
return primaryGateway
}
const entry = secondaries.get(activeKey)
if (!entry) {
return null
}
if (!isOpen(entry.gateway)) {
await reconnectSecondary(entry)
}
return isOpen(entry.gateway) ? entry.gateway : null
}
// Wake signal (sleep/network/visibility): nudge every live secondary back open.
export function reconnectSecondaryGateways(): void {
for (const entry of secondaries.values()) {
if (!entry.wantOpen || isOpen(entry.gateway)) {
continue
}
entry.reconnectAttempt = 0
clearTimer(entry)
void reconnectSecondary(entry)
}
}
// Keep the idle reaper from killing a backend we still need: ping every live
// secondary. The active one is pinged separately (touchActiveGatewayBackend).
export function touchSecondaryGateways(): void {
const desktop = window.hermesDesktop
for (const entry of secondaries.values()) {
if (entry.wantOpen) {
void desktop?.touchBackend?.(entry.profile).catch(() => undefined)
}
}
}
// Close + evict secondaries whose profile is neither active nor in `keep`
// (profiles with a running / needs-input session). Bounds cost to live work.
export function pruneSecondaryGateways(keep: Set<string>): void {
for (const [key, entry] of [...secondaries]) {
if (key === activeKey || keep.has(key)) {
continue
}
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
secondaries.delete(key)
}
}
export function closeSecondaryGateways(): void {
for (const entry of secondaries.values()) {
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
}
secondaries.clear()
}

View File

@@ -7,7 +7,7 @@ const STORAGE_KEY = 'hermes.desktop.visible-models'
/** Models shown per provider in the status-bar dropdown before the user has
* customized the list. Backend `models` are already relevance-ordered. */
export const DEFAULT_VISIBLE_PER_PROVIDER = 5
export const DEFAULT_VISIBLE_PER_PROVIDER = 50
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
* that contain a single colon, e.g. `model:tag`). */

View File

@@ -8,7 +8,8 @@ import {
type OnboardingContext,
refreshOnboarding,
requestDesktopOnboarding,
saveOnboardingLocalEndpoint
saveOnboardingLocalEndpoint,
submitOnboardingCode
} from './onboarding'
function provider(id: string, name = id): OAuthProvider {
@@ -30,6 +31,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
...overrides
}
@@ -145,6 +147,100 @@ describe('refreshOnboarding', () => {
})
})
describe('OAuth onboarding', () => {
beforeEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
})
afterEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
vi.restoreAllMocks()
})
it('clears stale readiness errors after OAuth succeeds and model confirmation is shown', async () => {
const model = 'anthropic/claude-opus-4.8'
const calls: { body?: unknown; path: string }[] = []
installApiMock(async ({ body, path }: { body?: unknown; path: string }) => {
calls.push({ body, path })
if (path === '/api/providers/oauth/nous/submit') {
return { ok: true, status: 'approved' }
}
if (path === '/api/model/options') {
return {
providers: [
{
name: 'Nous Portal',
slug: 'nous',
models: [model]
}
]
}
}
if (path.startsWith('/api/model/recommended-default?')) {
return { provider: 'nous', model, free_tier: false }
}
if (path === '/api/model/set') {
return { ok: true, provider: 'nous', model, gateway_tools: [] }
}
throw new Error(`unexpected api path: ${path}`)
})
const requestGateway: OnboardingContext['requestGateway'] = async method => {
if (method === 'reload.env') {
return {} as never
}
if (method === 'setup.status') {
return { provider_configured: true } as never
}
if (method === 'setup.runtime_check') {
return { ok: true } as never
}
throw new Error(`unexpected gateway method: ${method}`)
}
$desktopOnboarding.set(
baseState({
flow: {
status: 'awaiting_user',
provider: provider('nous', 'Nous Portal'),
start: {
auth_url: 'https://portal.example/auth',
expires_in: 600,
flow: 'pkce',
session_id: 'portal-session'
},
code: 'fresh-code'
},
reason:
'No access token found for Nous Portal login. setup.status reports configured credentials, but runtime resolution still failed.',
requested: true
})
)
await submitOnboardingCode(onboardingContext(requestGateway))
const state = $desktopOnboarding.get()
expect(state.reason).toBeNull()
expect(state.flow.status).toBe('confirming_model')
if (state.flow.status === 'confirming_model') {
expect(state.flow.label).toBe('Nous Portal')
expect(state.flow.currentModel).toBe(model)
}
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
})
})
describe('saveOnboardingLocalEndpoint', () => {
beforeEach(() => {
window.localStorage.clear()

View File

@@ -60,6 +60,13 @@ export interface DesktopOnboardingState {
providers: null | OAuthProvider[]
reason: null | string
requested: boolean
/** True when the user explicitly chose "I'll choose a provider later" on the
* first-run picker. Persisted to localStorage so the blocking overlay never
* re-nags on subsequent launches — the user can connect a provider any time
* from Settings → Providers (or the model picker's "Add provider"). Distinct
* from `configured`: the app still has no usable provider, so chat won't work
* until one is connected; we just stop forcing the choice up front. */
firstRunSkipped: boolean
/** True when the user explicitly opened the provider selector to add /
* switch providers from an already-configured app (e.g. via the model
* picker's "Add provider" button). Forces the overlay to show the picker
@@ -73,6 +80,7 @@ export interface OnboardingContext {
}
const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
const SKIP_CACHE_KEY = 'hermes-onboarding-skipped-v1'
const POLL_MS = 2000
const COPY_FLASH_MS = 1500
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
@@ -105,6 +113,34 @@ function writeCachedConfigured(value: boolean) {
}
}
function readCachedSkipped(): boolean {
if (typeof window === 'undefined') {
return false
}
try {
return window.localStorage.getItem(SKIP_CACHE_KEY) === '1'
} catch {
return false
}
}
function writeCachedSkipped(value: boolean) {
if (typeof window === 'undefined') {
return
}
try {
if (value) {
window.localStorage.setItem(SKIP_CACHE_KEY, '1')
} else {
window.localStorage.removeItem(SKIP_CACHE_KEY)
}
} catch {
// localStorage unavailable — degrade silently.
}
}
const INITIAL: DesktopOnboardingState = {
configured: readCachedConfigured(),
flow: { status: 'idle' },
@@ -112,6 +148,7 @@ const INITIAL: DesktopOnboardingState = {
providers: null,
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false
}
@@ -125,7 +162,8 @@ const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
const patch = (update: Partial<DesktopOnboardingState>) =>
$desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
const setFlow = (flow: OnboardingFlow) => patch({ flow })
const setFlow = (flow: OnboardingFlow) =>
patch(flow.status === 'idle' ? { flow } : { flow, reason: null })
const sessionIdFor = (flow: OnboardingFlow) => ('start' in flow && flow.start ? flow.start.session_id : undefined)
@@ -398,6 +436,9 @@ export function closeManualOnboarding() {
export function completeDesktopOnboarding() {
clearPoll()
writeCachedConfigured(true)
// A real provider is now connected, so any earlier "choose later" skip is
// moot — clear it so the flag never lingers in a configured install.
writeCachedSkipped(false)
$desktopOnboarding.set({
configured: true,
flow: { status: 'idle' },
@@ -405,10 +446,23 @@ export function completeDesktopOnboarding() {
providers: null,
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
})
}
// "I'll choose a provider later" on the first-run picker. Persists the skip so
// the blocking overlay never re-nags on future launches, and dismisses it now
// so the user lands in the app. Chat won't work until a provider is connected
// (from Settings → Providers or the model picker's "Add provider") — this only
// stops forcing the choice up front. Distinct from completeDesktopOnboarding,
// which marks the app actually configured.
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
patch({ mode })
}

View File

@@ -0,0 +1,299 @@
import { atom, computed } from 'nanostores'
import { getProfiles, setApiRequestProfile } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import {
arraysEqual,
persistBoolean,
persistStringArray,
persistStringRecord,
storedBoolean,
storedStringArray,
storedStringRecord
} from '@/lib/storage'
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
import type { ProfileInfo } from '@/types/hermes'
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
// compare a session's owning profile against the live gateway's profile.
export function normalizeProfileKey(name: string | null | undefined): string {
const value = (name ?? '').trim()
return value || 'default'
}
// The profile the running local backend is actually scoped to (mirrors
// /api/profiles/active `current`). "default" is the root ~/.hermes. This is the
// display source of truth for the statusbar pill; the desktop's *stored*
// preference (which may be unset) lives in the Electron main process.
export const $activeProfile = atom<string>('default')
// Cached profile list for the picker. Refreshed lazily; the dropdown also
// re-fetches on open so a profile created elsewhere shows up.
export const $profiles = atom<ProfileInfo[]>([])
export function setActiveProfile(name: string): void {
$activeProfile.set(name || 'default')
}
// ── Rail order ─────────────────────────────────────────────────────────────
// User-defined order for the named (non-default) profile squares in the rail.
// Names absent from the list fall back to alphabetical, appended at the tail —
// so a freshly created profile lands at the end until the user drags it.
const PROFILE_ORDER_STORAGE_KEY = 'hermes.desktop.profileOrder'
export const $profileOrder = atom<string[]>(storedStringArray(PROFILE_ORDER_STORAGE_KEY))
$profileOrder.subscribe(value => persistStringArray(PROFILE_ORDER_STORAGE_KEY, [...value]))
export function setProfileOrder(names: string[]): void {
if (!arraysEqual($profileOrder.get(), names)) {
$profileOrder.set(names)
}
}
// Sort items by the stored order; unordered names alphabetise at the tail.
export function sortByProfileOrder<T extends { name: string }>(items: T[], order: string[]): T[] {
const rank = new Map(order.map((name, index) => [name, index]))
return [...items].sort((a, b) => {
const ra = rank.get(a.name)
const rb = rank.get(b.name)
if (ra != null && rb != null) {
return ra - rb
}
return ra != null ? -1 : rb != null ? 1 : a.name.localeCompare(b.name)
})
}
// ── Rail colors ────────────────────────────────────────────────────────────
// Optional per-profile color override (long-press a rail square to pick). Absent
// names fall back to the deterministic hue from profileColor(); a local-only
// cosmetic preference, so single-profile users never touch it.
const PROFILE_COLORS_STORAGE_KEY = 'hermes.desktop.profileColors'
export const $profileColors = atom<Record<string, string>>(storedStringRecord(PROFILE_COLORS_STORAGE_KEY))
$profileColors.subscribe(value => persistStringRecord(PROFILE_COLORS_STORAGE_KEY, value))
// Set (or, with null, clear) a profile's color override.
export function setProfileColor(name: string, color: null | string): void {
const key = normalizeProfileKey(name)
const next = { ...$profileColors.get() }
if (color) {
next[key] = color
} else {
delete next[key]
}
$profileColors.set(next)
}
interface ActiveProfileResponse {
active: string
current: string
}
// Pull the running backend's current profile + the available profile list.
// Best-effort: failures (backend not up yet) leave the prior values intact.
export async function refreshActiveProfile(): Promise<void> {
try {
const res = await window.hermesDesktop.api<ActiveProfileResponse>({ path: '/api/profiles/active' })
setActiveProfile(res.current || 'default')
} catch {
// Backend may not be ready; keep the last known value.
}
try {
const { profiles } = await getProfiles()
$profiles.set(profiles)
} catch {
// Leave the cached list in place.
}
}
// Persist the choice and relaunch the backend under the new HERMES_HOME. The
// main process reloads the window, so this normally never returns to the caller
// (the renderer is torn down). We optimistically reflect the selection first so
// the pill updates instantly if the reload is delayed.
export async function switchProfile(name: string): Promise<void> {
if (!name || name === $activeProfile.get()) {
return
}
setActiveProfile(name)
await window.hermesDesktop.profile.set(name)
}
// ── Swap-minimal gateway routing ──────────────────────────────────────────
// One live gateway at a time. When the user opens/sends a session whose profile
// differs from the gateway's current profile, we lazily reconnect the single
// gateway to that profile's backend (spawned on demand by the Electron pool).
// A single-profile user never triggers a swap, so their path is unchanged.
// The profile the live gateway WebSocket is currently connected to. Initialized
// to the primary (window) backend's profile on boot.
export const $activeGatewayProfile = atom<string>('default')
// Profile for the NEXT new chat (chosen via the new-chat picker). null = primary
// / default, so single-profile users are unaffected.
export const $newChatProfile = atom<string | null>(null)
// Bumped whenever the profile context actually changes (switch or create). The
// chat controller subscribes and drops to a fresh new-session draft, so the
// session you were in doesn't stay sticky across a profile switch.
export const $freshSessionRequest = atom(0)
function requestFreshSession(): void {
$freshSessionRequest.set($freshSessionRequest.get() + 1)
}
// Route profile-scoped REST settings (config/env/skills/tools/model/…) to the
// profile the live gateway is currently on, and drop cached settings from the
// previous profile so pages refetch against the right backend. Fires once
// immediately (no real change → no invalidation), so single-profile users just
// get "default" (→ the primary backend) with no extra fetches.
let _lastRoutedProfile: string | null = null
$activeGatewayProfile.subscribe(value => {
const key = normalizeProfileKey(value)
setApiRequestProfile(key)
if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) {
// Profile-scoped settings + the unified session list are now stale.
void queryClient.invalidateQueries()
}
_lastRoutedProfile = key
})
// Target profile while a gateway swap is mid-flight (spawning/reconnecting that
// profile's backend), else null. Drives the chat's "waking up <profile>" loader
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
export const $gatewaySwapTarget = atom<string | null>(null)
let gatewaySwitch: Promise<void> | null = null
// Make `profile`'s backend the active gateway, lazily opening its socket if it
// isn't live yet. Unlike the old single-socket swap, background profiles keep
// their sockets — so their sessions keep streaming concurrently. A null/empty
// target means "no explicit profile" → keep the current gateway (a plain new
// chat stays put; single-profile users never leave the primary).
export async function ensureGatewayProfile(profile: string | null | undefined): Promise<void> {
if (profile == null || !String(profile).trim()) {
// "No explicit profile" = use the current gateway. But if an explicit swap
// (e.g. the user just picked a profile in the switcher) is still in flight,
// let it settle first so a new chat doesn't race session.create against a
// half-open socket and land on the wrong backend.
if (gatewaySwitch) {
await gatewaySwitch.catch(() => undefined)
}
return
}
const target = normalizeProfileKey(profile)
if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()) {
return
}
// Serialize concurrent activations so two rapid session switches don't race
// the active pointer.
if (gatewaySwitch) {
await gatewaySwitch.catch(() => undefined)
if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()) {
return
}
}
$gatewaySwapTarget.set(target)
gatewaySwitch = (async () => {
// ensureGatewayForProfile opens (or reuses) the target's socket and points
// the active gateway at it — without closing the profile you came from.
await ensureGatewayForProfile(target)
$activeGatewayProfile.set(target)
})()
try {
await gatewaySwitch
} finally {
gatewaySwitch = null
$gatewaySwapTarget.set(null)
}
}
// ── Sidebar profile scope (the "workspace switcher" model) ─────────────────
// Mirrors how Slack/VS Code/Linear do multi-context: you're "in" one profile at
// a time and the sidebar shows only that profile's sessions (clean rows, no
// per-row tags). The lone exception is an explicit "All profiles" mode that
// fans every profile's sessions into one grouped, browsable list.
export const ALL_PROFILES = '__all__'
const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes.desktop.showAllProfiles'
// Opt-in unified view. When false, scope follows the live gateway profile, so
// single-profile users (who never see the switcher) are completely unaffected.
export const $showAllProfiles = atom<boolean>(storedBoolean(SHOW_ALL_PROFILES_STORAGE_KEY, false))
$showAllProfiles.subscribe(value => persistBoolean(SHOW_ALL_PROFILES_STORAGE_KEY, value))
// The profile context the sidebar is currently showing: a concrete profile key,
// or ALL_PROFILES for the unified grouped view. Concrete scope is tied to the
// gateway so opening/selecting a profile (which swaps the gateway) moves the
// whole sidebar with it — a real context switch, not a separate filter to keep
// in sync.
export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile], (showAll, gateway) =>
showAll ? ALL_PROFILES : normalizeProfileKey(gateway)
)
// Switch the active context to `name`: leave "All profiles" mode, point new
// chats at it, and swap the single live gateway onto its backend (which moves
// $activeGatewayProfile → name, so $profileScope follows).
export function selectProfile(name: string): void {
const target = normalizeProfileKey(name)
// Switching profiles (or coming back from the all-profiles browse view) starts
// fresh; re-tapping the profile you're already in leaves your session be.
const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get())
$showAllProfiles.set(false)
$newChatProfile.set(target)
if (switching) {
requestFreshSession()
}
void ensureGatewayProfile(target)
}
// Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse
// view. Unlike selectProfile, it leaves $showAllProfiles untouched, so the
// unified sidebar stays put — used by the per-profile "+" in the all-profiles
// session list, where switching scope would throw away the browse state the user
// is in. Points new chats at the profile and opens its backend so the next
// message lands in the right place.
export function newSessionInProfile(name: string): void {
const target = normalizeProfileKey(name)
$newChatProfile.set(target)
requestFreshSession()
void ensureGatewayProfile(target)
}
export function setShowAllProfiles(value: boolean): void {
$showAllProfiles.set(value)
}
// Keepalive ping for the active pool backend so the main-process idle reaper
// (which can't see the direct renderer↔backend WS) spares it. No-op for the
// primary/default backend, which is never pooled.
export function touchActiveGatewayBackend(): void {
// Always ping: the main process no-ops for non-pool (primary) backends, so we
// don't need to know which profile is primary from here.
const target = normalizeProfileKey($activeGatewayProfile.get())
void window.hermesDesktop?.touchBackend?.(target).catch(() => undefined)
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
$approvalRequest,
@@ -12,13 +12,21 @@ import {
setSecretRequest,
setSudoRequest
} from './prompts'
import { $activeSessionId } from './session'
// Prompts are parked per-session; the exported $*Request views are scoped to the
// active session, so each test focuses the session it's asserting on.
beforeEach(() => {
$activeSessionId.set('s1')
})
afterEach(() => {
clearAllPrompts()
$activeSessionId.set(null)
})
describe('approval prompt store', () => {
it('holds the most recent session-keyed approval request', () => {
it('holds the active session-keyed approval request', () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'recursive delete', sessionId: 's1' })
expect($approvalRequest.get()).toEqual({
@@ -28,9 +36,20 @@ describe('approval prompt store', () => {
})
})
it('clears unconditionally (approval is session-keyed, no request id)', () => {
it('parks a background session prompt out of the active view', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's2' })
// Not visible while s1 is focused …
expect($approvalRequest.get()).toBeNull()
// … but surfaces once the user switches to the session that raised it.
$activeSessionId.set('s2')
expect($approvalRequest.get()?.sessionId).toBe('s2')
})
it('clears the active session prompt', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
clearApprovalRequest()
clearApprovalRequest('s1')
expect($approvalRequest.get()).toBeNull()
})
@@ -38,21 +57,21 @@ describe('approval prompt store', () => {
describe('sudo prompt store', () => {
it('clears only when the request id matches the in-flight prompt', () => {
setSudoRequest({ requestId: 'abc' })
setSudoRequest({ requestId: 'abc', sessionId: 's1' })
// A stale clear for a different request must NOT drop the live prompt —
// otherwise a late response to a prior sudo ask would dismiss the current
// one and leave the agent blocked.
clearSudoRequest('stale')
expect($sudoRequest.get()).toEqual({ requestId: 'abc' })
clearSudoRequest('s1', 'stale')
expect($sudoRequest.get()).toEqual({ requestId: 'abc', sessionId: 's1' })
clearSudoRequest('abc')
clearSudoRequest('s1', 'abc')
expect($sudoRequest.get()).toBeNull()
})
it('clears unconditionally when no request id is given', () => {
setSudoRequest({ requestId: 'abc' })
clearSudoRequest()
setSudoRequest({ requestId: 'abc', sessionId: 's1' })
clearSudoRequest('s1')
expect($sudoRequest.get()).toBeNull()
})
@@ -60,32 +79,43 @@ describe('sudo prompt store', () => {
describe('secret prompt store', () => {
it('carries env var and prompt, and clears on id match', () => {
setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key' })
setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key', sessionId: 's1' })
expect($secretRequest.get()).toEqual({
requestId: 'r1',
envVar: 'OPENAI_API_KEY',
prompt: 'Paste your key'
prompt: 'Paste your key',
sessionId: 's1'
})
clearSecretRequest('mismatch')
clearSecretRequest('s1', 'mismatch')
expect($secretRequest.get()).not.toBeNull()
clearSecretRequest('r1')
clearSecretRequest('s1', 'r1')
expect($secretRequest.get()).toBeNull()
})
})
describe('clearAllPrompts', () => {
it('drops every in-flight prompt at once (turn end / interrupt)', () => {
it('drops every kind for one session at once (turn end / interrupt)', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
setSudoRequest({ requestId: 'abc' })
setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p' })
setSudoRequest({ requestId: 'abc', sessionId: 's1' })
setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p', sessionId: 's1' })
clearAllPrompts()
clearAllPrompts('s1')
expect($approvalRequest.get()).toBeNull()
expect($sudoRequest.get()).toBeNull()
expect($secretRequest.get()).toBeNull()
})
it('leaves other sessions parked prompts intact', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
setApprovalRequest({ command: 'y', description: 'e', sessionId: 's2' })
clearAllPrompts('s1')
$activeSessionId.set('s2')
expect($approvalRequest.get()?.command).toBe('y')
})
})

View File

@@ -1,86 +1,115 @@
import { atom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
import { $activeSessionId } from './session'
// Blocking interactive prompts the gateway raises mid-turn. Each maps to a
// `*.request` event the Python side emits while it blocks the agent thread
// waiting for a `*.respond` RPC. Without a renderer for these, the agent
// silently stalls until its timeout (default 5 min) and the tool is BLOCKED
// — the desktop app previously handled clarify.request but not these three,
// so dangerous-command approval, sudo, and secret prompts never surfaced.
// silently stalls until its timeout (default 5 min) and the tool is BLOCKED.
//
// Like clarify, every prompt is parked under the runtime session id that raised
// it (not one shared slot), so a *background* session running concurrently can
// raise an approval/sudo/secret prompt and have it wait — surfaced via the
// sidebar "needs input" badge — until the user switches to that chat. The
// exported $*Request view is scoped to the active session, so a background
// prompt never hijacks the foreground.
export interface ApprovalRequest {
command: string
description: string
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
interface KeyedPrompt {
sessionId: string | null
}
// Approval is session-keyed on the backend (one in-flight approval per
// session, resolved via approval.respond {choice, session_id}). It carries
// no request_id, unlike sudo/secret which are _block()-style request/response.
export const $approvalRequest = atom<ApprovalRequest | null>(null)
export function setApprovalRequest(request: ApprovalRequest): void {
$approvalRequest.set(request)
interface PromptStore<T extends KeyedPrompt> {
$active: ReadableAtom<null | T>
clear: (sessionId?: string | null, requestId?: string) => void
reset: () => void
set: (request: T) => void
}
export function clearApprovalRequest(): void {
$approvalRequest.set(null)
// One per-session prompt kind: a map keyed by session, plus an active-session
// view for the overlays. `clear` drops one session's entry (a request-id
// mismatch is a no-op so a stale resolve can't wipe a newer prompt); with no
// session hint it drops every entry, optionally filtered by request id.
function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
const $all = atom<Record<string, T>>({})
const idOf = (value: T): string | undefined => (value as { requestId?: string }).requestId
return {
$active: computed([$all, $activeSessionId], (all, activeId) => all[keyFor(activeId)] ?? null),
reset: () => $all.set({}),
set: request => $all.set({ ...$all.get(), [keyFor(request.sessionId)]: request }),
clear(sessionId, requestId) {
const all = $all.get()
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = all[key]
if (current && !(requestId && idOf(current) !== requestId)) {
const next = { ...all }
delete next[key]
$all.set(next)
}
return
}
const next = Object.fromEntries(Object.entries(all).filter(([, v]) => requestId && idOf(v) !== requestId))
if (Object.keys(next).length !== Object.keys(all).length) {
$all.set(next as Record<string, T>)
}
}
}
}
export interface SudoRequest {
// Approval is session-keyed on the backend (one in-flight approval per session,
// resolved via approval.respond {choice, session_id}). It carries no request_id,
// unlike sudo/secret which are _block()-style request/response.
export interface ApprovalRequest extends KeyedPrompt {
command: string
description: string
}
export interface SudoRequest extends KeyedPrompt {
requestId: string
}
export const $sudoRequest = atom<SudoRequest | null>(null)
export function setSudoRequest(request: SudoRequest): void {
$sudoRequest.set(request)
}
export function clearSudoRequest(requestId?: string): void {
const current = $sudoRequest.get()
if (!current) {
return
}
if (requestId && current.requestId !== requestId) {
return
}
$sudoRequest.set(null)
}
export interface SecretRequest {
requestId: string
export interface SecretRequest extends KeyedPrompt {
envVar: string
prompt: string
requestId: string
}
export const $secretRequest = atom<SecretRequest | null>(null)
const approval = keyedPromptStore<ApprovalRequest>()
const sudo = keyedPromptStore<SudoRequest>()
const secret = keyedPromptStore<SecretRequest>()
export function setSecretRequest(request: SecretRequest): void {
$secretRequest.set(request)
}
export const $approvalRequest = approval.$active
export const setApprovalRequest = approval.set
export const clearApprovalRequest = approval.clear
export function clearSecretRequest(requestId?: string): void {
const current = $secretRequest.get()
export const $sudoRequest = sudo.$active
export const setSudoRequest = sudo.set
export const clearSudoRequest = sudo.clear
export const $secretRequest = secret.$active
export const setSecretRequest = secret.set
export const clearSecretRequest = secret.clear
// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds —
// or every parked prompt when no session is given (global reset / tests).
export function clearAllPrompts(sessionId?: string | null): void {
if (sessionId === undefined) {
approval.reset()
sudo.reset()
secret.reset()
if (!current) {
return
}
if (requestId && current.requestId !== requestId) {
return
}
$secretRequest.set(null)
}
// Drop every in-flight prompt. Called when a turn ends (message.complete /
// error) so a stale overlay can't linger past the turn that raised it — e.g.
// if the agent was interrupted while a prompt was open.
export function clearAllPrompts(): void {
$approvalRequest.set(null)
$sudoRequest.set(null)
$secretRequest.set(null)
approval.clear(sessionId)
sudo.clear(sessionId)
secret.clear(sessionId)
}

View File

@@ -76,6 +76,11 @@ export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
export const $sessionsTotal = atom<number>(0)
// Listable conversation count per profile (children excluded), keyed by profile
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
// huge default profile doesn't keep "Load more" visible while browsing a small
// one. Empty for single-profile users (fall back to $sessionsTotal).
export const $sessionProfileTotals = atom<Record<string, number>>({})
export const $sessionsLoading = atom(true)
export const $workingSessionIds = atom<string[]>([])
export const $activeSessionId = atom<string | null>(null)
@@ -114,6 +119,8 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)

View File

@@ -241,6 +241,14 @@ export interface PaginatedSessions {
offset: number
sessions: SessionInfo[]
total: number
/** Listable conversation count per profile (children excluded), keyed by
* profile name. Lets the sidebar scope its "Load more" footer to the active
* profile instead of the global total. Present only on
* `/api/profiles/sessions`. */
profile_totals?: Record<string, number>
/** Per-profile read failures from the cross-profile aggregator (e.g. a locked
* or corrupt state.db). Present only on `/api/profiles/sessions`. */
errors?: Array<{ profile: string; error: string }>
}
export interface RpcEvent<T = unknown> {
@@ -277,6 +285,12 @@ export interface SessionInfo {
started_at: number
title: null | string
tool_call_count: number
/** Owning profile name, set by the cross-profile aggregator
* (`/api/profiles/sessions`). Absent on legacy single-profile responses,
* which the UI treats as the default profile. */
profile?: string
/** True when {@link profile} is the default profile. */
is_default_profile?: boolean
}
export interface SessionMessage {
@@ -435,6 +449,8 @@ export interface CronJobUpdates {
}
export interface ProfileCreatePayload {
clone_all?: boolean
clone_from?: string
clone_from_default?: boolean
name: string
no_skills?: boolean
@@ -450,10 +466,6 @@ export interface ProfileInfo {
skill_count: number
}
export interface ProfileSetupCommand {
command: string
}
export interface ProfileSoul {
content: string
exists: boolean

View File

@@ -871,7 +871,7 @@ delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
# WARNING: values above 10 multiply API cost linearly.
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
# Raise to 2 to allow workers to spawn their own subagents.
# Requires role="orchestrator" on intermediate agents.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).

12
cli.py
View File

@@ -974,7 +974,7 @@ def _run_cleanup():
try:
from tools.mcp_tool import shutdown_mcp_servers
shutdown_mcp_servers()
except Exception:
except BaseException:
pass
# Close cached auxiliary LLM clients (sync + async) so that
# AsyncHttpxClientWrapper.__del__ doesn't fire on a closed event loop
@@ -13094,6 +13094,16 @@ class HermesCLI:
_welcome_color = "#FFF8DC"
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
# Warm the /model picker's provider-models cache off-thread during this
# idle window (banner shown, user about to type). The no-args picker
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
# it's opened in a session. Fire-and-forget, guarded once-per-process.
try:
from hermes_cli.model_switch import prewarm_picker_cache_async
prewarm_picker_cache_async()
except Exception:
pass
# Redaction opt-out warning (#17691): ON by default, loud when off.
# The redactor snapshots its state at import time so any toggle now
# won't affect the running process — we just want the operator to

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