Compare commits

...
Author SHA1 Message Date
Brooklyn Nicholson 17dc350a6b docs(voice): document the wake word feature
Adds the wake-word user guide (setup, config, per-engine phrase changes,
requirements, limits) and links it from the features overview.
2026-07-23 02:06:27 -05:00
Brooklyn Nicholson 64686e6ec5 feat(desktop,tui): arm the wake word over the gateway and start hands-free voice
The TUI and desktop GUI share the Python tui_gateway, which runs the detector
server-side and exposes wake.start/stop/pause/resume/status plus a wake.detected
event routed back over the same transport that armed it. Clients arm it on
connect; on wake the desktop opens a fresh session, starts voice, and hands the
mic between the detector and its browser voice loop. An empty STT transcript
(silence) is treated as a quiet re-listen rather than a "transcription failed"
toast.
2026-07-23 02:06:27 -05:00
Brooklyn Nicholson 129178bf09 feat(cli): add the /wake command and wake_word config section
Adds the wake_word config block (surface, provider, phrase, sensitivity, and
per-engine options) with wake_surface_enabled() so exactly one surface owns the
listener and the session it opens. In the CLI the detector runs in-process; on
wake it opens a fresh session and captures a single utterance through the
existing voice pipeline, with an idle watchdog that re-arms the mic. The
/wake [on|off|status] command reports what is configured and what is missing.
2026-07-23 02:06:27 -05:00
Brooklyn Nicholson 8ff79323d8 feat(voice): on-device wake-word detector with a bundled "hey hermes" model
tools/wake_word.py is a shared, engine-pluggable detector (openWakeWord
default, free/local; Porcupine premium) over the existing 16 kHz sounddevice
capture. A background daemon thread with pause()/resume() yields the mic during
a voice turn, and reset() on every (re)start keeps a resume from re-firing on
stale audio. Ships a bundled "hey hermes" openWakeWord model (tools/wakewords/,
Apache-2.0) as the default; a built-in name or a custom .onnx/.tflite path still
works. download_models() runs for any model so a fresh install fetches the
shared feature models instead of crashing on a missing melspectrogram.onnx.

The wake deps lazy-install on first use, or via the [wake] extra. Packaging
ships the bundled model in both wheel and sdist, guarded by a metadata test.
2026-07-23 02:06:27 -05:00
brooklyn! 3e163d29bd Merge pull request #69936 from NousResearch/bb/voice-speak-whole-turn
fix(voice): speak the whole desktop turn and idle-flush held narration
2026-07-23 02:00:30 -05:00
Brooklyn Nicholson 9859e1f7df fix(voice): speak the whole turn, not just its first bubble; idle-flush held narration
Two fixes for desktop hands-free voice:

- The live speech session bound to the first assistant bubble with text, so
  a tool-calling turn spoke only the opening narration and silently dropped
  every later interim AND the final answer. The conversation selector now
  aggregates all unspoken assistant bubbles in order (turn-scoped speech);
  auto-speak keeps its latest-reply-only behavior.

- The speak-stream WS producer blocked forever on the text queue, so a
  narration line with no trailing whitespace ("Let me check.") sat in the
  sentence chunker until end-of-turn — spoken long after the tool finished,
  with the UI stuck on "Preparing audio…". Mirror the CLI speaker's idle
  flush: sentence-terminated buffers flush after 0.5s of producer silence,
  anything else after ~2s; open <think> blocks are never flushed.
2026-07-23 01:53:37 -05:00
brooklyn! 7168845dc3 Merge pull request #69909 from NousResearch/bb/computer-use-perf
perf(computer_use): cap capture size and cache vision routing
2026-07-23 01:49:05 -05:00
Brooklyn Nicholson cc1765dce2 Merge remote-tracking branch 'origin/main' into bb/computer-use-perf
# Conflicts:
#	hermes_cli/config.py
#	tools/computer_use/cua_backend.py
2026-07-23 01:39:21 -05:00
brooklyn! 93f8da55cb Merge pull request #69903 from NousResearch/bb/salvage-53841-no-overlay
fix(computer_use): disable cua-driver overlay by default on macOS/WSL (supersedes #53841)
2026-07-23 01:36:49 -05:00
brooklyn! d3d989c4c2 feat(desktop): show credit-usage notices as toasts (#69808) (#69828)
* fix(desktop): render agent credit notices as toasts (#69808)

The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.

Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:

- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
  (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
  notice `key` as the toast id. Re-emitting the same key REPLACES the
  toast, so the credits 50→75→90 line escalates in place instead of
  stacking, and a key-matched `notification.clear` maps straight to
  `dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
  icon is added.
- Notices are account-wide, so the toast shows regardless of which
  session is focused.

The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.

Export `NotificationInput` so the mapping's return type can be named.

* feat(desktop): native OS credit alerts + billing-page nudge (#69808)

Round out the credit-notice handling from the previous commit with the two
optional pieces from the issue:

- Native OS notification for the urgent pair. `credits.depleted` /
  `credits.restored` also fire an Electron notification when Hermes is
  backgrounded, via a new `credits` NativeNotificationKind (the existing
  five didn't fit) with its own toggle in Settings → Notifications (the
  panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and
  i18n are the only additions). The escalating usage line and grant-spent
  notice stay in-app toasts only. Dispatch is `global` (account-wide, not
  session-bound) and gated by the user's prefs + backgrounded check.
- Billing-page nudge. A `credits.*` crossing invalidates the
  `['billing','state']` query so Settings → Billing reflects the change
  immediately instead of waiting up to 30s for its poll.

`nativeNoticeInput()` is a pure mapping (urgent-key gate → native input),
unit-tested directly; the gateway-event branch does the localized-title
lookup and gated dispatch. i18n added for all four locales.

* feat(credits): report $used of $cap instead of % in the usage notice

The usage gauge is Nous subscription-cap-only (used_fraction requires a cap;
non-Nous providers emit no headers, so no notice fires). A bare percentage
implied a universal unit that doesn't exist, so report the absolute dollars
used of the cap instead: used = cap - remaining, from micros (money-safe),
clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band
change, not every turn) to keep the single escalating line and stay quiet on
append-only surfaces (messaging pushes one message per crossing).

* fix(desktop): de-dupe credit toast icon, band-color the figure, split detail

Three fixes to how agent credit notices render as toasts:
- Strip the leading severity glyph (the toast already draws a kind icon, so the
  raw text doubled it). Native OS notifications keep the glyph (no icon there).
- Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem.
- Band-color the $used figure (semibold) by $used/$cap: muted <75%,
  --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the
  existing --ui-* usage palette. Icon shares the accent.
- Split a trailing '. detail' into a muted secondary line (title+description
  convention) instead of an inline middot.
Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully
when a notice has no figure.

* fix(desktop): billing page always fetches fresh state (team-account desync)

The x-nous-credits-* headers are best-effort and can drift out of sync,
notably in team/org accounts where another member's spend moves the shared
balance without touching this client's headers. The billing endpoint is the
source of truth, so the page no longer trusts a cache: staleTime 0 +
refetchOnMount 'always' force a fresh fetch on every open and focus (still
polling 30s while mounted). The credits.* invalidation nudge still pulls a
crossing in immediately.

* chore(desktop): dev-only credit-notice demo hotkey

Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice
lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real
gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/
billing-invalidation paths are testable without hitting real usage bands.
Installed only under import.meta.env.DEV, so it's tree-shaken from production.
2026-07-23 01:36:28 -05:00
Brooklyn Nicholson 8cfc9e4e27 chore(desktop): dev-only credit-notice demo hotkey
Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice
lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real
gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/
billing-invalidation paths are testable without hitting real usage bands.
Installed only under import.meta.env.DEV, so it's tree-shaken from production.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson 664b131c30 fix(desktop): billing page always fetches fresh state (team-account desync)
The x-nous-credits-* headers are best-effort and can drift out of sync,
notably in team/org accounts where another member's spend moves the shared
balance without touching this client's headers. The billing endpoint is the
source of truth, so the page no longer trusts a cache: staleTime 0 +
refetchOnMount 'always' force a fresh fetch on every open and focus (still
polling 30s while mounted). The credits.* invalidation nudge still pulls a
crossing in immediately.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson d010220588 fix(desktop): de-dupe credit toast icon, band-color the figure, split detail
Three fixes to how agent credit notices render as toasts:
- Strip the leading severity glyph (the toast already draws a kind icon, so the
  raw text doubled it). Native OS notifications keep the glyph (no icon there).
- Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem.
- Band-color the $used figure (semibold) by $used/$cap: muted <75%,
  --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the
  existing --ui-* usage palette. Icon shares the accent.
- Split a trailing '. detail' into a muted secondary line (title+description
  convention) instead of an inline middot.
Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully
when a notice has no figure.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson 59a735b8f3 feat(credits): report $used of $cap instead of % in the usage notice
The usage gauge is Nous subscription-cap-only (used_fraction requires a cap;
non-Nous providers emit no headers, so no notice fires). A bare percentage
implied a universal unit that doesn't exist, so report the absolute dollars
used of the cap instead: used = cap - remaining, from micros (money-safe),
clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band
change, not every turn) to keep the single escalating line and stay quiet on
append-only surfaces (messaging pushes one message per crossing).
2026-07-23 01:28:58 -05:00
Brooklyn Nicholson 69b97a97f7 Merge remote-tracking branch 'origin/main' into bb/salvage-53841-no-overlay
# Conflicts:
#	tools/computer_use/cua_backend.py
2026-07-23 01:26:21 -05:00
Brooklyn Nicholson b8a2b9b93e feat(desktop): native OS credit alerts + billing-page nudge (#69808)
Round out the credit-notice handling from the previous commit with the two
optional pieces from the issue:

- Native OS notification for the urgent pair. `credits.depleted` /
  `credits.restored` also fire an Electron notification when Hermes is
  backgrounded, via a new `credits` NativeNotificationKind (the existing
  five didn't fit) with its own toggle in Settings → Notifications (the
  panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and
  i18n are the only additions). The escalating usage line and grant-spent
  notice stay in-app toasts only. Dispatch is `global` (account-wide, not
  session-bound) and gated by the user's prefs + backgrounded check.
- Billing-page nudge. A `credits.*` crossing invalidates the
  `['billing','state']` query so Settings → Billing reflects the change
  immediately instead of waiting up to 30s for its poll.

`nativeNoticeInput()` is a pure mapping (urgent-key gate → native input),
unit-tested directly; the gateway-event branch does the localized-title
lookup and gated dispatch. i18n added for all four locales.
2026-07-23 01:25:58 -05:00
Brooklyn Nicholson 58e3d41582 fix(desktop): render agent credit notices as toasts (#69808)
The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.

Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:

- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
  (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
  notice `key` as the toast id. Re-emitting the same key REPLACES the
  toast, so the credits 50→75→90 line escalates in place instead of
  stacking, and a key-matched `notification.clear` maps straight to
  `dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
  icon is added.
- Notices are account-wide, so the toast shows regardless of which
  session is focused.

The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.

Export `NotificationInput` so the mapping's return type can be named.
2026-07-23 01:25:58 -05:00
brooklyn! a2172547a8 Merge pull request #69902 from NousResearch/bb/salvage-cua-driver-path
fix(computer_use): resolve cua-driver under thin GUI PATH (supersedes #55631)
2026-07-23 01:24:04 -05:00
Brooklyn Nicholson cdc123ec2f fix(computer_use): only disable agent cursor after session handshake
Guard the post-start set_agent_cursor_enabled on _session._started so
call_tool cannot re-enter session.start() (matches the start_session
lifecycle guard).
2026-07-23 01:10:21 -05:00
Brooklyn Nicholson 8b6d34ad85 test(computer_use): pin resolved driver for update-check tests
cua_driver_update_check now short-circuits to None when no driver
resolves; CI has none installed, so pin resolve_cua_driver_cmd in the
update-check and env-sanitization tests.
2026-07-23 01:10:19 -05:00
Brooklyn Nicholson 12ad13ddca perf(computer_use): cap capture size and cache vision routing
Cut steady-state Computer Use latency without changing default behavior
or waiting on cua-driver:

- Cap screenshots via set_config(max_image_dimension) on session start
  (config: computer_use.max_image_dimension, default 1456)
- Cache aux-vision routing per (provider, model) so captures skip
  repeated load_config()
- Add computer_use.capture_after_mode (default som) so users can opt
  follow-ups down to ax (elements only) for speed
2026-07-23 00:52:43 -05:00
Brooklyn NicholsonandDavid Metcalfe f957fe3760 fix(computer_use): default --no-overlay on macOS for idle CPU
Auto-detect now disables the cursor overlay on darwin as well as
headless/WSL2 Linux. After start_session, also call
set_agent_cursor_enabled(false) when the policy is on so older drivers
without --no-overlay still tear the overlay down.

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
2026-07-23 00:45:26 -05:00
ethernet de5ece9944 fix(ci): report E2E evidence upload failures (#69901)
Print gh-image stdout and stderr on attachment failures, then replace the
pending inline-evidence marker in the PR review comment with an escaped
failure notice before preserving the failing workflow result.
2026-07-23 05:45:17 +00:00
David Metcalfe 3d84689714 fix(computer_use): address sweeper feedback on --no-overlay subprocess + manifest probe
The hermes-sweeper review #4701565902 (2026-07-15) flagged two
consistency issues in `_cua_driver_supports_no_overlay` and one
additive-config concern:

1. `cua-backend.py:260` — the `cua-driver --help` support probe
   inherited the full parent environment. cua-driver is a third-party
   binary; every other spawn site in this file (manifest probe at
   `:214`, MCP spawn at `:697`, install probe at `:997`) uses
   `_sanitize_subprocess_env(cua_driver_child_env())`. The `--help`
   probe should match. This was a low-impact leak (only help output
   exits), but inconsistency is the wrong default for a third-party
   subprocess.

2. `cua_backend.py:238` — when the manifest returned a `command`
   different from the input `driver_cmd` parameter (e.g. a relocated
   executable at `/opt/relocated/cua-driver` while the system binary
   is at `/usr/bin/cua-driver`), the support probe ran against
   `_CUA_DRIVER_CMD` (the default) instead of the manifest-discovered
   `command`. Two failure modes:
   - The wrapper binary supports `--no-overlay` but the system binary
     doesn't → probe returns False → overlay kept despite capability.
   - The system binary supports `--no-overlay` but the wrapper doesn't
     → probe returns True → MCP spawn crashes on the unknown flag.

3. The original commit bumped `_config_version` 31→32 for an additive
   default (`computer_use.no_overlay: None`). AGENTS.md specifies that
   additive defaults in existing sections are handled by deep merge
   and should NOT trigger a version bump. After cherry-picking onto
   current `origin/main` (which is already at 33), the bump is
   effectively dropped — resolved to main's 33.

Changes:

- Add `env=_sanitize_subprocess_env(cua_driver_child_env())` to the
  `--help` subprocess (with the same import + rationale comment as
  the manifest probe).
- Pass `driver_cmd=command` (or `driver_cmd=driver_cmd` for the
  fallback path) into `_mcp_args_with_overlay_flag`, so the support
  probe runs against the binary that will actually be launched.

Tests (3 new):

- `test_help_probe_passes_sanitized_env` — verifies `subprocess.run`
  is called with an `env=` kwarg.
- `test_manifest_command_drives_support_probe` — verifies the probe
  runs against the manifest command when it differs from the input
  driver_cmd.
- `test_fallback_uses_input_driver_cmd_for_support_probe` — verifies
  the fallback path (no command in manifest) uses the input
  driver_cmd.
- `test_probe_distinguishes_support_between_binaries` — sanity check
  that the lru_cache key on `driver_cmd` prevents cross-binary
  cache leakage.

File-revert negative test confirmed all three of the new
"manifest/probe" tests are load-bearing: with the pre-fix code, they
fail (probe runs against the default binary instead of the resolved
one); with the fix, they pass. 20/20 tests in
`tests/computer_use/test_cua_no_overlay.py` green.
`TestMcpInvocationResolution` (8/8) still green.

Refs: sweeper review #4701565902
2026-07-23 00:44:43 -05:00
David Metcalfe f7a6c7a6e5 fix(computer_use): add explicit encoding to /proc/version open() 2026-07-23 00:44:43 -05:00
David Metcalfe 8d4f7a0002 fix(computer_use): refine auto-detect to headless/WSL2 only, add driver version probe
Address review feedback from cross-vendor review (Flash + GPT-OSS):

1. Auto-detect now checks for headless Linux (no DISPLAY), WSL2
   (/proc/version contains 'microsoft'), instead of all Linux.
   Desktop Linux with a compositor keeps the overlay.

2. Add _cua_driver_supports_no_overlay() that probes cua-driver --help
   to check if the flag is supported. Older drivers (< 0.6.x) reject
   unknown flags, so passing --no-overlay would crash the MCP spawn.

3. Update tests to cover headless vs desktop Linux, WSL2 detection,
   version probe, and the unsupported-driver fallback path.
2026-07-23 00:44:43 -05:00
David Metcalfe f43ff5b4bb fix(computer_use): mock _cua_no_overlay in existing tests, fix platform-dependent assertions
- Add autouse fixture to TestMcpInvocationResolution to disable
  --no-overlay flag so existing tests assert baseline args
- Make test_config_load_failure_fails_safe and test_missing_section_enables
  platform-aware (Linux auto-detect returns True, macOS/Windows False)
2026-07-23 00:44:43 -05:00
David Metcalfe 8ceada6e30 fix(computer_use): pass --no-overlay to cua-driver on Linux/WSL2 to prevent idle CPU
cua-driver's cursor overlay rendering loop can consume CPU indefinitely
when idle (#28152, #47032). On Linux/WSL2, the overlay serves no visual
purpose and the rendering path is the primary source of idle CPU usage.

Add computer_use.no_overlay config option (default: auto-detect) that
passes --no-overlay to cua-driver when enabled. Auto-detection disables
the overlay on Linux (covers WSL2, headless, containers) where it has no
benefit, and keeps it enabled on macOS/Windows where it is visually
useful.

Refs: #28152, #47032
2026-07-23 00:44:43 -05:00
be96f22026 fix(computer_use): probe resolved cua-driver path in post_setup
Keep thin-PATH resolution while asserting the absolute binary on the
already-installed version check. Map contributor emails for the salvage.

Co-authored-by: Tianqing Yun <yuntianqing@yahoo.com>
Co-authored-by: Trevor Gordon <trevorbgordon@gmail.com>
Co-authored-by: Adrian Soto Mora <adrian.soto6@gmail.com>
2026-07-23 00:44:27 -05:00
Tianqing Yun 2f9d88caee fix: resolve cua-driver across computer-use surfaces 2026-07-23 00:42:46 -05:00
Tianqing Yun 030822b68d fix: resolve cua-driver from user-local install paths 2026-07-23 00:42:46 -05:00
a8e6c0f853 fix(dashboard): isolate Desktop-inherited env from standalone launch (#69891)
Supersedes #52948 and #67402. Closes #52945.

Standalone hermes dashboard/serve was trusting HERMES_WEB_DIST and
HERMES_SERVE_HEADLESS inherited from a Desktop Electron parent, which
could serve the packaged desktop renderer ("Desktop IPC bridge is
unavailable") or disable the SPA. Drop only Electron-packaged WEB_DIST
paths (app.asar*) when HERMES_DESKTOP!=1, and clear inherited headless
for non-serve launches, while preserving caller-managed custom dist
overrides and the desktop-spawned backend path.

Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: Commander <commander@tianji.local>
2026-07-23 00:31:23 -05:00
brooklyn! 0721d2ea80 Merge pull request #69884 from NousResearch/bb/salvage-55707-onboarding
fix(desktop): stop spurious onboarding for keyless custom providers (supersedes #55707, #45224)
2026-07-23 00:28:29 -05:00
brooklyn! 61e7db2820 Merge pull request #69887 from NousResearch/bb/tile-resume-profile
fix(desktop): route tiled session resumes to the owning profile
2026-07-23 00:28:01 -05:00
ethernet 26f1f6a76b feat(desktop): improve tool call detail views (#69868)
* fix(desktop): improve fallback tool-call details

Show failed image-generation calls through the normal fallback row, remove duplicate normal-mode web-search JSON, and format Technical Mode payloads as readable JSON.

* feat(desktop): render terminal tool calls as transcripts

Show terminal commands with a prompt and exit status, then reveal ANSI-safe stdout and stderr in the expanded tool row.

* fix(desktop): reconcile tool calls by command

Match context-only tool starts with command-bearing completions when their IDs differ, preventing stale duplicate terminal rows. Show the web-search query above its result cards.
2026-07-23 01:27:38 -04:00
Teknium c1b0f6f3c1 feat(kanban): per-task model dropdown — set/override worker model+provider from the board (#69876)
Adds the missing write path for the per-task model_override column (which
was previously only settable via manual SQL) and pairs it with a
provider_override so cross-provider switches resolve correctly:

- kanban_db: provider_override column (+migration), set_model_override()
  with model_override_set event, create_task(model_override=,
  provider_override=), dispatcher spawns worker with -m <model>
  [--provider <name>]
- dashboard: Model row in the task drawer — dropdown fed by a new
  /model-options endpoint (build_models_payload substrate, provider-grouped,
  free-text fallback), PATCH + bulk model override support
- CLI: kanban create --model/--provider, new kanban set-model subcommand,
  show prints the provider
- agent tools: kanban_create accepts model/provider; show/list expose
  provider_override

Rate-limit recovery flow: override is settable on running tasks and takes
effect on the next dispatch, without touching the worker profile's config.
2026-07-22 22:23:24 -07:00
Brooklyn Nicholsonandoliviaaaa7788 c3602f7f05 fix(desktop): route tiled session resumes to the owning profile
`resumeTile` — the cold-resume path for a session opened in a tile / split
pane — resumed with `{ session_id, cols }` and read messages with no profile,
so a tile opening a session from another profile let the gateway fall back to
the launch-profile DB and fork the conversation into the wrong profile: the
same cross-profile bleed the recovery resumes had (#67603), just a sibling
call path. Resolve the owning profile via the shared `resolveSessionProfile`
and carry it on both the transcript prefetch and the resume RPC.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
2026-07-23 00:18:35 -05:00
58ffc10c17 fix(desktop): stop spurious provider onboarding from credential warnings
Narrow the setup-error matcher, match the server's empty-key contract,
and route gateway-event plus create/resume/branch through one
credential-warning policy. Preserve configured=true only for
non-authoritative transport fallback.

Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
2026-07-23 00:18:21 -05:00
a01499136d fix(tui_gateway): skip credential warning for keyless custom providers
`no-key-required` is a valid sentinel for local/self-hosted/custom
routers. Warning on it made Desktop treat a working setup as missing
API keys.

Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
2026-07-23 00:18:21 -05:00
Gille 7cbdcf1ba6 fix(desktop): render remote markdown images in chat (#57944)
* fix(desktop): render remote markdown images in chat

* test(desktop): cover remote markdown image resolution
2026-07-23 00:16:20 -05:00
hermes-seaeye[bot]andgithub-actions[bot] ca16a8e07d fmt(js): npm run fix on merge (#69879)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 05:14:26 +00:00
e59dcf46f1 fix(desktop): end the #67603 model-switch dup, cross-profile session bleed, and [System:] bubble (#69861)
* fix(desktop): stop model-switch dup + route recovery resumes to the owning profile

Fixes two Desktop session-reconciliation symptoms from #67603.

Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.

Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).

Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.

Supersedes #68665 and #63590.
Closes #67603.

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>

* fix(desktop): scope the remembered session id per profile

A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).

Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>

* fix(gateway): hide [System:] bookkeeping markers from every transcript

Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>

---------

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
2026-07-23 00:05:25 -05:00
brooklyn! e1367b44bc fix(desktop): let clarify choices and overlays keep their keys from type-to-focus (#69869) 2026-07-23 00:04:12 -05:00
8e01309917 fix(tui_gateway): preserve websocket batch order (#69684)
* fix: serialize TUI gateway websocket sends

* fix(tui_gateway): preserve websocket batch order

* refactor(tui_gateway): drop unused _safe_send wrapper

The batch-serialization fix routes every send through _safe_send_many;
_safe_send became a dead single-line wrapper with no callers. Remove it.

---------

Co-authored-by: supplefrog <78985073+supplefrog@users.noreply.github.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-07-23 05:02:45 +00:00
Teknium b0358cf3c8 fix(slack): unify DM-resolution caches and bound wave-2 caches (C16 policy)
Post-rebase consolidation over the merged C7/C16/C10 work:
- _ensure_dm_conversation now records workspace ownership via
  _remember_channel_team and bounds _dm_conversation_cache (cap 5000)
- module-level _slack_dm_cache (C7 standalone path) bounded oldest-first
- _user_is_bot_cache (C10) bounded with _trim_oldest_dict_entries
- caption-mode contract tests updated for the C7+C8 merged media path
2026-07-22 21:58:13 -07:00
Teknium 527c68baa4 chore(contributors): add email mapping for slack media salvage (robzolkos) 2026-07-22 21:58:13 -07:00
Teknium 994a405f2d fix(slack): resolve bare user targets to DMs across all adapter send paths
Widen the #19237 send_message fix to the live adapter: send, _upload_file,
send_multiple_images, send_image, send_video, send_document,
send_exec_approval, send_slash_confirm, and send_clarify now route bare
Slack user IDs (U.../W...) through a shared _ensure_dm_conversation helper
before calling chat.postMessage / files_upload_v2, which reject user IDs.

This closes the gap in #17261 where an attachment worked when replying in
a thread but failed when directed at a user DM, and extends the DM-open
fallback to clarify/approval Block Kit prompts so gated actions can reach
a user directly.

Resolution uses the workspace-scoped client (multi-workspace installs open
the DM with the right bot token), caches per (team, user), and records the
opened D... channel in the channel→team map. On failure the original
target passes through so the downstream API call surfaces the real Slack
error.

Fixes #17261
Refs #19236
2026-07-22 21:58:13 -07:00
Teknium 4ab4894f44 fix(gateway): make post-stream media delivery explicit-only
The post-stream helper (_deliver_media_from_response) rescanned the
already-streamed response and promoted bare local filesystem paths into
real uploads via extract_local_files. Since the visible reply was already
streamed verbatim, any bare path there is either text the user has seen
or stale inspected/tool content — not an attachment request. On Slack this
uploaded images from stale inspected content after otherwise clean replies.

Post-stream delivery now honors only explicit MEDIA: directives. The
non-streaming path in gateway/platforms/base.py keeps its bare-path
auto-detect, because that path controls the visible text and strips the
path from the reply when it attaches — auto-attach is intentional there.

Regression tests: bare image/document paths in a streamed reply produce
no upload; explicit MEDIA: tags still deliver.

Fixes #20834
2026-07-22 21:58:13 -07:00
dsad c83a196402 fix(slack): gate message files before metadata fetch 2026-07-22 21:58:13 -07:00
dsad 5bb933eedf fix(slack): reject unauthorized users before event construction
Add early auth check in _handle_slack_message() that runs BEFORE any
API calls (thread context fetch, user name resolution, file downloads)
or file processing. Unauthorized users could previously trigger Slack
API calls and file downloads before the runner's _is_user_authorized
gate rejected them.

Same pattern as Telegram fix #54164: build a SessionSource and check
the runner's _is_user_authorized at the adapter level before event
construction consumes resources.

Fixes the gap where Slack has no adapter-level auth gate while Discord
and Telegram have adapter-level allowlists.
2026-07-22 21:58:13 -07:00
dsad f54e8706f7 fix(platforms): block image upload redirects to private URLs 2026-07-22 21:58:13 -07:00
Rob Zolkos 40351d0923 fix(slack): resolve file events with cached workspace team 2026-07-22 21:58:13 -07:00
davesecops 57f8ba3b19 fix(slack): open DMs for user send targets 2026-07-22 21:58:13 -07:00
HexLab98 8df8f86784 test(slack): cover send_message MEDIA delivery
Add standalone-sender media cases and route coverage; point the
non-media platform assertions at SMS now that Slack supports MEDIA.
2026-07-22 21:58:13 -07:00
HexLab98 5f4c952ab0 fix(slack): support MEDIA attachments in send_message
Slack could already deliver files in-channel via the gateway, but
send_message omitted MEDIA for Slack and told the model it was
unsupported — causing agents to inconsistently refuse PDF sends.
Wire Slack through files_upload_v2 in the standalone sender.
2026-07-22 21:58:13 -07:00
Brooklyn NicholsonandDolverin 62bfba521d fix(gateway): hide [System:] bookkeeping markers from every transcript
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
2026-07-22 23:54:05 -05:00
Brooklyn Nicholsonandoliviaaaa7788 143942d497 fix(desktop): scope the remembered session id per profile
A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).

Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.

Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
2026-07-22 23:53:59 -05:00
brooklyn!andMapurite 6096f73ce8 feat(desktop): add keyboard navigation to clarify choices (#69799)
Co-authored-by: Mapurite <272619650+mapu-og@users.noreply.github.com>
2026-07-23 04:48:32 +00:00
5fccc9aae9 fix(desktop): stop model-switch dup + route recovery resumes to the owning profile
Fixes two Desktop session-reconciliation symptoms from #67603.

Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.

Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).

Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.

Supersedes #68665 and #63590.
Closes #67603.

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
2026-07-22 23:42:43 -05:00
hermes-seaeye[bot]andgithub-actions[bot] 328e4f5a1e fmt(js): npm run fix on merge (#69852)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 04:40:24 +00:00
brooklyn!andwebtecnica dc861964fc fix(desktop): clarify options defensive rendering and layout fix (#69796)
- Replace undefined  CSS class with Tailwind v4's
   so long choice text wraps properly
  instead of overflowing the button container.
- Add  validation that strips non-string items,
  blanks, newlines, and text >200 chars — preventing garbage/JSON
  arrays from rendering as raw values.
- Add diagnostic  logging at both the gateway event
  handler and the tool-args parser when choices are dropped, so
  malformed payloads are no longer silent.
- Apply  in both the gateway event handler
  () and the inline tool-args parser
  () for consistent defense in depth.
- Add unit tests for  covering null, non-array,
  mixed-type, blank, multiline, and overlong inputs.

Closes #69122

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-07-23 04:32:33 +00:00
brooklyn! 46fb10203b Merge pull request #69841 from NousResearch/bb/salvage-69428-expandable-overlay
fix(desktop): free code-block scrollbar & last-line selection from the toggle overlay
2026-07-22 23:29:34 -05:00
brooklyn!and정수환 d21165c2f0 fix(desktop): keep clarify answerable across reconnect/hydration + tool-progress off (#69795)
* fix(desktop): keep clarify lifecycle when tool progress is off

* fix(desktop): render clarify prompt from the request event

Re-authored onto the current use-message-stream/gateway-event.ts (the
original patched the pre-split use-message-stream.ts). When the tool.start
row that normally mounts the inline clarify UI is missed (stream reconnect
/ hydration race), upsert a stable pending clarify tool row from
clarify.request itself so the prompt stays answerable; a real
tool.start/complete with the same request id merges rather than duplicates.

Co-authored-by: 정수환 <centerid@naver.com>

* chore(contributors): map centerid@naver.com -> lidises

Attribution mapping for the salvaged #47544 commit.

* fix(desktop): correlate clarify rows by question so hydration can't duplicate

The hydrated row (from clarify.request's request_id) and the real tool.start
row (the model's tool_call_id) have different ids, so id-only matching appended
a second clarify card in the normal path (caught by the BLOCKING_CLARIFY e2e:
'question' resolved to 2 elements). Add 'question' to the tool match-value keys
so a clarify upsert merges into the existing pending clarify row regardless of
id (same request<->args correlation ClarifyToolPending already uses); when no
row exists yet (reconnect/hydration) it still creates one.

---------

Co-authored-by: 정수환 <centerid@naver.com>
2026-07-22 23:25:48 -05:00
brooklyn!andMatvii Sakhnenko 7d28e84e72 fix(gateway): deliver assistant prose before the clarify poll (#69775)
* fix(gateway): deliver assistant prose before clarify poll

The clarify poll is sent on a separate, agent-thread-blocking path while
buffered assistant prose (interim commentary / streamed deltas) sits in
the GatewayStreamConsumer queue, drained asynchronously. The poll won the
race, so the question rendered ABOVE its own explanation, and a redundant
'clarify: ...' tool-progress bubble wedged between them.

- Add GatewayStreamConsumer.flush_pending_sync(): a synchronous flush
  barrier (_FLUSH sentinel + threading.Event) that blocks the agent
  thread until everything queued before it is finalized and delivered.
- Call it in the gateway clarify callback before send_clarify, so prose
  always lands before the poll. Best-effort with a 3s timeout.
- Suppress the redundant clarify tool-progress bubble (the poll already
  shows the question + options).

Tests: 3 new ordering/timeout cases in test_stream_consumer.py.
(cherry picked from commit 9a6e27badb7646c839902db1d2f4a8affcf38b1e)

* chore(contributors): map matvey.sakhnenko03@icloud.com -> sakhnenkoff

Attribution mapping for the salvaged #54328 commit (Cluster C).

---------

Co-authored-by: Matvii Sakhnenko <matvey.sakhnenko03@icloud.com>
2026-07-22 23:25:35 -05:00
507d479c8c fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway (#69774)
* test(clarify-gateway): cover signature, timeout fallback, and notify paths for 100% coverage

Fixes #36531

(cherry picked from commit 5265dfe2f5d484c5fb97c71eb311cbb230c0110e)

* fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway

The clarify wait timeout was resolved three different (wrong) ways:

- CLI (`cli.py`, `hermes_cli/callbacks.py`) read a non-existent top-level
  `clarify.timeout`, so it always fell through to a hardcoded 120s instead of
  the canonical `agent.clarify_timeout` (default 3600) the gateway uses (#42969).
- The TUI/desktop bridge called `_block("clarify.request", …)` with no timeout,
  so it used the hardcoded 300s `_block` default and ignored config (#51960).
- There was no way to disable the auto-skip: a user who wanted the agent to wait
  indefinitely while they think couldn't get it.

Collapse all of this onto a single resolver:

- `tools.clarify_gateway.resolve_clarify_timeout(config)` is the one source of
  truth. Order: explicit legacy `clarify.timeout` (back-compat) → canonical
  `agent.clarify_timeout` → 3600. `<= 0` is preserved verbatim as "unlimited".
- CLI, callbacks, and the TUI bridge (`_clarify_timeout_seconds`) all route
  through it, so the three surfaces can't drift.
- `<= 0` means unlimited everywhere: `wait_for_response` and `_block` drop the
  deadline (heartbeat still fires), and the CLI hides its countdown.

Tests: resolver order / default / non-numeric / unlimited-sentinel; an
unlimited `wait_for_response` blocks until resolved rather than auto-skipping;
the TUI clarify bridge passes the configured timeout to `_block`.

Supersedes #42974 (CLI key), #51993 (TUI honors config), and #68986 (unlimited
wait); folds in #52031 (clarify_gateway coverage).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>

---------

Co-authored-by: Christopher-Schulze <210261288+Christopher-Schulze@users.noreply.github.com>
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
2026-07-22 23:25:13 -05:00
3dd9d5e692 fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout (#69773)
`_block()` bridges four blocking request types — secret, sudo, clarify,
terminal.read — with an identical lifecycle: on timeout the tool gives up and
returns empty, but a slow renderer (or a WebSocket reconnect that dropped
tool.complete) can still answer afterward. Only secret and sudo tolerated that
late reply; clarify and terminal.read still hit the generic 4009 "no pending
request" error, which clients surface as a raw JSON-RPC string (and at least
one desktop fork re-armed the pending request on the error).

Bring the two stragglers in line with the pair that already works:
- `_block` now emits `{event}.expire` on timeout for all four request types.
- `clarify.respond` and `terminal.read.respond` pass `allow_expired=True`, so a
  late answer resolves to `{"status": "expired"}` instead of erroring.

Tests parametrize the timeout-expiry and late-idempotent-response cases over
all four bridges; the old test asserting clarify stays a 4009 error is updated
to the new graceful contract.

Supersedes #56571 (clarify) and #64886 (terminal.read) — same root cause, one fix.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: pierrenode <pierrenode@users.noreply.github.com>
2026-07-22 23:24:42 -05:00
936b407a72 fix(desktop): free code-block scrollbar & last-line selection from the toggle overlay
`ExpandableBlock` used a single full-width `absolute inset-x-0 bottom-0`
button that was BOTH the fade overflow cue and the expand/collapse control.
It sat on top of the whole 28px bottom strip, so it swallowed:

- the horizontal scrollbar of a wide code block (couldn't scroll sideways), and
- pointer/drag events on the block's last line (couldn't select/copy the tail).

Split the two responsibilities: the full-width fade stays as a pure
`pointer-events-none` cue, and the only clickable target is a compact toggle
pinned to the right edge (`pointer-events-auto`), clear of the draggable
scrollbar track. The inner container gains `overflow-x-auto` so wide code
gets a working scrollbar. Applies uniformly to code cards and the plain-text
fallback — no per-call prop needed.

Regression test asserts the pointer-events contract (fade `pointer-events-none`
+ full-width, toggle `pointer-events-auto` + right-pinned, not `inset-x-0`) and
that the toggle still flips `aria-expanded`.

Supersedes #69558, #69428
Fixes #69168

Co-authored-by: alelpoan <alelpoan@proton.me>
Co-authored-by: BerneYue <xiongyue_hnu@163.com>
2026-07-22 23:23:39 -05:00
Gille 390b03c455 fix(cli): persist provider on global model switch 2026-07-22 21:18:50 -07:00
Gille b3ff5fc5b1 fix(vision): resolve namespaced custom provider overrides 2026-07-22 21:18:45 -07:00
HexLab98 125fac7943 test(cli): cover custom provider false shrink warning 2026-07-22 21:17:21 -07:00
HexLab98 c428e725aa fix(cli): honor custom_providers in preflight shrink warning
Classic /model confirmation already threaded custom_providers into the
context display, but the shrink-warning path did not. Probe-down then
matched the hardcoded "qwen" catalog (131072) and falsely warned that a
1M custom endpoint had shrunk — while the status bar still showed 1M.

Pass the same fresh inventory list used by switch_model/TUI (with
agent-snapshot fallback), and fall back to agent._custom_providers inside
merge_preflight_compression_warning when the kwarg is omitted.
2026-07-22 21:17:21 -07:00
HexLab98 b0b7f15598 test(computer-use): assert exact capture skips X11 active-window probe
Cover exact_target selection and capture(pid=, window_id=) so the
xprop helper is not invoked on the hot path.
2026-07-22 21:15:18 -07:00
HexLab98 9881328651 fix(computer-use): skip X11 active-window probe for exact targets
Limit the _NET_ACTIVE_WINDOW xprop fallback to unqualified default
captures so exact pid/window_id targeting does not pay up to a 2s
subprocess probe on Linux/X11.
2026-07-22 21:15:18 -07:00
HexLab98 a57bab1c84 test(computer-use): cover Linux X11 active-window selection
Direct xprop parse-boundary coverage plus tied/unknown z_index and
higher-z-frontmost regressions for #58026.
2026-07-22 21:15:18 -07:00
HexLab98 f320f3e5d7 fix(computer-use): prefer X11 active window when z_index ties
When Linux/X11 reports the same z_index for every on-screen window,
prefer _NET_ACTIVE_WINDOW via xprop instead of list order. Keep the
higher-z-index-is-frontmost contract when ordering is informative.
2026-07-22 21:15:18 -07:00
Teknium ff6d86ed0f chore(contributors): map YLChen-007 bare-noreply email 2026-07-22 21:14:44 -07:00
Teknium f31c10a0da test/docs(slack): force-clear new gating env vars; document new options
- Add SLACK_THREAD_REQUIRE_MENTION, SLACK_IGNORE_OTHER_USER_MENTIONS and
  SLACK_REQUIRE_MENTION_CHANNELS to the conftest behavioral-env force-clear
  list so config-loader side effects can't leak between tests (same class
  of leak the existing SLACK_* entries guard against).
- Document thread_require_mention and require_mention_channels in the
  Slack messaging guide next to the other mention-gating options.
2026-07-22 21:14:44 -07:00
Teknium 2c159f00e4 chore(contributors): add email mappings for slack C10 reply-storm salvage 2026-07-22 21:14:44 -07:00
LeonSGP43 0bbf679e4c test(slack): add peer-agent smoke target 2026-07-22 21:14:44 -07:00
Teknium b416907538 feat(slack): require_mention_channels per-channel force-mention override
Port of the Slack half of #13855 (by @kshitijk4poor), reimplemented against
the plugin adapter (the original PR targets the deleted
gateway/platforms/slack.py and six other legacy adapters).

Channels listed in slack.require_mention_channels (config.yaml) or
SLACK_REQUIRE_MENTION_CHANNELS ALWAYS require an explicit @mention, even
when require_mention is false globally or the channel is in
free_response_channels — the opposite direction of free_response_channels.
Instead of duplicating the PR's inline reply-to-bot-thread/mentioned-thread/
session checks, the forced channel falls through to the SAME decision chain
as normal mention gating, so all five wake checks in
_should_wake_on_unmentioned_message keep applying (single decision path).

Credit: adapted from #13855 by @kshitijk4poor (Slack half only; the other
platform halves target deleted legacy adapters and are out of scope for
this cluster).
2026-07-22 21:14:44 -07:00
YLChen-007 38863322a4 Harden gateway mass mention handling 2026-07-22 21:14:44 -07:00
saitama 094c883bc0 Add Slack thread mention gating 2026-07-22 21:14:44 -07:00
Ben Phillips f50e1e9c51 docs(slack): document ignore_other_user_mentions
Adds the option to the Mention & Trigger Behavior section: leading-
mention semantics, opt-in default, env var, and DM/MPIM scope (1:1 DMs
unaffected; MPIMs apply it like channels).

Claude-Session: https://claude.ai/code/session_01TKsNdptNdo9CqT2u7JMdkH
2026-07-22 21:14:44 -07:00
Ben Phillips 286ec6afb3 fix(slack): count pipe-form bot mentions as mentioned in ignore gate
The ignore_other_user_mentions gate relied solely on is_mentioned, which
only recognises exact <@UID> markup, so a message mentioning the bot in
pipe form (<@UID|name>) alongside a leading other-user mention was
wrongly suppressed. The gate now also scans for the bot's own mention in
either markup before ignoring a message.

Claude-Session: https://claude.ai/code/session_01TKsNdptNdo9CqT2u7JMdkH
2026-07-22 21:14:44 -07:00
Ben Phillips 7f9cab15d8 feat(slack): add ignore_other_user_mentions option
Once the bot is @mentioned in a Slack thread it auto-follows and replies
to every later message in that thread, including ones a human addresses to
another human (e.g. "@rasha check this out"). The bot butts in.

Add slack.ignore_other_user_mentions (env SLACK_IGNORE_OTHER_USER_MENTIONS,
default off). When on, a channel/thread message whose first token @mentions
someone other than the bot is treated as addressed to that person and the
bot stays silent unless it is also mentioned. This is Slack parity for the
Discord option of the same name (#33501), adapted to Slack's thread model:
the trigger is a leading mention ("addressed to"), so a message that merely
references another user mid-sentence still reaches the bot.

The gate sits ahead of the free-response / require_mention ladder so it also
overrides the mentioned-thread auto-follow. DMs are never filtered.
2026-07-22 21:14:44 -07:00
Seth Miller 8ed766025b style(slack): wrap edited mention conditionals 2026-07-22 21:14:44 -07:00
Seth Miller 49497bcddb fix(slack): handle edited-in bot mentions 2026-07-22 21:14:44 -07:00
Hafiz Ahmad Ashfaq 69e15d630e fix(slack): filter unlabeled app and bot authored events
(cherry picked from commit e2e11dab5100ea66dd6e070638e7d33ae2ee643a)
2026-07-22 21:14:44 -07:00
Teknium 2168df37b8 fix(slack): harden parent-text wake check against None + restore fixture parity
Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
  parent_text — _fetch_thread_parent_text is typed to return str but tests
  (and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
  AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
  #24848 tests exercise the real parent-text path via conversations_replies
  side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
  cache key introduced by the multi-workspace name cache on main.
2026-07-22 21:14:44 -07:00
Alan Alwakeel 3f08201bac Fix Slack peer bot status routing loops 2026-07-22 21:14:44 -07:00
ethernet 406d7a67f0 fix(desktop): split steer and queue keyboard shortcuts (#69797)
Keep Enter as the Cursor-style live-turn steering gesture and assign
Ctrl/Cmd+Enter to queue the current draft. Align keybind settings,
tooltips, translations, and the Electron queue-boundary coverage.
2026-07-23 00:13:58 -04:00
hermes-seaeye[bot]andgithub-actions[bot] 2ebeede006 fmt(js): npm run fix on merge (#69823)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 04:09:14 +00:00
brooklyn! 88a9557385 Merge pull request #69812 from NousResearch/bb/desktop-tab-close-shift
fix(desktop): session-tab UX — ⌘W tab-shift, draft new-tab, unified status dot, stable lanes & optimistic delete
2026-07-22 23:01:17 -05:00
Teknium 90d6296808 chore(contributors): map markoub email for C16 cache-bounds salvage
EloquentBrush's noreply email already maps to AhmetArif0 in the frozen
legacy AUTHOR_MAP (same account, renamed) — no new mapping needed.
2026-07-22 20:59:12 -07:00
Teknium 533e541237 fix(slack): bound remaining per-message/per-user tracking structures with oldest-first eviction
Widening pass over the whole adapter following the cluster-C16 audit
(#51019, #51097, #23676, #23375): every in-memory structure that
accumulates per-message, per-user, or per-thread state is now bounded,
and every eviction is oldest-first — never arbitrary set-iteration
order, which is the #51019 failure mode (bot silently going quiet on
the most ACTIVE thread because set.pop-order eviction removed it).

Newly bounded:
- _approval_resolved / _clarify_resolved (caps 1000): unclicked
  approval/clarify prompts leaked their double-click-guard entries
  forever; oldest-insertion eviction via _trim_oldest_dict_entries.
- _reacting_message_ids (cap 5000): reaction lifecycle entries leaked
  when an exception fired between add and finalize; oldest-ts eviction.
- _active_status_threads (cap 1000): statuses abandoned by error paths
  accumulated; oldest-thread-ts eviction so the newest live status is
  never cleared.
- _channel_team (cap 10000): grew with every DM channel the bot ever
  saw (DM channel IDs are per-user). All four write sites now route
  through _remember_channel_team; eviction is safe because entries are
  re-learned from the next event and _get_client falls back to the
  primary client.
- _slash_command_contexts (cap 1000): TTL cleanup only ran on lookup,
  so contexts whose ephemeral replies never happened accumulated;
  overflow purges expired entries first, then oldest-stash-first.

Converted from arbitrary set-order eviction to oldest-first:
- _titled_assistant_threads: keys are (team, channel, thread_ts) —
  now evicts oldest thread first via _discard_oldest_by_thread_ts.
- _thread_rehydration_checked: keys are team:channel:thread_ts[:user] —
  arbitrary eviction here would re-run an ACTIVE thread's restart
  rehydration check and re-inject the missed-delta context; now evicts
  oldest thread first.
- _reacting_message_ids uses #51097's _discard_oldest_slack_timestamps.

Deliberately NOT bounded (naturally tiny, per-workspace):
_team_clients, _team_bot_user_ids, _team_bot_names (one entry per
installed workspace), _assistant_threads / _agent_view_contexts /
_bot_message_ts / _mentioned_threads / _user_name_cache /
_thread_context_cache (already bounded), _dedup (MessageDeduplicator
has max_size + TTL internally).

New helpers: _trim_oldest_dict_entries (dicts preserve insertion
order, so oldest-first is exact) and _discard_oldest_by_thread_ts
(chronological sort on the embedded Slack ts for keyed sets).

Tests: caps hold under churn, eviction removes OLDEST not arbitrary
entries, newest/active entries survive eviction pressure (regression
shape for #51019), plus end-to-end paths through _resolve_user_name
and _handle_slash_command.

Part of the C16 cache-bounds consolidation with #51097 (markoub),
#23676 and #23375 (EloquentBrush). Fixes #51019.
2026-07-22 20:59:12 -07:00
EloquentBrush d42b295792 fix(slack): bound _thread_context_cache with eviction on overflow
_fetch_thread_context() caches per-thread Slack history under a
60-second TTL, but expired entries are never removed. On the current
adapter this is worse than when first reported: each entry now also
retains the raw conversations.replies payloads (messages) for
watermark re-formatting, so a busy multi-channel workspace accumulates
full message lists forever. _bot_message_ts, _mentioned_threads, and
_assistant_threads all enforce a MAX constant with active eviction in
the same __init__; _thread_context_cache had the TTL declared but no
matching eviction path.

Fix: add _THREAD_CACHE_MAX = 2500 and purge expired entries
(fetched_at older than _THREAD_CACHE_TTL) whenever a new write pushes
the cache past the limit. TTL-based eviction is used instead of LRU
because fresh entries are still needed; evicting them would
immediately re-trigger a rate-limited conversations.replies call.

Adds two unit tests: one verifying stale entries are purged on
overflow, one verifying fresh entries survive.

Reapplied from #23375 onto the current adapter (moved to
plugins/platforms/slack/adapter.py; cache entries now carry raw
message payloads and parent_user_id).
2026-07-22 20:59:12 -07:00
EloquentBrush 91693f9d4c fix(slack): cap _user_name_cache to prevent unbounded growth
_resolve_user_name() caches every resolved (team_id, user_id) → display
name but never evicts entries. On a long-running bot in a large
workspace every unique user the bot encounters adds a permanent entry.
Sibling structures _bot_message_ts (BOT_TS_MAX=5000) and
_assistant_threads (ASSISTANT_THREADS_MAX=5000) already have caps;
_user_name_cache had none.

Fix: add _USER_NAME_CACHE_MAX = 5000 and evict the oldest half on
overflow after each write, matching the existing sibling pattern.
Consolidates the two cache-write branches (success + API error) into a
single write so eviction runs once per resolution.

Reapplied from #23676 onto the current adapter (cache moved to
plugins/platforms/slack/adapter.py and is now keyed by
(team_id, user_id) tuples for multi-workspace safety).
2026-07-22 20:59:12 -07:00
markoub 4fb1796331 fix(slack): evict oldest tracked thread timestamps 2026-07-22 20:59:12 -07:00
Ben Barclay d8cd7ac2e7 fix(relay): declare explicit relevance policy when require_mention is configured false (#69816)
Companion to gateway-gateway#160 (connector absent-row default flips to
mention-gated / requireAddress=true).

relay_relevance_policy() previously returned None whenever the projected
policy was all-falsy, on the premise that 'the connector's quiet default
already matches'. Once the connector defaults requireAddress to true,
that premise inverts for one case: an agent EXPLICITLY configured with
require_mention: false would stay silent and get mention-gated anyway,
with no way out.

The nothing-to-declare check now keys on 'require_mention is unset'
rather than 'require_mention is falsy': an explicit false IS a
configured knob and is declared to the connector; a genuinely
unconfigured platform still declares nothing and inherits the
connector's default. No wire/contract change.
2026-07-23 13:57:57 +10:00
Teknium a4795da3cc chore(contributors): map esther@feedmob.com -> Esther-Zhu023 2026-07-22 20:57:25 -07:00
Esther c7b9dfa961 fix(slack): resolve user IDs to DM channels in standalone cron delivery
Cron jobs targeting a Slack user (deliver=slack:U…) failed with
channel_not_found: chat.postMessage and files_upload_v2 require a
conversation ID, and a DM must be opened first via conversations.open
to obtain a D… ID. The tool-level resolution in send_message() does not
cover cron's direct _send_to_platform → standalone_sender_fn path.

Add _resolve_slack_user_dm to the Slack plugin: resolves U…/W… targets
via conversations.open (proxy-aware, cached per token+user so repeated
cron fires don't re-open the DM), wired into _standalone_send ahead of
both the text and media delivery branches so every standalone entry
point benefits.

Adapted from #17444 by @Esther-Zhu023 — the original patched the legacy
_send_slack helper in tools/send_message_tool.py, which has since moved
into the plugin as _standalone_send (#41112).

Closes #17444
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 20:57:25 -07:00
kandotrun 4f726ed467 fix(slack): preserve media in standalone cron delivery 2026-07-22 20:57:25 -07:00
Jake Long Vu f279f7fcf1 Preserve Slack cron thread origin 2026-07-22 20:57:25 -07:00
Wesley Simplicio 0c07a19752 fix(cron): keep bare platform delivery on home target 2026-07-22 20:57:25 -07:00
Wesley Simplicio b8939e8316 fix(slack): guard _resolve_thread_ts against async/cron deliveries using stale thread context
Cron/async deliveries (reply_to=None) should always go to the home/target
channel, but _resolve_thread_ts() returns metadata.thread_id/thread_ts even
when reply_to is None, routing messages to the thread where the cron job
was created instead of the configured home channel.

Add early return None when reply_to is None, so async deliveries never
inherit stale thread context from metadata.

Closes #59097
2026-07-22 20:57:25 -07:00
Vinay BikkinaandCopilot 141d5db922 fix(cron): scope in_channel thread_id clear to the live-send/seed gate
The in_channel flat-delivery clear was gated on `runtime_adapter is not
None`, but the flat continuation session is seeded ONLY on the live-send
path, which also requires a running event loop. When an adapter is
present but the loop is absent/not-running, the live-send/seed block is
skipped and delivery falls through to the standalone path — clearing
thread_id there flattened an unseeded brief with no continuable session
behind it (and bypassed the D6 capability check).

Factor the live-send condition into `live_adapter_ready` and gate both
the thread_id clear and the live-send block on it, so the clear can no
longer drift from the seed. Add an adapter-present/no-running-loop
regression test (verified it fails against the un-scoped clear).

Addresses review r3609147550.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 20:57:25 -07:00
Vinay Bikkina 7337a9d997 fix(cron): clear inherited thread_id for in_channel delivery
cron_continuable_surface=in_channel is meant to deliver a cron brief FLAT
into a channel (thread_id=None) so a plain channel reply continues the job
via the shared-channel session. The scheduler already seeds that flat
session (_seed_cron_channel_session) but never cleared the ORIGINAL
target/origin thread_id before routing the actual delivery, so a job
scheduled from inside a Slack thread still delivered into that origin
thread — the seeded continuable session then never matched where the brief
actually landed.

Clear thread_id once in_channel_surface is finalized, before it is re-read
for route_thread_id / the standalone fallback. Scope the clear to EXACTLY
the target that gets flat-seeded — the origin-continuable target
(mirror_this_target) on a live, in_channel-capable adapter
(runtime_adapter is not None):

- fan-out / broadcast / explicit-thread targets keep their thread_id — they
  are not continuable and are never seeded, so flattening them would
  reroute an explicit thread and could collapse two targets into duplicate
  flat sends;
- the standalone no-live-adapter path keeps the origin thread — it can
  never seed the flat session, and without an adapter the D6 capability
  fail-safe can't run, so flattening there would both drop the brief out of
  any continuable lane and bypass D6.

This keeps the clear in lockstep with the seed condition
(in_channel_surface and mirror_this_target) and the D6 fail-safe.
mirror_this_target / origin_user_id are computed earlier using the original
thread_id to match the origin conversation.

Regression tests in TestCronContinuableSurfaceInChannel:
- a job whose origin carries a thread_id must not forward that thread_id to
  the live adapter (DeliveryRouter folds target.thread_id into send
  metadata), and the seeded continuable session stays flat (thread_id=None);
- with no live adapter, the standalone send falls back to the origin thread
  rather than flattening.
2026-07-22 20:57:25 -07:00
Teknium 700c8bfc5c fix(slack): fall back to chat.postEphemeral, never public channel, for slash replies
Per review (Victor): response_url failure does not mean ephemeral delivery
is impossible — chat.postEphemeral is an independent API path that keeps
the reply private. Public-channel fallback removed entirely; when both
ephemeral paths fail the reply is dropped with a logged error rather than
leaked to the channel. No config knob needed.
2026-07-22 20:55:51 -07:00
Teknium 79896788f3 chore(contributors): map PavelTajdus bare-noreply email 2026-07-22 20:55:51 -07:00
Teknium 1593bb82ac test(slack): adapt salvaged tests to current main
- test_thread_command_skips_context_prefix: post-#69320 thread context IS
  fetched on first thread entry but rides channel_context; assert the
  command token stays at char zero and the backfill is preserved, instead
  of asserting the fetch never happens.
- test_slack_send_retry.py: main's _get_client() now takes team_id;
  update the lambda stubs.
2026-07-22 20:55:51 -07:00
Teknium dcd3e917e8 chore(contributors): add email mappings for slack command salvage authors 2026-07-22 20:55:51 -07:00
Teknium 2a8d4879f2 fix(slack): never silently drop or truncate slash replies (#19688)
Two silent-loss modes in the ephemeral slash reply path:

1. Delivery failure was swallowed: _send_slash_ephemeral returned
   success=True on any POST failure, so the user's actual command reply
   vanished behind the stale 'Running /cmd…' ack. It now returns
   success=False and send() falls back to normal channel delivery.
2. Long replies were truncated to the first ~39k chunk with no notice.
   Replies are now chunked across response_url POSTs (first replaces
   the ack, follow-ups append, all ephemeral), capped at Slack's 5-POST
   response_url budget with an explicit truncation notice when exceeded.

Also updates the #55357 bounded-error-read test for the #26788 precise
context matching (ContextVar must be set) and channel fallback.

Fixes #19688
2026-07-22 20:55:51 -07:00
Pavel Tajduš 0a53663ef8 fix(slack): preserve typed command integrity across enrichment paths
Commands typed in Slack could be mangled by every enrichment layer the
adapter applies to normal messages:

- Block Kit / unfurl / attachment-notice / text-file injection could
  prepend or append content around a command, moving the command token
  away from character zero or polluting its arguments. Commands are now
  restored from canonical authored input after all enrichment
  (final is_command_text guard before MessageEvent construction).
- @bot /cmd (typed slash behind a mention) was never classified as a
  command; the mention-strip branch now re-probes for both slash and
  bang forms.
- The Slack Agent-view context label ([Slack app context: ...]) was
  prepended to command events too; now command-exempt.
- Native slash payload arguments were strip()ed, destroying meaningful
  spacing inside/after arguments; only the command delimiter is
  nonsemantic now.
- Slash payload thread identity (thread_ts/message_ts, top-level or
  nested in message/container) is preserved onto SessionSource so
  session-scoped commands hit the same thread session.
- /queue and /steer queued fallbacks now propagate channel_context so a
  command that triggered first-entry thread backfill doesn't lose the
  history when re-queued.

Adapted from PR #66310 to the post-#69320 channel_context design (thread
history already rides MessageEvent.channel_context, never text).
2026-07-22 20:55:51 -07:00
srojk34 5243fcafa1 fix(slack): surface retryable + Retry-After on send() rate-limit errors (#46762)
Slack's send() caught all exceptions and returned a bare
SendResult(success=False) — never setting retryable=True or extracting
the server's Retry-After header.  When Slack returned a 429 rate-limit
error, the base _send_with_retry() layer saw retryable=False and did
not retry, silently dropping remaining message chunks.

Reuse the existing _is_retryable_upload_error() helper (which already
detects 429, 500+, and connection-type errors) to set retryable=True,
and extract the Retry-After header from the SlackApiError response
when present so the base retry layer honors Slack's backoff schedule
instead of its own default.

Sibling of the Telegram FloodWait fix (PR #46762 / commit 404b06ac4)
which added the SendResult.retry_after plumbing to the base layer.

Adds five regression tests covering 429 with/without Retry-After,
500 server errors, 403 non-retryable errors, and connection errors.
2026-07-22 20:55:51 -07:00
luyifan f8aef2e4e0 Bound Slack response_url error reads 2026-07-22 20:55:51 -07:00
Soynchux f36c748c85 fix(slack): stop consuming slash reply context outside slash sends
_pop_slash_context fell back to a channel-only scan when the
_slash_user_id ContextVar was unset (i.e. send() invoked from a
non-slash code path such as a cron delivery or a normal channel reply).
That scan could steal another user's pending slash reply context: the
normal message got swallowed into an ephemeral response_url POST that
replaces the invoker's ack, and the slash invoker's actual reply then
posted publicly. Remove the fallback — when the ContextVar is unset,
match nothing.

Surgical reapply of PR #26788 (originally against gateway/platforms/slack.py).
2026-07-22 20:55:51 -07:00
drafish 02f5ced766 fix(slack): pass event-derived chat_type to _has_active_session_for_thread
_has_active_session_for_thread() hardcoded chat_type='group', causing
session key mismatch for DM and MPIM threads. DM sessions key as
agent:main:slack:dm:{chat_id}:{thread_ts} but the lookup built
agent:main:slack:group:{chat_id}:{user_id}:{thread_ts}.

Impact: _has_active_session_for_thread always returned False for DM
threads, causing thread context to be prepended on every message. The
prepended context broke slash command detection (get_command() checks
text.startswith('/')), so /cmd and !cmd never worked in DM threads.

Fix: accept event-derived chat_type parameter instead of hardcoding
'group'. Both call sites pass chat_type='dm' if is_dm else 'group',
where is_dm is already computed from channel_type in {'im', 'mpim'}.

This correctly handles:
- IM channels (D-prefix): chat_type='dm'
- MPIM channels (G-prefix): chat_type='dm' (was missed by D-prefix heuristic)
- Channel messages (C-prefix): chat_type='group' (unchanged)

Added regression tests covering DM thread lookup, MPIM thread lookup,
and negative cases verifying the old hardcoded 'group' behavior fails.
2026-07-22 20:55:51 -07:00
Greg Duraj a83ec95c76 fix(slack): avoid rich-text duplication in commands + keep slash thread identity
Slack rich_text blocks mirror the original message text. When bang
commands are rewritten from !model to /model, appending block text makes
the command arguments include a duplicate payload, so the model switcher
sees spaces in the model name and rejects valid commands like:

  !model qwen3.7-plus --provider opencode-go

Skip block extraction for command messages while preserving it for
normal messages. Also preserve Slack thread_ts (top-level or nested in
message/container payload shapes) on native slash-command payloads so
session-scoped commands like /model apply to the intended thread instead
of a channel+user key the next threaded message never matches.

Surgical reapply of PR #43533 (originally against gateway/platforms/slack.py,
now plugins/platforms/slack/adapter.py). Thread-shape widening credit also
to #66310.
2026-07-22 20:55:51 -07:00
cypres0099 5f3f1948b7 fix(slack): dispatch mentioned bang commands in threads 2026-07-22 20:55:51 -07:00
nu ef8936d599 fix(slack): handle leading-space text commands 2026-07-22 20:55:51 -07:00
Teknium ad8c06047d test: cover api_key_hint in strict pool doubles + real-pool routing regression
Follow-up to the #43755 salvage:
- Update the strict _Pool doubles in tests/run_agent/test_run_agent.py to
  accept api_key_hint and assert it carries the agent's failed key.
- Add a real-CredentialPool regression (no mocks) proving the hint routes
  exhaustion to the entry whose key actually failed, not pool.current(),
  plus the no-hint baseline (#43747 wrong-entry marking).
2026-07-22 20:54:29 -07:00
liuhao1024 702f5f10ad fix(agent): pass api_key_hint to mark_exhausted_and_rotate in credential pool recovery
recover_with_credential_pool() called mark_exhausted_and_rotate() without
api_key_hint, causing it to fall back to current() or _select_unlocked().
When a prior rotation left current() as None, _select_unlocked() returned
the NEXT (healthy) entry instead of the one that actually failed — marking
the wrong credential as exhausted (#43747).

Extract the current API key from agent.api_key (or pool.current().runtime_api_key
as fallback) and pass it as api_key_hint to all 4 call sites.
2026-07-22 20:54:29 -07:00
Brooklyn Nicholson 56b2b5a331 fix(desktop): mark session delete/archive in-flight so the row can't flash back
removeSession/archiveSession now pin their tombstone via beginSessionMutation
until the RPC settles (finally → endSessionMutation), keyed per id so
concurrent deletes across worktrees stay independent. This lets the
projects.tree prune hold the optimistic removal through the whole in-flight
window — no shared lock, no serialization.

Drops the now-redundant post-success re-filter band-aid in archiveSession:
the refresh honors tombstones generically, so there's nothing left to win.
2026-07-22 22:54:28 -05:00
Brooklyn Nicholson 4db2f78ec7 fix(desktop): honor optimistic session tombstones on list & tree refresh
A refresh racing an in-flight delete/archive resurrected the just-removed
row: refreshSessions repopulated $sessions straight from the backend page
ignoring tombstones, and the projects.tree prune dropped a tombstone the
moment its id left scoped_session_ids — so the grouped lane un-filtered it
too. The row only vanished on a later refresh once the RPC finally landed.

refreshSessions now filters tombstoned rows (and their lineage tip) before
merging. The prune keeps a tombstone while its mutation is still in flight
locally via $sessionMutationsInFlight, then hands it back to the normal
scoped-based prune once settled.
2026-07-22 22:54:25 -05:00
Brooklyn Nicholson 66747f154c fix(desktop): auto-expand a lane when starting a session in it, and stabilize sidebar collapse state
Clicking '+' on a collapsed worktree/branch lane (or repo) created a session in a folder the user couldn't see. It now force-expands the target node.

The root cause was that collapse state was stored as an XOR override of defaultOpen. defaultOpen flips for a worktree lane (collapsed while empty, open once it holds a session), so an explicit expand of an empty lane silently re-read as a 'collapse' the moment the lane gained its first row - collapsing the very lane you'd just opened to work in. Store the resolved open/collapsed boolean per node instead ($sidebarWorkspaceNodeOpen), which survives the default flip; one-time migration off the old set. The review file-tree shares this store and is updated to match.
2026-07-22 22:43:55 -05:00
Brooklyn Nicholson 87aaf87748 fix(desktop): render session status dot from one primitive (sidebar, tiles, main tab)
The sidebar row and the pane tabs each painted their own dot from different data: the sidebar read color from $sessionColorById[id] (map-only, no resolver fallback) layered with live state, while a tab painted a flat 'accent' color via sessionColorFor (map + fallback). A session older than the recents page missed the map, so the same session showed grey in the sidebar and its project color in the tab.

Add a single SessionStatusDot primitive keyed by the stored session id (the key every live-state atom already uses) that resolves color (override -> project, with fallback) and live state (working/needs-input/stalled/unread/background) itself. The sidebar row, session tiles, and the main workspace tab all render it, so a session's status/color can never disagree across surfaces. Tabs gain the full live status (pulse etc.), not just a static color. Collapses the now-orphaned generic 'accent' tab-dot path into the one primitive.
2026-07-22 22:43:51 -05:00
ethernet 3f9944bad9 fix(ci): run trusted Docker publish directly (#69803) 2026-07-22 23:36:45 -04:00
hermes-seaeye[bot]andgithub-actions[bot] 7d96e602a9 fmt(js): npm run fix on merge (#69805)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 03:32:32 +00:00
ethernet 9f5e568812 fix(desktop): prevent stale worktree status (#69781)
Clear the coding rail while a session's workspace changes and discard late
Git status results for the previous cwd. This prevents Ctrl+Shift+B from
opening a worktree dialog against an old branch.
2026-07-22 23:24:17 -04:00
ethernet 4baf2ed8ad fix(ci): authenticate gh-image installation (#69793)
Use the trusted publisher's scoped GitHub token when resolving the pinned
gh-image release. This avoids GitHub-hosted runners exhausting their shared
anonymous API rate limit before the evidence publisher can run.
2026-07-23 03:23:28 +00:00
ethernet dbf4f69b72 fix(desktop): queue prompts during context compaction (#69783) 2026-07-22 23:19:22 -04:00
Brooklyn Nicholson 067cb9e033 feat(desktop): shift next tab into main on ⌘W + always-shown new-session tab
- ⌘W on the main tab promotes the next stacked session tab into main
- '+' / ⌘T open a new 'New session' tab, unlisted until first message
  (no sidebar pollution); multiple new-session tabs allowed
- tile resolves its own row via by-id lookup once it has a message
2026-07-22 22:18:33 -05:00
brooklyn! 5c4d358a7e Merge pull request #69750 from NousResearch/bb/desktop-fork-new-tab
fix(desktop): open branched chat in a new tab and switch to it
2026-07-22 22:17:17 -05:00
brooklyn! 6d76f5ca51 Merge pull request #69769 from NousResearch/bb/salvage-69720-clarify-late
feat(desktop): skipped clarify keeps its choices visible and answerable
2026-07-22 22:12:53 -05:00
Brooklyn Nicholson f91d5b2d13 chore(desktop): dedupe session-color precedence, tighten tileStoredRow
- Extract resolveSessionColor(): the sessionColorFor fallback no longer
  re-implements the override -> project-color precedence that the
  $sessionColorById computed already owns.
- tileStoredRow: collapse the nested project-tree walk into a flatMap +
  find, matching the local style.
2026-07-22 22:11:49 -05:00
Brooklyn Nicholson e80e036086 fix(desktop): use <Tip> instead of native title= on clarify skip buttons
The no-native-title guard (added on main after this PR's first CI run)
bans native title= on <button>. Wrap the shared ChoiceButton in the
themed <Tip>, which renders the child untouched when the label is falsy
so the live card is unaffected.
2026-07-22 21:56:01 -05:00
Ben BarclayandVictor Kyriazakos 305a3c7424 fix(relay): restore streaming delivery, Slack command parity, and status clearing (salvage of #69716) (#69747)
* fix(gateway): restore relay streaming delivery

* fix(relay): route Slack parent commands before session gates

* fix(relay): clear Slack typing status after turns

---------

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-07-23 12:51:13 +10:00
Brooklyn Nicholson 30f6fc81e2 fix(desktop): keep ever-active tab panes mounted so revisiting a tab doesn't layout-shift
A tab group rendered only the active pane's content, so every tab switch
unmounted and remounted the whole surface — revisiting a session tab
re-measured and re-scrolled the thread from scratch, visibly shifting
layout each time.

Panes that have been active in a zone now stay mounted in absolutely
positioned layers; the inactive ones hide via visibility (keeping their
layout box, so scroll positions and measurements survive) with
pointer-events disabled and aria-hidden. Mounting stays lazy — a pane
first mounts when first activated — so a boot-restored tab stack still
doesn't resume every session up front, and panes that leave the zone
(closed / moved) unmount as before.
2026-07-22 21:49:12 -05:00
Brooklyn Nicholson bf21ba08e3 fix(desktop): resolve tab title and project color for sessions outside the recents page
Opening a session in a new tab left the tab titled 'Session' with no
project-color dot until new activity landed the row in the paginated
recents list. Two gaps:

1. tileTitle/tileAccent/tileDragPayload only looked the stored row up in
   $sessions (the recents page). Sessions opened from a project group are
   often older than that page, so the lookup missed entirely. Resolve
   through the project tree as a fallback (tileStoredRow) and re-sync pane
   titles/accents when $projectTree loads.

2. Even with the row present, liveSessionProjectId returned null for a
   session whose cwd sits outside its recorded git_repo_root (mid-session
   relocation / sibling worktree), because the cwd-under-root guard ran
   before the explicit-project folder match. The backend tree groups such
   rows under the project; the client now agrees — an explicit folder
   match is authoritative, only the auto-project (repo root) fallback
   still needs cwd-under-root confidence.

sessionColorFor also computes directly (overrides -> project color) when
the row isn't in the $sessionColorById map, which is keyed over $sessions
only.
2026-07-22 21:46:15 -05:00
ethernet d63a1c4ccb fix(desktop): separate workspace defaults from live cwd (#69765) 2026-07-23 02:44:26 +00:00
ethernet b162371f50 fix(ci): retain delayed and composite-action jobs in review (#69768)
Keep the live PR review comment polling for a short grace period after
visible jobs complete. Preserve composite-action jobs in timing and live
status collection, and split the timing HTML from its linked review-status
artifact.
2026-07-22 22:43:21 -04:00
Brooklyn NicholsonandSHL0MS efa002dd5d refactor(desktop): share the clarify choice row and fix skip copy
Builds on the skipped-clarify card so it holds up beyond the timeout case:

- Extract a shared `ChoiceButton` (letter badge + label + row chrome) used
  by both the live pending card and the settled skip card. The two blocks
  had drifted into duplicated markup; now they can't diverge.
- Fix the hint copy. An empty `user_response` is emitted for BOTH a
  server-side timeout AND a manual Skip (tools/clarify_tool.py) — there is
  no field on the result that tells them apart — so asserting "This question
  timed out" was wrong half the time. Neutral wording ("This prompt is no
  longer waiting…") is correct for either, and the recover-your-answer path
  now also helps someone who mis-clicked Skip. Updated en/ja/zh/zh-hant.

No behavior change to the live prompt or the follow-up-message flow.

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-07-22 21:39:27 -05:00
SHL0MS 8b96fc57ed feat(desktop): skipped clarify keeps its choices visible and answerable
When a clarify prompt times out, the settled card collapsed to just
'Skipped' — the options were unrecoverable (the args carry them, the
renderer dropped them) and there was no way to answer late.

The skipped card now:
- renders the original choices, letter-badged like the live card
- clicking one drafts a quoted follow-up ('Re: "<question>" — my
  answer: <choice>') into the composer via the insert bus. Enter sends
  it; if the agent is mid-turn it queues like any other prompt.
- a hint line explains the question timed out and what picking does

No retroactive resolution of the expired request: the tool already
returned empty and the turn moved on — injecting into past context
would break prompt-cache and role-alternation invariants, and
clarify.respond on an expired id hard-errors (#56558). The follow-up
message path needs no backend change and works against old backends.

Answered clarifies and free-text (no-choice) skips are unchanged.

Interim UX for #44845 (durable ID-addressable clarify decisions).
2026-07-22 21:38:32 -05:00
Brooklyn Nicholson 35cdc63ca0 fix(desktop): use repo-forked codicon (git-fork has no glyph)
The bundled @vscode/codicons font has no `git-fork` glyph (only
`git-fork-private`), so the Branch menu item rendered blank. Use
`repo-forked`, the actual fork icon, to match the inline GitFork action.
2026-07-22 21:32:23 -05:00
Brooklyn Nicholson a0a24ba215 fix(desktop): use fork icon for branch in session context menu
The row/tab context-menu 'Branch from here' item used the git-branch
codicon while the inline message action uses a GitFork icon. Switch the
menu item to the git-fork codicon so branching looks consistent across
surfaces.
2026-07-22 21:27:56 -05:00
ethernet 957ea640de fix(ci): publish inline E2E evidence (#69699)
* fix(ci): publish inline E2E evidence

Upload bounded screenshot evidence from E2E, then publish validated images
from a trusted workflow_run job to commit-pinned branches in the evidence repo.

Wait briefly for the live CI review comment marker before publishing, so
GitHub's read-after-write delay cannot leave an orphaned evidence branch.

* fix(ci): isolate privileged credentials from PR jobs

Keep App private keys and Docker Hub credentials out of PR-controlled
workflows. Use protected environments for trusted publishing and a public
repository variable for the App client ID.

* fix(ci): attach E2E evidence with restricted bot session

Replace the App-backed evidence repository publisher with gh-image uploads
from a dedicated bot session in the gh-image environment.

* fix(ci): publish validated E2E evidence from forks

Let the trusted default-branch publisher handle bounded, validated evidence
artifacts from fork PR CI without checking out or executing fork code.
2026-07-23 02:15:23 +00:00
Brooklyn Nicholson 6326b30c93 fix(desktop): open branched chat in a new tab and switch to it
Branching/forking a chat replaced the primary chat (setActiveSessionId +
setSelectedStoredSessionId + navigate). Instead, open the branch as its own
session tile tab in the center zone and reveal it, leaving the parent chat
exactly where it is — mirroring openNewSessionTile. All branch entry points
(message GitFork button, /branch and /fork, sidebar Branch, tile Branch) go
through forkBranch, so this covers every surface.
2026-07-22 21:13:06 -05:00
ethernet 8a21df18ac fix(desktop): place steer messages before redirected replies (#69739)
* test(desktop): reproduce steer transcript placement

* fix(desktop): place steer messages before redirected replies
2026-07-22 22:04:57 -04:00
brooklyn! 2c1d585002 Merge pull request #69655 from NousResearch/bb/out-of-credits-ux
feat(billing): consistent out-of-credits UX across CLI, TUI, and desktop
2026-07-22 21:03:38 -05:00
brooklyn! da3c506db5 Merge pull request #69691 from NousResearch/bb/desktop-billing-polish
Desktop billing polish + shared Progress primitive + settings skeletons
2026-07-22 21:03:34 -05:00
ethernet 6d17b2a593 fix(desktop): preserve active correction on warm resume (#69725)
Update the gateway's live turn projection after an accepted correction so a warm session resume does not add the stale original prompt beside the persisted correction.

Cover the inference-time correction path end to end and assert both redirect entry points refresh the live user text.
2026-07-22 21:53:56 -04:00
ethernet 2a2474512b test(desktop): cover queued prompt turn boundary (#69729) 2026-07-23 01:36:31 +00:00
Ben Barclay e5b83633ec feat(relay): egress typing indicators through the connector (op="typing") (#69721)
The base class spawns _keep_typing for every adapter — a 2s refresh loop
that runs for the whole turn, including while the stream consumer sends
the response — but RelayAdapter inherited BasePlatformAdapter's no-op
send_typing. The loop ran every turn and emitted nothing, so relay-fronted
chats (hosted Discord/Telegram/Signal/Slack) never showed 'is typing…'.

The rest of the pipe already existed on both sides: OutboundOp "typing"
is in the wire contract (contract_version 1), and every connector-side
sender implements it (Discord POST /channels/{id}/typing, Telegram
sendChatAction, Signal sendTyping, Slack assistant status).

This bridges the tick onto the existing outbound frame, mirroring send():
- _with_scope re-attaches the tenant discriminator (metadata.scope_id, or
  user_id for DMs) — the connector's routedEgressGuard wraps ALL ops, so
  an undiscriminated typing frame is declined like a bare send would be.
- the Phase 1.5 per-frame platform tag routes typing through the platform
  the chat lives on for multi-platform gateways.

Best-effort by design: transport errors are swallowed (typing is cosmetic;
the next tick retries), no transport is a silent no-op, and stop_typing
stays the base no-op (platform indicators self-expire). Additive within
contract_version 1 — an older connector returns an unsupported-op result
we ignore. Contract doc §4 updated (typing now carries metadata?).
2026-07-23 11:21:03 +10:00
Brooklyn Nicholson 43787dab14 feat(desktop/settings): DOM-shaped skeleton loaders on every settings page
Model settings was the only page that kept its shape while loading; the rest
flashed a centered spinner (LoadingState) or empty placeholders. Standardize on
skeletons that mirror the settings rhythm.

- Add shared SectionHeadingSkeleton / ListRowSkeleton / SettingsSkeleton to
  settings/primitives.tsx (mirror SectionHeading + ListRow).
- Convert keys, providers, sessions, gateway, custom-endpoints, and config
  (non-model) from LoadingState to SettingsSkeleton; remove now-dead LoadingState.
- billing: BillingSkeleton (summary cards + sections) on first load instead of
  "—" placeholder cards.
- pet: skeleton grid on first load instead of a premature "unreachable" message.
2026-07-22 19:22:37 -05:00
Brooklyn Nicholson e4b2b77852 refactor(desktop): extract shared Progress primitive; billing uses it
Add components/ui/progress.tsx — one rounded track + animated fill that owns
role="progressbar" + aria. Migrate the hand-rolled bars (updates overlay,
onboarding, install overlay, billing usage, pet hatch) onto it.

- Pet's bar was never a color variant (--primary and --ui-accent are the same
  brand color); its only real difference is the sliding indeterminate, now an
  `animated` prop. Color stays a normal `fillClassName` override.
- Billing usage now uses the plain primitive — dropped the bespoke dither
  track, inset shadow, and danger nub; tone rides `destructive`/`fillClassName`.
- Drop the redundant emoji "no saved card" row description; the page-level
  warn banner is the single explainer.
- Rename pet CSS .pet-progress* -> generic .progress-slide.
2026-07-22 18:53:26 -05:00
Brooklyn Nicholson 066b53b282 fix(desktop): narrow billing custom top-up input (w-20 -> w-16) 2026-07-22 18:31:21 -05:00
Brooklyn Nicholson c4acc4d2c5 feat(desktop): add prefix/suffix adornments to Input primitive; use $ prefix in billing top-up 2026-07-22 18:17:33 -05:00
Brooklyn Nicholson dd3bd70f35 feat(cli): out-of-credits panel below the response
Pin a provider-agnostic "Out of credits" panel after a billing-classified turn so
the one recovery action (Nous → /topup, other providers → their billing page)
stays visible instead of scrolling away as prose.
2026-07-22 18:09:08 -05:00
Brooklyn Nicholson 9c274db89f feat(tui): billing wall opens a confirm dialog
Open the shared ConfirmPrompt (full-width, themed, same lane as approval/clarify)
rather than a truncating status-bar notice. One recovery action: Nous → /topup
(opens the rich billing overlay), other providers → their billing page, or /model
to switch when there's no URL. The transcript keeps the full guidance; the dialog
is the concise actionable layer.
2026-07-22 18:09:08 -05:00
Brooklyn Nicholson d0c4a82da9 feat(desktop): billing toast + in-chat status-row banner with smart CTA
On a billing wall, raise a sticky, billing-specific toast (never the generic
error toast) and a persistent in-composer banner for the active session — both
with one recovery action: Nous → in-app Settings → Billing, other providers →
their billing page (deep-linked). The banner reuses the shared StatusRow chrome
(no bordered alert, Codicon glyph, shared buttons), and the composer stays usable
so slash commands keep working.
2026-07-22 18:09:08 -05:00
Brooklyn Nicholson 960d339f86 feat(billing): shared cross-surface out-of-credits signal
Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and
map it to a recovery link + label in one place, then carry that structured
BillingBlock to every surface instead of re-parsing free-form error text per
surface.

- agent/billing_links.py: provider-agnostic slug/host → (label, billing URL)
  table (single source of truth), Nous-aware (is_nous routes to the in-app flow);
  unknown providers degrade to a readable label with no invented URL.
- conversation_loop: both billing exit paths return a billing_block through one
  helper; the guidance message carries the derived URL for every provider.
- gateway forwards billing_block on message.complete (it was dropped).
- @hermes/shared: BillingBlock type shared by desktop + TUI.
2026-07-22 18:08:59 -05:00
Brooklyn Nicholson f9a8ecc0ce polish(desktop/billing): auto-poll, no-card notice, grouped card layout
Post-merge review polish of #68722 + #68761:
- Usage: drop the manual refresh + 'Updated Xm ago'; billing queries now
  refetchInterval-poll while the page is mounted, like every other data view.
- No card: lead the page with a warn notice naming the blocker + 'Add card ↗',
  so the silently-disabled buy/auto-refill controls have an obvious cause.
- Layout: unify on a shared SettingsCard/SettingsSection primitive; collapse the
  three floating one-row sections (Payment / One-time top-up / Automatic refill)
  into a single divide-y 'Payment & credits' card.
- Dev fixture switcher: relabel as a wrench + 'preview' dashed control so it
  reads as the DEV-only tool it is (compiled out of production).

Removed formatUsageUpdatedAgo/oldestUpdatedAt/UsageRefreshRow + their tests;
added no-card-notice and no-manual-refresh tests. vitest 107 green, tsc + eslint clean.
2026-07-22 16:48:04 -05:00
261 changed files with 17913 additions and 1795 deletions
+18 -8
View File
@@ -5,24 +5,32 @@ description: >-
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
and are scoped to the App's installation permissions, not a user account.
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
this happens on fork PRs where repo secrets are unavailable. The fallback
ensures classification, timings, and review comments still work on
forks (with the lower GITHUB_TOKEN rate limit).
Callers must source App credentials from a protected, main-only environment.
Never pass an App private key to a pull_request job, a local action, or a
reusable workflow resolved from an untrusted PR ref. The fallback keeps a
trusted caller functional when its protected environment is misconfigured.
Composite actions cannot access the secrets context directly, so the
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
as inputs. When both are empty (fork PRs), the fallback fires.
Composite actions cannot access contexts directly, so callers pass the
public vars.APP_CLIENT_ID and protected secrets.APP_PRIVATE_KEY as inputs.
When the private key is empty, the fallback fires.
inputs:
client-id:
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
required: false
default: ''
private-key:
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
required: false
default: ''
owner:
description: GitHub App installation owner. Empty scopes the token to the current repository.
required: false
default: ''
repositories:
description: Comma- or newline-separated repositories to scope within the installation owner.
required: false
default: ''
outputs:
token:
@@ -51,6 +59,8 @@ runs:
with:
client-id: ${{ inputs.client-id }}
private-key: ${{ inputs.private-key }}
owner: ${{ inputs.owner }}
repositories: ${{ inputs.repositories }}
- name: Fall back to GITHUB_TOKEN
id: fallback
+46 -50
View File
@@ -9,6 +9,10 @@ name: CI
# definitions, matrices, and concurrency settings. They no longer have
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
# through this file.
#
# SECURITY: this workflow runs PR-controlled actions, workflows, and code.
# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted
# main-only automation uses protected environments in its own workflows.
on:
pull_request:
@@ -50,19 +54,11 @@ jobs:
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ github.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@@ -75,7 +71,6 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
@@ -84,14 +79,12 @@ jobs:
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
secrets: inherit
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit
e2e-desktop:
name: Desktop E2E
@@ -104,48 +97,44 @@ jobs:
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit
lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit
docker:
name: Build&Test Docker image
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
# Trusted main pushes run docker.yml directly so its container-publish
# environment secrets never cross this reusable-workflow call. PR runs
# remain build/test-only and secret-free.
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true')
uses: ./.github/workflows/docker.yml
secrets: inherit
supply-chain:
name: Supply-chain scan
@@ -167,12 +156,10 @@ jobs:
ci_review_files: ${{ needs.detect.outputs.ci_review_files }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Live-updating PR review comment.
@@ -182,9 +169,8 @@ jobs:
# whatever results are available, and upserts it via the
# ``<!-- hermes-ci-review-bot -->`` marker.
#
# The poller exits when all non-infra jobs are completed (or on
# timeout). ci-timings' review_status is picked up automatically when
# its artifact becomes available — the poller downloads and merges it.
# When the visible job set goes quiet, the poller waits 10 seconds and polls
# once more so downstream jobs created by an aggregate gate get included.
# ─────────────────────────────────────────────────────────────────────
comment-live:
name: CI review comment (live)
@@ -195,6 +181,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Run live comment poller
env:
@@ -315,8 +304,8 @@ jobs:
# report with a gantt chart + per-step breakdown. The report is uploaded
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
#
# The live comment poller picks up ci-timings' completion automatically —
# it reads review-status.json from the artifact when the job finishes.
# The live comment poller can read the standalone review-status artifact
# after the HTML report is uploaded, so its link points straight at that report.
# ─────────────────────────────────────────────────────────────────────
ci-timings:
name: CI timing report
@@ -328,13 +317,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Restore baseline cache (PR only)
if: github.event_name == 'pull_request'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -348,38 +330,52 @@ jobs:
- name: Collect timings and generate report
env:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so the timings API read still works
# there instead of hard-failing this advisory job on every fork PR.
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
--output ci-timings-report.html \
--json-out ci-timings.json \
--summary-out ci-timings-summary.md \
--review-status-out review-status.json
--summary-out ci-timings-summary.md
- name: Upload HTML report + review status
- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
id: ci-timings-html
with:
name: ci-timings-report
path: |
ci-timings-report.html
review-status.json
path: ci-timings-report.html
retention-days: 14
- name: Build linked review status
if: hashFiles('ci-timings.json') != ''
env:
CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }}
run: |
python3 scripts/ci/timings_report.py \
--from-json ci-timings.json \
--baseline ci-timings-baseline.json \
--review-status-out review-status.json \
--review-status-only
- name: Upload review status
if: hashFiles('review-status.json') != ''
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-timings-review-status
path: review-status.json
retention-days: 14
- name: Output summary
env:
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}}
run: |
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
{
echo "# CI Timing report"
echo "[View the full interactive report]($REPORT_URL)"
} >> "$GITHUB_STEP_SUMMARY"
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Save baseline cache (main only)
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+80 -45
View File
@@ -1,8 +1,15 @@
name: Docker Build, Test, and Publish
on:
# Trusted main pushes run this workflow directly so environment-scoped
# Docker Hub secrets are resolved by the top-level workflow, never across
# a reusable-workflow boundary.
push:
branches: [main]
release:
types: [published]
# CI calls this only for untrusted PR build/test coverage. Those runs never
# reach the protected publish or merge jobs below.
workflow_call:
permissions:
@@ -20,7 +27,9 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# Build, test, and optionally push the image for each architecture.
# Build and test the image for each architecture. This job runs PR code,
# so it must remain secret-free. Publishing happens in the separate,
# protected publish job after these tests pass.
build:
if: github.repository == 'NousResearch/hermes-agent'
strategy:
@@ -62,49 +71,6 @@ jobs:
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the
# tagged manifest list. `push-by-digest=true` is docker's recommended
# pattern for multi-runner multi-platform builds.
- name: Push ${{ matrix.arch }} by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
# Write the digest to a file and upload it as an artifact so the
# merge job can stitch both per-arch digests into a manifest list.
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`).
@@ -147,6 +113,74 @@ jobs:
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
# ---------------------------------------------------------------------------
# Rebuild and push each architecture only after the unprivileged build/test
# matrix passes. This job is the sole Docker Hub credential boundary.
# ---------------------------------------------------------------------------
publish:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
needs: [build]
environment: container-publish
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- arch: arm64
runner: ubuntu-24.04-arm
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the tagged
# manifest list after both architecture publishers complete.
- name: Push ${{ matrix.arch }} by digest
id: push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
@@ -158,8 +192,9 @@ jobs:
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
runs-on: ubuntu-latest
needs: [build]
needs: [publish]
timeout-minutes: 10
environment: container-publish
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+13
View File
@@ -171,6 +171,19 @@ jobs:
echo '__E2E_REVIEW_STATUS__'
} >> "$GITHUB_OUTPUT"
# The trusted workflow_run publisher consumes only this flat, bounded
# artifact. It turns selected images into GitHub attachment URLs; it
# never checks out or runs this PR's code.
- name: Upload inline E2E evidence
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-evidence-${{ github.sha }}
path: /tmp/e2e-evidence
retention-days: 14
overwrite: true
if-no-files-found: error
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
+2 -1
View File
@@ -122,6 +122,7 @@ jobs:
if: needs.generate-patch.outputs.has-fixes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
permissions:
contents: write # needed to push to bot/js-autofix
pull-requests: write # needed for PR creation + auto-merge
@@ -132,7 +133,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch
@@ -0,0 +1,71 @@
name: Publish E2E evidence
# This runs only from the default branch after CI completes. It intentionally
# checks out main, never the PR ref, and treats the downloaded artifact as
# untrusted input before uploading validated GitHub attachments.
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
actions: read
contents: read
pull-requests: write
concurrency:
group: publish-e2e-evidence-${{ github.event.workflow_run.id }}
cancel-in-progress: false
jobs:
publish:
name: Publish inline E2E evidence
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: gh-image
steps:
- name: Check out trusted publisher
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
# v1.2.0 resolves to 44f4b93ecbbe22de6c45fa2f62f519aee564ca8c.
- name: Install gh-image
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install drogers0/gh-image --pin v1.2.0
- name: Download and attach evidence
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
GH_SESSION_TOKEN: ${{ secrets.GH_IMAGE_SESSION_TOKEN }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
run: |
set -euo pipefail
PR_NUMBER=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID" --jq '.pull_requests[0].number // empty')
if [ -z "$PR_NUMBER" ]; then
echo "No pull request is associated with CI run $SOURCE_RUN_ID."
exit 0
fi
ARTIFACT_NAME=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID/artifacts" \
--jq '.artifacts[] | select(.expired == false and (.name | startswith("e2e-evidence-"))) | .name' \
| python3 -c 'import sys; print(next(iter(sys.stdin), "").strip())')
if [ -z "$ARTIFACT_NAME" ]; then
echo "No E2E evidence artifact was produced for CI run $SOURCE_RUN_ID."
exit 0
fi
EVIDENCE_DIR="$RUNNER_TEMP/e2e-evidence"
mkdir -p "$EVIDENCE_DIR"
gh run download "$SOURCE_RUN_ID" --repo "$SOURCE_REPO" --name "$ARTIFACT_NAME" --dir "$EVIDENCE_DIR"
python3 scripts/ci/publish_e2e_evidence.py \
--evidence-dir "$EVIDENCE_DIR" \
--source-repo "$SOURCE_REPO" \
--pr-number "$PR_NUMBER"
+2 -1
View File
@@ -21,6 +21,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: trusted-automation
steps:
- name: Probe live index
id: probe
@@ -113,7 +114,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Open issue on degraded / failed probe
+4 -2
View File
@@ -21,6 +21,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -28,7 +29,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -60,12 +61,13 @@ jobs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
steps:
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Trigger Deploy Site workflow
env:
+1 -8
View File
@@ -65,17 +65,10 @@ jobs:
with:
fetch-depth: 0
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ github.token }}
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
run: |
set -euo pipefail
+19 -7
View File
@@ -922,6 +922,18 @@ def recover_with_credential_pool(
)
return False, has_retried_429
# Capture the current API key before any rotation — needed to
# identify which credential actually failed when
# mark_exhausted_and_rotate is called. Without this hint the
# pool falls back to current() or _select_unlocked(), which may
# return the NEXT (healthy) entry after a prior rotation, marking
# the wrong credential as exhausted (#43747).
_api_key_hint = getattr(agent, "api_key", None) or None
if not _api_key_hint:
_cur = pool.current()
if _cur:
_api_key_hint = getattr(_cur, "runtime_api_key", None)
effective_reason = classified_reason
if effective_reason is None:
if status_code == 402:
@@ -952,13 +964,13 @@ def recover_with_credential_pool(
if effective_reason == FailoverReason.billing:
rotate_status = status_code if status_code is not None else 402
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
api_key_hint=getattr(agent, "api_key", None),
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
@@ -983,7 +995,7 @@ def recover_with_credential_pool(
current_last_status,
)
rotate_status = status_code if status_code is not None else 429
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
@@ -1007,7 +1019,7 @@ def recover_with_credential_pool(
if not has_retried_429 and not usage_limit_reached:
return False, True
rotate_status = status_code if status_code is not None else 429
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit) — rotated to pool entry %s",
@@ -1107,7 +1119,7 @@ def recover_with_credential_pool(
# Refresh failed — rotate to next credential instead of giving up.
# The failed entry is already marked exhausted by try_refresh_current().
rotate_status = status_code if status_code is not None else 401
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
if next_entry is not None:
_ra().logger.info(
"Credential %s (auth refresh failed) — rotated to pool entry %s",
+124
View File
@@ -0,0 +1,124 @@
"""Provider-agnostic billing/credit recovery links.
Maps a billing-classified failure onto a recovery link + label. *Detection*
is not done here — that is :mod:`agent.error_classifier`
(``FailoverReason.billing``), the single source of truth for "credit wall vs.
rate limit / auth / transport". The resulting :class:`BillingBlock` rides the
turn result and the gateway ``message.complete`` event so every surface (CLI,
TUI, desktop) renders one structured signal instead of re-parsing error text.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Optional
from utils import base_url_host_matches
@dataclass
class BillingBlock:
"""Structured billing-wall descriptor shared across every surface.
``is_nous`` is the routing bit: Nous has a first-class in-app billing surface
(desktop Settings → Billing, TUI/CLI ``/topup``), so surfaces prefer that over
``billing_url``; third-party providers have no in-app flow, so ``billing_url``
is the deep link the user actually needs.
"""
provider: str
provider_label: str
model: str
billing_url: Optional[str]
is_nous: bool
message: str
def to_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class _Provider:
label: str
url: str
slugs: tuple[str, ...]
hosts: tuple[str, ...] = ()
# Single source of truth: internal slug(s) + base_url host(s) → billing page.
# Curated "add credits / manage billing" landing pages, not marketing homes.
# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket
# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown
# provider degrades to a readable label with no invented URL.
_PROVIDERS: tuple[_Provider, ...] = (
_Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)),
_Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)),
_Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)),
_Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)),
_Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)),
_Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)),
_Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)),
_Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")),
_Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)),
_Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)),
_Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)),
_Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)),
_Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)),
_Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)),
)
_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs}
def is_nous_inference_route(provider: str, base_url: str) -> bool:
"""True when the failing route is the Nous-managed inference gateway."""
if (provider or "").strip().lower() == "nous":
return True
return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com")
def _nous_billing_url() -> Optional[str]:
"""Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow)."""
try:
from hermes_cli.nous_account import nous_portal_billing_url
return nous_portal_billing_url(None)
except Exception:
return "https://portal.nousresearch.com/billing"
def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]:
"""Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback."""
hit = _BY_SLUG.get(slug)
if hit:
return hit.label, hit.url
base = str(base_url or "")
for p in _PROVIDERS:
if any(base_url_host_matches(base, host) for host in p.hosts):
return p.label, p.url
return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None
def build_billing_block(
*,
provider: str,
base_url: str,
model: str,
message: str = "",
) -> BillingBlock:
"""Build the billing descriptor for a billing-classified failure.
``message`` is the guidance already assembled by the agent loop
(:func:`agent.conversation_loop._billing_or_entitlement_message`), carried
through unchanged so every surface shows identical copy.
"""
slug = (provider or "").strip().lower()
model = (model or "").strip()
if is_nous_inference_route(slug, base_url):
return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "")
label, url = _resolve_provider_link(slug, base_url)
return BillingBlock(slug, label, model, url, False, message or "")
+59 -2
View File
@@ -307,6 +307,19 @@ def _billing_or_entitlement_message(
]
return "\n".join(lines)
# Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq,
# OpenRouter, …) so every text surface — CLI, gateway messaging, TUI
# transcript — shows the same actionable link, not just OpenRouter.
try:
from agent.billing_links import build_billing_block
_link = build_billing_block(provider=provider, base_url=base_url, model=model)
if _link.provider_label:
provider_label = _link.provider_label
billing_url = _link.billing_url
except Exception:
billing_url = None
lines = [
(
f"{provider_label} reported that billing, credits, or account "
@@ -314,12 +327,24 @@ def _billing_or_entitlement_message(
),
"Add credits or update billing with that provider, then retry.",
]
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
if billing_url:
lines.append(f"{provider_label} billing: {billing_url}")
lines.append("You can switch providers temporarily with /model <model> --provider <provider>.")
return "\n".join(lines)
def _billing_block_dict(provider, base_url, model, message="") -> Optional[dict]:
"""Best-effort structured billing descriptor (None if billing_links is unavailable)."""
try:
from agent.billing_links import build_billing_block
return build_billing_block(
provider=provider, base_url=str(base_url), model=model, message=message
).to_dict()
except Exception:
return None
def _print_billing_or_entitlement_guidance(
agent,
*,
@@ -4284,6 +4309,31 @@ def run_conversation(
final_response=_policy_response,
error_detail=_nonretryable_summary,
)
# Billing walls are the common non-retryable abort: enrich
# the result with the same structured recovery descriptor as
# the max-retries path so every surface (CLI, TUI, desktop)
# renders one consistent billing signal.
if classified.reason == FailoverReason.billing:
_ce_guidance = _billing_or_entitlement_message(
capability="model access",
provider=_provider,
base_url=str(_base),
model=_model,
)
_ce_final = f"Billing or credits exhausted: {_nonretryable_summary}"
if _ce_guidance:
_ce_final += f"\n\n{_ce_guidance}"
_ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance)
return {
"final_response": _ce_final,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": _nonretryable_summary,
"failure_reason": classified.reason.value,
"billing_block": _ce_block,
}
return {
"final_response": _nonretryable_summary,
"messages": messages,
@@ -4444,10 +4494,14 @@ def run_conversation(
api_kwargs, reason="max_retries_exhausted", error=api_error,
)
agent._persist_session(messages, conversation_history)
_billing_block = None
if classified.reason == FailoverReason.billing:
_final_response = f"Billing or credits exhausted: {_final_summary}"
if _billing_guidance:
_final_response += f"\n\n{_billing_guidance}"
# Structured recovery descriptor so every surface renders
# the same link + label from one signal (see helper).
_billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance)
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _is_thinking_timeout:
@@ -4487,6 +4541,9 @@ def run_conversation(
# different exit code. ``rate_limit`` / ``billing`` here
# mean "quota wall, not a task error".
"failure_reason": classified.reason.value,
# Present only for billing walls: structured recovery
# descriptor (provider, billing_url, is_nous, message).
"billing_block": _billing_block,
}
# For rate limits, respect the Retry-After header if present
+11 -2
View File
@@ -316,12 +316,21 @@ def evaluate_credits_notices(
active.discard(CREDITS_USAGE_KEY)
if target_band is not None:
# Belt-and-suspenders: a producer could set subscription_limit_micros
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
# without subscription_limit_usd. Render "$?" rather than "$None".
_cap_usd = state.subscription_limit_usd or "?"
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
# Report absolute dollars used, not a bare "N% used": the percentage is
# only meaningful against a Nous subscription cap (no cap → never fires),
# so dollars are clearer and don't imply a universal %. Used = cap −
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
_lim = state.subscription_limit_micros or 0
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
_glyph = "⚠" if _level == "warn" else "•"
to_show.append(
AgentNotice(
text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
level=_level,
kind=CREDITS_NOTICE_KIND,
key=CREDITS_USAGE_KEY,
+12 -7
View File
@@ -210,16 +210,21 @@ def _supports_vision_override(
# get rewritten to provider="custom" at runtime
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
# config still holds the user-declared name under model.provider. Try
# both as candidate provider keys, plus the stripped suffix from
# "custom:<name>" (where <name> is the key under providers:).
# both as candidate provider keys. Either identity may use the
# "custom:<name>" form while providers: is keyed by bare <name>.
config_provider = str(model_cfg.get("provider") or "").strip()
# Extract the stripped name from "custom:<name>" if present
stripped_suffix = ""
if config_provider.startswith("custom:"):
stripped_suffix = config_provider[len("custom:"):]
provider_candidates: List[str] = []
for candidate in (provider, config_provider):
if not candidate:
continue
provider_candidates.append(candidate)
if candidate.startswith("custom:"):
stripped_candidate = candidate[len("custom:"):]
if stripped_candidate:
provider_candidates.append(stripped_candidate)
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
for p in dict.fromkeys(provider_candidates):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
@@ -17,6 +17,9 @@ const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must rema
const CORRECTION = 'E2E correction must stay after the original prompt.'
const TOOL_STARTED = 'Checking the long-running task before I continue.'
const CORRECTED_REPLY = 'The corrected task finished.'
const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
@@ -26,6 +29,17 @@ async function send(page: Page, text: string): Promise<void> {
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
}
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
await page.waitForFunction(
(expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
@@ -61,6 +75,17 @@ async function transcriptTextOrder(page: Page): Promise<string[]> {
})
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
await page.waitForFunction(
@@ -83,15 +108,34 @@ async function reopenOriginalSession(page: Page): Promise<void> {
await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
}
async function reopenInferenceSession(page: Page): Promise<void> {
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first()
await row.waitFor({ state: 'visible', timeout: 30_000 })
await row.click()
await waitForTranscriptText(page, INFERENCE_PROMPT)
}
function relevantOrder(messages: string[]): string[] {
return messages.filter(message => message.includes(ORIGINAL_PROMPT) || message.includes(CORRECTION))
}
function steerTurnOrder(messages: string[]): string[] {
return messages.flatMap(message => {
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
if (message.includes(CORRECTION)) return [CORRECTION]
if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
return []
})
}
test.describe('correction session switch', () => {
let fixture: MockBackendFixture | null = null
test.beforeEach(async () => {
fixture = await setupMockBackend()
fixture = await setupMockBackend({
mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER },
})
await waitForAppReady(fixture, 120_000)
})
@@ -113,9 +157,9 @@ test.describe('correction session switch', () => {
await waitForTranscriptText(page, TOOL_STARTED)
await waitForTranscriptText(page, ORIGINAL_PROMPT)
// The historical session redirected while a foreground terminal task was
// running. Enter records the accepted correction at the next tool boundary.
await send(page, CORRECTION)
// The historical session redirects while a foreground terminal task is
// running. Use the visible Steer action to cover the real composer path.
await steer(page, CORRECTION)
await waitForTranscriptText(page, CORRECTION)
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
@@ -136,5 +180,31 @@ test.describe('correction session switch', () => {
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
await waitForTranscriptText(page, CORRECTED_REPLY)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
})
test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => {
const { mock, page } = fixture!
await send(page, OTHER_SESSION_PROMPT)
await waitForTranscriptText(page, MOCK_REPLY)
await openFreshDraft(page, OTHER_SESSION_PROMPT)
await send(page, INFERENCE_PROMPT)
await mock.waitForHeldStream()
await waitForTranscriptText(page, INFERENCE_PROMPT)
await send(page, INFERENCE_CORRECTION)
await waitForTranscriptText(page, INFERENCE_CORRECTION)
await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT)
await reopenInferenceSession(page)
expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1)
expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1)
await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') })
mock.releaseHeldStream()
await waitForTranscriptText(page, MOCK_REPLY)
})
})
+26 -9
View File
@@ -141,22 +141,29 @@ export function createSandbox(prefix: string): Sandbox {
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*
* @param extraConfig optional YAML lines appended to the `display:` section,
* used by the interim-message e2e test to toggle
* `display.interim_assistant_messages`.
* @param extraDisplayConfig optional YAML lines appended to the `display:`
* section, used by the interim-message e2e test.
* @param extraConfig optional top-level YAML sections for a test scenario.
* @param modelContextLength optional primary-model context limit.
*/
export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void {
export function writeMockProviderConfig(
hermesHome: string,
mockUrl: string,
extraDisplayConfig?: string,
extraConfig?: string,
modelContextLength?: number,
): void {
const configPath = path.join(hermesHome, 'config.yaml')
const displaySection = extraConfig
? `\ndisplay:\n${extraConfig}\n`
const displaySection = extraDisplayConfig
? `\ndisplay:\n${extraDisplayConfig}\n`
: ''
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
provider: mock
providers:
${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
mock:
api: ${mockUrl}/v1
name: Mock
@@ -165,7 +172,7 @@ providers:
models:
mock-model: {}
context_length: 4096
${displaySection}`
${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
fs.writeFileSync(configPath, config, 'utf8')
}
@@ -339,6 +346,10 @@ export interface MockBackendOptions {
* `display.interim_assistant_messages`.
*/
extraDisplayConfig?: string
/** Additional top-level config.yaml sections for an E2E scenario. */
extraConfig?: string
/** Override the mock model's context window for compression scenarios. */
modelContextLength?: number
}
/**
@@ -358,7 +369,13 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig)
writeMockProviderConfig(
sandbox.hermesHome,
mock.url,
options.extraDisplayConfig,
options.extraConfig,
options.modelContextLength,
)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
+23 -2
View File
@@ -23,6 +23,8 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c
export interface MockServerOptions {
/** Pause the matching stream after its first token for session-switch E2E coverage. */
holdFirstStreamForPrompt?: string
/** Pause the first completion whose request JSON contains this text. */
holdFirstCompletionContaining?: string
}
export interface MockServer {
@@ -30,7 +32,9 @@ export interface MockServer {
url: string
receivedPrompts: string[]
waitForHeldStream: () => Promise<void>
waitForHeldCompletion: () => Promise<void>
releaseHeldStream: () => void
heldCompletionCount: () => number
close: () => Promise<void>
}
@@ -248,6 +252,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const receivedPrompts: string[] = []
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
let heldCompletionCount = 0
const heldStreamStarted = new Promise<void>(resolveHeld => {
resolveHeldStreamStarted = resolveHeld
})
@@ -313,6 +318,11 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
const holdThisCompletion = Boolean(
options.holdFirstCompletionContaining &&
heldCompletionCount === 0 &&
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
)
// Detect the interim-message test trigger: the user's message
// contains a specific keyword. The mock walks through the
@@ -405,12 +415,21 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
)
streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => {
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
if (holdThisCompletion) {
heldCompletionCount++
}
resolveHeldStreamStarted?.()
return heldStreamReleased
} : undefined)
} else {
nonStreamingTextResponse(res, model, MOCK_REPLY)
if (holdThisCompletion) {
heldCompletionCount++
resolveHeldStreamStarted?.()
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
} else {
nonStreamingTextResponse(res, model, MOCK_REPLY)
}
}
})
@@ -443,7 +462,9 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
url,
receivedPrompts,
waitForHeldStream: () => heldStreamStarted,
waitForHeldCompletion: () => heldStreamStarted,
releaseHeldStream: () => releaseHeldStream?.(),
heldCompletionCount: () => heldCompletionCount,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
@@ -0,0 +1,119 @@
/**
* A queued prompt must remain local until the current inference turn settles.
*
* Hold the first streamed reply open after its first token. This gives the
* composer a live, busy turn while the user queues a follow-up, then lets us
* assert against the mock provider's real request log before and after the
* held turn completes.
*/
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY } from './mock-server'
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function queue(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Control+Enter')
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
function steerTurnOrder(messages: string[]): string[] {
return messages.flatMap(message => {
if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
return []
})
}
test.describe('queued prompt turn boundary', () => {
let fixture: MockBackendFixture | null = null
test.beforeEach(async () => {
fixture = await setupMockBackend({
mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
})
await waitForAppReady(fixture, 120_000)
})
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('submits a queued prompt only after the active turn completes', async () => {
const { mock, page } = fixture!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await queue(page, QUEUED_PROMPT)
await expect(page.getByText('1 Queued')).toBeVisible()
// The mock keeps the active SSE stream open, so a queued prompt has no
// completed-turn boundary that could legitimately drain it. Wait past the
// queue retry interval and assert the provider saw only the active turn.
await page.waitForTimeout(1_000)
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
})
test('places a steer prompt before the reply it redirects', async () => {
const { mock, page } = fixture!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await steer(page, STEER_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
})
})
@@ -9,15 +9,23 @@ import {
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { receivedUserTexts, restartMockServer } from './mock-server'
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
async function send(page: Page, text: string): Promise<void> {
async function send(page: Page, text: string, delay = 15): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 15 })
await composer.type(text, { delay })
await page.keyboard.press('Enter')
}
async function pasteAndSend(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await page.keyboard.insertText(text)
await page.keyboard.press('Enter')
}
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
await page.waitForFunction(
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
@@ -81,3 +89,58 @@ test.describe('session compression', () => {
await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
})
})
test.describe('session compression in progress', () => {
let fixture: MockBackendFixture
test.beforeAll(async () => {
fixture = await setupMockBackend({
modelContextLength: 64_000,
extraConfig: `compression:
threshold_tokens: 22000
protect_first_n: 0
protect_last_n: 1
auxiliary:
compression:
provider: custom
model: mock-model`,
mockServer: {
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
}
})
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
const { page } = fixture
const queued = 'E2E_QUEUED_DURING_COMPACTION'
// A normal message crosses the tiny configured context budget. The mock
// blocks only the resulting summary request, so these assertions run
// during automatic compaction rather than a slash-command path.
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
await waitForTranscript(page, MOCK_REPLY)
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
await waitForTranscript(page, MOCK_REPLY)
await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500))
await fixture.mock.waitForHeldCompletion()
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
await send(page, queued)
await expect(page.getByText('1 Queued')).toBeVisible()
expect(fixture.mock.heldCompletionCount()).toBe(1)
expect(receivedUserTexts()).not.toContain(queued)
await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
fixture.mock.releaseHeldStream()
await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
expect(fixture.mock.heldCompletionCount()).toBe(1)
})
})
@@ -73,7 +73,6 @@ test('creating a branch with ctrl-shift-b updates the composer git-status branch
const codingRow = page.locator('.coding-status-bar')
const composer = page.locator('[contenteditable="true"]').first()
await expect(codingRow).toContainText('main')
await composer.click()
await composer.type('create a repo-backed e2e session', { delay: 2 })
await page.keyboard.press('Enter')
@@ -82,6 +81,7 @@ test('creating a branch with ctrl-shift-b updates the composer git-status branch
'create a repo-backed e2e session',
{ timeout: 15_000 },
)
await expect(codingRow).toContainText('main')
await page.keyboard.press('Control+Shift+B')
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
+6 -1
View File
@@ -12,7 +12,12 @@ import path from 'node:path'
import { test } from 'vitest'
import { canImportHermesCli, hermesRuntimeImportProbe, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import {
canImportHermesCli,
hermesRuntimeImportProbe,
shouldTrustHermesOverride,
verifyHermesCli
} from './backend-probes'
// Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't
+32 -2
View File
@@ -2,17 +2,25 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* ⌘W — close the tab of the context you're in, by precedence:
* 1. a focused terminal → its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
* 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
* the workspace can't close, so ⌘W shifts the NEXT session tab into main
* (loads it as the primary + drops its now-redundant tile).
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main" (the two call sites have router access); omitting it disables the
* step-4 promotion (⌘W stays the pre-existing no-op on the main tab).
*/
export function closeActiveTab(): boolean {
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()
@@ -28,5 +36,27 @@ export function closeActiveTab(): boolean {
return closeActiveRightRailTab()
}
return closeWorkspaceTab()
// A closeable main-zone tab (a session tile that's the active tab) closes
// outright; the uncloseable workspace tab returns false and falls through.
if (closeWorkspaceTab()) {
return true
}
// The main (workspace) tab is active and can't be closed — but if session
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
// its tile (the session stays alive, no busy-close prompt) and load it into
// main. Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
return false
}
@@ -65,15 +65,15 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Send', '↵')
})
it('shows Ctrl+Enter for Steer', async () => {
it('shows Enter for Steer', async () => {
renderControls({ busy: true, busyAction: 'steer' })
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
await expectShortcutTooltip('Steer the current run', '↵')
})
it('shows Enter for Queue', async () => {
it('shows Ctrl+Enter for Queue', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', '↵')
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})
@@ -81,7 +81,7 @@ export function ComposerControls({
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
@@ -115,7 +115,16 @@ export function ComposerControls({
<Tip
label={
busy ? (
<TipKeybindLabel actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'} text={busyLabel} />
<TipKeybindLabel
actionId={
busyAction === 'steer'
? 'composer.steer'
: busyAction === 'queue'
? 'composer.queue'
: 'composer.send'
}
text={busyLabel}
/>
) : (
<TipKeybindLabel actionId="composer.send" text={c.send} />
)
@@ -8,10 +8,16 @@ import { useComposerSubmit } from './use-composer-submit'
interface SubmitHarnessOptions {
attachments?: ComposerAttachment[]
busy?: boolean
compacting?: boolean
text?: string
}
function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitHarnessOptions = {}) {
function renderSubmitHook({
attachments = [],
busy = false,
compacting = false,
text = ''
}: SubmitHarnessOptions = {}) {
const draftRef = { current: text }
const editor = document.createElement('div')
editor.dataset.slot = 'composer-rich-input'
@@ -33,6 +39,7 @@ function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitH
activeQueueSessionKeyRef: { current: 'stored-session' },
attachments,
busy,
compacting,
clearDraft,
disabled: false,
draftRef,
@@ -79,6 +86,23 @@ describe('useComposerSubmit busy-turn routing', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('queues a plain-text follow-up while the active turn is compacting', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
compacting: true,
text: 'wait for the summary'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('runs slash commands immediately while busy', async () => {
const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
@@ -17,6 +17,7 @@ interface UseComposerSubmitArgs {
activeQueueSessionKeyRef: RefObject<string | null>
attachments: ComposerAttachment[]
busy: boolean
compacting: boolean
clearDraft: () => void
disabled: boolean
draftRef: RefObject<string>
@@ -51,6 +52,7 @@ export function useComposerSubmit({
activeQueueSessionKeyRef,
attachments,
busy,
compacting,
clearDraft,
disabled,
draftRef,
@@ -149,7 +151,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (!attachments.length && text.trim()) {
} else if (!compacting && !attachments.length && text.trim()) {
// Cursor-style stop-and-correct: interrupt the live turn and redirect
// it with this text. redirect() preserves the shown reasoning/work; if
// the turn already ended, steerDraft re-queues so nothing is lost.
@@ -1,9 +1,12 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
@@ -60,6 +63,7 @@ export function useComposerVoice({
onTranscribeAudio
})
/** Auto-speak selector: the latest unspoken reply only — a backlog collapses to the newest. */
const pendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
@@ -81,6 +85,13 @@ export function useComposerVoice({
}
}
/**
* Voice-conversation selector: every unspoken assistant bubble of the turn,
* in order — narration interims AND the final answer, not just whichever
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
*/
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
@@ -108,7 +119,7 @@ export function useComposerVoice({
onFatalError: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse
pendingResponse: pendingTurnResponse
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
@@ -132,6 +143,57 @@ export function useComposerVoice({
[target, toggleVoiceConversation]
)
// "Hey Hermes" wake word: a latched start request (nanostore) the composer
// claims once it's mounted and the gateway is ready. Survives the fresh-session
// remount the wake handler triggers, and waits out a transient `disabled`.
const voiceStartReq = useStore($voiceConversationStartRequest)
useEffect(() => {
if (disabled) {
return // not ready — re-runs when `disabled` flips false
}
if (!takeVoiceConversationStart(voiceStartReq)) {
return
}
if (!voiceConversationActive) {
setVoiceConversationActive(true)
}
}, [voiceStartReq, disabled, voiceConversationActive])
// Hand the mic between the server-side wake detector and the browser's voice
// loop: pause the detector while a conversation is live, resume it after
// (no-ops server-side when the wake word isn't armed). wakePausedRef tracks
// whether WE paused, so resume always runs once — including on unmount, where
// ending voice can tear the composer down before the `false` render lands and
// would otherwise leave the detector paused forever.
const wakePausedRef = useRef(false)
const wakeRpc = useCallback(
(method: string) => void $gateway.get()?.request(method, {}).catch(() => undefined),
[]
)
const resumeWakeIfPaused = useCallback(() => {
if (!wakePausedRef.current) {
return
}
wakePausedRef.current = false
wakeRpc('wake.resume')
}, [wakeRpc])
useEffect(() => {
if (voiceConversationActive) {
wakePausedRef.current = true
wakeRpc('wake.pause')
} else {
resumeWakeIfPaused()
}
}, [voiceConversationActive, wakeRpc, resumeWakeIfPaused])
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
+22 -7
View File
@@ -11,6 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $compactionActive } from '@/store/compaction'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
@@ -104,6 +105,7 @@ export function ChatBar({
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const compacting = useStore($compactionActive)
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
@@ -230,11 +232,15 @@ export function ChatBar({
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
// attachments queue for the next turn, an empty composer stops.
const busyAction: 'steer' | 'queue' | 'stop' = canSteer ? 'steer' : hasComposerPayload ? 'queue' : 'stop'
const busyAction: 'steer' | 'queue' | 'stop' = canSteer
? 'steer'
: compacting || hasComposerPayload
? 'queue'
: 'stop'
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
@@ -243,6 +249,7 @@ export function ChatBar({
activeQueueSessionKeyRef,
attachments,
busy,
compacting,
clearDraft,
disabled,
draftRef,
@@ -580,14 +587,22 @@ export function ChatBar({
return
}
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
// Steer when there's a steerable draft, otherwise swallow it so it can't
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
// Cmd/Ctrl+Enter queues a follow-up while a turn runs. Plain Enter steers
// a text-only draft, so both live-turn actions stay reachable by keyboard.
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
event.preventDefault()
if (canSteer) {
steerDraft()
if (busy && !disabled) {
// As with plain Enter, source the just-typed content from the DOM so a
// fast keypress cannot queue a stale draft.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
if (editorText !== draftRef.current) {
draftRef.current = editorText
setComposerText(editorText)
}
queueDraft()
}
return
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
@@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import {
$statusItemsBySession,
type ComposerStatusItem,
@@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
@@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const sections: { key: string; node: ReactNode }[] = []
// Billing wall sits at the very top of the stack — it's the most important
// thing above the composer when the account is out of credits. Rendered here
// (not as a composer-disable) so slash commands stay usable.
if (billing && sessionId && billing.sessionId === sessionId) {
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
for (const group of groups) {
sections.push({
key: group.type,
+9 -9
View File
@@ -31,9 +31,10 @@ export interface PaneMirror<T> {
before?: (tile: T) => null | string | undefined
minWidth: string
title: (key: string) => string
/** Lead-dot color for the tile's tab (e.g. a session's project color). Re-read
* on every `also` change, so pass the color source in `also` to keep it live. */
accent?: (key: string) => string | undefined
/** Custom lead NODE for the tile's tab (rendered before the label). A live,
* self-subscribing component (e.g. a session's status dot) so the strip needn't
* re-sync on status/color change — only `title` drives re-registration. */
tabLead?: (key: string) => ReactNode
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
@@ -52,7 +53,7 @@ export interface PaneMirror<T> {
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
* Module-level state lives in the returned closure, so call it once per app. */
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
const registered = new Map<string, { dispose: () => void; title: string; accent?: string }>()
const registered = new Map<string, { dispose: () => void; title: string }>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
@@ -62,11 +63,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
for (const tile of tiles) {
const key = cfg.key(tile)
const title = cfg.title(key)
const accent = cfg.accent?.(key)
const current = registered.get(key)
// register() replaces same-id in place — safe for live title/accent refreshes.
if (current && current.title === title && current.accent === accent) {
// register() replaces same-id in place — safe for live title refreshes.
if (current && current.title === title) {
continue
}
@@ -75,7 +75,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
area: 'panes',
title,
data: {
accent,
tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined,
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
@@ -92,7 +92,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
render: () => cfg.render(key)
})
registered.set(key, { dispose, title, accent })
registered.set(key, { dispose, title })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))
@@ -0,0 +1,141 @@
import { useStore } from '@nanostores/react'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { type SessionDotState, sessionDotState } from './sidebar/session-row-state'
// A pure lookup table: each state maps to its className, aria-label, and title.
// No priority resolution here — sessionDotState already picked one. Label/title
// resolve from sidebar.row translations, keyed by name.
type DotVariant = {
ariaLabel?: (r: Translations['sidebar']['row']) => string
className: string
role?: 'status'
title?: (r: Translations['sidebar']['row']) => string
}
// Shared base for every active dot; idle is smaller and uses its own class.
const DOT_BASE = 'relative size-1.5 rounded-full'
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
// the two pulsing dots. The `before:bg-*` color is written inline per variant
// (NOT interpolated here): Tailwind only generates utilities it can see as
// complete static strings, so a `before:bg-${color}` template never emits.
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
// Amber steady — a clarify/approval is blocking the turn. Steady (not
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
'needs-input': {
ariaLabel: r => r.needsInput,
className: `${DOT_BASE} quest-glow bg-amber-500`,
role: 'status',
title: r => r.waitingForAnswer
},
// Accent pulse — the LLM turn is actively running.
working: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Quiet accent pulse — the turn is still authoritative-running, but no
// stream activity has arrived for the watchdog window.
stalled: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
role: 'status',
title: r => r.sessionRunning
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along". Brighter
// than muted-foreground so it's visible against the surface.
background: {
ariaLabel: r => r.backgroundRunning,
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
role: 'status',
title: r => r.backgroundRunning
},
// Steady green — a background session's turn completed and the user hasn't
// opened it since. "Something new here, go look."
unread: {
ariaLabel: r => r.finishedUnread,
className: `${DOT_BASE} bg-emerald-500`,
role: 'status',
title: r => r.finishedUnread
},
idle: {
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
}
}
export interface SessionStatusDotProps {
/** The STORED session id — the key every live-state atom (working /
* attention / stalled / unread / background) is keyed by, on BOTH surfaces:
* the sidebar row's `session.id` and a pane tile's `storedSessionId` are the
* same stored id (`$workingSessionIds` et al. map `storedSessionId`). */
storedSessionId: string
/** The session row for color resolution — recents OR the project tree. Both
* call sites already hold it; passing it lets the idle dot inherit the
* project color even for a session older than the paginated recents page
* (which has no `$sessionColorById` entry). */
session?: null | SessionInfo
/** TUI-style tree stem for a branched session (`└─ ` / `├─ `). */
branchStem?: string
/** Applied to the OUTER wrapper (stem + dot) — e.g. hover-fade on the
* reorder handle. */
className?: string
}
/**
* SESSION STATUS DOT — the ONE primitive both the sidebar row and the pane tab
* render, so a session's status/color can never disagree between the two
* surfaces. It reads every signal itself from the shared stores keyed by the
* stored session id: live state (working / needs-input / stalled / unread /
* background, mutually exclusive via `sessionDotState`) and the resolved color
* (override → project color, via `sessionColorFor`). An idle session shows its
* project color; the active states own the dot with their semantic color so an
* attention cue is never masked by the inherited tint.
*/
export function SessionStatusDot({ storedSessionId, session, branchStem, className }: SessionStatusDotProps) {
const { t } = useI18n()
const r = t.sidebar.row
// Subscribe to the shared color map for reactivity; sessionColorFor falls
// back to the resolver for a session outside the recents page.
useStore($sessionColorById)
const color = sessionColorFor(session) ?? null
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
{dotState === 'idle' && color ? (
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
) : (
<span
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
className={DOT_VARIANTS[dotState].className}
role={DOT_VARIANTS[dotState].role}
title={DOT_VARIANTS[dotState].title?.(r)}
/>
)}
</span>
)
}
+88 -13
View File
@@ -22,6 +22,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useModelControls } from '@/app/session/hooks/use-model-controls'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
@@ -36,6 +37,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { $projectTree } from '@/store/projects'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
@@ -44,7 +46,6 @@ import {
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import {
$sessionStates,
$sessionTiles,
@@ -54,12 +55,14 @@ import {
type SessionTile,
sessionTileDelegate
} from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import type { SessionDragPayload } from './composer/inline-refs'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { startSessionDrag } from './session-drag'
import { SessionStatusDot } from './session-status-dot'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
@@ -199,6 +202,53 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
// A tab-strip "+"/⌘T tab is created UNLISTED — its session stays out of
// $sessions (no sidebar clutter) until it's actually used, so the tab shows
// "New session". The moment this tile has a message, pull its row into
// $sessions via the lightweight by-id lookup so the tab (and a sidebar row)
// resolve the real title. `resolveStoredSession` no-ops when it's already
// listed, and 404s harmlessly for an in-memory draft that hasn't persisted a
// turn yet — so we retry across that brief persist lag and stop as soon as it
// lands (a global turn-complete refresh may beat us to it).
const hasMessages = useStore(view.$messagesEmpty) === false
useEffect(() => {
const alreadyListed = () => $sessions.get().some(s => sessionMatchesStoredId(s, storedSessionId))
if (!runtimeId || !hasMessages || alreadyListed()) {
return
}
let cancelled = false
let timer: number | undefined
const attempt = (remaining: number) => {
if (cancelled || alreadyListed()) {
return
}
void resolveStoredSession(storedSessionId)
.then(resolved => {
if (cancelled || resolved || remaining <= 0) {
return
}
timer = window.setTimeout(() => attempt(remaining - 1), 500)
})
.catch(() => undefined)
}
attempt(6)
return () => {
cancelled = true
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [hasMessages, runtimeId, storedSessionId])
// Same gating as the primary's route resume (use-route-resume): never fire
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
// while it's still connecting — an ungated resume rejected there and
@@ -278,21 +328,34 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
// Tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
function tileTitle(storedSessionId: string): string {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
/** Resolve a tile's stored row: the recents list first, then the project
* tree. A session opened as a tab from a project group is often older than
* the paginated recents page, so it has no `$sessions` row at all until new
* activity lands it there — resolving through the tree keeps its tab titled
* and tinted instead of a grey "Session" placeholder. */
export function tileStoredRow(storedSessionId: string): SessionInfo | undefined {
const match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId)
return stored ? sessionTitle(stored) : 'Session'
return (
$sessions.get().find(match) ??
$projectTree
.get()
.flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])])
.find(match)
)
}
/** The tab's lead-dot color — the tile's session resolved through the SAME
* shared map the sidebar reads, so a row and its tab always agree. */
function tileAccent(storedSessionId: string): string | undefined {
return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
function tileTitle(storedSessionId: string): string {
const stored = tileStoredRow(storedSessionId)
// A tab-strip "+" tab is unlisted until its first turn persists, so it isn't
// in $sessions yet — label it "New session" rather than a bare "Session".
return stored ? sessionTitle(stored) : 'New session'
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
}
@@ -381,9 +444,12 @@ export function SessionTabMenu({
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
const sessions = useStore($sessions)
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
// (which spans both sources), so the values themselves are unused here.
useStore($sessions)
useStore($projectTree)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)
@@ -440,7 +506,10 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions, $sessionColorById],
// $projectTree: a tile whose session is older than the recents page resolves
// its title through the tree, which loads after the tiles register. (The tab's
// status dot subscribes to color/state itself, so it needs no `also` entry.)
also: [$sessions, $projectTree],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
@@ -448,7 +517,13 @@ export const watchSessionTiles = paneMirror<SessionTile>({
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
accent: tileAccent,
// The tab's status dot — the SAME primitive the sidebar row renders, keyed by
// the stored id, so a session's status/color can never disagree between the
// two surfaces. Self-subscribing (live state + resolved color), so the strip
// needn't re-sync when it changes.
tabLead: storedSessionId => (
<SessionStatusDot session={tileStoredRow(storedSessionId)} storedSessionId={storedSessionId} />
),
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu
@@ -15,7 +15,7 @@ import {
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
import { $dismissedWorktreeIds, dismissWorktree, setWorkspaceNodeOpen } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { removeWorktreePath } from '@/store/projects'
@@ -257,7 +257,15 @@ function RepoFlatSection({
<WorkspaceHeader
action={
onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
<WorkspaceAddButton
label={s.newSessionIn(repo.label)}
onClick={() => {
// Reveal the repo the new session targets if the user had it
// collapsed — the session lands in one of its lanes.
setWorkspaceNodeOpen(repo.id, true)
onNewSession(repo.path)
}}
/>
)
}
count={repoCount}
@@ -5,7 +5,7 @@ import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { desktopGit } from '@/lib/desktop-git'
import { mapPool } from '@/lib/pool'
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $sidebarWorkspaceNodeOpen, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $worktreeRefreshToken } from '@/store/projects'
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
@@ -123,14 +123,13 @@ export function useRepoWorktreeMap(
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
// layout auto-restore when you enter it, and survive reloads.
//
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
// list: XOR lets one store serve both polarities. A default-open node (repo,
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
// sessions yet) instead records an explicit expand. So empty worktree/branch
// lanes start collapsed and only open when the user clicks in.
// State is stored as the RESOLVED boolean per node (see `$sidebarWorkspaceNodeOpen`),
// so a node whose `defaultOpen` flips — an empty worktree/branch lane defaults
// collapsed, then defaults open once it holds a session — keeps whatever the
// user explicitly chose instead of having it silently reinterpreted. An absent
// id follows `defaultOpen`, so empty lanes still start collapsed until opened.
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const overridden = collapsed.includes(id)
const state = useStore($sidebarWorkspaceNodeOpen)
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
return [state[id] ?? defaultOpen, () => toggleWorkspaceNodeCollapsed(id, defaultOpen)]
}
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { setWorkspaceNodeOpen } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
@@ -68,6 +69,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
}
const handleNewSession = async () => {
// Reveal the lane the new session targets — an empty worktree/branch lane
// starts collapsed, so without this the session lands in a folder the user
// can't see. Stable across the lane's default flipping open once populated.
setWorkspaceNodeOpen(group.id, true)
if (isProfileGroup) {
newSessionInProfile(group.id)
@@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => {
expect(id).toBe('p_app')
})
it('places a cwd-outside-root session under an explicit project matching either path', () => {
// A mid-session relocation (or a sibling worktree) leaves cwd outside the
// recorded repo root. An explicit folder match is still authoritative —
// only the auto-project (repo root) fallback needs cwd-under-root
// confidence. Match via the repo root...
expect(
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
makeProject('p_proj', ['/home/u/proj'])
])
).toBe('p_proj')
// ...and via the cwd.
expect(
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
makeProject('p_www', ['/www/elsewhere'])
])
).toBe('p_www')
})
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
// The bug: a fresh Windows session drops into the overlay before the next
// backend refresh; case-sensitive matching missed its project until then.
@@ -553,6 +571,14 @@ describe('sessionProjectColor', () => {
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})
it('colors a cwd-outside-root session when an explicit project folder matches', () => {
// The backend tree groups such a row under the project; the client color
// derivation must agree instead of leaving the row (and its tab) grey.
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
})
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
// liveSessionProjectId falls back to the repo root id, which is not a
// project row and therefore carries no color.
@@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean {
* the overview at once instead of waiting for the next backend refresh. Returns
* null only for sessions we genuinely can't place from the row alone: cwd-less,
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
* lives OUTSIDE the repo root (a sibling dir) AND under no explicit project
* folder. An explicit-project folder match always places the row — even when
* the row's cwd sits outside its recorded repo root (a mid-session relocation,
* or a sibling worktree of a project repo), the folder match is authoritative;
* only the repo-root AUTO-project fallback needs cwd-under-root confidence.
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
@@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
return null
}
// With a cwd present it must sit under the repo root (a sibling worktree
// outside the root can't be placed from the row alone); a root-only session
// skips this — the root IS the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}
let projectId = ''
let bestLen = -1
@@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
}
}
return projectId || repoRoot
if (projectId) {
return projectId
}
// AUTO-project fallback (the repo root itself): with a cwd present it must
// sit under the repo root (a sibling worktree outside the root can't be
// placed from the row alone); a root-only session skips this — the root IS
// the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}
return repoRoot
}
/**
@@ -273,7 +273,10 @@ function useSessionActions({
const workItems: ItemSpec[] = [
spec({
disabled: !onBranch,
icon: 'git-branch',
// Fork glyph to match the inline message action's GitFork icon
// (assistant-message.tsx). NB: this codicon font has no `git-fork`
// glyph (only `git-fork-private`); `repo-forked` is the fork icon.
icon: 'repo-forked',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
@@ -14,15 +14,14 @@ import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById } from '@/store/session-color'
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionStatusDot } from '../session-status-dot'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
import { sessionShowsRunningArc } from './session-row-state'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
@@ -88,22 +87,6 @@ export function SidebarSessionRow({
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// True when a clarify prompt in this session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
// True when the session's most recent turn finished in the background (while
// the user was viewing a different session) and hasn't been opened since.
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
// True when the turn is still running but the stream has been quiet long
// enough to soften the animation. This must never look like an idle row.
const isStalled = useStore($stalledSessionIds).includes(session.id)
// True when a terminal(background=true) process is alive in this session.
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
// The session's resolved color (idle dot tint), read from the ONE shared map
// the pane tabs also read — an O(1) lookup, never re-derived per render.
const projectColor = useStore($sessionColorById)[session.id] ?? null
// Resolve the dot's display state once — the four signals are mutually
// exclusive by priority, so threading them as booleans through wrappers just
// to collapse them at the leaf is backwards.
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
return (
<SessionContextMenu
@@ -237,16 +220,16 @@ export function SidebarSessionRow({
dragHandleProps={dragHandleProps}
leadClassName={needsInput ? 'overflow-visible' : undefined}
>
<SessionRowLeadDot
<SessionStatusDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
dotState={dotState}
projectColor={projectColor}
session={session}
storedSessionId={session.id}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
<SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
@@ -267,128 +250,3 @@ export function SidebarSessionRow({
</SessionContextMenu>
)
}
function SessionRowLeadDot({
branchStem,
dotState = 'idle',
className,
projectColor
}: {
branchStem?: string
dotState?: SessionDotState
className?: string
projectColor?: null | string
}) {
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
</span>
)
}
// A pure lookup table: each state maps to its className, aria-label, and
// title. No priority resolution here — the call site already picked one.
// Label/title are resolved from sidebar.row translations, keyed by name.
type DotVariant = {
ariaLabel?: (r: Translations['sidebar']['row']) => string
className: string
role?: 'status'
title?: (r: Translations['sidebar']['row']) => string
}
// Shared base for every active dot; idle is smaller and uses its own class.
const DOT_BASE = 'relative size-1.5 rounded-full'
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
// the two pulsing dots. The `before:bg-*` color is written inline per variant
// (NOT interpolated here): Tailwind only generates utilities it can see as
// complete static strings, so a `before:bg-${color}` template never emits.
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
// Amber steady — a clarify/approval is blocking the turn. Steady (not
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
'needs-input': {
ariaLabel: r => r.needsInput,
className: `${DOT_BASE} quest-glow bg-amber-500`,
role: 'status',
title: r => r.waitingForAnswer
},
// Accent pulse — the LLM turn is actively running.
working: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Quiet accent pulse — the turn is still authoritative-running, but no
// stream activity has arrived for the watchdog window.
stalled: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
role: 'status',
title: r => r.sessionRunning
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along". Brighter
// than muted-foreground so it's visible against the sidebar surface.
background: {
ariaLabel: r => r.backgroundRunning,
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
role: 'status',
title: r => r.backgroundRunning
},
// Steady green — a background session's turn completed and the user hasn't
// opened it since. "Something new here, go look."
unread: {
ariaLabel: r => r.finishedUnread,
className: `${DOT_BASE} bg-emerald-500`,
role: 'status',
title: r => r.finishedUnread
},
idle: {
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
}
}
function SidebarRowDot({
dotState,
className,
projectColor
}: {
dotState: SessionDotState
className?: string
projectColor?: null | string
}) {
const { t } = useI18n()
const r = t.sidebar.row
// An idle session inherits its project's color (a quiet marker matching the
// project row's own color dot). The active states (working / needs-input /
// background / unread) own the dot and keep their semantic color, so the
// inherited tint never competes with an attention cue.
if (dotState === 'idle' && projectColor) {
return (
<span
aria-hidden="true"
className={cn('size-1 rounded-full', className)}
style={{ backgroundColor: projectColor }}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (
<span
aria-label={variant.ariaLabel?.(r)}
className={cn(variant.className, className)}
role={variant.role}
title={variant.title?.(r)}
/>
)
}
+5 -5
View File
@@ -3,6 +3,7 @@ import { computed } from 'nanostores'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { IdleMount } from '@/components/idle-mount'
@@ -53,7 +54,6 @@ import {
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@@ -410,9 +410,10 @@ const syncWorkspaceTitle = () => {
area: 'panes',
title: stored ? storedSessionTitle(stored) : 'New session',
data: {
// The tab's lead dot — same shared map the sidebar row reads, so the
// main tab and its sidebar row always show the same color.
accent: sessionColorFor(stored),
// The tab's status dot — the SAME primitive the sidebar row and session
// tiles render, so the main tab never disagrees with its sidebar row. No
// dot on a fresh draft (no session yet).
tabLead: selected ? () => <SessionStatusDot session={stored} storedSessionId={selected} /> : undefined,
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
@@ -427,7 +428,6 @@ const syncWorkspaceTitle = () => {
$selectedStoredSessionId.listen(syncWorkspaceTitle)
$sessions.listen(syncWorkspaceTitle)
$sessionColorById.listen(syncWorkspaceTitle)
$workspaceIsPage.listen(syncWorkspaceTitle)
// Layout reset collapses every session tile into main as a tab (after the
@@ -0,0 +1,128 @@
// Dev-only: drive the credit-notice UX without a backend that can hit real
// usage bands. Each trigger emits ONE synthetic `notification.show` /
// `notification.clear` gateway event through the real fan-out
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
//
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
// of this ships in a production build.
import type { GatewayEvent } from '@hermes/shared'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { registry } from '@/contrib/registry'
import { CreditCard } from '@/lib/icons'
import { emitLocalGatewayEvent } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
interface NoticeStep {
key: string
level: string
kind: 'sticky' | 'ttl'
text: string
ttl_ms?: number
}
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
// previous notice when the key changes, so the demo shows one toast at a time
// (real usage CAN stack these, but that's noise when you're eyeballing a single
// transition). The same-key usage steps still demonstrate in-place escalation.
const STEPS: readonly NoticeStep[] = [
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
]
let cursor = 0
let lastShownKey: null | string = null
function clearNotice(key: string): void {
emitLocalGatewayEvent({
payload: { key },
session_id: $activeSessionId.get() ?? '',
type: 'notification.clear'
} as GatewayEvent)
}
function showNotice(step: NoticeStep): void {
emitLocalGatewayEvent({
payload: {
id: `${step.key}:${Date.now()}`,
key: step.key,
kind: step.kind,
level: step.level,
text: step.text,
ttl_ms: step.ttl_ms ?? null
},
session_id: $activeSessionId.get() ?? '',
type: 'notification.show'
} as GatewayEvent)
}
/** Fire the next notice in the scripted sequence, wrapping at the end. */
export function stepCreditsNoticeDemo(): void {
const step = STEPS[cursor % STEPS.length]
// One toast at a time: retire the previous notice when we move to a new key.
// (Same-key steps skip this, so the usage line still escalates in place.)
if (lastShownKey && lastShownKey !== step.key) {
clearNotice(lastShownKey)
}
showNotice(step)
lastShownKey = step.key
cursor += 1
}
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
// with a system Cmd chord and works the same on Windows/Linux). Matched on
// `code` so it's keyboard-layout independent, and captured before the composer
// so a focused input can't swallow it.
function isTriggerChord(e: KeyboardEvent): boolean {
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
}
/**
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
*/
export function installCreditsNoticeDemo(): () => void {
const onKeyDown = (e: KeyboardEvent) => {
if (!isTriggerChord(e)) {
return
}
e.preventDefault()
e.stopPropagation()
stepCreditsNoticeDemo()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
const disposePalette = registry.register({
id: 'dev.creditsNotice',
area: PALETTE_AREA,
data: {
id: 'dev.creditsNotice',
icon: CreditCard,
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
label: 'Dev: cycle credit notices',
run: stepCreditsNoticeDemo
} satisfies PaletteContribution
})
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
disposePalette()
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
}
}
@@ -3,7 +3,15 @@ import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
import { $activeGatewayProfile } from '@/store/profile'
import {
$sessions,
getRememberedRoute,
getRememberedSessionId,
rememberedSessionProfile,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isSecondaryWindow } from '@/store/windows'
@@ -63,7 +71,10 @@ export function useDesktopIntegrations({
// you don't want to boot into a modal.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
setRememberedSessionId(
routedSessionId,
rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get())
)
}
if (!isOverlayView(appViewForPath(locationPathname))) {
@@ -92,7 +103,7 @@ export function useDesktopIntegrations({
return
}
const last = getRememberedSessionId()
const last = getRememberedSessionId($activeGatewayProfile.get())
if (last) {
navigate(sessionRoute(last), { replace: true })
@@ -100,8 +111,14 @@ export function useDesktopIntegrations({
}, [locationPathname, navigate])
useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
if (!resumeExhaustedSessionId) {
return
}
const owner = rememberedSessionProfile($sessions.get(), resumeExhaustedSessionId, $activeGatewayProfile.get())
if (getRememberedSessionId(owner) === resumeExhaustedSessionId) {
setRememberedSessionId(null, owner)
}
}, [resumeExhaustedSessionId])
@@ -155,10 +172,12 @@ export function useDesktopIntegrations({
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(
() => void closeActiveTab(id => navigate(sessionRoute(id)))
)
return () => unsubscribe?.()
}, [])
}, [navigate])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
@@ -0,0 +1,100 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesModule from '@/hermes'
import { setSessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useSessionTileDelegate } from './use-session-tile-delegate'
vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
}))
const { getSessionMessages } = await import('@/hermes')
const row = (over: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: null,
started_at: 0,
title: null,
...over
}) as SessionInfo
function renderTile(requestGateway: ReturnType<typeof vi.fn>) {
renderHook(() =>
useSessionTileDelegate({
archiveSession: vi.fn(async () => undefined),
branchStoredSession: vi.fn(async () => undefined),
executeSlashCommand: vi.fn(async () => undefined) as never,
removeSession: vi.fn(async () => undefined),
requestGateway: requestGateway as never,
runtimeIdByStoredSessionIdRef: { current: new Map() },
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn()
})
)
}
describe('useSessionTileDelegate resumeTile', () => {
beforeEach(() => {
setSessions([])
vi.mocked(getSessionMessages).mockClear()
})
afterEach(() => {
setSessions([])
})
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
// A tile opens a session owned by another profile. Resuming without the
// profile lets the gateway fall back to the launch-profile DB and clone the
// conversation into the wrong profile (#67603). The owning profile must ride
// both the transcript prefetch and the resume RPC.
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
expect(runtimeId).toBe('runtime-1')
expect(getSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-x',
cols: 96,
profile: 'ai-engineer'
})
})
it('resolves and carries a default-profile session explicitly', async () => {
setSessions([row({ id: 'stored-y', profile: 'default' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-2' } as never) : ({} as never)
)
renderTile(requestGateway)
await sessionTileDelegate()!.resumeTile('stored-y')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-y',
cols: 96,
profile: 'default'
})
})
})
@@ -6,6 +6,7 @@ import { publishSessionState, setSessionTileDelegate } from '@/store/session-sta
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import { resolveSessionProfile } from '../../session/hooks/use-session-actions/utils'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
@@ -66,9 +67,20 @@ export function useSessionTileDelegate({
return existing
}
// Resolve the owning profile before binding a runtime. A tile can open a
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
const profile = await resolveSessionProfile(storedSessionId)
const [prefetch, resumed] = await Promise.all([
getSessionMessages(storedSessionId).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
getSessionMessages(storedSessionId, profile).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(profile ? { profile } : {})
})
])
const runtimeId = resumed?.session_id
+85 -6
View File
@@ -19,6 +19,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
@@ -27,6 +28,8 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { $billingSettingsRequest } from '@/store/billing-block'
import { requestVoiceConversationStart } from '@/store/composer'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
@@ -125,6 +128,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
// Billing recovery routes to Settings → Billing from surfaces without router
// context (the sticky toast). The shell owns `navigate`, so it consumes the
// intent counter here; the ref skips the initial mount value.
const billingSettingsSeenRef = useRef(0)
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
// Stable identity for the whole callback surface (see WiringActions). Mutated
// in place each render so memoized surfaces never re-render on churn.
@@ -132,7 +139,20 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const billingSettingsRequest = useStore($billingSettingsRequest)
const currentCwd = useStore($currentCwd)
useEffect(() => {
if (billingSettingsRequest === billingSettingsSeenRef.current) {
return
}
billingSettingsSeenRef.current = billingSettingsRequest
if (billingSettingsRequest > 0) {
navigate(`${SETTINGS_ROUTE}?tab=billing`)
}
}, [billingSettingsRequest, navigate])
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
@@ -235,10 +255,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
requestGateway
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
activeSessionIdRef,
refreshProjectBranch
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef })
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
queryClient,
@@ -256,6 +273,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
}, [navigate])
// Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
// / window.__creditsDemo). Dynamic import inside the DEV guard so the module
// is dropped from production builds.
useEffect(() => {
if (!import.meta.env.DEV) {
return
}
let dispose: (() => void) | undefined
void import('./dev/credits-notice-demo').then(m => {
dispose = m.installCreditsNoticeDemo()
})
return () => dispose?.()
}, [])
// Post-turn rehydrate from stored history (same behavior as DesktopController,
// including finished-todos restoration).
const hydrateFromStoredSession = useCallback(
@@ -612,9 +646,25 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const handleGatewayEventWithPlugins = useCallback(
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
emitGatewayEvent(event)
// "Hey Hermes": handle the wake event on the canonical onEvent pipeline —
// open a fresh session (unless the config keeps the current one) and begin
// back-and-forth voice via the composer's latched start request.
if (event.type === 'wake.detected') {
const payload = event.payload as { start_new_session?: boolean } | undefined
if (payload?.start_new_session !== false) {
startFreshSessionDraft()
}
requestVoiceConversationStart()
return
}
handleDesktopGatewayEvent(event)
},
[handleDesktopGatewayEvent]
[handleDesktopGatewayEvent, startFreshSessionDraft]
)
useGatewayBoot({
@@ -635,6 +685,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
refreshSessions
})
// "Hey Hermes" wake word: arm the server-side detector for this surface
// (gated on config server-side). Detection arrives as a wake.detected event
// handled in handleGatewayEventWithPlugins. Idempotent, so reconnects are safe.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void requestGateway('wake.start', { surface: 'gui' }).catch(() => undefined)
}, [gatewayState, requestGateway])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
const activeIsMessaging =
@@ -694,15 +755,33 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
}, [])
// The tab-strip "+" and ⌘T share one action: open a new session as its own
// tab (stacked into the workspace zone) WITHOUT polluting the session list.
// Created `listed: false`, so each new tab's in-memory session stays out of
// the sidebar until its first message persists a turn and a refresh surfaces
// it — Cursor-style. Every click opens a fresh "New session" tab (multiple
// empty tabs are fine since none touch the session list).
const openNewSessionTab = useCallback(() => {
void openNewSessionTile('center', { listed: false })
}, [openNewSessionTile])
// Single global listener for every rebindable hotkey plus the on-screen
// keybind editor's capture mode (same as DesktopController).
useKeybinds({
openNewSessionTab: () => void openNewSessionTile('center'),
openNewSessionTab,
startFreshSession: startFreshSessionDraft,
toggleCommandCenter,
toggleSelectedPin
})
// Register the tab-strip "+" action (the generic renderer stays
// session-agnostic; null until wired hides the glyph).
useEffect(() => {
$newSessionTabAction.set(openNewSessionTab)
return () => $newSessionTabAction.set(null)
}, [openNewSessionTab])
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one
+4 -2
View File
@@ -181,10 +181,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
'view.flipPanes': togglePanesFlipped,
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
// On macOS the menu accelerator owns ⌘W and routes through the same
// On the main tab with session tabs stacked, it shifts the next one in —
// the loader navigates to that session's route (loads it into main). On
// macOS the menu accelerator owns ⌘W and routes through the same
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
// the Win/Linux path where ⌘W reaches the renderer directly.
'view.closeTab': () => void closeActiveTab(),
'view.closeTab': () => void closeActiveTab(id => navigate(sessionRoute(id))),
'view.reopenTab': reopenLastClosedTile,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
@@ -66,6 +66,13 @@ export function OverlayView({
'p-[calc(var(--titlebar-height)+0.625rem)]',
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
)}
// Every OverlayView-based overlay (settings, command-center, agents, cron,
// profiles, star map, …) covers the chat while the composer stays mounted
// beneath it. This marker tells `composerFocusBlockedBySurface` to stand
// the global type-to-focus / soft `/` / Enter down, so keystrokes don't
// leak into the hidden composer (and the overlay's own bare-key shortcuts,
// e.g. star map's Space, keep working).
data-overlay-surface=""
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()
@@ -19,7 +19,7 @@ import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $sidebarWorkspaceNodeOpen, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import {
@@ -198,9 +198,9 @@ function ReviewDirRow({
motion: boolean
node: ReviewTreeNode
}) {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const nodeOpen = useStore($sidebarWorkspaceNodeOpen)
const id = `review:${node.id}`
const open = !collapsed.includes(id)
const open = nodeOpen[id] ?? true
const toggle = () => toggleWorkspaceNodeCollapsed(id)
return (
@@ -47,133 +47,37 @@ describe('useHermesConfig refreshHermesConfig', () => {
persistString(WORKSPACE_CWD_KEY, null)
})
it('applies terminal.cwd from config even when localStorage has a stale value', async () => {
// Simulate a stale remembered workspace cwd
persistString(WORKSPACE_CWD_KEY, '/Users/old/stale-project')
setCurrentCwd('/Users/old/stale-project')
it('does not let terminal.cwd replace an inactive selected workspace', async () => {
setCurrentCwd('/Users/example/repo/.worktrees/feature')
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
await act(async () => {
await result.current.refreshHermesConfig()
})
// The configured terminal.cwd must override the stale localStorage value
expect($currentCwd.get()).toBe('/Users/example/new-workspace')
expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/feature')
})
it('keeps the active session workspace when a session is running', async () => {
setCurrentCwd('/workspace/attached-project')
it('does not let terminal.cwd replace an active session workspace', async () => {
setCurrentCwd('/Users/example/repo/.worktrees/attached')
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: 'session-1' },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: 'session-1' } }))
await act(async () => {
await result.current.refreshHermesConfig()
})
// Config refreshes mid-session must not yank the workspace out from
// under the attached session.
expect($currentCwd.get()).toBe('/workspace/attached-project')
})
it('uses empty string when terminal.cwd is not set and localStorage is empty', async () => {
mockConfig({})
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect($currentCwd.get()).toBe('')
})
it('ignores terminal.cwd when it is "."', async () => {
mockConfig({ terminal: { cwd: '.' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect($currentCwd.get()).toBe('')
})
it('calls refreshProjectBranch with the configured cwd', async () => {
const refreshProjectBranch = vi.fn().mockResolvedValue(undefined)
setCurrentCwd('')
mockConfig({ terminal: { cwd: '/workspace/project-a' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/project-a')
})
it('refreshes the branch for the session cwd (not config) when a session is active', async () => {
const refreshProjectBranch = vi.fn().mockResolvedValue(undefined)
setCurrentCwd('/workspace/attached-project')
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: 'session-1' },
refreshProjectBranch
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/attached-project')
expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/attached')
})
it('does not let a stale forced config refresh overwrite newer draft selector intent', async () => {
const profileConfig = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
vi.mocked(getHermesConfig).mockReturnValueOnce(profileConfig.promise)
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
let pendingRefresh!: Promise<void>
act(() => {
@@ -203,12 +107,7 @@ describe('useHermesConfig refreshHermesConfig', () => {
const profileC = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
vi.mocked(getHermesConfig).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise)
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
let refreshB!: Promise<void>
let refreshC!: Promise<void>
@@ -4,11 +4,9 @@ import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import { normalize } from '@/lib/text'
import {
$currentCwd,
getComposerSelectionGeneration,
getCurrentModelSource,
setAvailablePersonalities,
setCurrentCwd,
setCurrentFastMode,
setCurrentPersonality,
setCurrentReasoningEffort,
@@ -43,10 +41,9 @@ function normalizeConfigEffort(value: unknown): string {
interface HermesConfigOptions {
activeSessionIdRef: MutableRefObject<string | null>
refreshProjectBranch: (cwd: string) => Promise<void>
}
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const profileRefreshEpochRef = useRef(0)
@@ -83,16 +80,6 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
])
])
const cwd = (config.terminal?.cwd ?? '').trim()
if (cwd && cwd !== '.') {
// Configured terminal.cwd beats a stale remembered workspace cwd
// (#38855) — but never yank the workspace out from under an active
// session; those keep their own cwd until the user detaches.
setCurrentCwd(prev => (activeSessionIdRef.current ? prev : cwd))
void refreshProjectBranch($currentCwd.get() || cwd)
}
const reasoning = normalizeConfigEffort(config.agent?.reasoning_effort)
const tier = (config.agent?.service_tier ?? '').trim()
@@ -115,7 +102,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
// Config is nice-to-have; chat still works without it.
}
},
[activeSessionIdRef, refreshProjectBranch]
[activeSessionIdRef]
)
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
@@ -0,0 +1,116 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { type MutableRefObject, useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearClarifyRequest } from '@/store/clarify'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
// A `clarify.request` must leave an answerable inline row even when the
// `tool.start` that normally mounts it was missed (stream reconnect /
// hydration race). Without it the sidebar says "needs input" but the
// transcript has nowhere to render the choices, so the agent blocks forever.
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
stateRef = sessionStateByRuntimeIdRef
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const clarifyRequest = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' }))
const toolStart = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' }))
function clarifyParts() {
const messages = stateRef?.current.get(SID)?.messages ?? []
return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify')
}
describe('clarify.request stream hydration', () => {
beforeEach(() => {
handleEvent = null
stateRef = null
clearClarifyRequest()
})
afterEach(() => {
cleanup()
clearClarifyRequest()
vi.restoreAllMocks()
})
it('mounts an answerable clarify row when the tool.start row was missed', async () => {
await mountStream()
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' })
const parts = clarifyParts()
expect(parts).toHaveLength(1)
expect(parts[0].type === 'tool-call' && parts[0].toolCallId).toBe('req-1')
expect(parts[0].type === 'tool-call' && parts[0].args).toMatchObject({
choices: ['yes', 'no'],
question: 'Ship it?'
})
})
it('merges with the real tool.start row even though its id differs from the request id', async () => {
await mountStream()
// Reality: tool.start carries the model's tool_call_id, clarify.request a
// separately-generated request_id. They must still collapse to ONE card
// (correlated by question), not two.
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' })
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-2' })
expect(clarifyParts()).toHaveLength(1)
})
it('does not duplicate when clarify.request arrives before the tool.start row', async () => {
await mountStream()
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' })
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' })
expect(clarifyParts()).toHaveLength(1)
})
})
@@ -1,3 +1,4 @@
import type { BillingBlock } from '@hermes/shared'
import type { HermesSkin } from '@hermes/shared/skin'
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
@@ -14,14 +15,16 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { revealDesktopPane } from '@/store/pane-focus'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
@@ -57,6 +60,50 @@ import type { ClientSessionState } from '../../../types'
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
function firstBillingLine(text: string): string {
return (text || '').split('\n')[0]?.trim() ?? ''
}
/**
* A turn failed on a billing wall (out of credits / payment required). The
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
* billing-specific toast — never the generic "Hermes error" — with a smart CTA
* (Nous → in-app Settings → Billing, other providers → their billing page).
*/
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
if (!raw || typeof raw !== 'object') {
return
}
const block = raw as BillingBlock
if (typeof block.provider !== 'string') {
return
}
setBillingBlock(sessionId, block)
const ctaCopy = {
addCredits: translateNow('billingBlock.addCredits'),
openBilling: translateNow('billingBlock.openBilling')
}
notify({
// Collapse repeat walls from the same provider into one toast.
id: `billing-block:${block.provider}`,
kind: 'warning',
icon: 'credit-card',
title: block.is_nous
? translateNow('billingBlock.titleNous')
: translateNow('billingBlock.titleProvider', block.provider_label),
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
// Sticky: a credit wall blocks every turn until resolved.
durationMs: 0,
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
})
}
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'message.interim',
@@ -361,9 +408,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
if (typeof payload?.credential_warning === 'string' && payload.credential_warning) {
requestDesktopOnboarding(payload.credential_warning)
}
requestDesktopOnboardingForCredentialWarning(payload?.credential_warning)
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
@@ -390,6 +435,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
// A fresh turn on this session optimistically clears its billing wall;
// if credits are still exhausted the next failure re-raises it.
clearBillingBlock(sessionId)
if (isActiveEvent) {
triggerHaptic('streamStart')
@@ -513,6 +561,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
// Structured billing wall forwarded by the gateway (out of credits /
// payment required) — cache it + raise a billing-specific toast.
if (payload?.billing) {
surfaceBillingBlock(sessionId, payload.billing)
}
if (isActiveEvent) {
setTurnStartedAt(null)
@@ -622,21 +676,36 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// over; the inline ClarifyTool reads the active session's entry.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
const rawChoices = payload?.choices
const choices = normalizeChoices(rawChoices)
if (requestId && question) {
if (rawChoices != null && choices.length === 0) {
warnDroppedChoices('gateway', question, rawChoices)
}
setClarifyRequest({
requestId,
question,
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
choices: choices.length > 0 ? choices : null,
sessionId: sessionId ?? null
})
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
if (sessionId) {
// `clarify.request` is the blocking event the Python side waits on,
// while the inline UI normally mounts from the earlier `tool.start`
// row. If that row was missed (stream reconnect / hydration race) the
// sidebar still says "needs input" but there is nowhere to render the
// choices. Upsert a stable pending clarify tool row from the request
// itself so the prompt stays answerable; a real tool.start/complete
// with the same request id merges rather than duplicates.
upsertToolCall(sessionId, { args: { choices, question }, name: 'clarify', tool_id: requestId }, 'running')
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
@@ -797,6 +866,37 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
]
}))
}
} else if (event.type === 'notification.show') {
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
// status bar; the desktop renders them as toasts. The notice key doubles
// as the toast id, so the escalating 50→75→90 credits line replaces in
// place instead of stacking. Account-wide signal — shown regardless of
// which session is focused.
const notice = event.payload as AgentNoticePayload | undefined
showAgentNotice(notice)
// The urgent pair (access paused / restored) also breaks through as a
// native OS notification when Hermes is backgrounded; dispatch is gated
// by the user's notification prefs + backgrounded check.
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
if (native) {
dispatchNativeNotification(native)
}
// A credits crossing moves the account balance. Settings → Billing polls
// `billing.state` every 30s; nudge it so the page reflects the crossing
// immediately instead of up to 30s late.
if (notice?.key?.startsWith('credits.')) {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
} else if (event.type === 'notification.clear') {
// Key-matched dismissal (e.g. credits restored clears the depleted
// notice). notify() keys the toast by the notice key, so this maps
// straight to dismissNotification(key).
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getSession } from '@/hermes'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $notifications, clearNotifications } from '@/store/notifications'
@@ -25,6 +26,7 @@ import { uploadComposerAttachment, usePromptActions } from '.'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
getSession: vi.fn(),
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
setApiRequestProfile: vi.fn(),
transcribeAudio: vi.fn()
@@ -1062,11 +1064,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
markVoicePlaybackInterrupted()
@@ -1838,6 +1836,99 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
// #67603 (second symptom): a recovery resume must re-register on the session's
// OWNING profile. Resuming on whichever profile is live forks the conversation
// into the wrong profile's DB — the session then appears under both profiles.
it('carries the owning profile from the cache into the recovery resume', async () => {
setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'work' })])
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
expect(await handle!.submitText('message after wake')).toBe(true)
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
setSessions(() => [])
})
// The session lives on another profile and is outside the paginated sidebar
// cache: resolve it by id across profiles rather than resuming profile-blind.
it('resolves the owning profile across profiles when the session is not cached', async () => {
// module-factory vi.fn is not reset by restoreAllMocks — reset explicitly in
// the finally below so this resolved value never leaks into sibling tests.
setSessions(() => [])
vi.mocked(getSession).mockResolvedValue(sessionInfo({ id: STORED_SESSION_ID, profile: 'work' }))
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
expect(await handle!.submitText('message after wake')).toBe(true)
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
vi.mocked(getSession).mockReset()
setSessions(() => [])
})
it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
@@ -43,6 +43,7 @@ import type {
ImageAttachResponse,
SessionRedirectResponse
} from '../../../types'
import { resolveSessionProfile } from '../use-session-actions/utils'
import {
applyBranchVisibility,
@@ -220,7 +221,13 @@ export function usePromptActions({
const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string, storedSessionId?: string | null) => {
(
sessionId: string,
role: ChatMessage['role'],
text: string,
storedSessionId?: string | null,
options: { insertBeforeActiveReply?: boolean } = {}
) => {
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
@@ -231,21 +238,39 @@ export function usePromptActions({
return
}
const messageId = `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
updateSessionState(
sessionId,
state => ({
...state,
messages: [
...state.messages,
{
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role,
parts: [textPart(body)]
}
]
}),
state => {
const message: ChatMessage = {
id: messageId,
role,
parts: [textPart(body)]
}
const streamIndex =
options.insertBeforeActiveReply && state.streamId
? state.messages.findIndex(candidate => candidate.id === state.streamId)
: -1
const lastAssistantIndex = options.insertBeforeActiveReply
? state.messages.map(candidate => candidate.role).lastIndexOf('assistant')
: -1
const insertionIndex = streamIndex >= 0 ? streamIndex : lastAssistantIndex
const messages =
insertionIndex >= 0
? [...state.messages.slice(0, insertionIndex), message, ...state.messages.slice(insertionIndex)]
: [...state.messages, message]
return { ...state, messages }
},
storedSessionId ?? selectedStoredSessionIdRef.current
)
return messageId
},
[selectedStoredSessionIdRef, updateSessionState]
)
@@ -577,9 +602,12 @@ export function usePromptActions({
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const recoveredId = resumed?.session_id
@@ -620,15 +648,51 @@ export function usePromptActions({
// message after the interrupted checkpoint, matching the durable core
// transcript rather than a system note that changes role after reload.
const send = async (id: string): Promise<boolean> => {
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
// Redirect aborts the model request, so the completion event can race
// its RPC response. Insert before the live reply *before* awaiting the
// gateway; appending after the response leaves the correction below a
// reply that the redirect has already replaced.
const messageId = appendSessionTextMessage(id, 'user', text, undefined, { insertBeforeActiveReply: true })
if (result?.status === 'redirected' || result?.status === 'queued') {
triggerHaptic('submit')
appendSessionTextMessage(id, 'user', text)
const discardOptimisticMessage = () =>
updateSessionState(id, state => ({
...state,
messages: state.messages.filter(message => message.id !== messageId)
}))
return true
const moveOptimisticMessageToEnd = () =>
updateSessionState(id, state => {
const message = state.messages.find(candidate => candidate.id === messageId)
return message
? { ...state, messages: [...state.messages.filter(candidate => candidate.id !== messageId), message] }
: state
})
try {
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
if (result?.status === 'redirected') {
triggerHaptic('submit')
return true
}
if (result?.status === 'queued') {
// Build-window redirects become the next turn, not part of the
// active reply, so retain the optimistic row at the tail.
moveOptimisticMessageToEnd()
triggerHaptic('submit')
return true
}
} catch (err) {
discardOptimisticMessage()
throw err
}
discardOptimisticMessage()
return false
}
@@ -640,9 +704,12 @@ export function usePromptActions({
// correction right after a reconnect isn't lost to the race.
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const recoveredId = resumed?.session_id
@@ -661,7 +728,14 @@ export function usePromptActions({
return false
},
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway, selectedStoredSessionIdRef]
[
activeSessionId,
activeSessionIdRef,
appendSessionTextMessage,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
)
const reloadFromMessage = useCallback(
@@ -24,6 +24,7 @@ import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
import { resolveSessionProfile } from '../use-session-actions/utils'
import {
_submitInFlight,
@@ -384,9 +385,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// background queue drain only has the durable id). Continue that target
// conversation; only a genuine new-chat draft may create a new session.
try {
// Re-register on the session's OWNING profile — resuming on whichever
// profile is live would fork the conversation into the wrong DB (#67603).
const resumeProfile = await resolveSessionProfile(targetStoredSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: targetStoredSessionId,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const resumeDrift = sessionDriftReason()
@@ -528,9 +534,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// backend loop (#55578 symptom d) rejects the submit even though
// the stored session is fine — resume + retry instead of erroring
// out and losing the session binding.
const resumeProfile = await resolveSessionProfile(recoverStoredSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: recoverStoredSessionId,
source: 'desktop'
source: 'desktop',
...(resumeProfile ? { profile: resumeProfile } : {})
})
const resumeRetryDrift = sessionDriftReason()
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages, type SessionInfo } from '@/hermes'
import { getSession, getSessionMessages, type SessionInfo } from '@/hermes'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
@@ -33,6 +33,7 @@ import {
setSelectedStoredSessionId,
setSessions
} from '@/store/session'
import { $sessionTiles } from '@/store/session-states'
import { sessionRoute } from '../../routes'
import type { ClientSessionState } from '../../types'
@@ -42,6 +43,7 @@ import { useSessionActions } from './use-session-actions'
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
deleteSession: vi.fn(),
getSession: vi.fn(),
getSessionMessages: vi.fn(),
listAllProfileSessions: vi.fn(),
setApiRequestProfile: vi.fn(),
@@ -975,9 +977,11 @@ describe('resumeSession failure recovery', () => {
})
function BranchHarness({
navigate = vi.fn(),
onReady,
requestGateway
}: {
navigate?: ReturnType<typeof vi.fn>
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
@@ -991,7 +995,7 @@ function BranchHarness({
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId: () => null,
navigate: vi.fn() as never,
navigate: navigate as never,
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
@@ -1013,9 +1017,48 @@ describe('branchStoredSession desktop source tagging', () => {
afterEach(() => {
cleanup()
setSessions([])
$sessionTiles.set([])
setSelectedStoredSessionId(null)
vi.restoreAllMocks()
})
it('opens the branch as a new tab and leaves the parent chat selected', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
// Parent is the currently-open (primary) chat.
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
setSelectedStoredSessionId('stored-parent')
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
const navigate = vi.fn()
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(
<BranchHarness
navigate={navigate}
onReady={branch => (branchStoredSession = branch)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
// The branch opened as its own tab...
expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true)
// ...without stealing the primary selection or navigating away from the parent.
expect($selectedStoredSessionId.get()).toBe('stored-parent')
expect(navigate).not.toHaveBeenCalledWith(sessionRoute('branch-stored'))
})
it('tags desktop branch sessions as desktop sessions', async () => {
let createParams: Record<string, unknown> | undefined
@@ -1046,6 +1089,40 @@ describe('branchStoredSession desktop source tagging', () => {
source: 'desktop'
})
})
// #67603: right-clicking a session outside the paginated sidebar window is a
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
// swap to it before reading the transcript / creating the branch, so the fork
// is not created on whichever profile happens to be live.
it('resolves and swaps to the parent profile when the branched session is not cached', async () => {
setSessions([])
vi.mocked(getSession).mockResolvedValue(storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' }))
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) | null =
null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
vi.mocked(getSession).mockReset()
})
})
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
@@ -13,7 +13,13 @@ import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
import {
beginSessionMutation,
endSessionMutation,
resolveNewSessionCwd,
tombstoneSessions,
untombstoneSessions
} from '@/store/projects'
import {
$activeSessionStoredIdRotation,
$currentCwd,
@@ -457,10 +463,19 @@ export function useSessionActions({
)
/** Create a fresh session and open it as a tile — leaves the primary chat alone.
* Used by the New session row's "Open in split" menu (and any future
* "new chat beside" affordance). */
* Used by the New session row's "Open in split" menu and the tab-strip "+".
*
* `listed` (default true) controls sidebar visibility. A brand-new backend
* session is IN-MEMORY only until its first turn persists a row, so
* `listSessions(min_messages=1)` already hides an unused one — the sidebar
* pollution comes solely from the optimistic upsert here. The tab-strip "+"
* passes `listed: false` so an unused new tab never clutters the session
* list (Cursor-style draft tab); it surfaces on the next refresh once the
* first message persists a turn. "Open in split" keeps the listed behavior. */
const openNewSessionTile = useCallback(
async (dir: TileDock = 'right') => {
async (dir: TileDock = 'right', options?: { listed?: boolean }) => {
const listed = options?.listed ?? true
try {
// Fresh tile → the resolved new-session cwd (project/default), not the
// primary composer's live cwd.
@@ -476,17 +491,25 @@ export function useSessionActions({
}
createdThisRun.add(stored)
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
// selection — this session lives in the tile. Prime it with the create
// runtime so the tile skips a redundant resume.
upsertOptimisticSession(created, stored, null, null)
// Seed the per-runtime cache so the tile renders immediately without a
// redundant resume. Only add the row to the SIDEBAR when `listed` — an
// unlisted (draft) tab stays out of the session list until its first
// turn persists and a refresh surfaces it.
if (listed) {
upsertOptimisticSession(created, stored, null, null)
}
const runtimeInfo = applyRuntimeInfo(created.info)
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
openSessionTile(stored, dir)
patchSessionTile(stored, { runtimeId: created.session_id })
revealTreePane(`session-tile:${stored}`)
broadcastSessionsChanged()
if (listed) {
broadcastSessionsChanged()
}
} catch (error) {
notifyError(error, copy.createSessionFailed)
}
@@ -1030,7 +1053,8 @@ export function useSessionActions({
)
// Shared fork: create a child session seeded with `branchMessages`, linked to
// `parentStoredId` so it nests under its parent, then make it the active chat.
// `parentStoredId` so it nests under its parent, then open it as its own tab
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
const forkBranch = useCallback(
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
creatingSessionRef.current = true
@@ -1067,8 +1091,6 @@ export function useSessionActions({
parent ? parent.last_active || parent.started_at : undefined
)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
updateSessionState(
branched.session_id,
state => ({
@@ -1079,9 +1101,6 @@ export function useSessionActions({
}),
routedSessionId
)
setSelectedStoredSessionId(routedSessionId)
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
@@ -1090,6 +1109,15 @@ export function useSessionActions({
updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId)
}
// Open the branch as its own tab and switch to it, leaving the parent
// chat exactly where it is. Prime the tile with the create runtime so it
// skips a redundant resume. Do NOT select it as the primary session
// first — openSessionTile no-ops when the id is already primary.
openSessionTile(routedSessionId, 'center')
patchSessionTile(routedSessionId, { runtimeId: branched.session_id })
revealTreePane(`session-tile:${routedSessionId}`)
broadcastSessionsChanged()
return true
} catch (err) {
notifyError(err, copy.branchFailed)
@@ -1101,16 +1129,7 @@ export function useSessionActions({
}, 0)
}
},
[
activeSessionIdRef,
copy,
creatingSessionRef,
ensureSessionState,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
[copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState]
)
// Branch the open chat — optionally from a specific message — off its live transcript.
@@ -1158,7 +1177,13 @@ export function useSessionActions({
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
clearNotifications()
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
// Right-clicking a session outside the paginated sidebar window is a cache
// miss: resolve it (cache → active backend → cross-profile) so the branch
// is created on the parent's OWNING profile, not whichever is live (#67603).
const stored =
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ??
(sessionProfile ? undefined : await resolveStoredSession(storedSessionId))
const profile = sessionProfile ?? stored?.profile
try {
@@ -1194,12 +1219,15 @@ export function useSessionActions({
// Pins are keyed on the durable lineage-root id; the stored id may be the
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
const removedIds = [storedSessionId, removed?.id, removed?._lineage_root_id]
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Evict from the project tree's optimistic layer too (the backend snapshot
// still lists it until its next refresh), so grouped + flat views drop the
// row in lockstep.
tombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
// row in lockstep. Pin the tombstone against the projects.tree prune while
// the delete RPC is in flight, so a racing refresh can't flash it back.
tombstoneSessions(removedIds)
beginSessionMutation(removedIds)
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -1240,7 +1268,7 @@ export function useSessionActions({
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
untombstoneSessions(removedIds)
$pinnedSessionIds.set(previousPinned)
if (wasSelected) {
@@ -1263,6 +1291,11 @@ export function useSessionActions({
}
notifyError(err, copy.deleteFailed)
} finally {
// Release the tombstone to the normal projects.tree prune now the RPC has
// settled (kept on success — the backend has deleted it; cleared on the
// rollback above on failure).
endSessionMutation(removedIds)
}
},
[
@@ -1289,10 +1322,12 @@ export function useSessionActions({
// Pins are keyed on the durable lineage-root id; the stored id may be the
// live tip after compression. Drop both so the pin can't linger.
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
const archivedIds = [storedSessionId, archived?.id, archived?._lineage_root_id]
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
tombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
tombstoneSessions(archivedIds)
beginSessionMutation(archivedIds)
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -1305,12 +1340,6 @@ export function useSessionActions({
try {
await setSessionArchived(storedSessionId, true, archived?.profile)
// A sidebar refresh can race the optimistic removal while the PATCH is
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
// An archived session is hidden from the sidebar; its tile must go too.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
@@ -1328,9 +1357,11 @@ export function useSessionActions({
setSessionsTotal(prev => prev + 1)
}
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
untombstoneSessions(archivedIds)
$pinnedSessionIds.set(previousPinned)
notifyError(err, copy.archiveFailed)
} finally {
endSessionMutation(archivedIds)
}
},
[copy, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, sessionStateByRuntimeIdRef, startFreshSessionDraft]
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $desktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
@@ -38,6 +39,32 @@ describe('applyRuntimeInfo approval mode', () => {
})
})
const initialOnboardingState = $desktopOnboarding.get()
describe('applyRuntimeInfo credential warnings', () => {
beforeEach(() => {
$desktopOnboarding.set({ ...initialOnboardingState, reason: null, requested: false })
})
afterEach(() => {
$desktopOnboarding.set(initialOnboardingState)
})
it('requests setup for the exact empty-key warning returned by the server', () => {
const warning = "No API key configured for provider 'openrouter'. First message will fail."
applyRuntimeInfo({ credential_warning: warning })
expect($desktopOnboarding.get()).toMatchObject({ reason: warning, requested: true })
})
it('ignores an auxiliary-provider warning', () => {
applyRuntimeInfo({ credential_warning: 'OPENROUTER_API_KEY not set' })
expect($desktopOnboarding.get()).toMatchObject({ reason: null, requested: false })
})
})
describe('isSessionGoneError', () => {
it('is true for 404 / session-not-found, false otherwise', () => {
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
@@ -342,11 +369,9 @@ describe('preserveLocalPendingTurnMessages', () => {
msg('user-inflight', 'user', 'the one genuinely in-flight prompt')
]
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual([
'stored-user',
'stored-assistant',
'user-inflight'
])
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual(
['stored-user', 'stored-assistant', 'user-inflight']
)
})
it('drops the live tail once the latest authoritative user has persisted it after compression', () => {
@@ -363,6 +388,58 @@ describe('preserveLocalPendingTurnMessages', () => {
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
})
// #67603: the gateway persists model-switch / personality notices as role=user
// ([System: …], tui_gateway/server.py). A single trailing marker is already
// handled by the latestAuthoritativeUser guard above, but TWO switches around
// one turn put a marker BEFORE the committed prompt (shifting its ordinal) and
// another AFTER it (so the prompt is no longer the last user row, so the text
// guard can't rescue it). Naive ordinal pairing then pairs the optimistic row
// against a marker, treats it as uncommitted, and re-appends it — the
// duplicated user bubble stacked at the bottom of the chat.
it('does not duplicate the optimistic prompt when markers bracket it (two model switches)', () => {
const marker = (name: string) => `[System: The active model for this chat has changed to ${name}.]`
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'second question')
]
const next = [
msg('s1-user', 'user', 'first'),
msg('s2-assistant', 'assistant', 'first answer'),
msg('s3-marker', 'user', marker('k2')),
msg('s4-user', 'user', 'second question'),
msg('s5-assistant', 'assistant', 'second answer'),
msg('s6-marker', 'user', marker('k3'))
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it('still keeps a genuinely uncommitted optimistic turn when a marker is present', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'new question')
]
// The marker is persisted but the new prompt has not committed yet — the
// optimistic row must survive (marker exclusion must not over-correct).
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-marker-stored', 'user', '[System: The active model for this chat has changed to k3.]')
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user-stored',
'2-assistant-stored',
'3-marker-stored',
'user-optimistic'
])
})
})
describe('appendLiveSessionProjection', () => {
@@ -3,7 +3,7 @@ import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
@@ -240,7 +240,17 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
* history, not in-flight work. The latest authoritative user confirms whether
* that tail has persisted; any authoritative assistant at the same ordinal
* supersedes the local stream.
*
* Gateway bookkeeping markers (the model-switch / personality notices written
* by tui_gateway/server.py) are persisted as role=user but are not user turns.
* They must not take part in ordinal pairing on either side: a stored marker
* between two real user turns shifts every later user ordinal, so the optimistic
* row misses its committed copy and is appended a second time at the end of the
* transcript — the duplicated user bubble of #67603.
*/
const isGatewaySystemMarker = (message: ChatMessage): boolean =>
message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:')
export function preserveLocalPendingTurnMessages(
nextMessages: ChatMessage[],
previousMessages: ChatMessage[]
@@ -253,6 +263,10 @@ export function preserveLocalPendingTurnMessages(
const nextRoleCounts = new Map<ChatMessage['role'], number>()
for (const message of nextMessages) {
if (isGatewaySystemMarker(message)) {
continue
}
const ordinal = nextRoleCounts.get(message.role) ?? 0
nextRoleCounts.set(message.role, ordinal + 1)
nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
@@ -260,13 +274,19 @@ export function preserveLocalPendingTurnMessages(
const nextIds = new Set(nextMessages.map(message => message.id))
const previousRoleCounts = new Map<ChatMessage['role'], number>()
const newestOptimisticUser = [...previousMessages]
.reverse()
.find(message => message.role === 'user' && message.id.startsWith('user-'))
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
const preserved: ChatMessage[] = []
for (const message of previousMessages) {
if (isGatewaySystemMarker(message)) {
continue
}
const ordinal = previousRoleCounts.get(message.role) ?? 0
previousRoleCounts.set(message.role, ordinal + 1)
@@ -283,7 +303,11 @@ export function preserveLocalPendingTurnMessages(
continue
}
if (isOptimisticUser && latestAuthoritativeUser && chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()) {
if (
isOptimisticUser &&
latestAuthoritativeUser &&
chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()
) {
continue
}
@@ -491,6 +515,28 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
return undefined
}
/**
* The profile that owns a stored session, resolved through the same
* cache → active-backend → cross-profile ladder as `resolveStoredSession`.
*
* Recovery `session.resume` calls (stale runtime id, session-not-found, wedged
* loop) must re-register the conversation on ITS backend, not on whichever
* profile happens to be live. Omitting the profile lets the gateway fall back to
* the launch-profile DB (tui_gateway/server.py), which is how a session bleeds
* from one profile into another (#67603, second symptom). A cache-only lookup
* misses any session outside the paginated sidebar window, so route through the
* resolver, which probes uncached ids across profiles.
*/
export async function resolveSessionProfile(storedSessionId: null | string): Promise<string | undefined> {
if (!storedSessionId) {
return undefined
}
const profile = (await resolveStoredSession(storedSessionId))?.profile?.trim()
return profile || undefined
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
@@ -511,9 +557,7 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode)
}
if (info.credential_warning) {
requestDesktopOnboarding(info.credential_warning)
}
requestDesktopOnboardingForCredentialWarning(info.credential_warning)
reportInstallMethodWarning(info.install_warning)
@@ -62,9 +62,18 @@ vi.mock('@/hermes', async importOriginal => ({
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
}))
// The refresh only reads the optimistic tombstone set; stub it so we don't pull
// the whole projects store (gateway / fs / git) into this hook's test.
const removed = vi.hoisted(() => ({ ids: new Set<string>() }))
vi.mock('@/store/projects', () => ({
$removedSessionIds: { get: () => removed.ids }
}))
beforeEach(() => {
listSidebarSessions.mockReset()
listAllProfileSessions.mockReset()
removed.ids = new Set()
setSessions([])
setCronSessions([])
setMessagingSessions([])
@@ -146,6 +155,28 @@ describe('refreshSessions identity + loading hygiene', () => {
expect(loadingStates).toEqual([false])
})
it('drops rows the user just deleted, even when the backend page still lists them', async () => {
// A delete RPC is in flight: the row is tombstoned optimistically but the
// batched refresh still carries it (and a lineage-tip variant). Both must be
// filtered so the optimistic removal never flashes back.
removed.ids = new Set(['b', 'root-c'])
listSidebarSessions.mockResolvedValue(
sidebar({
sessions: [row('a'), row('b'), row('c', { _lineage_root_id: 'root-c' } as Partial<SessionInfo>)],
total: 3,
profile_totals: {}
})
)
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get().map(s => s.id)).toEqual(['a'])
})
it('still shows loading for the initial (empty-list) fetch', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
@@ -11,6 +11,7 @@ import {
import { setCronJobs } from '@/store/cron'
import { $pinnedSessionIds, $sessionsLimit, bumpSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout'
import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
import { $removedSessionIds } from '@/store/projects'
import {
$messagingSessions,
$selectedStoredSessionId,
@@ -180,12 +181,24 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
if (refreshSessionsRequestRef.current === requestId) {
const recents = result.recents
// Drop rows the user just deleted/archived: a refresh can race an
// in-flight mutation and the backend page still carries the doomed row.
// Honoring the optimistic tombstone keeps the removal from flashing back
// (the tombstone self-clears once projects.tree confirms the delete).
const tombstones = $removedSessionIds.get()
const incoming = tombstones.size
? recents.sessions.filter(
s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id))
)
: recents.sessions
// Signature-gate the swap (same pattern as cron/messaging): a refresh
// that returns content-identical rows must keep the previous array
// identity, or every sidebar memo keyed on $sessions recomputes and the
// whole list re-renders once per turn/broadcast for nothing.
setSessions(prev => {
const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())
const next = mergeSessionPage(prev, incoming, sessionsToKeep())
return sameCronSignature(prev, next) ? prev : next
})
@@ -15,7 +15,6 @@ import {
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { formatUsageUpdatedAgo } from './use-billing-state'
import { BillingSettings } from './index'
@@ -121,7 +120,9 @@ describe('BillingSettings', () => {
renderBilling()
expect(await screen.findByText('No card on file')).toBeTruthy()
// No card → the payment row collapses to a single "Add payment method" link.
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
@@ -265,7 +266,7 @@ describe('BillingSettings', () => {
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
@@ -277,7 +278,7 @@ describe('BillingSettings', () => {
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
@@ -314,7 +315,7 @@ describe('BillingSettings', () => {
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
// Scheduled → back on the overview.
expect(await screen.findByText('Payment')).toBeTruthy()
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
})
@@ -617,9 +618,11 @@ describe('BillingSettings', () => {
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
// Plain shared primitive track (no bespoke dither/tinted chrome); the
// over-limit signal rides the destructive fill instead.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
@@ -644,74 +647,47 @@ describe('BillingSettings', () => {
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
// Empty tracks are the plain shared primitive now — no hatched placeholder.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
})
it('refreshes both billing queries from the usage refresh button', async () => {
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect(await screen.findByText('No payment method on file')).toBeTruthy()
expect(
screen.getByText(
'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.'
)
).toBeTruthy()
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
})
it('does not show the no-card notice when a card is on file', async () => {
renderBilling()
await screen.findByText('$996.47')
expect(screen.queryByText('No payment method on file')).toBeNull()
})
it('polls billing on an interval without a manual refresh control', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
})
it('disables the usage refresh button while either query is fetching', async () => {
let settleBilling: (value: unknown) => void = () => {}
let settleSubscription: (value: unknown) => void = () => {}
apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
new Promise(resolve => {
settleBilling = resolve
})
)
apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
new Promise(resolve => {
settleSubscription = resolve
})
)
renderBilling()
const refresh = await screen.findByRole('button', { name: 'Refresh' })
fireEvent.click(refresh)
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
settleBilling(okBilling(todayBillingState))
settleSubscription(okSubscription(todaySubscriptionState))
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
})
})
describe('formatUsageUpdatedAgo', () => {
it('formats sub-second and current timestamps as just now', () => {
expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
})
it('formats seconds below a minute', () => {
expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
})
it('rounds elapsed time to whole minutes from 61 seconds', () => {
expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
})
it('formats one hour and later as hours', () => {
expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
// The manual refresh affordance is gone — the queries poll on their own.
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
expect(screen.queryByText(/Updated/)).toBeNull()
})
})
+159 -169
View File
@@ -3,13 +3,22 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip } from '@/components/ui/tooltip'
import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
import { Skeleton } from '@/components/ui/skeleton'
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
import { ListRow, SectionHeading, SettingsContent } from '../primitives'
import {
ListRow,
ListRowSkeleton,
SectionHeading,
SectionHeadingSkeleton,
SettingsContent,
SettingsSection
} from '../primitives'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
@@ -27,7 +36,6 @@ import {
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
formatUsageUpdatedAgo,
useBillingState,
useSubscriptionState
} from './use-billing-state'
@@ -64,9 +72,18 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | '
}
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
const warn = notice.tone === 'warn'
return (
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
<div
className={cn(
'text-[length:var(--conversation-text-font-size)] font-medium',
warn ? 'text-(--ui-yellow)' : 'text-foreground'
)}
>
{notice.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
@@ -86,6 +103,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
)
}
// The payment method as it rides in the "Payment & credits" heading: the current
// card (muted) plus a single underline text action (Update / Add payment method).
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
return (
<div className="flex min-w-0 items-center gap-2.5">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
{row.value}
</span>
)}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
size="sm"
type="button"
variant="textStrong"
>
{row.action.label}
</Button>
)}
</div>
)
}
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
return <BuyCreditsRow billing={billing} row={row} />
@@ -143,22 +185,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{presets.map(preset => (
<Button
aria-pressed={amount === preset.amount}
disabled={controlsDisabled}
key={preset.amount}
onClick={() => setAmount(preset.amount)}
size="sm"
type="button"
variant={amount === preset.amount ? 'default' : 'outline'}
>
{preset.label}
</Button>
))}
<SegmentedControl
disabled={controlsDisabled}
onChange={value => setAmount(value)}
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
value={amount}
/>
<Input
aria-label="Custom credit amount"
className="w-24 py-[3px]"
containerClassName="w-16"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
@@ -168,13 +203,14 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
size="sm"
placeholder={billing.min_usd ?? ''}
prefix="$"
size="xs"
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
</Button>
</div>
@@ -283,43 +319,20 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
value: 0
}
const width = Math.round(resolvedBar.value * 100)
const isEmpty = resolvedBar.value === 0
const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
// Plain shared primitive — no bespoke track chrome. Only the fill tone carries
// billing meaning: destructive when over-limit, green for healthy remaining
// credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
return (
<div
<Progress
aria-label={resolvedBar.label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={width}
className={cn(
// Radius follows the app-wide rounded-full progress-bar idiom.
'relative h-2 w-full overflow-hidden rounded-full',
resolvedBar.track === 'danger'
? 'dither text-destructive/60 bg-destructive/10'
: isEmpty
? 'dither bg-(--ui-bg-elevated)'
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
)}
role="progressbar"
>
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
resolvedBar.state === 'danger'
? 'bg-destructive'
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
? 'bg-(--ui-green)'
: 'bg-muted-foreground/45'
)}
style={{
minWidth: resolvedBar.value > 0 ? 4 : undefined,
width: `${width}%`
}}
/>
</div>
destructive={resolvedBar.state === 'danger'}
fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
size="lg"
value={resolvedBar.value}
/>
)
}
@@ -351,53 +364,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) {
)
}
function UsageRefreshRow({
fixtureName,
isFetching,
onRefresh,
updatedAt
}: {
fixtureName?: BillingFixtureSelection
isFetching: boolean
onRefresh: () => void
updatedAt: number
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 30_000)
return () => window.clearInterval(interval)
}, [])
if (fixtureName && fixtureName !== 'live') {
return (
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
fixture: {fixtureName}
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
<Tip label="Refresh">
<Button
aria-label="Refresh"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={isFetching}
onClick={onRefresh}
size="sm"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
</Button>
</Tip>
</div>
)
}
// DEV-only preview switcher: swaps the whole page onto a canned fixture so every
// billing state can be reviewed without a matching live account. Marked with a
// wrench + "preview" so it never reads as a shipping control (it's compiled out of
// production builds entirely).
function BillingFixtureSelect({
onValueChange,
value
@@ -406,23 +376,27 @@ function BillingFixtureSelect({
value: BillingFixtureSelection
}) {
return (
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing fixture"
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
<Wrench className="size-3.5 shrink-0" />
<span className="text-xs font-normal">preview</span>
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing preview fixture (dev only)"
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -446,6 +420,34 @@ function BillingHeader({
)
}
// Loading shape for the billing overview: three summary cards over the Plan /
// Payment & credits / Usage sections. Rendered under the real header.
function BillingSkeleton() {
return (
<>
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{[0, 1, 2].map(i => (
<div className="min-w-0 space-y-2" key={i}>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-20" />
</div>
))}
</div>
</div>
{[0, 1, 2].map(section => (
<section className="mb-6" key={section}>
<SectionHeadingSkeleton />
<div className="grid gap-1">
<ListRowSkeleton />
<ListRowSkeleton />
</div>
</section>
))}
</>
)
}
function BillingSettingsContent({
fixtureName,
onFixtureChange
@@ -460,19 +462,30 @@ function BillingSettingsContent({
// fixture short-circuit here.
const billingState = useBillingState()
const subscriptionState = useSubscriptionState()
// First load keeps the page's shape via a skeleton instead of flashing "—"
// summary cards (background refetches leave `isPending` false, so no flicker).
if (billingState.isPending) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingSkeleton />
</SettingsContent>
)
}
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
const usageIsFetching = billingState.isFetching || subscriptionState.isFetching
const refreshUsage = () => {
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
}
const { paymentRow, refillRow, topupRow } = view
// The payment method rides in the section header (right-aligned) — the
// "Payment & credits" title already names it, so a full labelled row would just
// repeat "Payment method". The stacked rows are the remaining money controls.
const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined)
// Gate the plans sub-view on the SAME capability that renders the in-app button
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
// reach a grid of live Choose buttons — it falls back to the overview.
@@ -491,59 +504,42 @@ function BillingSettingsContent({
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<div className="@container mb-5">
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
{view.notice && <NoticeCard notice={view.notice} />}
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</div>
{view.notice && <NoticeCard notice={view.notice} />}
{view.plan && (
<div className="mb-5">
<SectionHeading icon={Package} title="Plan" />
<SettingsSection icon={Package} title="Plan">
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</div>
</SettingsSection>
)}
{paymentRow && (
<div className="mb-5">
<SectionHeading icon={Lock} title="Payment" />
<AccountRow billing={billing} row={paymentRow} />
</div>
)}
{topupRow && (
<div className="mb-5">
<SectionHeading icon={Plus} title="One-time top-up" />
<AccountRow billing={billing} row={topupRow} />
</div>
)}
{refillRow && (
<div className="mb-5">
<SectionHeading icon={RefreshCw} title="Automatic refill" />
<AccountRow billing={billing} row={refillRow} />
</div>
{(paymentRow || accountRows.length > 0) && (
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</SettingsSection>
)}
{view.usageRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Usage" />
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
<SettingsSection icon={BarChart3} title="Usage">
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
<UsageRefreshRow
fixtureName={fixtureName}
isFetching={usageIsFetching}
onRefresh={refreshUsage}
updatedAt={usageUpdatedAt}
/>
</div>
</>
</SettingsSection>
)}
{
@@ -586,9 +582,3 @@ export function BillingSettings() {
return <BillingSettingsContent />
}
function oldestUpdatedAt(...timestamps: number[]): number {
const populated = timestamps.filter(timestamp => timestamp > 0)
return populated.length > 0 ? Math.min(...populated) : Date.now()
}
@@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
@@ -129,8 +129,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
)}
ref={cardRef}
tabIndex={-1}
@@ -219,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
))}
</div>
) : (
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}
@@ -147,11 +147,14 @@ describe('deriveBillingView', () => {
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
description:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card)."
// The no-card blocker is explained once by the page-level notice, not
// duplicated (emoji and all) into the row description.
description: 'A single charge on your card, added to your balance today.'
})
expect(buyCredits?.description).not.toContain('💳')
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
// The page still leads with the warn banner naming the blocker + fix.
expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
})
it('derives a calm logged-out card with no account or usage rows', () => {
@@ -11,10 +11,22 @@ export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
// The billing endpoint is the authoritative source of truth for balance / cap /
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
// out of sync (notably in team/org accounts where another member's spend moves
// the shared balance without ever touching THIS client's headers). So the page
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
// fresh fetch every time it opens or regains focus, and it keeps polling every
// 30s while mounted (react-query only ticks an active observer; it pauses when
// the window is backgrounded — refetchIntervalInBackground defaults to false).
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
// pull the change in immediately rather than waiting for the next poll tick.
const BILLING_QUERY_OPTIONS = {
refetchInterval: 30_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
retry: false,
staleTime: 30_000
staleTime: 0
} as const
export interface BillingSummaryItemView {
@@ -30,6 +42,8 @@ export interface BillingNoticeView {
}
message: string
title: string
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
tone?: 'info' | 'warn'
}
export interface BillingRowActionView {
@@ -207,7 +221,7 @@ export function deriveBillingView(
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
return {
notice: undefined,
notice: noCardNotice(billing),
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
@@ -276,26 +290,6 @@ export function formatBillingDate(value?: null | string): string {
return fmtDate.format(date)
}
export function formatUsageUpdatedAgo(updatedAt: number, now: number): string {
const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000))
if (elapsedSeconds < 1) {
return 'just now'
}
if (elapsedSeconds < 60) {
return `${elapsedSeconds}s ago`
}
const elapsedMinutes = Math.floor(elapsedSeconds / 60)
if (elapsedMinutes < 60) {
return `${elapsedMinutes}m ago`
}
return `${Math.floor(elapsedMinutes / 60)}h ago`
}
function emptySummary(): BillingSummaryItemView[] {
return [
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
@@ -311,7 +305,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
return {
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
message: resolved.message,
title: resolved.title
title: resolved.title,
tone: 'warn'
}
}
// A logged-in account with no card can't buy credits or manage auto-refill, and
// every one of those controls disables silently — so lead the page with a single
// warn banner that names the blocker and links straight to the fix.
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
if (billing.card) {
return undefined
}
return {
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
title: 'No payment method on file',
tone: 'warn'
}
}
@@ -530,17 +541,19 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
const card = billing.card
if (!card) {
// No card → a single "Add payment method" link, the way every other app does
// it. The reason (buys/auto-refill are blocked) already leads the page as a
// notice, so the row stays a bare call-to-action with no redundant status text.
return {
action: { label: 'Update ↗', url: portalUrl },
description: 'Add a payment method on the portal before buying top-up credits.',
action: { label: 'Add payment method', url: portalUrl },
description: '',
id: 'payment_method',
title: 'Payment method',
value: 'No card on file'
title: 'Payment method'
}
}
return {
action: { label: 'Update ↗', url: portalUrl },
action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
id: 'payment_method',
title: 'Payment method',
@@ -550,14 +563,13 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
// The no-card blocker is already spelled out by the page-level warn banner
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
// so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: resolveRefusal({
kind: 'no_payment_method',
message: '',
portalUrl: billing.portal_url ?? undefined
}).message,
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
@@ -21,7 +21,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
// provider — otherwise every provider's options render at once (the "totally
@@ -264,8 +264,8 @@ export function ConfigSettings({
)
}
// Model keeps its shape via a skeleton (its catalog fetch is the slow part);
// other sections are quick config/schema reads, so a light loader is fine.
// Every section keeps its shape via a skeleton; model gets its bespoke one
// (its catalog fetch is the slow part), the rest the shared field rhythm.
if (activeSectionId === 'model') {
return (
<SettingsContent>
@@ -276,7 +276,7 @@ export function ConfigSettings({
)
}
return <LoadingState label={c.loading} />
return <SettingsSkeleton sections={[{ rows: 6 }]} />
}
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
interface CustomEndpointsSettingsProps {
onConfigSaved?: () => void
@@ -218,7 +218,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
}
if (loading) {
return <LoadingState label="Loading custom endpoints..." />
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
}
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
@@ -27,7 +27,7 @@ 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'
import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives'
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
@@ -985,7 +985,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
if (loading) {
return <LoadingState label={g.loading} />
return (
<SettingsSkeleton
sections={[
{ heading: true, rows: 3 },
{ heading: true, rows: 3 }
]}
/>
)
}
if (!window.hermesDesktop?.getConnectionConfig) {
@@ -6,7 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes'
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
import { SettingsContent, SettingsSkeleton } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
@@ -64,7 +64,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
}, [vars])
if (!vars) {
return <LoadingState label={t.settings.keys.loading} />
return <SettingsSkeleton sections={[{ rows: 5 }]} />
}
const visible = groups.filter(g => g.category === view)
+16 -1
View File
@@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -139,7 +140,21 @@ export function PetSettings() {
{/* Fixed-height scroll area so filtering never grows/shrinks the
page (no layout thrash); the grid scrolls inside it. */}
<div className="mt-3 h-72 overflow-y-auto pr-1">
{pets.length === 0 ? (
{status === 'loading' && pets.length === 0 ? (
// First load keeps the grid's shape rather than flashing the
// "unreachable" copy before the gallery has even arrived.
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div className="flex items-center gap-2.5 px-2.5 py-2" key={i}>
<Skeleton className="size-10 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-24 max-w-full" />
<Skeleton className="h-3 w-16 max-w-full" />
</div>
</div>
))}
</div>
) : pets.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.unreachable}
</p>
+93 -12
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { triggerHaptic } from '@/lib/haptics'
import type { IconComponent } from '@/lib/icons'
@@ -28,16 +28,53 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
return <Badge variant={PILL_VARIANT[tone]}>{children}</Badge>
}
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
export function SectionHeading({
aside,
icon: Icon,
meta,
title
}: {
// Right-aligned trailing content on the heading row (e.g. a compact status +
// action), so a single-item section needn't repeat its own label as a row.
aside?: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span>{title}</span>
{meta && <Pill>{meta}</Pill>}
{aside && <div className="ml-auto flex min-w-0 items-center">{aside}</div>}
</div>
)
}
// A titled section: heading + body with the shared vertical rhythm. Keeps the
// heading and its content welded together so pages stop hand-rolling
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
export function SettingsSection({
aside,
children,
icon,
meta,
title
}: {
aside?: ReactNode
children: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<section className="mb-6">
<SectionHeading aside={aside} icon={icon} meta={meta} title={title} />
{children}
</section>
)
}
export function NavLink({
icon: Icon,
label,
@@ -143,16 +180,60 @@ export function ToggleRow({
)
}
// The settings panels render this as the sole child of the top-padded
// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx).
// Cancel that top pad so the loader centers in the whole card, not just the
// band beneath it. Inline loaders (mid-panel) should use <PageLoader> directly.
export function LoadingState({ label }: { label: string }) {
// Skeleton primitives mirroring the settings layout rhythm — a loading page keeps
// its shape (like ModelSettings) instead of collapsing to a centered spinner.
export function SectionHeadingSkeleton() {
return (
<PageLoader
className="-mt-[calc(var(--titlebar-height)+1rem)] h-[calc(100%+var(--titlebar-height)+1rem)]"
label={label}
/>
<div className="mb-2.5 flex items-center gap-2 pt-2">
<Skeleton className="size-4" />
<Skeleton className="h-4 w-36 max-w-full" />
</div>
)
}
export function ListRowSkeleton({ wide = false }: { wide?: boolean }) {
return (
<div className="@container">
<div
className={cn(
'grid gap-3 py-3',
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
)}
>
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-3.5 w-40 max-w-full" />
<Skeleton className="h-3 w-64 max-w-full" />
</div>
{!wide && <Skeleton className="h-8 w-full @2xl:w-72 @2xl:justify-self-end" />}
</div>
</div>
)
}
// A full settings page in its loading shape: an optional leading search field
// over one or more sections, each an optional heading above a run of rows.
// `<SettingsSkeleton search sections={[{ heading, rows }]} />`.
export function SettingsSkeleton({
search = false,
sections = [{ rows: 4 }]
}: {
search?: boolean
sections?: { heading?: boolean; rows: number }[]
}) {
return (
<SettingsContent>
{search && <Skeleton className="mb-3 h-8 w-full" />}
{sections.map((section, i) => (
<section className={cn(i > 0 && 'mt-6')} key={i}>
{section.heading && <SectionHeadingSkeleton />}
<div className="grid gap-1">
{Array.from({ length: section.rows }, (_, r) => (
<ListRowSkeleton key={r} />
))}
</div>
</section>
))}
</SettingsContent>
)
}
@@ -28,7 +28,7 @@ import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { CustomEndpointsSettings } from './custom-endpoints-settings'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
import { SettingsContent, SettingsSkeleton } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
@@ -431,7 +431,7 @@ export function ProvidersSettings({
}
if (!vars) {
return <LoadingState label={t.settings.providers.loading} />
return <SettingsSkeleton search sections={[{ rows: 6 }]} />
}
const hasOauth = oauthProviders.length > 0
@@ -12,7 +12,7 @@ import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const ARCHIVED_FETCH_LIMIT = 200
@@ -107,7 +107,7 @@ export function SessionsSettings() {
})
if (loading) {
return <LoadingState label={s.loading} />
return <SettingsSkeleton sections={[{ rows: 1 }, { heading: true, rows: 4 }]} />
}
return (
@@ -12,7 +12,12 @@ interface ContextUsagePanelProps {
sessionId: string | null
}
export function ContextUsagePanel({ currentUsage, onUsageSnapshot, requestGateway, sessionId }: ContextUsagePanelProps) {
export function ContextUsagePanel({
currentUsage,
onUsageSnapshot,
requestGateway,
sessionId
}: ContextUsagePanelProps) {
const { t } = useI18n()
const copy = t.shell.statusbar.contextUsagePanel
const [breakdown, setBreakdown] = useState<ContextBreakdown | null>(null)
+7 -9
View File
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/dialog'
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { Progress } from '@/components/ui/progress'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
@@ -396,15 +397,12 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
) : null}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
percent === null && 'w-1/3 animate-pulse'
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>
</div>
<Progress
aria-label={label}
indeterminate={percent === null}
size="lg"
value={percent === null ? 0 : percent / 100}
/>
{recentLog.length > 1 ? (
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">
@@ -1,14 +1,28 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { onComposerInsertRequest } from '@/app/chat/composer/focus'
import { I18nProvider } from '@/i18n'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
import { ClarifyTool, readClarifyResult } from './clarify-tool'
// The live pending card only renders while its message is running. Force that so
// keyboard-navigation tests can exercise ClarifyToolPending directly.
vi.mock('@assistant-ui/react', () => ({
useAuiState: () => true
}))
afterEach(() => {
cleanup()
clearClarifyRequest()
$activeSessionId.set(null)
$gateway.set(null)
vi.clearAllMocks()
})
function renderClarify(ui: ReactNode) {
@@ -39,6 +53,40 @@ function settledClarifyProps(
}
}
function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps {
const args = { choices, question: 'Which deployment target?' }
return {
addResult: vi.fn(),
args,
argsText: JSON.stringify(args),
isError: false,
respondToApproval: vi.fn(),
result: undefined,
resume: vi.fn(),
status: { type: 'running' },
toolCallId: 'clarify-live',
toolName: 'clarify',
type: 'tool-call'
}
}
function renderLiveClarify() {
const request = vi.fn().mockResolvedValue({ ok: true })
$activeSessionId.set('session-1')
$gateway.set({ request } as never)
setClarifyRequest({
choices: ['staging', 'production'],
question: 'Which deployment target?',
requestId: 'request-1',
sessionId: 'session-1'
})
renderClarify(<ClarifyTool {...liveClarifyProps()} />)
return request
}
describe('readClarifyResult', () => {
it('reads question + user_response from the tool JSON payload', () => {
expect(
@@ -114,4 +162,179 @@ describe('ClarifyTool settled view', () => {
expect(screen.getByText('Anything else?')).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
})
it('keeps the original choices visible and clickable after a skip', async () => {
const inserts: string[] = []
const stop = onComposerInsertRequest(detail => {
inserts.push(detail.text)
})
try {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{ question: 'Which deployment target?', user_response: '' },
'clarify-3'
)}
/>
)
// The skip label renders AND the original options are still on screen.
expect(screen.getByText('Skipped')).toBeTruthy()
const group = document.querySelector('[data-clarify-late-choices]')
expect(group).toBeTruthy()
expect(screen.getByText('staging')).toBeTruthy()
expect(screen.getByText('prod')).toBeTruthy()
// Picking one drafts a quoted follow-up into the composer. The insert
// bus defers dispatch by a macrotask, so flush one tick.
fireEvent.click(screen.getByText('prod'))
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(inserts).toHaveLength(1)
expect(inserts[0]).toContain('Which deployment target?')
expect(inserts[0]).toContain('prod')
} finally {
stop()
}
})
it('does not render late choices on an answered clarify', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{ question: 'Which deployment target?', user_response: 'staging' },
'clarify-4'
)}
/>
)
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})
it('does not render late choices for a free-text (no-choice) skip', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Anything else?' },
{ question: 'Anything else?', user_response: '' },
'clarify-5'
)}
/>
)
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
})
})
describe('ClarifyTool keyboard navigation', () => {
it('cycles through choices and Other with the arrow keys', () => {
renderLiveClarify()
const staging = screen.getByRole('button', { name: /staging/ })
const production = screen.getByRole('button', { name: /production/ })
const other = screen.getByPlaceholderText(/Other/)
expect(staging.getAttribute('data-highlighted')).toBe('true')
expect(staging.getAttribute('aria-current')).toBe('true')
expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1')
fireEvent.keyDown(window, { key: 'ArrowDown' })
expect(production.getAttribute('data-highlighted')).toBe('true')
fireEvent.keyDown(window, { key: 'ArrowDown' })
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
expect(other.getAttribute('aria-current')).toBe('true')
expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3')
fireEvent.keyDown(window, { key: 'ArrowDown' })
expect(staging.getAttribute('data-highlighted')).toBe('true')
fireEvent.keyDown(window, { key: 'ArrowUp' })
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
})
it('selects by number and confirms the answer with Enter', async () => {
const request = renderLiveClarify()
fireEvent.keyDown(window, { key: '2' })
fireEvent.keyDown(window, { key: 'Enter' })
await waitFor(() => {
expect(request).toHaveBeenCalledWith('clarify.respond', {
answer: 'production',
request_id: 'request-1'
})
})
})
it('focuses Other when its number is pressed and leaves typing keys alone', () => {
renderLiveClarify()
const other = screen.getByPlaceholderText(/Other/)
fireEvent.keyDown(window, { key: '3' })
expect(document.activeElement).toBe(other)
fireEvent.change(other, { target: { value: 'canary' } })
fireEvent.keyDown(window, { key: 'ArrowUp' })
expect(document.activeElement).toBe(other)
expect((other as HTMLTextAreaElement).value).toBe('canary')
})
it('does not intercept keyboard events while an action button has focus', () => {
const request = renderLiveClarify()
const skip = screen.getByRole('button', { name: 'Skip' })
skip.focus()
expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)
expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true)
expect(request).not.toHaveBeenCalled()
})
})
describe('ClarifyTool pending marker', () => {
it('marks a live choices card so type-to-focus yields its shortcut keys', () => {
renderLiveClarify()
// The marker is what `composerFocusBlockedBySurface` keys off of, so the
// global type-to-focus listener stands down and A/B/C… + 1-9 + Enter reach
// the card instead of the composer.
expect(document.querySelector('[data-clarify-choices]')).toBeTruthy()
})
it('does not mark a free-text (no-choice) pending card', () => {
$activeSessionId.set('session-1')
$gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never)
setClarifyRequest({
choices: null,
question: 'Anything else?',
requestId: 'request-1',
sessionId: 'session-1'
})
const args = { question: 'Anything else?' }
renderClarify(
<ClarifyTool
addResult={vi.fn()}
args={args}
argsText={JSON.stringify(args)}
isError={false}
respondToApproval={vi.fn()}
result={undefined}
resume={vi.fn()}
status={{ type: 'running' }}
toolCallId="clarify-free"
toolName="clarify"
type="tool-call"
/>
)
// No shortcuts → nothing to protect → composer type-to-focus stays live.
expect(document.querySelector('[data-clarify-choices]')).toBeNull()
})
})
@@ -13,16 +13,18 @@ import {
useState
} from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
@@ -52,11 +54,18 @@ function stringField(row: Record<string, unknown>, ...keys: string[]): string |
function readClarifyArgs(args: unknown): ClarifyArgs {
const row = parseMaybeObject(args)
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
const rawChoices = row.choices
const choices = normalizeChoices(rawChoices)
const question = stringField(row, 'question')
if (rawChoices != null && choices.length === 0 && question) {
warnDroppedChoices('tool_args', question, rawChoices)
}
return {
question: stringField(row, 'question'),
choices: choices && choices.length > 0 ? choices : null
question,
choices: choices.length > 0 ? choices : null
}
}
@@ -125,6 +134,60 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
)
}
/** A letter-badged option row. Shared by the live pending card (where a click
* selects an answer) and the settled skip card (where a click drafts a
* follow-up), so both stay visually identical. */
function ChoiceButton({
active = false,
char,
choice,
disabled,
keyShortcuts,
onClick,
selected = false,
title
}: {
active?: boolean
char: string
choice: string
disabled?: boolean
keyShortcuts?: string
onClick: () => void
selected?: boolean
title?: string
}) {
// `Tip` is the repo's themed replacement for native `title=` (a native
// tooltip on a <button> is banned by the no-native-title guard). It renders
// the child untouched when `label` is falsy, so the live card (no tip) is
// unaffected and only the settled skip card gets the hover hint.
//
// `active` is the keyboard cursor on the live card (arrow-key navigation);
// it highlights the row and previews its key badge. The settled skip card
// never passes it, so its rows stay plain.
return (
<Tip label={title}>
<button
aria-current={active || undefined}
aria-keyshortcuts={keyShortcuts}
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
active && 'bg-(--chrome-action-hover) text-(--ui-text-primary)',
selected && 'text-(--ui-text-primary)'
)}
data-choice
data-highlighted={active || undefined}
disabled={disabled}
onClick={onClick}
type="button"
>
<KeyBadge char={char} preview={active} selected={selected} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
</Tip>
)
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
// Answered → settled Q&A (ToolFallback collapsed the answer away).
if (props.result !== undefined) {
@@ -156,6 +219,22 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
const error = fromResult.error
const skipped = !error && answer !== undefined && !answer.trim()
const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim())
const choices = fromArgs.choices ?? []
// A skipped (timed-out) clarify keeps its choices on screen and actionable.
// The blocking request is long gone — the tool already returned empty — so a
// pick can't resolve it retroactively. Instead it drafts a quoted follow-up
// into the composer (Enter sends; if the agent is mid-turn it queues like
// any other prompt). Without this the card collapsed to just "Skipped" and
// the options were unrecoverable.
const followUp = useCallback(
(choice: string) => {
requestComposerInsert(copy.lateAnswer(question, choice), { mode: 'block' })
requestComposerFocus()
triggerHaptic('selection')
},
[copy, question]
)
return (
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
@@ -178,6 +257,20 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
</p>
</ClarifyLine>
) : null}
{skipped && choices.length > 0 ? (
<div className="grid gap-px" data-clarify-late-choices="" role="group">
{choices.map((choice, index) => (
<ChoiceButton
char={letterFor(index)}
choice={choice}
key={`${index}-${choice}`}
onClick={() => followUp(choice)}
title={copy.lateAnswerTip}
/>
))}
<p className="px-1.5 pt-0.5 text-[0.6875rem] leading-4 text-(--ui-text-tertiary)">{copy.lateAnswerHint}</p>
</div>
) : null}
</ClarifyShell>
)
}
@@ -217,6 +310,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
// The keyboard cursor. Indices 0..choices.length-1 are the options; the
// trailing index (=== choices.length) is the "Other" free-text row.
const [activeIndex, setActiveIndex] = useState(0)
const [otherFocused, setOtherFocused] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
@@ -265,12 +361,31 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
// confirms with Continue (or Enter from the field).
const pendingAnswer = selectedChoice ?? (trimmedDraft || null)
const selectChoice = useCallback((choice: string) => {
const selectChoice = useCallback((choice: string, index: number) => {
// Picking a choice and typing are mutually exclusive answers.
setDraft('')
setSelectedChoice(choice)
setActiveIndex(index)
}, [])
// Keep the cursor in range when the choice set changes (never past "Other").
useEffect(() => {
setActiveIndex(index => Math.min(index, choices.length))
}, [choices.length])
const moveActive = useCallback(
(delta: number) => {
const itemCount = choices.length + 1
// Arrow navigation is a move, not a pick — clear any staged answer so the
// cursor and the selection can't disagree.
setDraft('')
setSelectedChoice(null)
setActiveIndex(index => (index + delta + itemCount) % itemCount)
},
[choices.length]
)
const submitAnswer = useCallback(() => {
if (selectedChoice !== null) {
void respond(selectedChoice)
@@ -283,6 +398,27 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}
}, [respond, selectedChoice, trimmedDraft])
const activateActive = useCallback(() => {
// A staged answer (picked choice or typed text) wins — confirm it.
if (pendingAnswer) {
submitAnswer()
return
}
// Otherwise act on the highlighted row: a choice responds immediately, and
// the trailing "Other" row focuses the free-text field.
const choice = choices[activeIndex]
if (choice) {
void respond(choice)
return
}
textareaRef.current?.focus()
}, [activeIndex, choices, pendingAnswer, respond, submitAnswer])
const handleTextareaKey = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.nativeEvent.isComposing) {
@@ -305,10 +441,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[submitAnswer]
)
// Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps
// into "Other", and Enter confirms the current pick. Stands down whenever a
// field is focused (you're typing, not navigating) so it never eats keystrokes
// meant for the composer or the Other box.
// Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter
// confirms the current answer (or acts on the highlighted row). Stands down
// whenever a focusable control (a field, a choice button, the action bar) is
// focused, so it never eats keystrokes meant for the composer, the Other box,
// or a button the user tabbed to.
useEffect(() => {
if (!ready || !hasChoices || submitting) {
return
@@ -321,7 +458,32 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
if (
active &&
(active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]'))
) {
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveActive(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (/^[1-9]$/.test(event.key)) {
const index = Number(event.key) - 1
if (index < choices.length) {
event.preventDefault()
selectChoice(choices[index], index)
} else if (index === choices.length) {
event.preventDefault()
setActiveIndex(index)
textareaRef.current?.focus()
}
return
}
@@ -332,25 +494,26 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
if (index < choices.length) {
event.preventDefault()
selectChoice(choices[index])
selectChoice(choices[index], index)
} else if (index === choices.length) {
event.preventDefault()
setActiveIndex(index)
textareaRef.current?.focus()
}
return
}
if (event.key === 'Enter' && pendingAnswer) {
if (event.key === 'Enter') {
event.preventDefault()
submitAnswer()
activateActive()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting])
}, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting])
if (loading) {
return (
@@ -375,7 +538,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}
return (
<ClarifyShell className="grid gap-2 px-2.5 py-2">
// `data-clarify-choices` marks the panel as owning printable/Enter keys
// while its A/B/C… shortcuts are live, so the global type-to-focus listener
// (`composerFocusBlockedBySurface`) stands down and the letters reach this
// card instead of being redirected into the composer.
<ClarifyShell className="grid gap-2 px-2.5 py-2" data-clarify-choices={hasChoices ? '' : undefined}>
<div className="flex items-start gap-2">
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
@@ -385,31 +552,40 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{hasChoices ? (
<div className="grid gap-px" role="group">
{choices.map((choice, index) => (
<button
className={cn(
OPTION_ROW_CLASS,
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
selectedChoice === choice && 'text-(--ui-text-primary)'
)}
data-choice
<ChoiceButton
active={activeIndex === index}
char={letterFor(index)}
choice={choice}
disabled={submitting}
key={`${index}-${choice}`}
onClick={() => selectChoice(choice)}
type="button"
>
<KeyBadge char={letterFor(index)} selected={selectedChoice === choice} />
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
keyShortcuts={`${letterFor(index)} ${index + 1}`}
onClick={() => selectChoice(choice, index)}
selected={selectedChoice === choice}
/>
))}
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
<label
className={cn(
OPTION_ROW_CLASS,
'items-center',
activeIndex === choices.length && 'bg-(--chrome-action-hover)'
)}
data-highlighted={activeIndex === choices.length || undefined}
>
<KeyBadge
char={letterFor(choices.length)}
preview={otherFocused || activeIndex === choices.length}
selected={Boolean(trimmedDraft)}
/>
<Textarea
aria-current={activeIndex === choices.length || undefined}
aria-keyshortcuts={`${letterFor(choices.length)} ${choices.length + 1}`}
className={CLARIFY_TEXTAREA_CLASS}
disabled={submitting}
onBlur={() => setOtherFocused(false)}
onChange={event => onDraftChange(event.target.value)}
onFocus={() => {
setSelectedChoice(null)
setActiveIndex(choices.length)
setOtherFocused(true)
}}
onKeyDown={handleTextareaKey}
@@ -0,0 +1,51 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
describe('MarkdownTextContent remote images', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
return { dataUrl: REMOTE_IMAGE_DATA_URL }
}
throw new Error(`unexpected path ${path}`)
})
let originalDesktop: typeof window.hermesDesktop
beforeEach(() => {
api.mockClear()
originalDesktop = window.hermesDesktop
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
})
afterEach(() => {
cleanup()
$connection.set(null)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: originalDesktop
})
})
it('passes the gateway bridge data URL through Streamdown to the zoomable image', async () => {
render(<MarkdownTextContent isRunning={false} text={`![Remote preview](${REMOTE_IMAGE_PATH})`} />)
const image = await screen.findByRole('img', { name: 'Remote preview' })
expect(image.getAttribute('src')).toBe(REMOTE_IMAGE_DATA_URL)
expect(api).toHaveBeenCalledWith({
path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fimages%2Fremote-preview.png',
profile: 'remote-work'
})
})
})
@@ -20,14 +20,14 @@ import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
downloadGatewayMediaFile,
filePathFromMediaPath,
gatewayMediaDataUrl,
isInlineMediaSrc,
isRemoteGateway,
mediaExternalUrl,
mediaKind,
mediaName,
mediaPathFromMarkdownHref,
mediaStreamUrl
mediaStreamUrl,
resolveMediaDisplaySrc
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
@@ -60,27 +60,13 @@ function preprocessWithTailRepair(text: string): string {
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
}
// Stream audio/video through the custom protocol: data URLs are capped and
// load the whole file into memory, which broke playback for larger videos.
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return mediaStreamUrl(path)
}
// Remote gateway: the image lives on the gateway machine, so read it over the
// authenticated API rather than this machine's disk.
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return resolveMediaDisplaySrc(path)
}
function useOpenMediaFile(path: string) {
@@ -286,6 +272,65 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
const rawSrc = typeof src === 'string' ? src : ''
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
const [failed, setFailed] = useState(false)
const { open, openFailed } = useOpenMediaFile(rawSrc)
const name = mediaName(rawSrc || String(alt || 'image'))
useEffect(() => {
let cancelled = false
setFailed(false)
setResolvedSrc(rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : '')
if (!rawSrc || isInlineMediaSrc(rawSrc)) {
return () => {
cancelled = true
}
}
void resolveMediaDisplaySrc(rawSrc)
.then(value => {
if (!cancelled) {
setResolvedSrc(value)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
}
}, [rawSrc])
if (!rawSrc) {
return null
}
if (failed) {
return (
<span className="my-2 block text-sm text-muted-foreground">
Couldn&apos;t load {name}.{' '}
<button
className="bg-transparent font-medium text-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
onClick={open}
type="button"
>
Open image
</button>
{openFailed && <OpenMediaFailedNote name={name} />}
</span>
)
}
if (!resolvedSrc) {
return <span className="my-2 block text-sm text-muted-foreground">Loading {name}...</span>
}
return (
<ZoomableImage
alt={alt}
@@ -295,7 +340,7 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
)}
containerClassName="my-2 block w-fit max-w-full"
slot="aui_markdown-image"
src={src}
src={resolvedSrc}
{...props}
/>
)
@@ -14,12 +14,21 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
const { args, result } = props
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
// The image card owns successful generations. Failed or malformed results
// still need the normal tool row: it extracts the error text and gives the
// user an honest, expandable failure rather than silently dropping the call.
if (result !== undefined && !generatedImageFromResult(result)) {
return <ToolFallback {...props} />
}
return (
<div className="mt-1.5">
<GeneratedImage aspectRatio={aspectRatio} result={result} />
@@ -217,7 +217,10 @@ function assistantTodoMessage(
} as ThreadMessage
}
function assistantImageMessage(running = false): ThreadMessage {
function assistantImageMessage(
running = false,
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
role: 'assistant',
@@ -228,7 +231,7 @@ function assistantImageMessage(running = false): ThreadMessage {
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
...(running ? {} : { result })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
@@ -243,6 +246,32 @@ function assistantImageMessage(running = false): ThreadMessage {
} as ThreadMessage
}
function assistantTerminalMessage(): ThreadMessage {
return {
id: 'assistant-terminal-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'terminal-1',
toolName: 'terminal',
args: { command: 'npm run check --workspace=apps/desktop' },
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
result: { exit_code: 0, stdout: 'all checks passed' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
interface StreamingControls {
emitSecond: () => void
complete: () => void
@@ -634,4 +663,32 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
})
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
const { container } = render(
<MessageHarness
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
/>
)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('FAL rejected the prompt')
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
expect(container.textContent).not.toContain('"success":false')
})
it('shows the command prompt and exit code for terminal calls', async () => {
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
expect(container.textContent).toContain('exit 0')
expect(container.textContent).toContain('all checks passed')
})
})
})
@@ -76,6 +76,38 @@ describe('buildToolView terminal exit-code status', () => {
'error'
)
})
it('keeps the command and exit code for the terminal transcript', () => {
const view = buildToolView(
part({
args: { command: 'npm run check --workspace=apps/desktop' },
result: { exit_code: 0, output: 'done' },
toolName: 'terminal'
}),
''
)
expect(view.terminalCommand).toBe('npm run check --workspace=apps/desktop')
expect(view.terminalExitCode).toBe(0)
})
})
describe('buildToolView web-search query', () => {
it('keeps the query separate from structured search results', () => {
const view = buildToolView(
part({
args: { query: 'Hermes Agent Desktop tool calls' },
result: { web: [{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }] },
toolName: 'web_search'
}),
''
)
expect(view.searchQuery).toBe('Hermes Agent Desktop tool calls')
expect(view.searchHits).toEqual([
{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }
])
})
})
describe('buildToolView browser_navigate title', () => {
@@ -128,6 +128,14 @@ function readFileDisplayTarget(args: Record<string, unknown>, result: Record<str
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
}
function shellCommand(args: Record<string, unknown>): string {
return (
firstStringField(args, ['context', 'preview']) ||
firstStringField(args, ['command', 'code']) ||
contextValue(args)
)
}
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
browser_click: {
icon: 'globe',
@@ -1315,10 +1323,7 @@ function dynamicTitle(
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command =
firstStringField(args, ['context', 'preview']) ||
firstStringField(args, ['command', 'code']) ||
contextValue(args)
const command = shellCommand(args)
if (command) {
const action =
@@ -1384,6 +1389,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const searchHits =
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
const searchQuery =
part.toolName === 'web_search' ? firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) : ''
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
@@ -1398,6 +1405,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
// field — otherwise the merged `detail` already covers it and double-
// rendering would duplicate output.
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
const terminalCommand = part.toolName === 'terminal' ? shellCommand(argsRecord) : undefined
const terminalExitCode = part.toolName === 'terminal' ? numericField(resultRecord, 'exit_code') : undefined
return {
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
@@ -1409,8 +1418,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
inlineDiff,
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
rendersAnsi: rendersAnsi || undefined,
searchQuery: searchQuery || undefined,
searchHits: searchHits?.length ? searchHits : undefined,
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
terminalCommand,
terminalExitCode,
stdout: hasSplitStreams ? stdout || undefined : undefined,
status,
subtitle,
@@ -40,11 +40,17 @@ export interface ToolView {
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
/** Original query, shown above structured web-search results. */
searchQuery?: string
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout — distinct from an error tone. */
stderr?: string
/** Terminal-only command shown as the prompt in the expanded transcript. */
terminalCommand?: string
/** Terminal-only process exit code, when the backend reported one. */
terminalExitCode?: number
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { shouldBoundToolGroup, UNBOUNDABLE_TOOLS } from './fallback'
import { shouldBoundToolGroup, technicalTrace, UNBOUNDABLE_TOOLS } from './fallback'
describe('shouldBoundToolGroup', () => {
it('bounds long runs of ordinary tool calls', () => {
@@ -22,3 +22,16 @@ describe('UNBOUNDABLE_TOOLS', () => {
expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true)
})
})
describe('technicalTrace', () => {
it('indents object payloads and persisted JSON strings', () => {
expect(technicalTrace({ offset: 2, path: '/tmp/demo.txt' }, '{"success":true,"lines":["a","b"]}')).toBe(
'Arguments:\n{\n "offset": 2,\n "path": "/tmp/demo.txt"\n}\n\nResult:\n{\n "success": true,\n "lines": [\n "a",\n "b"\n ]\n}'
)
})
it('leaves scalar strings untouched', () => {
expect(technicalTrace(undefined, 'plain text')).toBe('Result:\nplain text')
expect(technicalTrace(undefined, '"already quoted"')).toBe('Result:\n"already quoted"')
})
})
@@ -62,7 +62,6 @@ import {
type ToolStatus,
type ToolTitleAction
} from './fallback-model'
import { prettyJson } from './fallback-model/format'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@@ -104,23 +103,39 @@ interface ToolStatusCopy {
statusRunning: string
}
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
.map(value => {
if (typeof value === 'string') {
return value
}
function prettyTechnicalValue(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim()
try {
return JSON.stringify(value)
} catch {
return String(value)
}
})
.filter(Boolean)
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return value
}
return clampForDisplay(parts.join('\n'))
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : value
} catch {
return value
}
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
export function technicalTrace(args: unknown, result: unknown): string {
const parts = [
['Arguments', args],
['Result', result]
]
.filter(([, value]) => value !== undefined && value !== null)
.map(([label, value]) => `${label}:\n${prettyTechnicalValue(value)}`)
return clampForDisplay(parts.join('\n\n'))
}
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
@@ -363,7 +378,8 @@ function ToolEntry({ part }: ToolEntryProps) {
const showDetail =
!view.inlineDiff &&
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(Boolean(view.stdout || view.stderr) ||
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle))
const renderDetailAsCode =
@@ -373,21 +389,16 @@ function ToolEntry({ part }: ToolEntryProps) {
const hasSearchHits = Boolean(view.searchHits?.length)
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
// Only web_search renders the raw JSON drilldown, so serialize the result
// lazily here instead of prettyJson-ing every tool's result in buildToolView.
const rawResult = useMemo(
() => (part.toolName === 'web_search' && toolViewMode !== 'technical' ? prettyJson(part.result) : ''),
[part.toolName, part.result, toolViewMode]
)
const showRawSearchDrilldown =
part.toolName === 'web_search' &&
part.result !== undefined &&
toolViewMode !== 'technical' &&
Boolean(rawResult.trim())
const hasExpandableContent = Boolean(
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
view.stdout ||
view.stderr ||
view.terminalCommand ||
view.terminalExitCode !== undefined ||
toolViewMode === 'technical'
)
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
@@ -511,6 +522,9 @@ function ToolEntry({ part }: ToolEntryProps) {
text={copyAction.text}
/>
)}
{part.toolName === 'terminal' && toolViewMode !== 'technical' && (
<TerminalTranscript command={view.terminalCommand} exitCode={view.terminalExitCode} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
@@ -518,6 +532,12 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.searchQuery && (
<p className="mb-1 flex min-w-0 gap-1.5 wrap-anywhere">
<span className="shrink-0 font-medium text-(--ui-text-tertiary)">Search</span>
<span>{view.searchQuery}</span>
</p>
)}
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
<SearchResultsList hits={view.searchHits} />
</div>
@@ -596,22 +616,16 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
</div>
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>{rawResult}</pre>
</details>
)}
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
{technicalTrace(part.args, part.result)}
</pre>
)}
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
{technicalTrace(part.args, part.result)}
</pre>
</details>
)}
@@ -621,6 +635,38 @@ function ToolEntry({ part }: ToolEntryProps) {
)
}
interface TerminalTranscriptProps {
command?: string
exitCode?: number
}
function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
if (!command && exitCode === undefined) {
return null
}
return (
<div className="flex min-w-0 items-center gap-2 rounded-[0.25rem] border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed">
{command && (
<code className="min-w-0 flex-1 whitespace-pre-wrap wrap-anywhere text-(--ui-text-secondary)">
<span aria-hidden className="select-none text-(--ui-accent-secondary)">$ </span>
{command}
</code>
)}
{exitCode !== undefined && (
<span
className={cn(
'shrink-0 rounded bg-(--ui-bg-tertiary) px-1 py-px text-[0.6rem] tabular-nums',
exitCode === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
)}
>
exit {exitCode}
</span>
)}
</div>
)
}
// A back-to-back run of this many tool calls collapses into the bounded,
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3
@@ -0,0 +1,72 @@
import { useStore } from '@nanostores/react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block'
function firstLine(text: string): string {
return (text || '').split('\n')[0]?.trim() ?? ''
}
/**
* Persistent, in-stack billing wall for THIS session. Rendered as a shared
* {@link StatusRow} — same chrome as its status-stack siblings, so it reads as
* one piece with the composer card (no bordered alert-in-a-card). It never
* disables the composer — slash commands (`/topup`, `/model`, `/login`) stay
* usable — it only offers recovery: Nous opens Settings → Billing in-app, other
* providers deep-link out. The sticky toast is the loud surface; this is the calm
* reminder that outlives it.
*/
export function BillingBanner({ sessionId }: { sessionId: null | string }) {
const active = useStore($billingBlock)
const { t } = useI18n()
if (!active || !sessionId || active.sessionId !== sessionId) {
return null
}
const { block } = active
const copy = t.billingBlock
const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label)
const message = firstLine(block.message) || copy.fallbackMessage
return (
<StatusRow
leading={<Codicon aria-hidden className="text-destructive/85" name="credit-card" size="0.8rem" />}
trailing={
<>
<Button
className="text-foreground/90 hover:text-foreground"
onClick={() => runBillingRecovery(block)}
size="micro"
type="button"
variant="text"
>
{billingCtaLabel(block, copy)}
</Button>
<Tip label={copy.dismiss}>
<Button
aria-label={copy.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={() => clearBillingBlock(sessionId)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
</>
}
trailingVisible
>
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
<span className="font-medium">{title}</span>
{message && <span className="text-muted-foreground/80"> · {message}</span>}
</span>
</StatusRow>
)
}
@@ -0,0 +1,72 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ExpandableBlock } from './expandable-block'
// jsdom has no ResizeObserver and reports scrollHeight === 0, so the block
// never flips to `overflowing` on its own. Stub RO to fire immediately and
// force a tall scrollHeight on the observed node so the toggle mounts.
class TestResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
Object.defineProperty(target, 'scrollHeight', { configurable: true, value: 400 })
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver)
}
unobserve() {}
disconnect() {}
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ExpandableBlock', () => {
it('lets horizontal scroll through and keeps the last line selectable', () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
const { container } = render(
<ExpandableBlock>
<pre data-testid="content">{'const x = 1\n'.repeat(20)}</pre>
</ExpandableBlock>
)
const inner = container.querySelector('[data-testid="content"]')!.parentElement!
const toggle = screen.getByRole('button', { name: /expand|collapse/i })
const fade = toggle.parentElement!
// Inner container allows horizontal scroll so wide code gets a scrollbar.
expect(inner.className).toContain('overflow-x-auto')
// The full-width fade is a pure cue: it spans the bottom edge but must not
// intercept pointer events, so the scrollbar drag and text selection on the
// last line pass through to the content underneath.
expect(fade.className).toContain('pointer-events-none')
expect(fade.className).toContain('inset-x-0')
// Only the compact toggle is clickable, and it is pinned to the right edge
// rather than spanning the full width (the old bug).
expect(toggle.className).toContain('pointer-events-auto')
expect(toggle.className).toContain('w-9')
expect(toggle.className).not.toContain('inset-x-0')
})
it('still toggles expanded state when the compact control is clicked', () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
render(
<ExpandableBlock>
<pre data-testid="content">{'line\n'.repeat(20)}</pre>
</ExpandableBlock>
)
const toggle = screen.getByRole('button', { name: 'Expand' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: 'Collapse' }).getAttribute('aria-expanded')).toBe('true')
})
})
@@ -31,19 +31,30 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
return (
<div className="relative">
<div className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)} ref={innerRef}>
<div
className={cn('overflow-y-auto overflow-x-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
// The fade is a pure overflow cue and must not intercept pointer events:
// it spans the full bottom edge (over the horizontal scrollbar of a wide
// code block AND the block's last line), so making it clickable killed
// both sideways scrolling and text selection. Keep the fade
// `pointer-events-none` and pin the only clickable target — a compact
// toggle — to the right edge, clear of the draggable scrollbar track.
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-7 justify-end bg-linear-to-t from-(--ui-chat-surface-background) to-transparent">
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="pointer-events-auto flex h-7 w-9 cursor-pointer items-end justify-center pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
</div>
)}
</div>
)
@@ -6,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { LogView } from '@/components/ui/log-view'
import { Progress } from '@/components/ui/progress'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
@@ -435,12 +436,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</span>
<span className="tabular-nums">{progressPct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<div
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${progressPct}%` }}
/>
</div>
<Progress
aria-label={copy.progress(completedCount, totalCount)}
className="bg-(--ui-bg-tertiary)"
destructive={failed}
value={progressPct / 100}
/>
</div>
)}
+37 -4
View File
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -162,6 +162,30 @@ function BottomRightStack({
)
}
// Emphasize only the leading money figure ("$16.00" — the amount used) with the
// accent color (semibold), leaving the rest of the line in its default muted
// tone. No accent, or no figure in the message → render the text untouched.
function renderMessage(message: string, accent?: string): ReactNode {
const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null
if (!match) {
return message
}
const start = match.index
const end = start + match[0].length
return (
<>
{message.slice(0, start)}
<span className="font-semibold" style={{ color: accent }}>
{match[0]}
</span>
{message.slice(end)}
</>
)
}
function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
@@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
const { t } = useI18n()
const copy = t.notifications
// Nudge the icon down to sit on the first text line, in `ch` so it tracks the
// toast's font size instead of a fixed rem. `accentColor` (when set) tints the
// icon + message as a severity ramp, overriding the kind's default color.
const accent = notification.accentColor
const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) }
return (
<Alert
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
@@ -177,14 +207,17 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
variant={styles.variant}
>
{notification.icon ? (
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" style={iconStyle} />
) : (
<Icon className={styles.iconClass} />
<Icon className={styles.iconClass} style={iconStyle} />
)}
<div className="col-start-2 min-w-0">
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
<AlertDescription className="col-start-auto">
<p className="m-0">{notification.message}</p>
<p className="m-0">{renderMessage(notification.message, accent)}</p>
{notification.meta && (
<p className="m-0 text-xs text-muted-foreground tabular-nums">{notification.meta}</p>
)}
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
{notification.action && (
<Button
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons'
@@ -372,15 +373,12 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
<p className="text-sm text-muted-foreground">
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
hasError && 'bg-destructive'
)}
style={{ width: `${progress}%` }}
/>
</div>
<Progress
aria-label={installing ? t.onboarding.preparingInstall : t.onboarding.starting}
destructive={hasError}
size="lg"
value={progress / 100}
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">{boot.message}</span>
<span>{progress}%</span>
@@ -58,11 +58,11 @@ interface PaneChrome {
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
* live: the workspace contribution re-registers it on route changes. */
headerVeto?: boolean
/** A lead-dot color for this pane's TAB (a session tab inheriting its
* project color). Generic — any pane may contribute one; the strip just
* renders a tinted dot before the label. Live: the owning contribution
* re-registers it when the resolved color changes. */
accent?: string
/** A lead NODE for this pane's TAB, rendered before the label. A session
* pane (main workspace + tiles) passes its live `SessionStatusDot` here so
* the tab and the sidebar row render status/color from the ONE primitive
* (self-subscribing — it updates without the strip re-registering). */
tabLead?: () => React.ReactNode
}
export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome
@@ -10,7 +10,7 @@
*/
import { useStore } from '@nanostores/react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
@@ -37,6 +37,7 @@ import {
$hiddenTreePanes,
$layoutTree,
$narrowViewport,
$newSessionTabAction,
$treeDragging,
activateTreePane,
closeTreePane,
@@ -162,6 +163,7 @@ export function TreeGroup({
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
const newSessionTabAction = useStore($newSessionTabAction)
const paneFor = (id: string) => panes.find(p => p.id === id)
@@ -178,6 +180,30 @@ export function TreeGroup({
const active = paneFor(activeId)
const isEmpty = node.panes.length === 0
// KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted —
// an inactive tab merely hides (visibility), it does not unmount. Remounting
// on every tab switch re-measured and re-scrolled the content from scratch
// (the thread visibly layout-shifted each time a session tab was revisited).
// Lazy on purpose: a pane first mounts when first activated, so a
// boot-restored tab stack doesn't resume every session up front.
const everActivePanesRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!node.minimized && !isEmpty) {
everActivePanesRef.current.add(activeId)
}
// Prune panes that left the zone (closed / moved to another group), so a
// long-lived zone doesn't pin stale ids forever.
for (const id of everActivePanesRef.current) {
if (!node.panes.includes(id)) {
everActivePanesRef.current.delete(id)
}
}
})
const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id))
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
@@ -446,12 +472,8 @@ export function TreeGroup({
role="tab"
style={{ cursor: 'grab' }}
>
{chrome.accent ? (
<span
aria-hidden="true"
className="ml-2 -mr-1 size-1 shrink-0 rounded-full"
style={{ backgroundColor: chrome.accent }}
/>
{chrome.tabLead ? (
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
) : null}
<PaneTabLabel>{title}</PaneTabLabel>
</PaneTab>
@@ -461,6 +483,23 @@ export function TreeGroup({
// tile tab); the wrapper needs the key since it's the root.
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
})}
{/* Plain "+" after the last tab of the MAIN strip (the workspace
zone) — always shown, no tab/button chrome, just the glyph.
Creates a new session tab (mirrors ⌘T) via the app-registered
action; hidden when unwired or the zone is minimized. */}
{node.panes.includes('workspace') && newSessionTabAction && !node.minimized && (
<button
aria-label={t.zones.newSessionTab}
className="grid size-7 shrink-0 place-items-center self-center bg-transparent text-(--ui-text-quaternary) transition-colors hover:text-foreground [-webkit-app-region:no-drag]"
onClick={() => newSessionTabAction()}
onPointerDown={e => e.stopPropagation()}
title={t.zones.newSessionTab}
type="button"
>
<Codicon name="add" size="0.8125rem" />
</button>
)}
</div>
{minimizable && (
<button
@@ -478,18 +517,40 @@ export function TreeGroup({
</ZoneMenu>
)}
{/* Body: the active pane's contributed content, or the empty zone. */}
{/* Body: the zone's pane content — every kept (ever-active) pane stays
mounted in an absolute layer; only the active one is visible.
`visibility` (not display) keeps the hidden pane's layout box, so
scroll positions and measurements survive the round-trip. */}
{!node.minimized && (
<div className="relative min-h-0 min-w-0 flex-1 overflow-auto">
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{isEmpty ? (
<div className="grid h-full place-items-center">
{/* Same decode primitive as the CONNECTING boot overlay. */}
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
) : active?.render ? (
<ContribBoundary id={active.id}>{active.render()}</ContribBoundary>
) : (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div>
keptPanes.map(paneId => {
const pane = paneFor(paneId)
const isActive = paneId === activeId
return (
<div
aria-hidden={!isActive || undefined}
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
key={paneId}
>
{pane?.render ? (
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
) : (
isActive && (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
{t.zones.missingPane(paneId)}
</div>
)
)}
</div>
)
})
)}
</div>
)}
@@ -343,6 +343,13 @@ export function treePanesWithPrefix(prefix: string): string[] {
return tree ? allPaneIds(tree).filter(id => id.startsWith(prefix)) : []
}
/** The main tab strip's "+": open a new session as its own tab (reusing an
* already-open unused tab when one exists, so repeated clicks don't pile up
* empty sessions). The app wiring registers the concrete action so this
* generic renderer stays session-agnostic; null until wired (the "+" hides).
* An atom so the strip re-renders when the action becomes available. */
export const $newSessionTabAction = atom<(() => void) | null>(null)
/** ⌘1…⌘9: activate the Nth tab of the FOCUSED zone (the interaction tracker's
* group), but only when it's a real tab strip (≥2 panes). Returns false so the
* caller falls back to its default (profile switch) — the number keys mean
@@ -9,6 +9,7 @@
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
interface PetEggHatchProps {
subtitle?: string
@@ -23,22 +24,17 @@ interface PetEggHatchProps {
*/
export function PetProgress({ done, total }: { done?: number; total?: number }) {
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={determinate ? pct : undefined}
className="pet-progress"
role="progressbar"
>
{determinate ? (
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
) : (
<div className="pet-progress__indeterminate" />
)}
</div>
<Progress
animated
aria-label="Hatching progress"
className="bg-[color-mix(in_srgb,var(--ui-accent)_15%,transparent)]"
fillClassName="bg-(--ui-accent)"
indeterminate={!determinate}
size="sm"
value={determinate ? done / total : 0}
/>
)
}
@@ -0,0 +1,66 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Input } from './input'
afterEach(cleanup)
describe('Input', () => {
it('renders a bare input carrying the control chrome when unadorned', () => {
const { getByRole } = render(<Input aria-label="plain" />)
const el = getByRole('textbox')
expect(el.tagName).toBe('INPUT')
expect(el.className).toContain('desktop-input-chrome')
// No group wrapper — the input is the top-level node.
expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group')
})
it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => {
const { getByRole, getByText } = render(<Input aria-label="amount" prefix="$" />)
const el = getByRole('textbox')
const group = el.closest('[data-slot="input-group"]')
expect(group).not.toBeNull()
// Chrome moves to the wrapper; the input itself goes transparent/borderless.
expect(group?.className).toContain('desktop-input-chrome')
expect(el.className).not.toContain('desktop-input-chrome')
expect(el.className).toContain('bg-transparent')
const prefix = getByText('$')
expect(group?.contains(prefix)).toBe(true)
})
it('renders a trailing suffix inside the group', () => {
const { getByText } = render(<Input aria-label="rate" suffix="%" />)
const suffix = getByText('%')
expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull()
})
it('forwards value/onChange through the adorned field', () => {
const onChange = vi.fn()
const { getByRole } = render(<Input aria-label="amount" onChange={onChange} prefix="$" value="" />)
fireEvent.change(getByRole('textbox'), { target: { value: '100' } })
expect(onChange).toHaveBeenCalledTimes(1)
})
it('dims the group and disables the field when disabled', () => {
const { getByRole } = render(<Input aria-label="amount" disabled prefix="$" />)
const el = getByRole('textbox') as HTMLInputElement
const group = el.closest('[data-slot="input-group"]')
expect(el.disabled).toBe(true)
expect(group?.className).toContain('opacity-50')
})
it('lets containerClassName override the wrapper width', () => {
const { getByRole } = render(<Input aria-label="amount" containerClassName="w-20" prefix="$" />)
const group = getByRole('textbox').closest('[data-slot="input-group"]')
// twMerge resolves the control's default w-full down to the caller's width.
expect(group?.className).toContain('w-20')
expect(group?.className).not.toContain('w-full')
})
})
+48 -3
View File
@@ -4,8 +4,22 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
// `prefix`/`suffix` are DOM string attributes on the native element; we shadow
// them with ReactNode adornments, so they're omitted from the base props.
type InputProps = Omit<React.ComponentProps<'input'>, 'size' | 'prefix' | 'suffix'> &
ControlVariantProps & {
/** Leading adornment rendered inside the field (e.g. a `$` for money). */
prefix?: React.ReactNode
/** Trailing adornment rendered inside the field (e.g. a unit label). */
suffix?: React.ReactNode
/** Applied to the wrapper when an adornment promotes the field to a group. */
containerClassName?: string
}
function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) {
const grouped = prefix != null || suffix != null
const field = (
<input
// Off by default for every consumer — these are code/config/search fields,
// not prose. Callers can re-enable per-instance by passing the prop.
@@ -13,7 +27,12 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
autoComplete="off"
autoCorrect="off"
className={cn(
controlVariants({ size }),
// When adorned, the wrapper owns the chrome (border/background/focus
// glow) and the input goes transparent so the whole thing reads as one
// box; otherwise the input carries the chrome itself.
grouped
? 'min-w-0 flex-1 border-0 bg-transparent p-0 text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground'
: controlVariants({ size }),
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
className
)}
@@ -23,6 +42,32 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
{...props}
/>
)
if (!grouped) {
return field
}
return (
<div
className={cn(
// Same control chrome/sizing as a bare input; `.desktop-input-chrome`
// lights on `:focus-within` (styles.css) since the div never focuses.
controlVariants({ size }),
'inline-flex items-center gap-1',
props.disabled && 'cursor-not-allowed opacity-50',
containerClassName
)}
data-slot="input-group"
>
{prefix != null && (
<span className="pointer-events-none shrink-0 select-none text-muted-foreground">{prefix}</span>
)}
{field}
{suffix != null && (
<span className="pointer-events-none shrink-0 select-none text-muted-foreground">{suffix}</span>
)}
</div>
)
}
export { Input }

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