Compare commits

...

106 Commits

Author SHA1 Message Date
teknium1
0880e8ad78 test(batch): cover approval-guard enforcement for #35164
Asserts batch_runner sets the cron-session marker and that a flagged
dangerous command is blocked under the default cron deny policy.
2026-06-26 00:58:08 -07:00
liuhao1024
f3b79200ff fix(batch): set HERMES_CRON_SESSION to enforce approval guards
When batch_runner.py processes dataset prompts, it creates AIAgent
instances without setting any of the interactive environment variables
(HERMES_INTERACTIVE, HERMES_GATEWAY_SESSION, HERMES_EXEC_ASK). This
causes the dangerous command approval system to auto-approve all
flagged commands, allowing prompt injection payloads in untrusted
datasets to achieve arbitrary command execution.

Fix: set HERMES_CRON_SESSION=1 via os.environ.setdefault() before
creating AIAgent instances, so the approval system enforces the
cron-style deny-by-default policy.

Fixes #35164
2026-06-26 00:55:51 -07:00
Ben
19b2624404 feat(gateway): external drain trigger + accept-gating (begin/cancel + control channel)
Tasks 2.1 + 2.2 + 2.3 of the safe-shutdown plan — the reversible
quiesce-without-restart machinery NAS drives during a lifecycle action (D4a).
These ship together because the endpoint, the control channel, and the gateway
state machine are one coherent slice.

2.2 — control channel (gateway/drain_control.py, new):
The dashboard has no HTTP path into a running gateway (guardrails: "there is NO
external control channel into a running gateway"); restart/drain is driven only
by markers the gateway reacts to. So begin/cancel-drain writes/removes a
presence-based marker .drain_request.json (HERMES_HOME-scoped, atomic write,
never-raises read; a corrupt marker reads as present-contentless → fail-safe
toward quiescing). This is Q-B option A.

2.2 — gateway state machine (gateway/run.py):
- _external_drain_active flag, DISTINCT from the shutdown _draining flag: this
  one does NOT exit the process and is fully reversible.
- _enter_external_drain / _exit_external_drain: idempotent transitions that
  flip gateway_state→draining / →running via _update_runtime_status (preserving
  the live active_agents count). exit refuses to revert to running during a
  real shutdown or after the loop stops (shutdown wins).
- _drain_control_watcher: 1s background task (modelled on _handoff_watcher)
  reconciling accept-state with the marker; honours a marker that survived a
  restart on its first tick. Registered alongside the other watchers in start.
- New-turn accept gate in _handle_message, placed BEFORE the session-slot
  claim: when draining, refuse to START a new turn (so active_agents can only
  fall → no TOCTOU race), while in-flight turns finish untouched. Internal/
  system events (restart-recovery replays, bg-process completions) bypass it.

2.1 — endpoint (hermes_cli/web_server.py):
POST /api/gateway/drain {action: drain|cancel}. Authenticated by the Task-2.0a
token seam (the drain plugin registered this exact path as a token route);
attributes the request to the verified token principal. Begin writes the
marker, cancel removes it — the gateway process owns the actual transition.
Force-override (D6) is NOT here; it maps onto the existing immediate
/api/gateway/restart force path.

Tests (mocked — necessary-not-sufficient; the HARD live gate Q-B is next):
- tests/gateway/test_external_drain_control.py — marker contract (write/clear/
  read/corrupt/atomic), state machine (enter/exit/idempotency/shutdown-wins/
  loop-stopped), watcher reconcile-enter-then-exit, new-turn refusal, and
  in-flight-not-interrupted. 15 tests.
- tests/hermes_cli/test_web_server.py — /api/gateway/drain begin/default-begin/
  cancel/cancel-idempotent/bad-action-400. 6 tests.
- dashboard.drain_auth config section already added in 2.0b commit.

All touched suites green: 301 (gateway+auth) + 9 (web_server endpoints) passed.

Intentionally deferred:
- HARD live-validation gate (Q-B): real isolated `hermes gateway run`, drive a
  real begin-drain marker, prove the 5-point checklist a–e.
- Spec-doc status flip + Phase-2 PR.

Build status: external-drain, restart-drain, status, dashboard-auth, drain-plugin,
token-auth, and web_server-endpoint suites green.
2026-06-26 00:47:19 -07:00
Ben
2e322466b1 feat(dashboard-auth): drain shared-bearer-secret provider plugin
Task 2.0b: the concrete shared-bearer-secret auth provider, the FIRST consumer
of the generic token-auth capability (Task 2.0a). Implements decisions.md Q-A.

plugins/dashboard_auth/drain/ (bundled, discovered like dashboard_auth/basic):
- DrainSecretProvider: non-interactive provider, supports_token=True. Verifies
  an inbound Authorization bearer token against a per-agent shared secret with
  hmac.compare_digest (constant-time, no timing oracle) and, on a match,
  vouches for the caller as the "drain-control" principal scoped to "drain".
  The five interactive ABC methods raise NotImplementedError; verify_session
  returns None (stacks harmlessly in the cookie-verify loop).
- assess_secret_strength(): fail-closed entropy gate. Rejects secrets shorter
  than 43 url-safe-b64 chars (~256 bits), with < 16 distinct characters, or
  below 128 bits Shannon entropy — so a weak/structured/repeated secret can
  never be silently accepted. Enforced both at register() (friendly skip
  reason) and in __init__ (raises — defence in depth).
- register(ctx): no-op + skip reason when HERMES_DASHBOARD_DRAIN_SECRET is
  unset; rejects a weak secret fail-closed (drain endpoint stays gated). On a
  strong secret, registers the provider AND opts /api/gateway/drain into the
  generic token-auth seam via register_token_route().

Config: the secret is a CREDENTIAL → carried via HERMES_DASHBOARD_DRAIN_SECRET
(per-agent, provisioned by NAS at deploy). Behavioural knobs only
(dashboard.drain_auth.{scope,min_secret_chars}) live in config.yaml — added to
DEFAULT_CONFIG with the .env-is-for-secrets rationale documented inline.

Tests: tests/plugins/dashboard_auth/test_drain_provider.py — entropy gate
(strong pass; empty/short/repeated/few-distinct/custom-min reject), verify_token
(match → scoped principal, wrong/empty → None, custom scope), protocol
compliance, interactive-methods-raise, and register() (skip-no-secret,
fail-closed-weak-secret, strong-env-secret registers + route opt-in, config
scope + min_secret_chars). 21 new tests; drain + token-auth suites 44 passed.
Verified the plugin is discovered as dashboard_auth/drain alongside basic/nous.

Intentionally deferred:
- The begin/cancel-drain endpoint handler itself — Task 2.1.
- The dashboard→gateway control channel — Task 2.2.

Build status: dashboard-auth + drain-plugin suites green.
2026-06-26 00:47:19 -07:00
Ben
cb9cb6ba1c feat(dashboard-auth): generic non-interactive API-token capability
Task 2.0a of the safe-shutdown drain-coordination plan. Widens the dashboard
auth framework GENERICALLY to support non-interactive (service-to-service)
bearer-token auth, mirroring the existing supports_password precedent. This is
a reusable capability — any future machine-credential provider plugs in without
core changes (decisions.md Q-C). The drain bearer-secret plugin (Task 2.0b) is
the first consumer, not the definition.

- base.py: add TokenPrincipal dataclass (the token analog of Session) +
  supports_token capability flag + verify_token() on the ABC (default raises
  NotImplementedError so a misconfigured provider fails loud). Contract mirrors
  verify_session stacking: return None for unrecognised tokens (never raise),
  raise ProviderError only on a genuine backing-store outage.
- registry.py: list_token_providers() — the supports_token subset, in
  registration order. Empty when none registered (token routes fail closed).
- token_auth.py (new): route-agnostic seam. Routes opt in via
  register_token_route(exact path); token_auth_middleware owns the auth
  decision for those routes only — authenticate via stacked providers, attach
  request.state.token_principal + token_authenticated, pass through. 401 on
  missing/unrecognised token, 503 when a provider was unreachable, untouched
  passthrough for non-token routes. Fails closed (never open).
- web_server.py: install the seam OUTERMOST (registered last → runs first).
  Both downstream gates (legacy auth_middleware + gated_auth_middleware) honour
  request.state.token_authenticated and skip enforcement, so a token-authed
  service request is never bounced to /login.
- audit.py: TOKEN_AUTH_SUCCESS / TOKEN_AUTH_FAILURE events.

Tests: tests/hermes_cli/test_dashboard_token_auth.py — ABC flag default,
verify_token NotImplementedError, registry filter, bearer extraction
(case-insensitive scheme, malformed/non-bearer → ""), provider stacking
(first-match-wins, unreachable-remembered, unreachable-then-valid, buggy
provider doesn't crash the gate), and the seam's passthrough/401/503/
fail-closed behaviour. 29 new tests; full dashboard-auth suite 169 passed.

Intentionally deferred:
- The concrete shared-bearer-secret provider plugin — Task 2.0b.
- The begin/cancel-drain endpoint that registers itself as a token route —
  Task 2.1.

Build status: dashboard-auth + plugin-hook suites green.
2026-06-26 00:47:19 -07:00
Teknium
099df3cd89 fix(security): stop blocking AGENTS.md/SOUL.md that name an agent 'Praxis' (#52925)
The known_c2_framework threat pattern included 'praxis' in its
alternation alongside genuine offensive-security tool brands (Cobalt
Strike, Sliver, Havoc, Mythic, Metasploit, Brainworm). Unlike those
distinctive brand names, 'praxis' is a common English word (Greek for
practice/action) and a legitimate agent name, so any context file that
mentioned an agent named Praxis matched at 'context' scope and the whole
AGENTS.md / SOUL.md was replaced with a [BLOCKED] placeholder before it
reached the system prompt.

Remove 'praxis' from the alternation and add a guard comment: every
token in this list must be a distinctive tool brand, not a common word.
Real C2 brands still fire.
2026-06-26 00:36:01 -07:00
teknium1
4d0dd6bd52 test(mcp): make invalid_client tests interactive under hermetic env
The new _maybe_flag_poisoned_client tests built a provider via
get_or_build_provider without an interactive stdin. Under the hermetic
test env (no TTY, no cached tokens), the non-interactive guard in
mcp_oauth_manager._make_provider raised OAuthNonInteractiveError before
the provider was built, failing 6 tests in CI parity (they passed
locally where stdin was a TTY).

Thread monkeypatch into _provider_with_token_endpoint and present an
interactive stdin, matching the sibling test_manager_builds_hermes_provider_subclass.
2026-06-26 00:35:27 -07:00
Max Hsu
075f93ad78 fix(mcp): auto-recover from invalid_client on stale OAuth client registration
Fixes #36767.

Two complementary recoveries for the recurring "delete three cache files and
re-auth by hand" ritual when an MCP server's dynamically-registered OAuth
client goes dead server-side (IdP redeploy / DB wipe / rebrand):

- Auto-heal (token-endpoint subset): HermesMCPOAuthProvider now sniffs
  auth-flow responses and, on a 400/401 `invalid_client` from the discovered
  token endpoint, backs up + deletes `<server>.client.json` and `.meta.json`
  and clears the in-memory client so the SDK re-runs RFC 7591 dynamic client
  registration on the next flow. Conservative by construction: only
  dynamically-registered (non config-supplied) clients, only the token
  endpoint, only on a word-boundary `invalid_client` match (so RFC 7591's
  `invalid_client_metadata` does not trip it); best-effort so a miss never
  breaks the live flow. Covers both code-exchange and refresh when the token
  endpoint was discovered. Tokens are preserved.

- `hermes mcp reauth [<name>|--all]`: the reporter's primary symptom — the
  IdP's in-browser "Redirect URI Mismatch" — produces no HTTP signal (the SDK
  only sees a callback timeout), so it cannot be auto-detected. The new
  command re-auths one or ALL `auth: oauth` servers, serially: one browser
  flow at a time, which also fixes the startup popup storm when several
  servers are stale at once. Single-server reauth is factored out of
  `mcp login` and shared.

Tests: +14 (poison helper x2; token-endpoint detection x5 incl. wrong-endpoint,
success-response, pre-registered, and invalid_client_metadata negative guards;
a bridge integration test driving the real async_auth_flow generator to prove
the detection hook preserves the bidirectional asend() forwarding contract;
reauth CLI x6). Verified against the pinned mcp==1.26.0: scripts/run_tests.sh
122/122 green for the touched suites; check-windows-footguns.py and ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:35:27 -07:00
Ben Barclay
6e4e5967f7 feat(relay): multi-platform-per-agent — list identity, provision-loop, N-hello, per-frame egress (Phase 1.5) (#52830)
Cut over the agent half of Shape A (D-Q1.5a/b.1/c) to front a SET of platforms on
one relay WS:

- relay_platform_identities() parses GATEWAY_RELAY_PLATFORMS (list) +
  GATEWAY_RELAY_BOT_IDS (JSON keyed map {platform:{botId,username?}}). Cut over
  from the scalar GATEWAY_RELAY_PLATFORM/_BOT_ID (no fallback, D-Q1.5c).
- self_provision_relay() loops one /relay/provision per platform under one
  gatewayId+secret, partial-failure-tolerant.
- WebSocketRelayTransport takes the identity SET, sends one hello per identity
  (connector accumulates the advertised set), and stamps the per-frame
  OutboundFrame.platform + its matching advertised botId on outbound.
- RelayAdapter remembers each chat's underlying source.platform (mirroring the
  existing guild/dm scope capture) and tags the reply's egress platform.
- send_relay_policy() declares one relevance policy per fronted platform (the
  connector keys policy by (tenant,platform,instanceId)).

Single-platform deploys are byte-identical on the wire (1-element list, no per-frame
tag -> connector session-default fallback). typecheck/ruff clean; relay unit 221 pass
(+10 new); all 15 cross-repo E2E drivers green vs connector origin/main.
2026-06-26 17:32:46 +10:00
brooklyn!
a2b49e60b6 Merge pull request #52412 from GodsBoy/fix/verify-on-stop-messaging-surface-leak
fix(agent): gate verify-on-stop nudge off for messaging surfaces
2026-06-26 02:30:08 -05:00
kshitij
7d568293f9 Merge pull request #52891 from kshitijk4poor/salvage/52623-aux-host
fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)
2026-06-26 12:24:19 +05:30
konsisumer
3cf900eb67 fix(install): discard managed lockfile churn before stashing 2026-06-25 23:49:11 -07:00
Ben Barclay
cb7d1f68f8 fix(relay): accept is_reconnect kwarg in RelayAdapter.connect (#52911)
The gateway reconnect watcher (gateway/run.py) recovers a platform after a
fatal adapter error by building a fresh adapter and calling
connect(is_reconnect=True). Every BasePlatformAdapter implements
connect(*, is_reconnect: bool = False) for this — except RelayAdapter, whose
connect() was bare. So the watcher's recovery path raised:

    TypeError: connect() got an unexpected keyword argument 'is_reconnect'

Observed live on a hosted staging agent: after a fatal relay adapter error the
watcher could never re-establish relay, so the shared-bot inbound never reached
the gateway and Discord DMs stopped (dashboard surfaced the TypeError).

Relay deliberately ignores the flag: the #46621 server-side-queue-preservation
concern doesn't apply, because relay's outage buffer is the connector's durable
buffer (replayed on the transport's re-handshake), not a gateway-side queue the
adapter owns. Routine WS drops are already handled by the transport's own
reconnect supervisor (WebSocketRelayTransport, reconnect=True); the watcher path
is fatal-error recovery, and the fatal handler disconnect()s the old adapter
(cancelling its supervisor) before a fresh adapter+transport is built, so there
is no double-dial.

Adds two regression tests (both proven red without the fix): connect(is_reconnect=True)
reaches the same transport-less RuntimeError instead of TypeError, and the
signature matches BasePlatformAdapter.connect.
2026-06-26 16:46:09 +10:00
brooklyn!
0f81b0d458 Merge pull request #52901 from NousResearch/bb/desktop-tui-lint-fixes
style(desktop,tui): fix all lint/type/formatting issues
2026-06-26 01:07:14 -05:00
Brooklyn Nicholson
62fe9fd101 style(desktop,tui): fix all lint/type/formatting issues
Bring apps/desktop and ui-tui to a clean state for typecheck, eslint,
and prettier:

- Run prettier across both trees (printWidth/wrap drift; prettier is not
  CI-enforced for these JS projects, so main had accumulated drift).
- Apply eslint --fix for padding-line-between-statements and perfectionist
  import/export sorting.
- Manual fixes for non-auto-fixable rules:
  - remove unused node:net import in electron/main.cjs (uses Electron net)
  - replace inline `typeof import(...)` annotations with top-level
    `import type * as EnvModule` in two ui-tui test files
  - scoped eslint-disable no-control-regex on intentional sentinel/ANSI
    regexes (mathUnicode.ts, text.ts)
  - resolve react-hooks/exhaustive-deps per-case: correct swapped/missing
    deps, collapse redundant session.* members, and justified disables on
    settings mount-only data-load effects to preserve run-once behavior

No behavior changes; test pass/fail counts are unchanged from the main
baseline.
2026-06-26 01:04:33 -05:00
Moonsong
4e66bf1f80 fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)
When operator config has provider=anthropic with model.base_url pointing
at a non-Anthropic host (e.g. https://openrouter.ai/api/v1 with provider=anthropic),
the auxiliary Anthropic path was unconditionally applying that override.
Main-session traffic routed correctly because the main path attaches the
right credential for the actual destination, but every side-channel call
(memory extractors, reflection, vision, title generation, janus
extractor/promise) sent ANTHROPIC_API_KEY to the foreign host and 401'd.

Gate the override on hostname == api.anthropic.com. Operators routing main
through a non-Anthropic provider must use that provider's own auxiliary
client; the Anthropic aux path now stays pointed at api.anthropic.com.

Regression tests cover openrouter, openai, anthropic-with-path, empty, and
anthropic-default-base_url cases.
2026-06-26 11:21:05 +05:30
kshitij
7aa32ec82f Merge pull request #52888 from kshitijk4poor/chore/author-map-tranquil-flow
chore: add Tranquil-Flow to AUTHOR_MAP for auxiliary base_url salvage
2026-06-26 11:20:54 +05:30
brooklyn!
2b86e9dae4 Merge pull request #52877 from NousResearch/bb/pet-scale-gesture
feat(desktop): Alt+wheel to scale the pet, never cropped
2026-06-26 00:45:07 -05:00
Brooklyn Nicholson
bf60bbb6c5 refactor(desktop): collapse overlay zoom-anchor math 2026-06-26 00:42:19 -05:00
kshitijk4poor
fe255ab28b chore: add Tranquil-Flow to AUTHOR_MAP for auxiliary base_url salvage (#52623) 2026-06-26 11:11:33 +05:30
Brooklyn Nicholson
7d1b72a15d feat(desktop): zoom the pet toward the cursor
Alt+wheel now scales about the pixel under the pointer instead of growing from a
corner, so the pet stays put under the cursor instead of running away. In-window
shifts its top-left; the overlay repositions its OS window (cursor-anchored on
wheel, bottom-center for slider-driven changes).
2026-06-26 00:37:45 -05:00
brooklyn!
6ba551e942 Merge pull request #52871 from NousResearch/bb/fix-tui-interrupt-queued
fix(tui-gateway): make stop interrupt queued turns
2026-06-26 00:37:13 -05:00
Brooklyn Nicholson
dd980aaba1 feat(desktop): Alt+wheel to scale the pet, never cropped
Hold Alt/Option and scroll over the mascot to resize it (same on Mac and
Windows); the modifier keeps a plain scroll passing through to the page. The
gesture drives the same `display.pet.scale` path as the settings slider.

The popped-out overlay grows its OS window to fit the pet at any scale (anchored
bottom-center) so the sprite is never clipped by the window edge, and the
in-window pet re-clamps against its actual size so growing near an edge can't
crop it. Also makes the overlay click-through per-pixel: only solid sprite
pixels (plus bubble / mail button) are interactive, transparent margins pass
clicks through.
2026-06-26 00:33:22 -05:00
Brooklyn Nicholson
594380d44a fix(tui): make stop interrupt queued desktop turns
Ensure TUI/desktop stop targets the actual conversation thread and cancels any queued next prompt, including the lazy agent-start window, so a stopped session cannot keep running or restart itself.
2026-06-26 00:31:06 -05:00
kshitij
a28b939092 Merge pull request #52678 from kshitijk4poor/salvage/52502-fuzzy-boundary
fix(fuzzy-match): preserve boundary space after whitespace-normalized match (#52491)
2026-06-26 10:59:14 +05:30
DavidMetcalfe
27c486e3b1 feat(agent): apply per-reasoning-model stale-timeout floor in stream + non-stream detectors
Wire get_reasoning_stale_timeout_floor() into both stale detectors so known
reasoning models (Nemotron 3 Ultra, OpenAI o1/o3, Opus 4.x thinking, DeepSeek
R1, Qwen QwQ, Grok reasoning) tolerate multi-minute thinking phases instead of
the upstream gateway idle-killing the socket (BrokenPipeError) before first
token. Applied as max(default, floor) — never overrides explicit user config,
never lowers an existing threshold.

The reasoning_timeouts.py allowlist module already landed on main via #52795,
so this salvage carries only the wiring + tests (the duplicate module and the
stale-base MoA reverts from the original PR branch are dropped).

Salvaged from #52238. Fixes #52217.
2026-06-25 22:12:06 -07:00
brooklyn!
f4c656b0a0 Merge pull request #52854 from NousResearch/bb/fix-interrupt-partial-reply
fix(interrupt): keep partial streamed reply when stopped mid-response
2026-06-26 00:04:37 -05:00
teknium1
4d04c652f2 fix(curator): make external-skill write guard actually fire during curation
The salvaged #51875 added a background-review write guard in skill_manage
that refuses mutations to skills.external_dirs skills — but it only fires
when is_background_review() is true. The curator's LLM review fork ran with
the default _memory_write_origin='assistant_tool', so the guard never
triggered during the exact curation pass it exists to protect against
(GH-47688).

- Set _memory_write_origin='background_review' on the curator review fork so
  turn_context binds it onto the write-origin ContextVar and the guard fires.
- Add a regression test asserting the fork runs under the background_review
  origin (the invariant linking the fork to the guard).
- AUTHOR_MAP: map yu-xin-c for the salvaged commit.
2026-06-25 22:03:02 -07:00
yu-xin-c
96bc524a71 fix(curator): protect external skills from background curation 2026-06-25 22:03:02 -07:00
teknium1
eed9bbeb0a chore(release): add rebel0789 to AUTHOR_MAP for salvaged PR #47308 2026-06-25 22:02:22 -07:00
teknium1
6c58878e7d fix(browser): force secret-pattern redaction on browser_type display
Force redact_sensitive_text(force=True) on the browser_type text arg so
recognized credentials (API keys, tokens, JWTs) are masked in tool
progress, previews, callbacks, and return payloads even when the global
security.redact_secrets opt-out is set — a typed credential reaching chat
history is a security boundary, not log hygiene. Normal typed text matches
no pattern and stays fully readable for debuggability.

Tests assert the API-key-shaped secret is masked across every surface and
that normal text passes through unchanged.
2026-06-25 22:02:22 -07:00
rebel
8ff426e53b fix: redact browser typed text surfaces 2026-06-25 22:02:22 -07:00
brooklyn!
5add283ec8 Merge pull request #52833 from NousResearch/bb/wsl-desktop-fixes
fix(desktop): WSL2 clipboard paste, titlebar layout, HMR survival, and GPU acceleration
2026-06-25 23:55:42 -05:00
Brooklyn Nicholson
8233598e64 fix(interrupt): keep partial streamed reply when stopped mid-response
Stopping a turn while the model is streaming (stop/esc to redirect) raised
InterruptedError, set final_response to the throwaway "waiting for model
response" sentinel, and persisted messages WITHOUT the assistant text that
was already streamed to the screen. The next turn then had no record of the
half-finished reply, so the model appeared to "forget" what it just said.

Recover the on-screen text from _current_streamed_assistant_text in the
InterruptedError branch and append it as the assistant turn (and surface it
as final_response). The metadata sentinel is kept only when nothing was
streamed yet, preserving the ACP/client suppression behavior.

Completes the partial-stream recovery from 397eae5d9 (which wired the same
_current_streamed_assistant_text salvage into the connection-failure twin
but missed the user-interrupt path). The lossy handler dates to c98ee9852.
2026-06-25 23:54:20 -05:00
Brooklyn Nicholson
76074b2145 fix(desktop): transparent WCO titlebar chrome on Windows/WSLg
Use a transparent native overlay so renderer chrome shows through the min/max/close
band. Sync window pre-paint bg to the computed chrome mix.
2026-06-25 23:50:59 -05:00
Brooklyn Nicholson
3b1344c18c fix(desktop): WSL titlebar layout and WSL2 GPU acceleration
Live-measure WCO width in the renderer, drop the right rail below the titlebar
band, and re-enable GPU compositing under WSLg when /dev/dxg is present.
2026-06-25 23:50:59 -05:00
Brooklyn Nicholson
da5484b61f fix(desktop): WSL2 clipboard image paste + Linux titlebar overlay
WSLg bridges clipboard text but not images — pull host screenshots via
PowerShell. Disable titleBarOverlay on plain Linux; gate overlay width per
platform in titlebar-overlay-width.cjs.
2026-06-25 23:50:59 -05:00
Teknium
5b5c79a8ef feat(kanban): typed block reasons + unblock-loop breaker (#52848)
* feat(kanban): typed block reasons + unblock-loop breaker

Stops the kanban blocked-task loop: a worker blocks a task, a cron
unblocks it, the worker re-blocks for the same reason, repeat forever.

block_task now takes a typed kind and a persistent block_recurrences
counter on the tasks table:

- kind=dependency routes to todo (parent-gated, auto-resumed), never
  the human 'blocked' bucket a cron would keep unblocking.
- needs_input/capability/transient/untyped land in blocked; each
  same-cause re-block after an unblock increments block_recurrences,
  and at BLOCK_RECURRENCE_LIMIT (default 2) the task routes to triage
  for a human instead of blocked.
- unblock_task no longer resets block_recurrences (the amnesia that
  let the loop run unbounded); complete_task clears it on success.

Wired through the worker kanban_block tool (new kind arg) and the
hermes kanban block --kind CLI flag, both reporting where the task
actually landed. Docs + 11 new tests; 536 existing kanban tests green.

* test(kanban): make second-block notify test use a distinct block cause

test_notifier_second_blocked_delivers blocked the same task twice with
the same (untyped) reason, which now trips the new unblock-loop breaker
and routes the second block to triage instead of blocked — so only one
'blocked' notification fired. The test's actual intent is that TWO
distinct block cycles each notify; give the two cycles different kinds
(needs_input then capability) so they're genuinely separate blocks. The
same-cause loop→triage path is covered by test_kanban_block_kinds.py.
2026-06-25 21:46:58 -07:00
teknium1
43b8ba4181 fix(telegram): preserve Bot API update queue on watcher reconnect
After a prolonged outage the in-process network-error ladder escalates to
fatal and GatewayRunner._platform_reconnect_watcher rebuilds a fresh adapter
that reconnects through the bootstrap path. That path called
start_polling(drop_pending_updates=True), discarding every update Telegram
queued during the outage — all messages sent while the bot was down were
silently lost. The in-process ladder and 409-conflict handler already passed
drop_pending_updates=False; only bootstrap did not distinguish a cold first
boot from a reconnect.

Thread an is_reconnect signal from the watcher through
_connect_adapter_with_timeout into adapter.connect(). The base
BasePlatformAdapter.connect() gains a keyword-only is_reconnect=False so every
adapter inherits a tolerant signature (no per-platform breakage when the
runner forwards the kwarg). Telegram translates is_reconnect into
drop_pending_updates=not is_reconnect on both the polling and webhook bootstrap
calls. Cold boot still drops the stale queue; a watcher reconnect preserves it.

Fixes #46621.

Co-authored-by: annguyenNous <annguyen@nousresearch.com>
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
Co-authored-by: Kewe63 <Kewe63@users.noreply.github.com>
2026-06-25 21:29:57 -07:00
liuhao1024
f44415e71a fix(gateway): add init-time provider fallback to _make_agent
When the primary provider raises AuthError (e.g. expired OAuth token),
_make_agent now walks the configured fallback_providers/fallback_model
chain before giving up — matching the behavior that cron/scheduler.py
and cli_agent_setup_mixin.py already have.

Fixes #47627
2026-06-25 21:21:58 -07:00
Teknium
0b7128582f fix(state): detect and repair FTS write corruption that silently drops gateway history (#52798)
A readable state.db can still reject every message write through the
messages_fts* triggers when the FTS5 index is corrupt: base-table reads and
PRAGMA integrity_check pass, but INSERT INTO messages fails with 'database
disk image is malformed'. The gateway reloads conversation_history from disk
each turn, so a silently-failed write hands the next turn stale/empty history
even though the same cached AIAgent still holds the live transcript — causing
immediate same-session amnesia. (#50502)

- hermes_state.py: _db_opens_cleanly() now drives a rolled-back message write
  through the FTS triggers, so write-only corruption (which the read-only
  probe reported healthy) is detected. repair_state_db_schema() gains an
  in-place FTS5 'rebuild' strategy (tier 0) before the dedup/drop tiers, plus
  an already_healthy short-circuit. Both 'hermes sessions repair' and
  'hermes doctor' route through these, so the fix covers the whole class.
- hermes_cli/doctor.py: the state.db check runs the write-health probe even on
  the success (readable) path and repairs in place with --fix.
- gateway/run.py: _select_cached_agent_history() prefers the cached agent's
  longer live _session_messages over a shorter persisted transcript, so an
  FTS write failure can't wipe in-session context.
- tests: regressions for write-health detection, in-place repair preserving
  rows + resuming writes, the already_healthy shortcut, and the gateway guard.

Combines the approaches from #50504 (@0-CYBERDYNE-SYSTEMS-0, issue author),
#52165 (@davidgut1982), and #50576 (@trevorgordon981).
2026-06-25 21:18:41 -07:00
teknium1
85e084d60d fix(email): reject spoofed From: header for authorization (GHSA-rxqh-5572-8m77)
The email adapter authorized senders entirely off the From: header, which is
attacker-controlled and unauthenticated by IMAP. An attacker could forge
From: an-allowlisted-address and pass both the adapter's EMAIL_ALLOWED_USERS
pre-filter and the gateway's allowlist authz (both key on the same spoofable
sender_addr), getting unauthorized commands executed by the agent.

Verify the From: domain against the trusted Authentication-Results header the
receiving mail server stamps (SPF/DKIM/DMARC) before trusting it for
authorization. Enforced only when an allowlist is in effect and allow-all is
off — fail-closed. Operators whose server does not stamp the header can opt
out via platforms.email.require_authenticated_sender: false (or
EMAIL_TRUST_FROM_HEADER=true).
2026-06-25 21:11:02 -07:00
Ben Barclay
dedf5643d8 fix(gateway): scale-to-zero never armed — arm-gate counted disabled placeholder platforms (#52831)
The scale-to-zero idle watcher never started on a correctly-opted-in,
relay-only instance, so the gateway never ran its idle decision, never called
go_dormant(), and never sent going_idle to the connector. Fly's autostop still
suspended the machine on traffic-idle, but the connector never flipped the
instance to buffered-only — so an inbound DM took the live delivery path,
found no live session for the suspended machine, and was dropped fail-closed
with no wake poke. The machine slept and never woke.

Root cause: _scale_to_zero_should_arm() passed list(config.platforms.keys())
to messaging_is_relay_only_or_absent(). config.platforms is pre-seeded with a
DISABLED placeholder PlatformConfig for every known platform (telegram,
discord, slack, matrix, …), so the key set is always the full ~20-entry
catalog regardless of what the instance actually runs. The relay-only check
discarded "relay", saw the disabled placeholders as live direct-socket
platforms, and returned False — so should_arm() was False and the watcher was
never created. Verified live on a staging instance: config.platforms keys =
[telegram, discord, slack, mattermost, matrix, relay] with only relay
enabled=True; should_arm() = False.

Fix: filter config.platforms to ENABLED entries before the relay-only check,
mirroring the adapter-connect loop which already gates on
`if not platform_config.enabled: continue`. This arms off the same notion of
"active platform" the rest of start() already uses — no parallel concept.

Also add a one-line not-armed diagnostic: when an instance IS opted in (the
HERMES_SCALE_TO_ZERO stamp is set) but the watcher still doesn't arm, log why
(relay_only_or_absent, the enabled platforms, wake_url present/missing). A
non-opted instance stays silent. The arm path previously logged only on
success, so a failed arm was invisible.

Tests: the existing pure-helper tests passed bare names so they never
exercised the call site that feeds the placeholder-laden config. Add
behaviour-contract tests against the REAL _scale_to_zero_should_arm with a
realistic config.platforms (relay enabled + others disabled). The F25
regression test (relay-only + disabled placeholders must arm) and the
no-platform case are RED without this fix, GREEN with it; the
genuinely-enabled-direct-platform / not-opted-in / no-wake-url cases stay
correctly non-arming so the filter can't over-broaden.

Wake mechanism itself verified healthy independently (direct wakeUrl GET
resumed a suspended staging instance in 1.15s, clean resume signature).
2026-06-26 14:01:48 +10:00
Teknium
a4091e49f1 fix(auth): write rotated Codex/xAI pool grant through to global root (#48415) (#52760)
CredentialPool._sync_device_code_entry_to_auth_store rotated single-use
OAuth refresh tokens but wrote the new chain only into the active profile
store. When a profile resolves a grant from the global-root fallback
(read_credential_pool, #18594) and the pool then refreshes it, root was
left holding a now-revoked refresh token — every other profile reading the
stale root grant subsequently died with refresh_token_reused / invalid_grant
once its access token expired.

This is the credential-pool analog of #43589 (which fixed the non-pool xAI
refresh path in _save_xai_oauth_tokens). Detect the read-from-root case
(profile lacks its own providers.<id> block) BEFORE the profile save and,
after it, write the rotated chain back to the global root via a best-effort,
seat-belted write-through. A profile that genuinely shadows root (owns the
block) is untouched; classic mode (profile == root) is a no-op; a failed root
write never breaks the profile's own save. Covers openai-codex (reported),
xai-oauth, and nous through the shared sync path.
2026-06-25 19:14:06 -07:00
Harjoth Khara
233ef98afe fix(docker): skip symlinked stage2 chown targets (#52789)
Prevents stage2-hook.sh recursive chown from following a symlinked $HERMES_HOME/home (or profiles/cron) and destroying the host user's home directory. Also guards top-level state-file chowns and refuses first-boot seeding through symlinks. Fixes #52781.

Co-authored-by: harjoth <harjoth.khara@gmail.com>
2026-06-26 12:09:52 +10:00
teknium1
1abfa66ba6 chore(release): add DavidMetcalfe to AUTHOR_MAP for PR #52272 salvage 2026-06-25 19:00:48 -07:00
DavidMetcalfe
865a09a610 fix(agent): detect thinking-timeout for reasoning models and surface actionable guidance instead of misleading file-write advice
Two-part fix:

Part 1 (classifier override at agent/error_classifier.py:720-738):
A transport disconnect on a reasoning model — even on a large session —
now routes to FailoverReason.timeout instead of context_overflow. Without
this, large-session reasoning-model disconnects route to the compression
branch and silently delete conversation history on a phantom
context-length error. The override is strictly targeted: non-reasoning
models (gpt-4o, claude-3-5-sonnet, llama-3.3-70b, etc.) still route to
context_overflow on large sessions — the existing intentional behavior
for chat models whose proxy doesn't idle-kill during prefill/generation.

Part 2 (new agent/thinking_timeout_guidance.py + integration at
agent/conversation_loop.py:3488-3567):
New is_thinking_timeout() and build_thinking_timeout_guidance() helpers.
When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3,
Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)
hits a transport-kill on a small session (classifier says timeout
directly) or after Part 1 routes correctly (large session), the user
now sees reasoning-specific guidance with three actionable workarounds
in priority order:

  1. Set providers.<provider>.models.<model>.stale_timeout_seconds: 900
     in ~/.hermes/config.yaml (Hermes's built-in floor is already 600s
     for known reasoning models; raise further if upstream is even
     tighter).
  2. Lower reasoning_budget or set reasoning_effort: medium on this
     model if the provider supports it.
  3. Use a smaller / faster reasoning model if the task doesn't
     require deep thinking.

The new guidance takes precedence via if/elif over the existing
_is_stream_drop block, so a reasoning-model user with a transport-kill
message sees actionable advice instead of the misleading "try
execute_code with Python's open() for large files" advice (which is
correct for the unrelated large-file-write stream-drop case but
actively wrong for the thinking-timeout case).

Verified:
- 478 tests passing across 9 directly-relevant files (49 new + 429
  existing, zero regressions).
- Ruff lint clean on all 4 modified/new files.
- Negative test: 6 parametrized regression guards confirm non-reasoning
  models still route to context_overflow on large sessions; 4
  parametrized gates confirm non-timeout classifier reasons never
  trigger the guidance; 5 parametrized cases confirm non-transport
  messages never trigger it.
- Regression guard: new guidance message does NOT contain
  "execute_code" or "open()" — the misleading advice is fully
  replaced, not appended alongside.
- Cross-vendor dual review via agy -p:
  - Gemini 3.5 Flash (Medium) — passed: true, zero blockers, one
    SHOULD-FIX (vprint block duplication — fixed by extracting
    detection into a helper module).
  - GPT-OSS 120B (Medium) — passed: true, zero blockers, two nits
    (test placement — adopted at tests/agent/test_thinking_timeout_guidance.py;
    primary-model capture — accepted as non-issue per Flash's nit).

Dependency note for maintainers:
This PR includes agent/reasoning_timeouts.py (the reasoning-model
allowlist module from PR #52238) because the Layer 1 override is
load-bearing on get_reasoning_stale_timeout_floor(). After PR #52238
lands on main, this PR's duplicate agent/reasoning_timeouts.py should
be rebased away. Either PR can land first; the other rebase is
mechanical.

Fixes #52271.
2026-06-25 19:00:48 -07:00
Teknium
811df74a10 fix(gateway): defer cross-process cache cleanup off the cache lock (#52197) (#52761)
The #45966 cross-process coherence guard popped the stale cached agent
and then called the blocking _cleanup_agent_resources (memory-provider
shutdown, tool-resource teardown, async-client teardown) while still
holding _agent_cache_lock, on the gateway event-loop thread. While that
ran, _sweep_idle_cached_agents (driven by _session_expiry_watcher)
blocked acquiring the same lock and the asyncio loop stalled for minutes,
tripping repeated Discord 'heartbeat blocked' warnings.

Fix mirrors the cap-enforcer / idle-sweep paths: pop the stale entry
under the lock, release it, then schedule the SOFT release on a daemon
thread. The soft path (_release_evicted_agent_soft) is also more correct
here than the hard teardown the regression used — the same session
rebuilds a fresh agent immediately after invalidation, so its terminal
sandbox / browser / bg processes (keyed on task_id) must be preserved
for the rebuilt agent to inherit, not torn down.

Verified the cross-process site was the only cleanup-under-lock instance;
the other _cleanup_agent_resources call sites run outside the lock.
2026-06-25 18:58:47 -07:00
teknium1
e29823f1e8 chore(release): map agt-user noreply email for #48496 salvage 2026-06-25 18:50:11 -07:00
Teknium
ce802e932c fix(telegram): heartbeat loop exits cleanly when bot has no get_me
CI shard test_telegram_conflict.py timed out (140s) because the new
_polling_heartbeat_loop, started by connect(), busy-spun under those
tests: they monkeypatch asyncio.sleep to instant and pass a bot double
with no get_me(), so the probe raised AttributeError (swallowed) and the
loop re-entered immediately with no real pacing, starving the event loop.

Guard the loop to return when bot.get_me is not callable — a real PTB Bot
always exposes it, so this only triggers on a torn-down app or a test
double, where there is nothing to probe. Also cancel the heartbeat task in
the conflict tests that call connect() without disconnect(), matching the
production disconnect() teardown.

Verified: test_telegram_conflict.py now runs in ~4.5s; the 22
heartbeat/reconnect tests still pass; E2E confirms a hanging get_me still
fires the reconnect ladder while a missing get_me exits without spinning.
2026-06-25 18:50:11 -07:00
agt-user
8501caf51f fix(telegram): persistent heartbeat loop to detect CLOSE-WAIT polling sockets
When a Telegram long-poll TCP socket enters CLOSE-WAIT (remote sent FIN
but httpx hasn't noticed), epoll still reports it readable so no
exception is raised. PTB's error_callback never fires, the reconnect
ladder never engages, and the gateway silently stops receiving messages
while the process stays alive — until a manual systemctl restart.

The existing recovery only covers two cases: error_callback-driven
reconnects (which require an exception PTB never gets) and a one-shot
_verify_polling_after_reconnect probe (which runs only right after an
explicit reconnect). A socket that wedges during steady-state operation
is never detected.

Add _polling_heartbeat_loop: a background asyncio.Task started in
connect() (polling mode only) that probes get_me() every 90s on the
general request pool (not the getUpdates pool, so healthy long-polls are
never interrupted). On asyncio.TimeoutError/OSError it hands off to the
existing _handle_polling_network_error ladder; other errors are
swallowed. disconnect() cancels and awaits the task. Worst-case
detection window ~105s.

Complementary to #51541 (general-pool keepalive limits / fd leak) — that
recycles idle pooled connections; this detects a wedged active read.

Fixes #48495

Co-authored-by: agt-user <267614622+agt-user@users.noreply.github.com>
2026-06-25 18:50:11 -07:00
liuhao1024
56cf517ccd fix(cron): detect partial job loss in restore_cron_jobs_if_emptied (#52144)
The desktop scheduler can overwrite cron/jobs.json with its own small
set of internally-tracked crons after an update/restart, causing
partial loss of tool-created cron jobs. The previous guard only
checked for total loss (live_count == 0), missing the case where
live_count > 0 but less than the pre-update snapshot count.

Compare live_count against snap_count instead of checking for zero,
so both total loss (0 vs N) and partial loss (1 vs 19) trigger
restoration.

Salvaged from #52161 by @liuhao1024.

Closes #52144
2026-06-25 18:49:18 -07:00
brooklyn!
6b639bc2b9 Merge pull request #52772 from NousResearch/bb/editor
feat(desktop): in-app spot editor for the file preview pane
2026-06-25 20:25:06 -05:00
brooklyn!
41f4dce828 Merge pull request #52756 from NousResearch/bb/delegate-bg-resume-ux
feat(delegation): calm "will resume" affordance for background delegate_task
2026-06-25 20:08:06 -05:00
Brooklyn Nicholson
985350dd85 feat(cli): note background delegate_task dispatch in _on_tool_complete
A top-level delegate_task dispatches in the background and re-enters as a
fresh turn when done. Print a one-line dispatch-time note — no spinner,
nothing to poll — so the idle prompt doesn't read as "nothing happened."
2026-06-25 19:57:58 -05:00
Brooklyn Nicholson
7f02f30b76 feat(tui): add width-budgeted "resumes when subagent finishes" status segment
When idle with a background subagent still in flight, append a tail status
segment spelling out that the agent resumes on its own. Width-budgeted like
every tail segment, so it drops first on a tight terminal where the ⛓ count
already carries the signal.
2026-06-25 19:57:58 -05:00
Brooklyn Nicholson
563d347e4d feat(desktop): show a calm "will resume" notice for background delegate_task
When idle with a top-level delegate_task still in flight, render a static,
shimmering system-note at the transcript tail instead of a spinner (which
reads as "stuck"). Reuses the shared steer / slash-status chrome (centered,
0.6875rem, muted, Codicon) so it sits in the thread like every other meta
line, and mirrors the primary child's latest stream line, falling back to
generic copy. i18n across en/ja/zh/zh-hant; markdown prose/heading rhythm
tuned so a re-entered turn breathes.
2026-06-25 19:57:51 -05:00
Brooklyn Nicholson
6e096a850a feat(desktop): add $backgroundResume store for parked delegate_task
Track top-level delegate_task work that dispatches in the background and
re-enters as a fresh turn. $backgroundResume returns {count, activity} for
the active session while idle — count of parked tasks plus the primary
child's latest stream line (tool/progress/thinking) when readable.
2026-06-25 19:57:45 -05:00
Brooklyn Nicholson
09623b4527 fix(desktop): make the tab modified dot amber with a separating ring
Use the app's amber warn color for the unsaved-edits tab dot (was inheriting
the label text color) and add a tab-bg ring + soft drop shadow so it stays
legible where it overlaps the filename.
2026-06-25 19:55:31 -05:00
Brooklyn Nicholson
c456029b4e Merge remote-tracking branch 'origin/main' into bb/editor 2026-06-25 19:53:31 -05:00
Brooklyn Nicholson
1f950e189c feat(desktop): vertical resize for the bottom-row terminal pane
Extends the pane store with heightOverride (alongside widthOverride) and a
get/set/clear API, and wires the pane shell + desktop controller so the
bottom-row terminal pane can be resized on the Y axis with its size persisted.
2026-06-25 19:50:29 -05:00
Brooklyn Nicholson
ff81365988 feat(desktop): in-app spot editor for the file preview pane
Adds a CodeMirror 6 spot editor to the right-rail file preview so users can
make quick edits in-app without leaving for an IDE. Entering edit mode is a
pure in-place swap of the read view — same fixed-height header, same gutter
geometry/typography (mirrors SourceView 1:1) so nothing shifts — toggled via
the Edit button, a bare `e` when the pane is hovered/focused, or the tab.

- Save path is transport-agnostic (writeDesktopFileText): local Electron IPC
  or a new hardened POST /api/fs/write-text on the dashboard server (path
  validation, parent-must-exist, regular-files-only, size cap, atomic
  temp-file + os.replace), behind the existing auth middleware.
- Stale-on-disk guard re-reads before writing and offers overwrite vs
  discard-and-reload instead of clobbering external/agent edits.
- VS Code-style modified dot on the tab; ⌘/Ctrl+S and ⌘/Ctrl+Enter save,
  Esc cancels; GitHub highlight style matched to the read view's Shiki theme.
- Typing stays render-free (draft in a ref; dirty flips once at the boundary).
2026-06-25 19:50:25 -05:00
Que0x
b8fc8c908b fix(approval): fold Windows absolute home paths in dangerous-command detection
The detector folds absolute home / Hermes-home prefixes into their canonical
~/ and ~/.hermes/ forms so static patterns catch /home/alice/.bashrc the same
way they catch ~/.bashrc (abd69b81). On native Windows this fold never fired,
so terminal commands writing to shell startup files, ~/.ssh/authorized_keys,
or ~/.hermes/config.yaml / .env returned "safe" and skipped the approval
prompt — and config.yaml carries the approval policy itself.

Two compounding causes:

1. The fold ran after the backslash-escape strip (r\m -> rm), which dissolves
   the backslash separators in a Windows path (C:\Users\alice\.bashrc ->
   C:Usersalice...) before the fold could match. It now runs before the strip.
2. The fold only recognized POSIX absolute paths and only the home prefix,
   leaving multi-segment backslash suffixes (\.ssh\authorized_keys) to be
   mangled by the strip.

Consolidated into _home_prefix_fold_regex / _fold_home_prefixes: match a home
prefix with either separator, capture the rest of the path token, and
normalize its separators to / so multi-segment patterns match. The
degenerate-path guard generalizes count("/") >= 2 to "at least two components
below the root" (also rejecting a bare drive root C:\). HOME is consulted
directly because Windows' expanduser ignores it; the more specific Hermes home
is folded first, longest candidate first, so neither fold clobbers the other.

POSIX behavior unchanged; the r\m -> rm anti-obfuscation strip still runs.
Adds TestWindowsAbsolutePathFolding, which monkeypatches a Windows-style
HOME/HERMES_HOME so the behavior is also exercised on the CI runner.
2026-06-25 17:49:39 -07:00
brooklyn!
7cd5eaa646 Merge pull request #52745 from NousResearch/desktop/bundle-main
desktop: bundle main.cjs for electron
2026-06-25 19:06:59 -05:00
ethernet
df514654ba desktop: bundle main.cjs for electron
fixes simple-git not found
2026-06-25 20:05:20 -04:00
brooklyn!
55af6c447a Merge pull request #52206 from NousResearch/bb/desktop-tools-curation
fix(desktop): hide platform/internal toolsets from the Skills & Tools list
2026-06-25 18:56:04 -05:00
teknium1
6dfb8326f5 fix(state): exclude delegate/branch/tool children from resume walk + reconcile salvaged fixes
Follow-up to the salvage of #45035 + #48682. The two PRs touched different
functions (resolve_resume_session_id vs get_compression_tip) but #45035's
descendant walk followed ANY parent_session_id child, so a delegate/subagent
child could hijack the resume target. Apply the same _branched_from /
_delegate_from / source!='tool' exclusion the rest of hermes_state.py uses,
so the resume walk only follows genuine compression continuations.

Also updates the unrealistic delegation test fixture to carry the real
_delegate_from marker, and updates 3 list_sessions_rich test mocks for the
order_by_last_active kwarg #48682 added.

AUTHOR_MAP: map PINKIIILQWQ + ailang323 salvage authors.
2026-06-25 16:29:09 -07:00
longer
6d9ca04574 fix(desktop): resume latest compression continuation 2026-06-25 16:29:09 -07:00
Pink
263f6b03eb chore: rename test to reflect new semantics of resolve_resume_session_id 2026-06-25 16:29:09 -07:00
PINKIIILQWQ
abd6b85200 fix(state): resolve compression chain tip in resolve_resume_session_id
After context compression, the parent session holds pre-compression messages
and a child (or deeper descendant) holds the continuation.
resolve_resume_session_id() short-circuited when the input session already
had messages (row is not None -> return session_id), causing REST API
endpoints, gateway resume, and CLI resume to serve stale parent messages.

Remove the early-return. Walk the full descendant chain, record the
deepest node that has messages (best), and return best if not None
else the original session_id (preserving the empty-chain fallback).

Callers (api_server.py, web_server.py, cli_agent_setup_mixin.py,
cli_commands_mixin.py) all use the resolved != input -> redirect pattern
and are transparent to this change.
2026-06-25 16:29:09 -07:00
Teknium
208f0d7c3b fix(update): default pre-update backup to off (#52729)
The pre-update HERMES_HOME zip shipped on by default (DEFAULT_CONFIG +
runtime fallback both True), so every `hermes update` zipped the entire
~/.hermes — sessions DB, caches, skills — adding minutes to each update.
The shipped cli-config.yaml.example, the --backup help, and the example
config all already said "off by default," so the live default
contradicted its own documentation.

Flip the default to off everywhere: DEFAULT_CONFIG, the runtime
`.get(..., False)` fallback in _run_pre_update_backup, and the stale
--backup help string. Users who want the #48200 safety net opt in via
updates.pre_update_backup: true or --backup for a single run.

Updated test_default_enabled_creates_backup -> test_default_disabled_is_silent
to assert the new default (silent no-op, no zip).
2026-06-25 16:01:09 -07:00
kshitij
e4ff494860 fix(cron): add default retention to per-run job output (#52383) (#52646)
* fix(cron): add default retention to per-run job output to bound disk usage (#52383)

Per-run cron output (cron/output/<job>/<timestamp>.md) is written once
per execution and was never pruned, so a frequently-scheduled job on
a long-running deploy accumulates one file per run indefinitely and
can fill the volume ('no space left on device').

save_job_output() now keeps the most recent N output files per job and
removes older ones. N defaults to 50 and is configurable via
cron.output_retention; a non-positive value disables pruning for
operators who manage cleanup externally.

Salvaged from #52402 by @0xDevNinja.

Closes #52383

* fix(config): add cron.output_retention to DEFAULT_CONFIG

Follow-up to #52383: the retention config key was functional via
get()-with-default but missing from DEFAULT_CONFIG, so the deep-merge
wouldn't auto-populate it for new installs. Add it explicitly.

---------

Co-authored-by: 0xDevNinja <manmit0x@gmail.com>
2026-06-25 16:00:13 -07:00
brooklyn!
ffa3d3c811 Merge pull request #49037 from NousResearch/bb/projects-paradigm
feat(desktop): first-class projects — sidebar, coding rail, review pane, and agent project tools
2026-06-25 17:49:05 -05:00
Teknium
fd2a35b169 fix: stop reporting cache-hit rate and cost across all UI surfaces (#52717)
* fix: stop reporting cache-hit rate and cost across all UI surfaces

Cost estimates and cache read/write token reporting are unreliable on
providers that don't surface cached_tokens (e.g. ollama-cloud, which doesn't
implement prompt_tokens_details.cached_tokens), producing misleading
near-zero 'cache hit' readouts and cost figures. Remove cost + cache-hit
reporting from every user-facing surface; keep input/output/total token
counts (provider-agnostic and accurate) and the Nous account billing UI
(real account money, separate from per-conversation estimates).

Surfaces:
- CLI /usage + model-info: drop cost lines + cache read/write token lines
- Gateway /usage + /model: drop cost + cache lines
- tui_gateway/server.py: stop emitting cost_usd / cache_read in usage and
  subagent.complete payloads
- TUI (Ink): drop cost from status bar (+ showCost plumbing), /usage panel,
  thinking rollup, agents overlay (incl. compare view); keep token counts
- Desktop Command Center: drop cost stat, per-model cost, actual-cost hint

Underlying estimate_usage_cost / format_cost / insights cost columns are
left intact but no longer surfaced (display-only change, reversible).

* test: update TUI + gateway + CLI tests for removed cost/cache-hit reporting

- CLI /usage test asserts cost/cache lines are absent, tokens present
- gateway /usage test drops cost + cache asserts; removes cost-included test
- TUI subagentTree summary expectation drops the cost segment
- useConfigSync + appChrome status-rule tests drop showCost prop/state
2026-06-25 15:21:22 -07:00
Brooklyn Nicholson
19ca295a84 fix(desktop): clarify branch convert actions
Open checked-out branches, switch the primary checkout for the default branch, and create linked worktrees only for non-trunk free branches.
2026-06-25 17:19:36 -05:00
teknium1
3e99ec0ff9 test(hermes_state): cover update_session_billing_route overwrite + prompt null
Regression for the salvaged #48254 fix: billing route is first-writer-wins
via update_token_counts (COALESCE), so a mid-session provider switch left
the dashboard attributing cost to the original provider. Asserts the new
update_session_billing_route() overwrites unconditionally, nulls system_prompt
so the next turn rebuilds Model:/Provider:, and preserves billing_mode when
omitted (COALESCE on None).
2026-06-25 14:44:00 -07:00
x7peeps
c7e934a5b4 fix(hermes_state): persist billing provider/base_url after mid-session /model switch
The session database records billing_provider and billing_base_url using
COALESCE(column, ?) in update_token_counts(), making them write-once.
When a user switches models mid-session via /model, the runtime (agent.provider,
agent.base_url) updates correctly, but the session row never reflects the new
provider. This causes the dashboard Models page to display a stale provider
badge and misattributes token usage / cost analytics.

Fix: add update_session_billing_route() that unconditionally sets
billing_provider, billing_base_url, and billing_mode (no COALESCE), and call
it from switch_model() in agent_runtime_helpers.py after the swap succeeds.

This follows the same pattern as update_session_model() which already
unconditionally updates the model column (added for the identical COALESCE
problem on the model field).

Closes #48248
2026-06-25 14:44:00 -07:00
Gille
bf0513bca0 test(windows): align gateway restart CI coverage 2026-06-25 14:42:38 -07:00
Gille
e7d2f0b93c fix(windows): suppress console flashes and harden gateway restarts 2026-06-25 14:42:38 -07:00
Brooklyn Nicholson
9f3aa1685c fix(cli): register project command beside MoA 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
890e890281 chore(desktop): update package lock 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
a391523bcc i18n(desktop): add project and worktree strings 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
b8d220f268 feat(desktop): wire project settings and shell chrome 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
62af32efe7 feat(desktop): keep active sessions aligned with cwd 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
68680db10d feat(desktop): add Codex-style review pane 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
7a7f9a5b3d feat(desktop): add composer coding rail and worktree flow 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
488ae376db feat(desktop): render backend-authoritative projects sidebar 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
74352a1e61 feat(desktop): add project and coding stores 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
344415892f feat(desktop): add shared project UI primitives 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
e2b8018729 feat(desktop): add git worktree and review IPC 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
86e748df13 fix(agent): require code for coding posture 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
cb3f8ec03d fix(tools): isolate per-session worktree cwd 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
4ffdedd369 feat(tools): add project workspace tools 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
4e023f5bc9 feat(gateway): build authoritative project tree 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson
e7811345c1 feat(kanban): link tasks to project worktrees 2026-06-25 16:40:26 -05:00
Brooklyn Nicholson
8a45ce2dd4 feat(projects): add per-profile project store 2026-06-25 16:40:26 -05:00
Brooklyn Nicholson
4cdd1a3230 feat(sessions): record git workspace metadata 2026-06-25 16:40:26 -05:00
brooklyn!
c4ba4770eb Merge pull request #52704 from NousResearch/bb/desktop-root-boundary-recover
fix(desktop): recover root error boundary from transient render races (salvage #41787)
2026-06-25 16:17:18 -05:00
brooklyn!
43f9d24513 Merge pull request #52703 from NousResearch/bb/desktop-resume-cross-wired-cache
fix(desktop): reject cross-wired runtime-id cache on session resume (salvage #50464)
2026-06-25 16:17:14 -05:00
Brooklyn Nicholson
2e3efce66e fix(desktop): recover the root error boundary from transient render races
A stale-index render race in assistant-ui (a just-shrunk thread rendered
at an old message index during a session switch / teardown) throws
errors like "tapClientLookup: Index N out of bounds", "Cannot read
properties of undefined (reading 'type')", or "Tried to unmount a fiber
that is already unmounted". These bubble to the root ErrorBoundary and
latch the WHOLE desktop app on the "Reload window" fallback even though
the next render against fresh state would be fine.

Teach the root boundary to treat that small set of known-transient
renderer errors as recoverable: log them and schedule a next-tick
reset() so React re-renders against current state instead of stranding
the user on the fallback.

Auto-recovery is BOUNDED -- at most MAX_RECOVERIES (3) attempts within a
5s window -- so a genuinely persistent error can't spin the boundary in
a reset -> throw -> reset loop; after the budget is spent the fallback
is left up for the user. Manual retry (the button) resets the budget.
Only the root boundary auto-recovers; scoped boundaries keep their own
fallbacks, and unrecognized errors are never swallowed.

Tests: transient race recovers (fallback never sticks), a persistent
recoverable error stops at the cap and surfaces the fallback (proving
the loop is bounded), and neither a non-root boundary nor an
unrecognized root error auto-recovers.

Closes #41693. Supersedes #41787 by @izumi0uu, reimplemented with a
bounded recovery budget so a non-transient error can't loop forever.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>
2026-06-25 16:15:20 -05:00
Brooklyn Nicholson
f7bf740640 fix(desktop): reject cross-wired runtime-id cache on session resume
resumeSession's warm-cache fast-path trusted the
storedSessionId -> runtimeId -> ClientSessionState mapping without
checking the cached state still BELONGS to the session being resumed.
A pooled profile backend that gets idle-reaped and respawned
(pruneSecondaryGateways) re-mints runtime ids, so a recycled id can
resolve to a live-but-DIFFERENT session's cache entry. The only
existing guard was a session.usage 404 -- that catches a fully-dead
runtime id, but a recycled id still 200s, so the fast-path happily
painted the wrong transcript under the current route (open chat A,
chat B loads).

Fold the belongs-to check into a single takeWarmCache() helper used at
BOTH cache reads -- the early transcript-keep decision and the fast-path
itself -- so a cross-wired entry can't even briefly flash a stale
transcript before the full resume repaints. On a mismatch the helper
purges both stale map entries and reports a miss, falling through to a
full resume that rebinds a correct runtime id. The full-resume path
already guards its final paint with isCurrentResume(), so only the
cached fast-path was missing the belongs-to check.

Pre-existing bug from the initial desktop app (#20059); not introduced
by the session-switch perf work (#49807), which left these lines
untouched.

Tests: two cases in use-session-actions.test.tsx driven through a
harness that owns the two cache maps -- a cross-wired mapping is
rejected + purged (the bug), and a correctly-wired cache still serves
from memory with no needless refetch (no perf regression).

Supersedes #50464 by @professorpalmer, reimplemented to also guard the
early transcript-keep read (whole-class fix, not just the fast-path).

Co-authored-by: professorpalmer <professorpalmer@users.noreply.github.com>
2026-06-25 16:11:18 -05:00
Teknium
c6575df927 feat(moa): expose MoA presets as selectable virtual models (#46081)
* feat(moa): expose MoA presets as selectable virtual models

Reconstructed onto current main (PR #46081's base had diverged with no common
ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual
provider: each named preset is a selectable model under provider 'moa', and the
preset's aggregator is the acting model that answers and calls tools.

Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same
batch pattern delegate_task uses) — all references dispatched at once, collected
when every one finishes, then handed to the aggregator. Output order is
preserved, failures and the MoA-recursion guard stay isolated per reference.

- Removed the old mixture_of_agents model tool and moa toolset.
- Added moa as a virtual provider in the provider/model inventory.
- /moa is shortcut behavior over model selection (default preset / named preset
  / one-shot prompt).
- Dashboard + Desktop manage named presets; presets appear in model pickers.
- Parallel reference fan-out in agent/moa_loop.py with regression test.

* fix(moa): thread moa_config through _run_agent to _run_agent_inner

The reconstructed gateway MoA wiring declared moa_config on _run_agent (the
profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper
never forwarded it — _run_agent_inner had no such parameter, so the runtime hit
NameError: name 'moa_config' is not defined on the compression-failure session
sync path. Add moa_config to _run_agent_inner's signature and forward it from
both wrapper call sites (multiplex and non-multiplex). Caught by
tests/gateway/test_compression_failure_session_sync.py on CI shard test(4).

* fix(moa): classify moa as a virtual provider in the catalog

The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so
provider_catalog() fell through to the default auth_type="api_key" with no
env vars — tripping two catalog invariants:
  - test_provider_catalog: api_key providers must expose a credential env var
  - test_provider_parity: every hermes-model provider must be desktop-configurable

moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that
overlay as an auth_type fallback so the catalog reports moa as virtual (no real
credential, no network endpoint). Exempt virtual providers from the desktop
parity union check the same way 'custom' is exempt — derived from the catalog,
not a hardcoded slug, so future virtual providers are covered too.
2026-06-25 13:52:06 -07:00
teknium1
f284d85efa fix(cron): restore [SILENT] silence + suppress empty-turn explainer on Telegram
Scheduled jobs delivering to Telegram/etc. started posting a literal
'⚠️ No reply: the model returned empty content…' message instead of
staying silent. Two interacting causes:

1. The turn-completion explainer (#34452) replaces an empty model turn
   with a user-facing '⚠️ No reply…' string. In a cron context that is
   not a silence marker, so the scheduler delivered it — a regression
   from the previously-silent empty turn. run_job now detects the
   explainer text deterministically (via the same formatter that
   produced it) for abnormal-empty turn_exit_reasons and strips it to
   empty, so the existing empty-response suppression + soft-fail guard
   apply. The explainer is unchanged on CLI/gateway.

2. The cron suppression used a loose 'SILENT_MARKER in ...upper()'
   substring check. It leaked bracketless near-markers the model emits
   ('SILENT', 'NO_REPLY', 'NO REPLY' — #51438, #46917) and wrongly
   swallowed a real report that merely quoted '[SILENT]' mid-sentence.
   Replaced with _is_cron_silence_response(): suppresses a canonical
   token as the whole response, its own first/last line, or the
   documented bracketed '[SILENT] <note>' prefix — while a token buried
   mid-sentence in a genuine report is delivered. Preserves the
   intentional cron trailing/prefix tolerance (existing tests unchanged).

Tests: bracketless-variant suppression, mid-sentence-quote delivery,
direct matcher contract, and explainer-strip + defensive real-report
delivery.
2026-06-25 13:45:09 -07:00
liuhao1024
f23d077b5f fix(fuzzy-match): preserve boundary space after whitespace-normalized match
The trailing-whitespace expansion in _map_normalized_positions
unconditionally consumed whitespace after the matched region — including
the word-boundary space that separates the match from the next token.
This caused silent file corruption when the fuzzy matcher fell back to
the whitespace_normalized strategy.

Guard the expansion on the normalized match actually ending with
whitespace (i.e. the original had a run of spaces that were collapsed).
When the match ends with a non-space character, the first whitespace in
the original is a boundary and must not be consumed.

Fixes #52491
2026-06-26 01:55:27 +05:30
GodsBoy
f168631be0 fix(agent): gate verify-on-stop nudge off for messaging surfaces
The verify-on-stop guard (PRs #52296, #52297) defaulted ON for every
session, so on gateway messaging surfaces (Telegram, Discord, etc.) the
model complied with the nudge by writing a hermes-verify temp script and
emitting an ad-hoc verification summary, which the gateway delivered to
the end user as chat noise.

Resolve a surface-aware default instead. The DEFAULT_CONFIG value becomes
the sentinel "auto", which verify_on_stop_enabled() resolves to ON for
interactive coding surfaces (CLI, TUI, desktop) and programmatic callers,
and OFF for conversational messaging surfaces. The surface is read from
HERMES_SESSION_PLATFORM (what the gateway actually binds), with
HERMES_SESSION_SOURCE and HERMES_PLATFORM as fallbacks, matching the
sibling resolution in skill_commands.py and prompt_builder.py. An explicit
HERMES_VERIFY_ON_STOP env var or a boolean agent.verify_on_stop config
still overrides in either direction.

The passive evidence ledger and the call site are untouched.
2026-06-25 10:05:04 +02:00
Brooklyn Nicholson
00779800f6 fix(desktop): hide platform/internal toolsets from the Skills & Tools list
GET /api/tools/toolsets returns the full CONFIGURABLE_TOOLSETS set with no
desktop curation, so the Skills & Tools → Toolsets list shows entries that
don't belong in a flat per-user toggle: platform-coupled toolsets (discord,
discord_admin, yuanbao — which `hermes tools` already platform-restricts off
the CLI) and internal plumbing (context_engine, moa). `hermes tools` curates
these out; the desktop didn't.

Add a small documented block-list + predicate (mirroring
desktop-slash-commands.ts) and apply it in the toolset list filter. Hiding a
row is cosmetic — enabled state and runtime gating are untouched.
2026-06-24 19:18:29 -05:00
624 changed files with 36772 additions and 6945 deletions

View File

@@ -74,7 +74,7 @@ _POLISHED_TOOLS = {
"kanban_create", "kanban_show", "kanban_comment", "kanban_complete",
"kanban_block", "kanban_link", "kanban_heartbeat",
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
"yb_send_dm", "yb_send_sticker", "mixture_of_agents",
"yb_send_dm", "yb_send_sticker",
}

View File

@@ -719,6 +719,15 @@ def init_agent(
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
elif agent.provider == "moa":
from agent.moa_loop import MoAClient
agent.api_mode = "chat_completions"
agent.client = MoAClient(agent.model or "default")
agent._client_kwargs = {}
agent.api_key = api_key or "moa-virtual-provider"
agent.base_url = base_url or "moa://local"
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
elif agent.api_mode == "bedrock_converse":
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
# Region is extracted from the base_url or defaults to us-east-1.

View File

@@ -1697,6 +1697,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
old_model, old_provider, new_model, new_provider,
)
# ── Persist billing route to session DB ──
# The agent's _session_db / session_id may not be set in all contexts
# (tests, bare agents without a session DB, etc.). This ensures the
# dashboard Model cards show the actual provider after a mid-session
# /model switch instead of the stale session-creation provider.
# See #48248 for the full bug description.
_session_db = getattr(agent, "_session_db", None)
_session_id = getattr(agent, "session_id", None)
if _session_db is not None and _session_id:
try:
_session_db.update_session_billing_route(
_session_id,
provider=agent.provider,
base_url=agent.base_url,
billing_mode=getattr(agent, "api_mode", None),
)
except Exception:
logger.warning(
"Failed to persist billing route after model switch",
exc_info=True,
)
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,

View File

@@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
return str(url or "").strip().rstrip("/")
# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
# be pointed at via config.yaml model.base_url. Anything else falls back to the
# Anthropic default — operators routing main-session traffic through a
# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
# must NOT have that foreign host leak into the auxiliary client. See #52608.
_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
"api.anthropic.com",
})
def _is_anthropic_compatible_host(url: str) -> bool:
"""Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
if not url:
return False
try:
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
return host in _ANTHROPIC_COMPATIBLE_HOSTS
except Exception:
return False
def _nous_min_key_ttl_seconds() -> int:
try:
return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
@@ -2256,9 +2278,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
if not token:
return None, None
# Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic otherwise a non-Anthropic
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
# Allow base URL override from config.yaml model.base_url, but only when:
# 1. the configured provider is anthropic (otherwise a non-Anthropic
# base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
# 2. the override URL actually points at an Anthropic-compatible endpoint.
# Without gate (2), operators who route main-session traffic through a
# non-Anthropic provider that accepts Anthropic-format requests (e.g.
# OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
# would have every auxiliary side-channel call (memory extractors,
# reflection, vision, title generation) 401 from the foreign host —
# see issue #52608.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
try:
from hermes_cli.config import load_config
@@ -2268,7 +2297,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
base_url = cfg_base_url
except Exception:
pass

View File

@@ -2561,6 +2561,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
else:
_stream_stale_timeout = _stream_stale_timeout_base
# Reasoning-model floor: known reasoning models (Nemotron 3 Ultra,
# OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ,
# xAI Grok reasoning, etc.) routinely exceed the default 180s chat-
# model threshold during their thinking phase. The cloud gateway
# upstream kills the socket first, surfacing as BrokenPipeError.
# Raises the floor only — never overrides explicit user config
# (handled by get_provider_stale_timeout above).
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
_reasoning_floor = get_reasoning_stale_timeout_floor(api_kwargs.get("model"))
if _reasoning_floor is not None:
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
t = threading.Thread(target=_call, daemon=True)
t.start()

View File

@@ -83,6 +83,59 @@ _PROJECT_MARKERS = (
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Source-file extensions that make a git repo a *code* workspace even with no
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
# non-coding use case) would flip the whole session into the coding posture just
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
_CODE_EXTENSIONS = frozenset({
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
".hs", ".clj", ".erl", ".pl",
})
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
_CODE_SCAN_SKIP_DIRS = frozenset({
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
"target", ".next", ".turbo", "vendor",
})
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
_CODE_SCAN_MAX_ENTRIES = 500
def _has_code_files(root: Path) -> bool:
"""Cheap, bounded check for source files in a repo's top two levels.
Lets a git repo of loose scripts (no manifest) still read as a code
workspace while a bare notes/writing repo does not. Scans the root and its
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
a handful of readdirs at session start, not a full walk.
"""
seen = 0
stack = [(root, True)]
while stack:
directory, is_root = stack.pop()
try:
with os.scandir(directory) as entries:
for entry in entries:
seen += 1
if seen > _CODE_SCAN_MAX_ENTRIES:
return False
name = entry.name
try:
if entry.is_file():
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
return True
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
stack.append((Path(entry.path), False))
except OSError:
continue
except OSError:
continue
return False
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
@@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
# workspace on its own — cheap stat checks, no scan.
if _marker_root(cwd) is not None:
return CODING_PROFILE.name
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
if git_root is not None or _marker_root(cwd) is not None:
# A bare git repo only counts when it actually holds code, so `git init` on a
# notes/writing/research folder stays in the general posture.
if git_root is not None and _has_code_files(git_root):
return CODING_PROFILE.name
return GENERAL_PROFILE.name

View File

@@ -502,6 +502,7 @@ def run_conversation(
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@@ -524,6 +525,19 @@ def run_conversation(
Returns:
Dict: Complete conversation result with final response and message history
"""
if moa_config is None:
try:
from hermes_cli.moa_config import decode_moa_turn
_decoded_message, _decoded_moa_config = decode_moa_turn(user_message)
if _decoded_moa_config is not None:
user_message = _decoded_message
moa_config = _decoded_moa_config
if persist_user_message is None:
persist_user_message = _decoded_message
except Exception:
pass
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
@@ -802,6 +816,29 @@ def run_conversation(
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
if moa_config:
try:
from agent.moa_loop import aggregate_moa_context
_moa_context = aggregate_moa_context(
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
api_messages=api_messages,
reference_models=moa_config.get("reference_models") or [],
aggregator=moa_config.get("aggregator") or {},
temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6),
aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4),
max_tokens=int(moa_config.get("max_tokens", 4096) or 4096),
)
if _moa_context:
for _msg in reversed(api_messages):
if _msg.get("role") == "user":
_base = _msg.get("content", "")
if isinstance(_base, str):
_msg["content"] = _base + "\n\n" + _moa_context
break
except Exception as _moa_exc:
logger.warning("MoA context aggregation failed: %s", _moa_exc)
# Inject ephemeral prefill messages right after the system prompt
# but before conversation history. Same API-call-time-only pattern.
if agent.prefill_messages:
@@ -1123,7 +1160,7 @@ def run_conversation(
# stream. Mirror the ACP exclusion used for Responses
# API upgrade (lines ~1083-1085).
elif (
agent.provider == "copilot-acp"
agent.provider in {"copilot-acp", "moa"}
or str(agent.base_url or "").lower().startswith("acp://copilot")
or str(agent.base_url or "").lower().startswith("acp+tcp://")
):
@@ -1974,9 +2011,21 @@ def run_conversation(
agent.thinking_callback("")
api_elapsed = time.time() - api_start_time
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
agent._persist_session(messages, conversation_history)
interrupted = True
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
# Preserve any assistant text already streamed to the user
# before the stop landed. Dropping it leaves history with no
# record of the half-finished reply on screen, so the next turn
# the model "forgets" what it just said — exactly what users hit
# when they stop to redirect mid-response.
_partial = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
if _partial:
messages.append({"role": "assistant", "content": _partial})
final_response = _partial
else:
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
agent._persist_session(messages, conversation_history)
break
except Exception as api_error:
@@ -3490,6 +3539,65 @@ def run_conversation(
force=True,
)
# Detect thinking-timeout pattern: a known reasoning model
# hit a transport-layer error before the first content
# token arrived. Distinct from _is_stream_drop above
# (which fires for large file-write stream drops) and
# from any classifier reason that's not a transport
# timeout. Reuses the reasoning-model allowlist from
# agent/reasoning_timeouts.py (Fixes #52217) so the
# trigger is consistent with what the per-model
# stale-timeout floor covers. After the classifier
# override at agent/error_classifier.py:720-738 (this
# PR), transport disconnects on reasoning models route
# to FailoverReason.timeout rather than
# context_overflow, so this branch actually fires.
# Detection and message text live in
# agent.thinking_timeout_guidance so they're
# unit-testable without driving the full retry loop.
# (Part 2 of Fixes #52310.)
from agent.thinking_timeout_guidance import (
is_thinking_timeout,
)
_is_thinking_timeout = is_thinking_timeout(
classified,
_model,
error_msg,
)
if _is_thinking_timeout:
agent._vprint(
f"{agent.log_prefix} 💡 The model's thinking "
f"phase exceeded the upstream proxy's idle "
f"timeout before the first content token "
f"arrived. This is a known issue with "
f"reasoning models behind cloud gateways "
f"(NVIDIA NIM, OpenAI, Anthropic, DeepSeek).",
force=True,
)
agent._vprint(
f"{agent.log_prefix} Workarounds in priority order:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 1. Set "
f"`providers.{_provider}.models.{_model}.stale_timeout_seconds: 900` "
f"in `~/.hermes/config.yaml` to extend the per-call "
f"timeout. (Hermes's built-in floor is 600s for "
f"known reasoning models — if you still see this "
f"after raising, the upstream cap is even shorter.)",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 2. Lower `reasoning_budget` or set "
f"`reasoning_effort: medium` on this model if the provider supports it.",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 3. Use a smaller / faster reasoning "
f"model if the task doesn't require deep thinking.",
force=True,
)
logger.error(
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
agent.log_prefix, max_retries, _final_summary,
@@ -3506,7 +3614,22 @@ def run_conversation(
_final_response += f"\n\n{_billing_guidance}"
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _is_stream_drop:
if _is_thinking_timeout:
# Thinking-timeout guidance overrides the generic
# stream-drop guidance — the latter is wrong for
# this case (it suggests splitting large file
# writes, which isn't what happened). See the
# reasoning-model override at
# agent/error_classifier.py:720-738 and the
# detection block above for context.
from agent.thinking_timeout_guidance import (
build_thinking_timeout_guidance,
)
_final_response += build_thinking_timeout_guidance(
provider=_provider,
model=_model,
)
elif _is_stream_drop:
_final_response += (
"\n\nThe provider's stream connection keeps "
"dropping — this often happens when generating "

View File

@@ -11,6 +11,7 @@ import uuid
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
@@ -447,6 +448,63 @@ def get_pool_strategy(provider: str) -> str:
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
def _write_through_provider_state_to_global_root(
provider_id: str, state: Dict[str, Any]
) -> None:
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
Best-effort write-through for the multi-profile rotation hazard
(#48415 / #43589): nous, openai-codex, and xai-oauth rotate the
refresh_token on refresh, so when a profile pool refresh rotates a grant
it resolved from the root fallback, the rotated chain must land back in
root. Otherwise root keeps a now-revoked refresh token and every other
profile reading the stale root grant dies with ``refresh_token_reused`` /
``invalid_grant`` once its access token expires.
Only updates ``providers.<provider_id>`` in the root store; never touches
the profile store (the caller already saved that). Swallows all errors — a
failed write-through degrades to the pre-existing behavior (root stale), it
must never break the profile's own successful save. Mirrors
``hermes_cli.auth._write_through_xai_oauth_to_global_root`` (which covers
the non-pool xAI refresh path) for the credential-pool refresh path.
"""
try:
global_path = auth_mod._global_auth_file_path()
except Exception:
return
if global_path is None:
# Classic mode (profile == root); the profile save already hit root.
return
# Seat belt: under pytest, refuse to write the real user's
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
# (mirrors the read-side guard in _load_global_auth_store). Uses the
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
if os.environ.get("PYTEST_CURRENT_TEST"):
real_home_env = os.environ.get("HOME", "")
if real_home_env:
real_root = Path(real_home_env) / ".hermes" / "auth.json"
try:
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
return
except Exception:
return
try:
if global_path.exists():
global_store = _load_auth_store(global_path)
else:
global_store = {}
if not isinstance(global_store, dict):
return
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
auth_mod._save_auth_store(global_store, global_path)
except Exception as exc: # pragma: no cover - best effort
logger.debug(
"%s pool refresh: write-through to global root failed: %s",
provider_id,
exc,
)
class CredentialPool:
def __init__(self, provider: str, entries: List[PooledCredential]):
self.provider = provider
@@ -800,6 +858,28 @@ class CredentialPool:
try:
with _auth_store_lock():
auth_store = _load_auth_store()
# Decide BEFORE writing whether this profile is reading the
# grant from the global root (no own providers.<id> block) vs.
# genuinely shadowing it. A pool refresh rotates single-use
# OAuth refresh tokens, so a profile that resolved the grant
# from root MUST write the rotated chain back to root too —
# otherwise root keeps a revoked refresh token and every other
# profile reading the stale root grant dies with
# refresh_token_reused / invalid_grant once its access token
# expires. This mirrors the xAI write-through in
# hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool
# refresh path is the Codex/xAI analog reported in #48415.
_wt_provider_id = {
"nous": "nous",
"openai-codex": "openai-codex",
"xai-oauth": "xai-oauth",
}.get(self.provider)
write_through_to_root = bool(_wt_provider_id) and not (
isinstance(auth_store.get("providers"), dict)
and isinstance(
auth_store["providers"].get(_wt_provider_id), dict
)
)
if self.provider == "nous":
state = _load_provider_state(auth_store, "nous")
if state is None:
@@ -855,6 +935,10 @@ class CredentialPool:
return
_save_auth_store(auth_store)
if write_through_to_root and _wt_provider_id:
_write_through_provider_state_to_global_root(
_wt_provider_id, state
)
except Exception as exc:
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)

View File

@@ -377,8 +377,10 @@ CURATOR_REVIEW_PROMPT = (
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
"Hard rules — do not violate:\n"
"1. DO NOT touch bundled or hub-installed skills. The candidate list "
"below is already filtered to agent-created skills only.\n"
"1. DO NOT touch bundled, hub-installed, or external-dir skills "
"(`skills.external_dirs`). The candidate list below is already filtered "
"to local curator-managed skills only; external skills are externally "
"owned and read-only to this background curator.\n"
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
"Archives are recoverable; deletion is not.\n"
@@ -469,8 +471,9 @@ CURATOR_REVIEW_PROMPT = (
"skill, or `absorbed_into=\"\"` when you're truly pruning with no "
"forwarding target. This drives cron-job skill-reference migration — "
"guessing from your YAML summary after the fact is fragile.\n"
" - terminal — mv a sibling into the archive "
"OR move its content into a support subfile\n\n"
" - terminal — move LOCAL candidate content into "
"a support subfile when package integrity requires it; never mv, cp, rm, "
"patch, or rewrite bundled, hub-installed, or external-dir skills\n\n"
"'keep' is a legitimate decision ONLY when the skill is already a "
"class-level umbrella and none of the proposed merges would improve "
"discoverability. 'This is narrow but distinct from its siblings' "
@@ -1843,6 +1846,14 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
# Disable recursive nudges — the curator must never spawn its own review.
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
# Tag this fork as autonomous background curation so skill_manage's
# background-review write guard fires. Without this the fork inherits
# the default "assistant_tool" origin, is_background_review() is False,
# and the external/bundled/hub-installed skill_manage guards never
# trigger during the curation pass they exist to protect against.
# turn_context.py binds this onto the write-origin ContextVar at turn
# start (see agent/turn_context.py).
review_agent._memory_write_origin = "background_review"
# Redirect the forked agent's stdout/stderr to /dev/null while it
# runs so its tool-call chatter doesn't pollute the foreground

View File

@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Any
from utils import safe_json_loads
from agent.redact import redact_sensitive_text
from agent.tool_result_classification import file_mutation_result_landed
# ANSI escape codes for coloring tool failure indicators
@@ -339,6 +340,62 @@ def _read_file_line_label(args: dict) -> str:
return f"L{offset}-{offset + limit - 1}"
def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:
"""Apply secret redaction to browser_type text in display-facing payloads.
Backends sometimes echo the attempted input in error strings or fallback
metadata. When the raw typed value contains a recognizable secret (API
key, token, JWT, etc.) the redacted form differs from the raw value, so we
replace every occurrence of the raw value with its redacted form before a
browser_type result reaches logs, callbacks, the model, or chat history.
Normal typed text (search queries, addresses, form fields) matches no
secret pattern, so it passes through unchanged and stays readable.
Redaction is forced here regardless of the global ``security.redact_secrets``
preference: a typed credential leaking into chat history is a security
boundary, not mere log hygiene.
"""
if typed_text is None:
return value
needle = str(typed_text)
if needle == "":
return value
redacted = redact_sensitive_text(needle, force=True)
if redacted == needle:
# Nothing secret-looking in the typed text; leave payload untouched.
return value
if isinstance(value, str):
return value.replace(needle, redacted)
if isinstance(value, dict):
return {
key: redact_browser_typed_text_for_display(item, typed_text)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_browser_typed_text_for_display(item, typed_text) for item in value]
if isinstance(value, tuple):
return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)
return value
def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:
"""Return a copy of tool args safe for logs/progress UI.
For ``browser_type`` the ``text`` argument is run through the same
secret-pattern redactor used for logs. Recognizable credentials (API
keys, tokens) are masked before the value reaches tool progress
notifications; normal typed text is left intact for debuggability.
"""
if not isinstance(args, dict):
return args
if tool_name == "browser_type" and isinstance(args.get("text"), str):
safe_args = dict(args)
safe_args["text"] = redact_sensitive_text(args["text"], force=True)
return safe_args
return args
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
@@ -362,13 +419,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
max_len = _tool_preview_max_len
if not args:
return None
args = redact_tool_args_for_display(tool_name, args) or args
primary_args = {
"terminal": "command", "web_search": "query", "web_extract": "urls",
"read_file": "path", "write_file": "path", "patch": "path",
"search_files": "pattern", "browser_navigate": "url",
"browser_click": "ref", "browser_type": "text",
"image_generate": "prompt", "text_to_speech": "text",
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
"vision_analyze": "question",
"skill_view": "name", "skills_list": "category",
"cronjob": "action",
"execute_code": "code", "delegate_task": "goal",
@@ -1085,6 +1143,7 @@ def get_cute_tool_message(
When *result* is provided the line is checked for failure indicators.
Failed tool calls get a red prefix and an informational suffix.
"""
args = redact_tool_args_for_display(tool_name, args) or args
dur = f"{duration:.1f}s"
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
skin_prefix = get_skin_tool_prefix()
@@ -1216,8 +1275,6 @@ def get_cute_tool_message(
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
if tool_name == "vision_analyze":
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
if tool_name == "mixture_of_agents":
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
if tool_name == "send_message":
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
if tool_name == "cronjob":

View File

@@ -717,6 +717,26 @@ def classify_api_error(
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
if is_disconnect and not status_code:
# Reasoning-model override: a transport disconnect on a reasoning
# model is much more likely the upstream proxy idle-killing a
# long thinking stream than a true context overflow — even on
# large sessions. The default disconnect+large-session routing
# below would otherwise send the user into the compression
# branch (should_compress=True) and silently delete
# conversation history on a phantom context-length error.
# Reasoning models have multi-minute thinking phases that
# routinely exceed the cloud gateway's idle window (NVIDIA
# NIM ~120s — first-party repro at NVIDIA/NemoClaw#4846;
# OpenAI worker / Anthropic stream-idle similar). The
# per-reasoning-model stale-timeout floor in
# agent/reasoning_timeouts.py raises the stale-detector
# threshold to tolerate long thinking, so a true
# transport-layer failure here is recoverable via the retry
# path — not via context compression. Reclassify as timeout.
# (Part 1 of Fixes #52310.)
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
if get_reasoning_stale_timeout_floor(model) is not None:
return _result(FailoverReason.timeout, retryable=True)
# Absolute token/message-count thresholds are only a proxy for smaller
# context windows. Large-context sessions can have hundreds of
# messages while still being far below their actual token budget.

306
agent/moa_loop.py Normal file
View File

@@ -0,0 +1,306 @@
"""Mixture-of-Agents runtime helpers for /moa turns.
The slash command is deliberately not a model tool. It marks one user turn as
MoA-enabled; the normal Hermes agent loop still owns tool calling and turn
termination, while this module gathers reference-model context before each model
iteration.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from agent.auxiliary_client import call_llm
from agent.transports import get_transport
logger = logging.getLogger(__name__)
# Upper bound on concurrent reference-model calls. References are independent
# advisory calls (no tools, no inter-dependence), so we fan them out the same
# way delegate_task runs a batch: all in flight at once, results collected when
# every reference finishes. Presets rarely list more than a handful of
# references; this cap just protects against a pathologically large preset
# opening dozens of sockets at once.
_MAX_REFERENCE_WORKERS = 8
def _slot_label(slot: dict[str, str]) -> str:
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
def _run_reference(
slot: dict[str, str],
ref_messages: list[dict[str, Any]],
*,
temperature: float,
max_tokens: int,
) -> tuple[str, str]:
"""Call one reference model and return ``(label, text)``.
Never raises: a failed reference becomes a labelled note so the aggregator
can still act with partial context. Designed to run inside a thread pool —
``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right
concurrency primitive, mirroring ``delegate_task``'s batch fan-out.
"""
label = _slot_label(slot)
try:
response = call_llm(
task="moa_reference",
provider=slot["provider"],
model=slot["model"],
messages=ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
return label, _extract_text(response) or "(empty response)"
except Exception as exc:
logger.warning("MoA reference model %s failed: %s", label, exc)
return label, f"[failed: {exc}]"
def _run_references_parallel(
reference_models: list[dict[str, str]],
ref_messages: list[dict[str, Any]],
*,
temperature: float,
max_tokens: int,
) -> list[tuple[str, str]]:
"""Fan out all reference models in parallel, returning outputs in order.
Like ``delegate_task``'s batch mode, every reference is dispatched at once
and we block until all of them finish before handing the joined results to
the aggregator. Output order matches ``reference_models`` so the
``Reference {idx}`` labelling stays stable. MoA presets that reference
another MoA preset are skipped here (recursion guard) with a labelled note.
"""
if not reference_models:
return []
results: list[tuple[str, str] | None] = [None] * len(reference_models)
futures = {}
workers = min(_MAX_REFERENCE_WORKERS, len(reference_models))
with ThreadPoolExecutor(max_workers=workers) as executor:
for idx, slot in enumerate(reference_models):
if slot.get("provider") == "moa":
results[idx] = (
_slot_label(slot),
"[skipped: MoA presets cannot recursively reference MoA]",
)
continue
futures[
executor.submit(
_run_reference,
slot,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
] = idx
# Collect every reference before returning — the aggregator needs the
# complete set, so there is no early-exit / first-completed path here.
for future, idx in futures.items():
results[idx] = future.result()
return [r for r in results if r is not None]
def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Build an advisory-safe view of the conversation for reference models.
Reference calls are advisory: they never call tools and never emit the
``tool_calls`` the main model did. Replaying the full transcript verbatim
(a) re-bills the ~8K-token Hermes system prompt per reference per
iteration and (b) risks 400s from strict providers (Mistral, Fireworks)
that reject orphan ``tool`` messages or ``tool_calls`` the reference never
produced. We keep only the user/assistant *text* turns, dropping the
system prompt, any ``tool``-role messages, and any ``tool_calls`` payloads.
"""
trimmed: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role not in ("user", "assistant"):
# Drop system prompt and tool-result messages.
continue
content = msg.get("content")
if not isinstance(content, str):
# Skip non-text (multimodal/tool-call-only) assistant turns.
if not content:
continue
text = content if isinstance(content, str) else ""
if role == "assistant" and not text.strip():
# Assistant turn that was purely tool calls — nothing advisory.
continue
trimmed.append({"role": role, "content": text})
if not trimmed:
# Degenerate case (e.g. first turn was stripped): fall back to a
# minimal user turn so the reference still has something to answer.
for msg in reversed(messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
return [{"role": "user", "content": msg["content"]}]
return trimmed
def _extract_text(response: Any) -> str:
try:
transport = get_transport("chat_completions")
if transport is None:
raise RuntimeError("chat_completions transport unavailable")
normalized = transport.normalize_response(response)
text = (normalized.content or "").strip()
if text:
return text
except Exception:
pass
try:
content = response.choices[0].message.content
return (content or "").strip()
except Exception:
return ""
def aggregate_moa_context(
*,
user_prompt: str,
api_messages: list[dict[str, Any]],
reference_models: list[dict[str, str]],
aggregator: dict[str, str],
temperature: float = 0.6,
aggregator_temperature: float = 0.4,
max_tokens: int = 4096,
) -> str:
"""Run configured reference models and synthesize their advice.
Failures are returned as model-specific notes instead of aborting the normal
agent loop; the main model can still act with partial context.
"""
reference_outputs: list[tuple[str, str]] = []
ref_messages = _reference_messages(api_messages)
reference_outputs = _run_references_parallel(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
joined = "\n\n".join(
f"Reference {idx}{label}:\n{text}"
for idx, (label, text) in enumerate(reference_outputs, start=1)
)
synth_prompt = (
"You are the aggregator in a Mixture of Agents process. Synthesize the "
"reference responses into concise, actionable guidance for the main "
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
"disagreements. Do not answer the user directly unless that is all that "
"is needed; produce context the main agent should use in its normal loop.\n\n"
f"Original user prompt:\n{user_prompt}\n\n"
f"Reference responses:\n{joined}"
)
agg_label = _slot_label(aggregator)
try:
response = call_llm(
task="moa_aggregator",
provider=aggregator["provider"],
model=aggregator["model"],
messages=[{"role": "user", "content": synth_prompt}],
temperature=aggregator_temperature,
max_tokens=max_tokens,
)
synthesis = _extract_text(response)
except Exception as exc:
logger.warning("MoA aggregator model %s failed: %s", agg_label, exc)
synthesis = ""
if not synthesis:
synthesis = joined
return (
"[Mixture of Agents context — use this as private guidance for the "
"normal Hermes agent loop. You may call tools, continue reasoning, or "
"finish normally.]\n"
f"Aggregator: {agg_label}\n"
f"References: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n"
f"{synthesis.strip()}"
)
class MoAChatCompletions:
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
def __init__(self, preset_name: str):
self.preset_name = preset_name or "default"
def create(self, **api_kwargs: Any) -> Any:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name)
messages = list(api_kwargs.get("messages") or [])
reference_models = preset.get("reference_models") or []
aggregator = preset.get("aggregator") or {}
max_tokens = int(preset.get("max_tokens", api_kwargs.get("max_tokens") or 4096) or 4096)
temperature = float(preset.get("reference_temperature", 0.6) or 0.6)
aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4)
# When the preset is disabled, skip the reference fan-out and let the
# configured aggregator act alone — it is the preset's acting model, so
# a disabled MoA preset is simply "use the aggregator directly."
if not preset.get("enabled", True):
reference_models = []
reference_outputs: list[tuple[str, str]] = []
ref_messages = _reference_messages(messages)
reference_outputs = _run_references_parallel(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
agg_messages = [dict(m) for m in messages]
if reference_outputs:
joined = "\n\n".join(
f"Reference {idx}{label}:\n{text}"
for idx, (label, text) in enumerate(reference_outputs, start=1)
)
guidance = (
"[Mixture of Agents reference context]\n"
f"Preset: {self.preset_name}\n"
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
f"References: {', '.join(label for label, _ in reference_outputs)}\n\n"
"Use the reference responses below as private context. You are the aggregator and acting model: "
"answer the user directly or call tools as needed.\n\n"
f"{joined}"
)
for msg in reversed(agg_messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
msg["content"] = msg["content"] + "\n\n" + guidance
break
else:
agg_messages.append({"role": "user", "content": guidance})
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
agg_kwargs["messages"] = agg_messages
agg_kwargs["model"] = aggregator.get("model")
agg_kwargs["temperature"] = aggregator_temperature
return call_llm(
task="moa_aggregator",
provider=aggregator.get("provider"),
model=aggregator.get("model"),
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
)
class MoAClient:
def __init__(self, preset_name: str):
self.chat = type("_MoAChat", (), {})()
self.chat.completions = MoAChatCompletions(preset_name)

View File

@@ -243,7 +243,10 @@ KANBAN_GUIDANCE = (
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
"with no `.git`, `git worktree add <path> "
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
"cd there.\n"
"cd there. For a project-linked task the workspace is a fresh "
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
"`git worktree add` from there.\n"
"- **Deliverables.** Files a human wants go in "
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
"`metadata` are NOT uploaded). Files must exist at completion.\n"

216
agent/reasoning_timeouts.py Normal file
View File

@@ -0,0 +1,216 @@
"""Per-reasoning-model stale-timeout floor for known reasoning models.
Reasoning models (those that emit extended thinking blocks before their
first content token) routinely exceed Hermes's default chat-model
stale detectors:
* Stream stale detector: ``HERMES_STREAM_STALE_TIMEOUT`` default 180s
``agent/chat_completion_helpers.py:2544``
* Non-stream stale detector: ``HERMES_API_CALL_STALE_TIMEOUT`` default 90s
``run_agent.py:1140``
For NVIDIA Nemotron 3 Ultra on the hosted NIM gateway the empirical
upstream idle kill is ~120s (first-party reproduction at
NVIDIA/NemoClaw#4846 — TTFB ~31s, stream dies at 120s). The same
failure mode exists on OpenAI o1/o3, Anthropic Opus 4.x thinking,
DeepSeek R1, Qwen QwQ, xAI Grok reasoning — every cloud reasoning
model hits upstream-proxies / load-balancers with idle timeouts
shorter than the model's thinking phase. Result: the stale detector
kills the connection mid-think, surfacing as
``BrokenPipeError``/``RemoteProtocolError`` on the next read.
This module provides a floor that the existing stale-detector scaling
blocks consult via :func:`get_reasoning_stale_timeout_floor` and
apply as ``max(default, floor)``. It is a FLOOR:
* Never overrides explicit user config (``providers.<id>.models.<model>.stale_timeout_seconds``
or ``request_timeout_seconds`` already wins — this code never runs
in that branch).
* Never lowers an existing threshold.
* Has zero effect on non-reasoning models — they are not in the
allowlist and the resolver returns ``None``.
Matching uses start-anchored regex on the slug-only component of
the model name (after stripping any aggregator prefix like
``openai/``, ``x-ai/``, ``anthropic/``). The right-anchor matches
end-of-string or a ``-``/``.``/``_`` slug separator, so ``qwen3-235b``
matches the ``qwen3`` family entry (a future model slug would be
``qwen3-235b-instruct`` and would also match) but ``some-other-qwen3``
does NOT match ``qwen3`` (the ``-qwen3`` is not at start of slug).
The ``o1`` case is the most delicate: a model named
``llama-4-70b-o1-preview`` is a hypothetical community derivative that
should NOT trigger the reasoning-model floor for the user (the user
chose a non-OpenAI model, not a reasoning model). The start-of-slug
anchor naturally excludes this — the matched ``o1-preview`` is at
position 11 of the slug, not at position 0. The previous substring-
with-trailing-hyphen design would have over-matched here, which is
why start-of-slug anchoring is the right shape.
Fixes #52217.
"""
from __future__ import annotations
import re
from typing import Optional
# (slug, floor_seconds). Each slug is matched as a discrete
# word-boundary component via the wrapper regex in ``_match_any``
# below. Order is irrelevant — the first regex match wins.
_REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# NVIDIA Nemotron — reasoning models behind hosted NIM with
# documented 60-180s upstream idle kill (NVIDIA/NemoClaw#4846:
# 120s measured).
("nemotron-3-ultra", 600),
("nemotron-3-super", 600),
("nemotron-3-nano", 300),
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
("deepseek-r1", 600),
("deepseek-reasoner", 600),
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
# preview is the stable slug; ``qwen3`` covers the family of
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
# without over-matching every Qwen3 instruct variant — the
# right-anchor requires the slug to be at the start of the
# remaining model name, so ``qwen3-235b-instruct`` (instruct is
# NOT a thinking variant) would still match. Acceptable
# trade-off: instruct variants of qwen3 get the 180s floor
# even though they don't reason. The cost is a slightly longer
# wait on a hung provider; the alternative (matching only
# ``qwen3-.*-thinking``) breaks the moment NVIDIA or Alibaba
# ships a slightly different naming shape.
("qwq-32b", 300),
("qwen3", 180),
# OpenAI o-series — known multi-minute TTFB. Each variant
# enumerated explicitly so bare ``o1`` doesn't over-match
# ``olmo-1`` or hypothetical future community derivatives.
("o1", 600),
("o1-mini", 600),
("o1-pro", 600),
("o1-preview", 600),
("o3", 600),
("o3-pro", 600),
("o3-mini", 300),
("o4-mini", 300),
# Anthropic Claude 4.x thinking variants. Anchored at
# ``claude-opus-4`` so non-thinking Claude 3.x or future
# non-reasoning Claude variants don't match.
("claude-opus-4", 240),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
# xAI Grok reasoning variants. Explicit reasoning-only keys
# plus one for the ``non-reasoning`` variant so users picking
# the fast variant don't get the 300s floor. Bare ``grok-3``,
# ``grok-4`` etc. don't match — only the explicit reasoning /
# non-reasoning pairs.
("grok-4-fast-reasoning", 300),
("grok-4.20-reasoning", 300),
("grok-4-fast-non-reasoning", 180),
)
# Pre-compile each pattern. Wrapper = start-of-slug + slug + end-or-
# separator, where ``start-of-slug`` means start-of-string OR
# immediately after the last ``/`` (aggregator separator) and
# ``end-or-separator`` means end-of-string OR a ``-``/``.``/``_``.
#
# Why start-of-slug and not start-of-string: aggregator prefixes
# like ``openai/`` should not affect matching — the slug identity is
# the part after the last ``/``. Stripping the aggregator prefix in
# :func:`get_reasoning_stale_timeout_floor` before regex matching
# gives the wrapper a clean start-of-string anchor.
#
# Why end-or-separator on the right: ``openai/o3-mini`` must match
# the ``o3-mini`` slug (the right anchor is end-of-string). And
# ``openai/o3-mini-2025-01-31`` must also match ``o3-mini`` (the right
# anchor is the ``-`` separator). But ``openai/o3-mini-fork`` should
# NOT match ``o3-mini`` if we wanted to exclude forks — though the
# pattern ``o3-mini-fork`` would be matched as a derivative anyway,
# so we accept that community forks inheriting the same prefix are
# treated as reasoning models (a reasonable default — the upstream
# gateway timing is the same).
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
def _get_pattern(slug: str) -> re.Pattern[str]:
compiled = _PATTERN_CACHE.get(slug)
if compiled is None:
compiled = re.compile(
r"^"
+ re.escape(slug)
+ r"(?:$|[\-._])"
)
_PATTERN_CACHE[slug] = compiled
return compiled
def _match_any(model_lower: str) -> Optional[float]:
"""Return the floor for the first matching slug, else None.
Each table entry is matched as a start-of-slug prefix with the
slug-separator-or-end-of-string right-anchor. Table iteration
order is irrelevant: longest slug wins (so ``o3-mini`` beats
``o3`` on a model like ``openai/o3-mini``).
"""
# Sort by slug length descending so longer / more-specific slugs
# win on shared prefixes (o3-mini beats o3).
sorted_floors = sorted(
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
)
for slug, floor in sorted_floors:
if _get_pattern(slug).search(model_lower):
return float(floor)
return None
def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
"""Return the stale-timeout floor (seconds) for a known reasoning model.
Returns ``None`` when the model is not in the allowlist or the
argument is empty / not a string. Matching uses
word-boundary-anchored regex on the lowercased model name, so
``openai/o3-mini`` matches the ``o3-mini`` slug but
``olmo-1`` does NOT match ``o1`` (the ``o1`` substring is not
at a word boundary inside ``olmo-1``).
Aggregator prefixes (``openai/``, ``x-ai/``, ``anthropic/`` etc.)
are preserved through matching — the ``/`` is itself a word
boundary, so ``openai/o3-mini`` matches ``o3-mini`` because the
``/`` before ``o3-mini`` satisfies the left-anchor alternation.
This is a FLOOR — callers must apply it as ``max(default, floor)``
and only when no explicit user-configured per-model
``stale_timeout_seconds`` exists.
>>> get_reasoning_stale_timeout_floor("nvidia/nemotron-3-ultra-550b-a55b")
600.0
>>> get_reasoning_stale_timeout_floor("openai/o3-mini")
300.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
600.0
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
180.0
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")
300.0
>>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6")
240.0
>>> get_reasoning_stale_timeout_floor("gpt-4o") is None
True
>>> get_reasoning_stale_timeout_floor("olmo-1") is None
True
>>> get_reasoning_stale_timeout_floor(None) is None
True
"""
if not model or not isinstance(model, str):
return None
name = model.strip().lower()
if not name:
return None
# Strip aggregator prefix (everything before and including the
# last ``/``). The wrapper regex anchors at start-of-string, so
# the slug identity is the bare model name.
if "/" in name:
name = name.rsplit("/", 1)[1]
return _match_any(name)

View File

@@ -507,6 +507,34 @@ def get_all_skills_dirs() -> List[Path]:
return dirs
def _resolve_for_skill_ownership(path) -> Path:
path_obj = path if isinstance(path, Path) else Path(str(path))
try:
return path_obj.expanduser().resolve()
except (OSError, RuntimeError):
return path_obj.expanduser().absolute()
def is_external_skill_path(path) -> bool:
"""Return True when ``path`` lives under a configured external skills dir.
``skills.external_dirs`` are externally owned: Hermes can discover and view
their skills, and foreground user-directed tool calls may still edit them,
but autonomous lifecycle maintenance must treat them as read-only. This
helper centralizes the ownership boundary so curator/reporting/tool paths do
not each need to re-interpret the config.
"""
candidate = _resolve_for_skill_ownership(path)
for root in get_external_skills_dirs():
resolved_root = _resolve_for_skill_ownership(root)
try:
candidate.relative_to(resolved_root)
return True
except ValueError:
continue
return False
# ── Condition extraction ──────────────────────────────────────────────────

View File

@@ -0,0 +1,136 @@
"""Thinking-timeout detection and user-facing guidance for reasoning models.
When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3,
Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)
hits a transport-layer error before the first content token arrives, the
upstream proxy has almost certainly idle-killed a long thinking stream —
not a true context overflow or a configuration error. The user needs
distinct guidance for this case:
"The model's thinking phase exceeded the upstream proxy's idle
timeout before the first content token arrived. This is a known
issue with reasoning models behind cloud gateways (NVIDIA NIM,
OpenAI, Anthropic, DeepSeek). Workarounds in priority order:
1. Set `providers.<provider>.models.<model>.stale_timeout_seconds: 900`
in `~/.hermes/config.yaml` to extend the per-call timeout...
2. Lower `reasoning_budget` or set `reasoning_effort: medium`...
3. Use a smaller / faster reasoning model..."
The existing `_is_stream_drop` guidance at
``agent/conversation_loop.py:3464-3486`` fires for large-file-write
stream drops ("try execute_code with Python's open() for large files")
which is the WRONG advice for the thinking-timeout case. This module
provides the detection and the message as standalone helpers so the
detection logic is unit-testable without driving the full retry loop,
and the message text can be regression-tested for spelling and accuracy.
Part 2 of Fixes #52310.
"""
from __future__ import annotations
from typing import Optional
# Substring set that identifies a transport-layer failure on the
# response stream. Same shape as the existing
# ``_SERVER_DISCONNECT_PATTERNS`` in ``agent/error_classifier.py:394``
# but extended to also catch the OSS-level error signature
# (``broken pipe`` / ``errno 32``) that the upstream kill surfaces
# to the OpenAI SDK wrapper.
_THINKING_TIMEOUT_SUBSTRINGS: tuple[str, ...] = (
"broken pipe",
"errno 32",
"remote protocol",
"connection reset",
"connection lost",
"peer closed",
"server disconnected",
)
def is_thinking_timeout(classified: object, model: str, error_msg: str) -> bool:
"""Return True when a reasoning model's thinking phase hit a transport kill.
Args:
classified: a :class:`agent.error_classifier.ClassifiedError` instance
(duck-typed here to avoid an import cycle in unit tests).
model: the model slug at failure time (e.g.
``"nvidia/nemotron-3-ultra-550b-a55b"``).
error_msg: lowercased string representation of the underlying
exception (typically ``str(api_error).lower()``).
Returns True when ALL conditions hold:
1. ``classified.reason == FailoverReason.timeout`` (the classifier
override at ``agent/error_classifier.py:720-738`` ensures this
is the case for reasoning models even on large sessions).
2. ``api_error`` has no ``.status_code`` attribute set (transport
disconnect, not an HTTP error).
3. ``model`` is in the reasoning-model allowlist (reuses
``agent.reasoning_timeouts.get_reasoning_stale_timeout_floor``).
4. ``error_msg`` contains one of the transport-kill substrings.
Non-reasoning models always return False. Non-transport errors
(billing / rate_limit / auth / context_overflow / format_error)
always return False. HTTP-status errors always return False.
"""
# Import here (not at module top) to keep this helper cheap to
# import even from callers that don't need it. ``agent.reasoning_timeouts``
# is small and dependency-free.
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
# Condition 1: classifier says timeout. Use a string/value check
# rather than importing FailoverReason so this module has zero
# import cycles from the error_classifier package.
reason = getattr(classified, "reason", None)
reason_value = getattr(reason, "value", None)
if reason_value != "timeout":
return False
# Condition 2: no HTTP status code (transport, not API error).
# Caller is expected to gate on ``getattr(api_error, "status_code", None) is None``
# before calling this helper; the surface here is just the post-gate
# boolean so the caller can pass an already-prepped error_msg.
# Condition 3: reasoning model allowlist.
if get_reasoning_stale_timeout_floor(model) is None:
return False
# Condition 4: transport-kill substring in the error message.
error_msg_lower = (error_msg or "").lower()
return any(p in error_msg_lower for p in _THINKING_TIMEOUT_SUBSTRINGS)
def build_thinking_timeout_guidance(
provider: str, model: str, model_label: Optional[str] = None,
) -> str:
"""Return the user-facing guidance string appended to ``_final_response``.
Args:
provider: provider slug (e.g. ``"nvidia"``, ``"openai"``).
model: bare model slug the user would put in their config
(e.g. ``"nemotron-3-ultra-550b-a55b"`` if the user uses
NVIDIA direct, or the full ``"nvidia/nemotron-3-ultra-550b-a55b"``
if they go through an aggregator). Used verbatim in the
config snippet so the user can copy-paste.
model_label: optional short label for the model name in the
prose (e.g. ``"Nemotron 3 Ultra"``). Falls back to the
slug if not provided.
"""
label = model_label or model
return (
"\n\nThe model's thinking phase exceeded the upstream proxy's "
"idle timeout before the first content token arrived. This is a "
f"known issue with reasoning models (like {label}) behind cloud "
"gateways (NVIDIA NIM, OpenAI, Anthropic, DeepSeek). Workarounds "
"in priority order:\n"
f"1. Set `providers.{provider}.models.{model}.stale_timeout_seconds: 900` "
"in `~/.hermes/config.yaml` to extend the per-call timeout. "
"(Hermes's built-in floor is 600s for known reasoning models — "
"if you still see this after raising, the upstream cap is even "
"shorter.)\n"
"2. Lower `reasoning_budget` or set `reasoning_effort: medium` on this "
"model if the provider supports it.\n"
"3. Use a smaller / faster reasoning model if the task doesn't "
"require deep thinking."
)

View File

@@ -26,6 +26,7 @@ from agent.display import (
build_tool_preview as _build_tool_preview,
get_cute_tool_message as _get_cute_tool_message_impl,
get_tool_emoji as _get_tool_emoji,
redact_tool_args_for_display as _redact_tool_args_for_display,
_detect_tool_failure,
)
from agent.tool_guardrails import ToolGuardrailDecision
@@ -469,10 +470,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
args_str = json.dumps(args, ensure_ascii=False)
display_args = _redact_tool_args_for_display(name, args) or args
args_str = json.dumps(display_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {name}({list(args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(args, indent=2, ensure_ascii=False)))
print(f" 📞 Tool {i}: {name}({list(display_args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
else:
args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}")
@@ -482,8 +484,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
continue
if agent.tool_progress_callback:
try:
preview = _build_tool_preview(name, args)
agent.tool_progress_callback("tool.started", name, preview, args)
display_args = _redact_tool_args_for_display(name, args) or args
preview = _build_tool_preview(name, display_args)
agent.tool_progress_callback("tool.started", name, preview, display_args)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
@@ -492,7 +495,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
continue
if agent.tool_start_callback:
try:
agent.tool_start_callback(tc.id, name, args)
display_args = _redact_tool_args_for_display(name, args) or args
agent.tool_start_callback(tc.id, name, display_args)
except Exception as cb_err:
logging.debug(f"Tool start callback error: {cb_err}")
@@ -792,7 +796,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if not blocked and agent.tool_complete_callback:
try:
agent.tool_complete_callback(tc.id, name, args, function_result)
display_args = _redact_tool_args_for_display(name, args) or args
agent.tool_complete_callback(tc.id, name, display_args, function_result)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
@@ -954,10 +959,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
agent._iters_since_skill = 0
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
args_str = json.dumps(function_args, ensure_ascii=False)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
args_str = json.dumps(display_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(function_args, indent=2, ensure_ascii=False)))
print(f" 📞 Tool {i}: {function_name}({list(display_args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
else:
args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}")
@@ -978,14 +984,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if not _execution_blocked and agent.tool_progress_callback:
try:
preview = _build_tool_preview(function_name, function_args)
agent.tool_progress_callback("tool.started", function_name, preview, function_args)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args)
agent.tool_progress_callback("tool.started", function_name, preview, display_args)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
if not _execution_blocked and agent.tool_start_callback:
try:
agent.tool_start_callback(tool_call.id, function_name, function_args)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
agent.tool_start_callback(tool_call.id, function_name, display_args)
except Exception as cb_err:
logging.debug(f"Tool start callback error: {cb_err}")
@@ -1215,7 +1223,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_ce_result = None
@@ -1248,7 +1257,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_mem_result = None
@@ -1279,7 +1289,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_spinner_result = None
@@ -1441,7 +1452,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if not _execution_blocked and agent.tool_complete_callback:
try:
agent.tool_complete_callback(tool_call.id, function_name, function_args, function_result)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")

View File

@@ -15,9 +15,77 @@ from typing import Any, Iterable
_MAX_CHANGED_PATHS_IN_NUDGE = 8
# Session identities (platform or source) that are NOT human conversational
# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex,
# local, gateway) and programmatic callers (API server, webhooks, tools).
# Verify-on-stop stays ON by default for these. Any other resolved gateway
# platform is a conversational messaging surface (Telegram, Discord, WhatsApp,
# Signal, Slack, etc.) where the verification narrative would reach a human as
# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
# local or programmatic surface. Default-deny by design: an unrecognized
# identity is treated as messaging (OFF) so a new chat platform never leaks the
# verification receipt before this set is updated.
_NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"cli",
"codex",
"desktop",
"gateway",
"local",
"tui",
"tool",
"api_server",
"webhook",
"msgraph_webhook",
}
)
def _session_is_messaging_surface() -> bool:
"""Return whether this turn is delivered over a human messaging channel.
The gateway binds the platform value (e.g. ``telegram``) to
``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE``
(e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context
helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM``
override, matching the sibling platform resolution in
``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a
messaging surface when a resolved identity is present and is not a known
non-messaging surface.
"""
try:
from gateway.session_context import get_session_env
platform = (
os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM", "")
)
source = get_session_env("HERMES_SESSION_SOURCE", "")
except Exception:
platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get(
"HERMES_SESSION_PLATFORM", ""
)
source = os.environ.get("HERMES_SESSION_SOURCE", "")
for identity in (platform, source):
identity = str(identity or "").strip().lower()
if identity and identity not in _NON_MESSAGING_SESSION_SURFACES:
return True
return False
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
"""Return whether edit -> verify-before-finish behavior is enabled."""
"""Return whether edit -> verify-before-finish behavior is enabled.
Precedence: an explicit ``HERMES_VERIFY_ON_STOP`` env var wins, then an
explicit boolean ``agent.verify_on_stop`` config value, then a surface-aware
default. The config default is the sentinel ``"auto"`` (see
``DEFAULT_CONFIG``), which resolves to ON for interactive coding surfaces
(CLI, TUI, desktop) and programmatic callers, and OFF for conversational
messaging surfaces (Telegram, Discord, etc.) where the verification
narrative would otherwise reach a human as chat noise.
"""
env = os.environ.get("HERMES_VERIFY_ON_STOP")
if env is not None:
return env.strip().lower() not in {"0", "false", "no", "off"}
@@ -29,9 +97,17 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
except Exception:
config = {}
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
return bool(agent_cfg.get("verify_on_stop"))
return True
cfg_val = agent_cfg.get("verify_on_stop") if isinstance(agent_cfg, dict) else None
if isinstance(cfg_val, bool):
return cfg_val
if isinstance(cfg_val, str):
token = cfg_val.strip().lower()
if token in {"1", "true", "yes", "on"}:
return True
if token in {"0", "false", "no", "off"}:
return False
# "auto", missing, or any other value -> surface-aware default.
return not _session_is_messaging_surface()
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:

View File

@@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "tabler"
}

View File

@@ -61,10 +61,7 @@ function buildDesktopBackendPath({
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries(
[hermesNodeBin, venvBin, currentPath, saneEntries],
{ delimiter }
)
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {

View File

@@ -76,10 +76,7 @@ test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root'
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
'/Users/test/.hermes'
)
assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
@@ -104,8 +101,5 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
})

View File

@@ -1,3 +1,5 @@
const fs = require('node:fs')
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// The announcement clock starts the instant the backend process is spawned —
@@ -94,9 +96,76 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
}
}
function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) {
return new Promise((resolve, reject) => {
let done = false
let interval = null
function cleanup() {
if (done) return
done = true
clearTimeout(timer)
if (interval) clearInterval(interval)
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
check()
})
}
function waitForDashboardPortAnnouncement(child, options = {}) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
}

View File

@@ -14,12 +14,18 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
readDashboardReadyFile,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
// A minimal stand-in for a spawned child process: an EventEmitter with a
@@ -119,3 +125,75 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
})
})
// ---------------------------------------------------------------------------
// ready-file port announcement
// ---------------------------------------------------------------------------
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
}
}
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
} finally {
tmp.cleanup()
}
})
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
assert.equal(readDashboardReadyFile(tmp.file), null)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
assert.equal(await p, 8765)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
assert.equal(await p, 9876)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
} finally {
tmp.cleanup()
}
})

View File

@@ -179,7 +179,13 @@ function downloadInstallScript(commit, destPath) {
})
}
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
async function resolveInstallScript({
installStamp,
sourceRepoRoot,
hermesHome,
emit,
_download = downloadInstallScript
}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
@@ -293,15 +299,19 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
}))
const child = spawn(
ps,
fullArgs,
hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
)
let stdout = ''
let stderr = ''

View File

@@ -261,12 +261,7 @@ function cookiesHaveSession(cookies) {
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
module.exports = {

View File

@@ -138,10 +138,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(
`cd ${q(agentRoot)} 2>/dev/null || true`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
)
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
@@ -169,7 +166,15 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
*/
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
function buildWindowsCleanupScript({
desktopPid,
pythonExe,
pythonPath,
agentRoot,
uninstallArgs,
appPath,
hermesHome
}) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be

View File

@@ -101,10 +101,7 @@ test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
assert.equal(
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
'/opt/hermes/linux-unpacked'
)
assert.equal(resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}), '/opt/hermes/linux-unpacked')
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})

View File

@@ -92,9 +92,7 @@ async function readDirForIpc(dirPath, options = {}) {
try {
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
entryForDirent(dirent, resolved, fsImpl)
)
const entries = await mapWithStatConcurrency(visibleDirents, dirent => entryForDirent(dirent, resolved, fsImpl))
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))

View File

@@ -349,7 +349,10 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
assert.equal(result.error, undefined)
assert.equal(result.entries.length, names.length)
assert.equal(statCalls.length, names.length)
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
assert.equal(
statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)),
false
)
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
assert.deepEqual(
@@ -357,8 +360,5 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
expectedNames
)
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
assert.equal(
result.entries.filter(entry => entry.isDirectory).length,
successfulDirectoryNames.size
)
assert.equal(result.entries.filter(entry => entry.isDirectory).length, successfulDirectoryNames.size)
})

View File

@@ -0,0 +1,96 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const fsp = fs.promises
// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32
// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
async function mapLimit(items, limit, fn) {
let cursor = 0
async function worker() {
while (cursor < items.length) {
const index = cursor
cursor += 1
await fn(items[index])
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
async function walk(dir, depth) {
if (depth > maxDepth) {
return
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
}
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)
return
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
}
subdirs.push(path.join(dir, entry.name))
}
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
walk(root, 0)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
module.exports = { scanGitRepos }

View File

@@ -0,0 +1,684 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
const simpleGit = require('simple-git')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
const UNTRACKED_LINE_COUNT_CONCURRENCY = 16
const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024
// GUI-launched Electron apps on macOS inherit only a minimal PATH (no
// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out
// to — aren't found. Augment PATH with the resolved gh dir + the common
// package-manager bins so gh runs the same way it does in a terminal.
function ghEnv(ghBin) {
const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter(
dir => dir && dir !== '.'
)
return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) }
}
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin) {
return new Promise(resolve => {
execFile(
ghBin || 'gh',
args,
{ cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') })
)
})
}
function gitFor(cwd, gitBin) {
return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false })
}
// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve
// to the NEW path so the row addresses the real file for diff/stage.
function resolveRenamePath(raw) {
const path = String(raw || '').trim()
if (!path.includes(' => ')) {
return path
}
const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
if (brace) {
const [, prefix, , to, suffix] = brace
return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/')
}
return path.split(' => ').pop().trim()
}
// DiffResult.files → Map<path, {added, removed}> (binary files carry no line
// delta).
function countsByPath(summary) {
const map = new Map()
for (const file of summary.files) {
map.set(resolveRenamePath(file.file), {
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions
})
}
return map
}
// Untracked files don't appear in diffSummary(); count insertions from disk so
// the review tree can show +N for new files (matches an all-add diff view).
// Insertions = line count: newline bytes, plus one for a final unterminated
// line. Binary (NUL byte) → 0, mirroring git numstat's "-".
async function untrackedInsertions(cwd, relPath) {
try {
const fullPath = path.join(cwd, relPath)
const stat = await fs.stat(fullPath)
if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) {
return 0
}
const buf = await fs.readFile(fullPath)
if (buf.includes(0)) {
return 0
}
let lines = 0
for (const byte of buf) {
if (byte === 10) {
lines++
}
}
return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines
} catch {
return 0
}
}
function capText(text, maxChars, label = 'truncated') {
const value = String(text || '')
if (value.length <= maxChars) {
return value
}
return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n`
}
async function fillUntrackedCounts(cwd, files) {
const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0)
for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
await Promise.all(
pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => {
file.added = await untrackedInsertions(cwd, file.path)
})
)
}
}
// Resolve the base ref for "all branch changes": merge-base with the remote
// default branch (origin/HEAD), falling back to common trunk names.
async function branchBase(git) {
const candidates = []
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
if (head) {
candidates.push(head)
}
} catch {
// No origin/HEAD configured.
}
candidates.push('origin/main', 'origin/master', 'main', 'master')
for (const ref of candidates) {
try {
const base = (await git.raw(['merge-base', 'HEAD', ref])).trim()
if (base) {
return base
}
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// Resolve the repo's default branch NAME ("main" / "master" / …), preferring
// the remote's HEAD, then common local trunk names. Null when none is found
// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the
// trunk" regardless of which branch you're currently on.
async function defaultBranchName(git) {
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
// "origin/main" → "main"; skip the bare "origin/HEAD" placeholder.
if (head && head !== 'origin/HEAD') {
return head.replace(/^origin\//, '')
}
} catch {
// No origin/HEAD configured.
}
// Prefer a local trunk, then a remote-only one (returns the clean name either
// way) so "branch off main" works even before main is checked out locally.
for (const ref of [
'refs/heads/main',
'refs/heads/master',
'refs/remotes/origin/main',
'refs/remotes/origin/master'
]) {
try {
await git.raw(['rev-parse', '--verify', '--quiet', ref])
return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// A status file's single-letter classification, preferring the staged (index)
// code over the worktree code; untracked wins (simple-git marks both '?').
function statusLetter(file) {
if (file.index === '?' || file.working_dir === '?') {
return '?'
}
const code = file.index && file.index !== ' ' ? file.index : file.working_dir
return (code || 'M').toUpperCase()
}
const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?')
async function reviewList(repoPath, scope, baseRef, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' })
} catch {
return { files: [], base: null }
}
const git = gitFor(cwd, gitBin)
try {
if (scope === 'branch' || scope === 'lastTurn') {
const base = scope === 'branch' ? await branchBase(git) : baseRef
if (!base) {
return { files: [], base: null }
}
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
status: 'M',
staged: false
}))
// "Last turn" also surfaces files created since the baseline (untracked).
if (scope === 'lastTurn') {
const status = await git.status()
for (const path of status.not_added) {
if (!files.some(f => f.path === path)) {
files.push({ path, added: 0, removed: 0, status: '?', staged: false })
}
}
}
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base }
}
// Default: uncommitted (staged + unstaged + untracked), one row per path.
const [status, staged, unstaged] = await Promise.all([
git.status(),
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
const files = status.files.map(file => {
const filePath = resolveRenamePath(file.path)
const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 }
const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 }
return {
path: filePath,
added: sc.added + uc.added,
removed: sc.removed + uc.removed,
status: statusLetter(file),
staged: isStaged(file)
}
})
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base: null }
} catch {
return { files: [], base: null }
}
}
async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
if (scope === 'branch') {
const base = await branchBase(git)
return base ? safe([`${base}...HEAD`, '--', filePath]) : ''
}
if (scope === 'lastTurn') {
return baseRef ? safe([baseRef, '--', filePath]) : ''
}
if (staged) {
return safe(['--cached', '--', filePath])
}
const worktree = await safe(['--', filePath])
if (worktree.trim()) {
return worktree
}
// Untracked file: no worktree diff exists, so synthesize an all-add diff via
// --no-index (exits non-zero by design when files differ, so go around
// simple-git's reject-on-nonzero with a raw execFile).
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last
// commit" view used by the file preview. Unlike reviewDiff this never synthesizes
// a full-add for a clean tracked file (so a pristine file shows no diff); it only
// all-adds a genuinely untracked file.
async function fileDiffVsHead(repoPath, filePath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const head = await git.diff(['HEAD', '--', filePath]).catch(() => '')
if (head.trim()) {
return head
}
// No tracked changes vs HEAD. Only synthesize an all-add diff for a file git
// doesn't know yet; a clean tracked file must return empty.
const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '')
if (!status.trim().startsWith('??')) {
return ''
}
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
async function reviewStage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' })
await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A'])
return { ok: true }
}
async function reviewUnstage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' })
await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD'])
return { ok: true }
}
// Discard changes back to the committed state. Destructive — the renderer
// confirms first. Restores tracked files and removes untracked ones.
async function reviewRevert(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' })
const git = gitFor(cwd, gitBin)
if (filePath) {
await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined)
await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined)
} else {
await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined)
await git.raw(['clean', '-fd']).catch(() => undefined)
}
return { ok: true }
}
// Resolve a ref to a commit sha (captures the turn baseline for "Last turn").
async function reviewRevParse(repoPath, ref, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' })
} catch {
return null
}
try {
return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null
} catch {
return null
}
}
// Commit the working tree. Mirrors VS Code: if nothing is staged, stage
// everything first ("commit all"), then commit. Optionally push afterward,
// setting upstream on the first push.
async function reviewCommit(repoPath, message, push, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.staged.length === 0) {
await git.raw(['add', '-A'])
}
await git.commit(message)
if (push) {
const fresh = await git.status()
if (fresh.tracking) {
await git.push()
} else if (fresh.current) {
await git.raw(['push', '-u', 'origin', fresh.current])
}
}
return { ok: true }
}
// Gather the context the model needs to draft a commit message: the diff of
// what *will* be committed (staged when anything is staged, else everything
// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule),
// the names of untracked files (which carry no diff), and recent commit
// subjects for style. Diff is capped so the payload stays bounded. Reads only.
async function reviewCommitContext(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' })
} catch {
return { diff: '', recent: '' }
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
return { diff: '', recent: '' }
}
// What will land: staged changes if any, otherwise all tracked changes vs HEAD.
let diff = capText(
status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']),
COMMIT_CONTEXT_DIFF_MAX_CHARS,
'diff truncated for commit-message generation'
)
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
diff = diff ? `${diff}${note}` : note
}
const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '')
return { diff: diff || '', recent: String(recent || '').trim() }
}
async function reviewPush(repoPath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.tracking) {
await git.push()
} else if (status.current) {
await git.raw(['push', '-u', 'origin', status.current])
}
return { ok: true }
}
// gh availability + auth + whether this branch already has a PR. Reads only;
// drives the PR button's enabled/label state. `ghReady` is false when gh is
// missing OR not authenticated — either way the PR action can't run.
async function reviewShipInfo(repoPath, ghBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' })
} catch {
return { ghReady: false, pr: null }
}
const auth = await runGh(['auth', 'status'], cwd, ghBin)
if (!auth.ok) {
return { ghReady: false, pr: null }
}
const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin)
if (!view.ok) {
// gh exits non-zero when no PR exists for the branch — that's not an error.
return { ghReady: true, pr: null }
}
try {
const pr = JSON.parse(view.stdout)
return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null }
} catch {
return { ghReady: true, pr: null }
}
}
// Create a PR for the current branch (pushing first so gh has a remote ref),
// letting gh fill title/body from the commits. Returns the new PR url.
async function reviewCreatePr(repoPath, gitBin, ghBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' })
await reviewPush(repoPath, gitBin).catch(() => undefined)
const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin)
if (!created.ok) {
throw new Error('gh pr create failed (is gh installed and authenticated?)')
}
const url = created.stdout.trim().split('\n').filter(Boolean).pop() || ''
return { url }
}
// Compact working-tree status for the composer coding rail: branch, ahead/behind,
// per-state change counts, +/- vs HEAD, and a capped changed-file list.
async function repoStatus(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' })
} catch {
return null
}
// Session cwds can point at a deleted worktree for a moment (or forever in a
// stale row). simple-git throws at construction time on a missing baseDir, so
// fail soft and hide the coding rail instead of spamming IPC handler errors.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
} catch {
return null
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
status = await git.status()
} catch {
// Not a repo / git unavailable / remote backend.
return null
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'),
untracked: file.index === '?' || file.working_dir === '?',
conflicted: file.index === 'U' || file.working_dir === 'U'
}))
const result = {
branch: detached ? null : status.current || null,
defaultBranch: await defaultBranchName(git),
detached,
ahead: status.ahead || 0,
behind: status.behind || 0,
staged: files.filter(f => f.staged).length,
unstaged: files.filter(f => f.unstaged).length,
untracked: status.not_added.length,
conflicted: status.conflicted.length,
changed: files.length,
added: 0,
removed: 0,
files: files.slice(0, 200)
}
// +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0.
try {
const summary = await git.diffSummary(['HEAD'])
result.added = summary.insertions
result.removed = summary.deletions
} catch {
// No commits yet.
}
// `git diff HEAD` ignores untracked files, so a turn that only creates new
// files (the common case — a fresh module, a demo dir) showed +0 in the rail
// while the review pane counted them. Fold untracked insertions into `added`
// so the rail matches reality. Bounded (size cap + concurrency) like the
// review tree; only the capped file slice is counted so a huge untracked tree
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
// Best-effort: a probe failure just leaves untracked lines uncounted.
}
return result
}
module.exports = {
branchBase,
fileDiffVsHead,
repoStatus,
resolveRenamePath,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
}

View File

@@ -0,0 +1,22 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
})
test('resolveRenamePath: simple rename resolves to the new path', () => {
assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts')
})
test('resolveRenamePath: brace rename resolves to the new path', () => {
assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts')
})
test('resolveRenamePath: brace rename collapsing a segment', () => {
assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts')
})

View File

@@ -0,0 +1,350 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these.
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function runGit(gitBin, args, cwd) {
return new Promise((resolve, reject) => {
execFile(
gitBin,
args,
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
err.stderr = String(stderr || '')
reject(err)
return
}
resolve(String(stdout || ''))
}
)
})
}
// Parse `git worktree list --porcelain`. The first record is the main worktree.
function parseWorktrees(out) {
const trees = []
let cur = null
for (const line of out.split('\n')) {
if (line.startsWith('worktree ')) {
if (cur) {
trees.push(cur)
}
cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false }
} else if (!cur) {
continue
} else if (line.startsWith('branch ')) {
cur.branch = line
.slice(7)
.trim()
.replace(/^refs\/heads\//, '')
} else if (line === 'detached') {
cur.detached = true
} else if (line === 'bare') {
cur.bare = true
} else if (line.startsWith('locked')) {
cur.locked = true
}
}
if (cur) {
trees.push(cur)
}
return trees
}
async function listWorktrees(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' })
} catch {
return []
}
try {
const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved)
return parseWorktrees(out).map((tree, index) => ({
path: tree.path,
branch: tree.branch,
isMain: index === 0,
detached: tree.detached,
locked: tree.locked
}))
} catch {
return []
}
}
// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges),
// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad
// value can't reach `git` no matter the caller (the GUI also enforces live).
function sanitizeBranch(name) {
return String(name || '')
.replace(/\s+/g, '-')
.replace(/[^\w./-]/g, '')
.replace(/-{2,}/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\.{2,}/g, '.')
.replace(/^[-./]+|[-./]+$/g, '')
}
function slugify(name) {
const slug = String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
return slug || 'work'
}
const TRUNK_BRANCHES = ['main', 'master']
async function gitLine(gitBin, args, cwd) {
try {
return (await runGit(gitBin, args, cwd)).trim()
} catch {
return ''
}
}
async function defaultBranch(gitBin, cwd) {
const remote = (
await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
).replace(/^origin\//, '')
if (remote) {
return remote
}
const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd)
if (configured) {
return configured
}
for (const branch of TRUNK_BRANCHES) {
if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) {
return branch
}
}
return ''
}
// A brand-new project folder isn't a git repo — and a freshly-init'd one has no
// commit to branch from — so `git worktree add` would fail. Make the dir a repo
// with a root commit on the user's behalf so worktrees "just work". No-op for a
// repo that already has commits; never touches the user's files (the seed commit
// is `--allow-empty`), and never inits a dir that already lives inside a repo.
async function ensureGitRepo(gitBin, dir) {
let needsRoot = false
try {
const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim()
if (inside !== 'true') {
await runGit(gitBin, ['init'], dir)
needsRoot = true
} else {
// Repo exists; a worktree still needs a HEAD to branch from.
try {
await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir)
} catch {
needsRoot = true
}
}
} catch {
await runGit(gitBin, ['init'], dir)
needsRoot = true
}
if (needsRoot) {
// Inline identity so the seed commit lands even with no global git config.
await runGit(
gitBin,
[
'-c',
'user.email=hermes@localhost',
'-c',
'user.name=Hermes',
'commit',
'--allow-empty',
'-m',
'Initial commit'
],
dir
)
}
}
// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the
// primary checkout even when called from a linked worktree.
async function mainRoot(gitBin, cwd) {
const list = await listWorktrees(cwd, gitBin)
const main = list.find(tree => tree.isMain)
return main ? main.path : cwd
}
function uniqueDir(base) {
let dir = base
let n = 1
while (fs.existsSync(dir)) {
n += 1
dir = `${base}-${n}`
}
return dir
}
async function addExistingBranchWorktree(gitBin, root, name) {
const branch = sanitizeBranch(name)
if (!branch) {
throw new Error('Branch name is required.')
}
if (branch === (await defaultBranch(gitBin, root))) {
await runGit(gitBin, ['switch', branch], root)
return { path: root, branch, repoRoot: root }
}
const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch)))
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
return { path: dir, branch, repoRoot: root }
}
async function addWorktree(repoPath, options, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' })
// A new project's folder may not be a git repo yet — init it (with a root
// commit) so the worktree has something to branch from.
await ensureGitRepo(gitBin, resolved)
const root = await mainRoot(gitBin, resolved)
const opts = options || {}
if (opts.existingBranch) {
return addExistingBranchWorktree(gitBin, root, opts.existingBranch)
}
const slug = slugify(opts.name || `work-${Date.now().toString(36)}`)
const branch = sanitizeBranch(opts.branch) || `hermes/${slug}`
const dir = uniqueDir(path.join(root, '.worktrees', slug))
const args = ['worktree', 'add', '-b', branch, dir]
if (opts.base) {
args.push(String(opts.base))
}
try {
await runGit(gitBin, args, root)
} catch (err) {
// Branch name may already exist — retry checking out the existing branch
// into a fresh worktree dir instead of failing the whole flow.
if (/already exists/i.test(err.stderr || '')) {
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
} else {
throw err
}
}
return { path: dir, branch, repoRoot: root }
}
async function removeWorktree(repoPath, worktreePath, options, gitBin) {
const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' })
const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' })
const root = await mainRoot(gitBin, resolvedRepo)
const args = ['worktree', 'remove']
if (options && options.force) {
args.push('--force')
}
args.push(resolvedTree)
await runGit(gitBin, args, root)
return { removed: resolvedTree }
}
// List local branches for the "convert a branch into a worktree" picker, most
// recently committed first. Each carries whether it's already checked out in a
// worktree and, when checked out, that worktree's path. Empty on a non-repo /
// remote backend where the probe can't run.
async function listBranches(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' })
} catch {
return []
}
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
return out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(name => ({
name,
checkedOut: pathByBranch.has(name),
isDefault: Boolean(trunk && name === trunk),
worktreePath: pathByBranch.get(name) || null
}))
} catch {
return []
}
}
async function switchBranch(repoPath, branch, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' })
const target = sanitizeBranch(branch)
if (!target) {
throw new Error('Branch name is required.')
}
await runGit(gitBin, ['switch', target], resolved)
return { branch: target }
}
module.exports = {
addWorktree,
ensureGitRepo,
listBranches,
listWorktrees,
parseWorktrees,
removeWorktree,
sanitizeBranch,
switchBranch
}

View File

@@ -0,0 +1,214 @@
'use strict'
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} = require('./git-worktree-ops.cjs')
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing')
assert.equal(sanitizeBranch(' wip~^:? '), 'wip')
assert.equal(sanitizeBranch('///'), '')
})
test('parseWorktrees: main checkout + linked worktree', () => {
const out = [
'worktree /repo',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /repo/.worktrees/feat',
'HEAD def456',
'branch refs/heads/hermes/feat',
''
].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 2)
assert.equal(trees[0].path, '/repo')
assert.equal(trees[0].branch, 'main')
assert.equal(trees[1].path, '/repo/.worktrees/feat')
assert.equal(trees[1].branch, 'hermes/feat')
})
test('parseWorktrees: detached + locked flags', () => {
const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 1)
assert.equal(trees[0].detached, true)
assert.equal(trees[0].locked, true)
assert.equal(trees[0].branch, null)
})
test('parseWorktrees: empty input', () => {
assert.deepEqual(parseWorktrees(''), [])
})
test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/)
// The whole point: a worktree can now branch off the seeded root commit.
execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir })
assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt')))
// Idempotent: an already-committed repo gets no extra commit.
await ensureGitRepo('git', dir)
assert.equal(git('rev-list', '--count', 'HEAD'), '1')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('switchBranch: switches a normal checkout branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
await switchBranch(dir, 'feature', 'git')
assert.equal(git('branch', '--show-current'), 'feature')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: lists locals and flags the checked-out branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-'))
try {
await ensureGitRepo('git', dir)
const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim()
execFileSync('git', ['branch', 'feature'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const names = branches.map(b => b.name).sort()
assert.deepEqual(names, [current, 'feature'].sort())
// The repo's own checkout is flagged; the unused branch is convertible.
assert.equal(branches.find(b => b.name === current).checkedOut, true)
assert.equal(branches.find(b => b.name === current).isDefault, true)
assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir))
assert.equal(branches.find(b => b.name === 'feature').checkedOut, false)
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
assert.equal(branches.find(b => b.name === 'feature').worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: flags a free default branch as default, not checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const defaultBranch = branches.find(b => b.name === trunk)
assert.equal(defaultBranch.checkedOut, false)
assert.equal(defaultBranch.isDefault, true)
assert.equal(defaultBranch.worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: a branch claimed by a worktree is flagged checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-'))
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
// addWorktree converts the existing "feature" branch into a worktree.
const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git')
assert.equal(result.branch, 'feature')
assert.ok(fs.existsSync(result.path))
const branches = await listBranches(dir, 'git')
assert.equal(branches.find(b => b.name === 'feature').checkedOut, true)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: empty on a non-repo path', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-'))
try {
assert.deepEqual(await listBranches(dir, 'git'), [])
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existingBranch checks the branch out without a new branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'cool/feature'], { cwd: dir })
const before = git('branch', '--list').split('\n').length
const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git')
// No new branch was created — only the existing one is checked out.
assert.equal(git('branch', '--list').split('\n').length, before)
assert.equal(result.branch, 'cool/feature')
// Dir is named off the branch slug, nested under the main repo's .worktrees.
assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/)
assert.equal(
execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(),
'cool/feature'
)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const result = await addWorktree(dir, { existingBranch: trunk }, 'git')
assert.equal(result.branch, trunk)
assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir))
assert.equal(git('branch', '--show-current'), trunk)
assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})

View File

@@ -1,174 +0,0 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}

View File

@@ -186,7 +186,10 @@ async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}
@@ -201,7 +204,10 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}

View File

@@ -21,7 +21,6 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
@@ -38,11 +37,13 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { createLinkTitleWindow } = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { readLiveUpdateMarker } = require('./update-marker.cjs')
const {
@@ -55,7 +56,23 @@ const {
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { scanGitRepos } = require('./git-repo-scan.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
@@ -170,6 +187,16 @@ if (REMOTE_DISPLAY_REASON) {
)
}
// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag.
// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote
// display already forced software (SSH'd-into-WSL).
if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
app.commandLine.appendSwitch('ignore-gpu-blocklist')
app.commandLine.appendSwitch('enable-gpu-rasterization')
app.commandLine.appendSwitch('enable-zero-copy')
console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration')
}
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
// Keep the renderer running at full speed while the window is in the background
@@ -302,9 +329,7 @@ function hermesManagedNodePathEntries() {
}
function pathWithHermesManagedNode(...entries) {
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH]
.filter(Boolean)
.join(path.delimiter)
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter)
}
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
@@ -382,14 +407,10 @@ const WINDOW_BUTTON_POSITION = {
x: 24,
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
}
// Width Electron reserves for the Windows/Linux native min/max/close cluster
// when `titleBarOverlay` is enabled. The OS paints these buttons in the
// top-right corner of the renderer; we have to leave that much room on the
// right edge so our system tools (file browser, haptics, settings) don't sit
// underneath them. macOS uses left-side traffic lights instead and reports a
// position via getWindowButtonPosition(), so this width is non-zero only on
// non-macOS platforms.
const NATIVE_OVERLAY_BUTTON_WIDTH = 144
// Right-edge window-control reservation lives in titlebar-overlay-width.cjs
// (pure + unit-testable); computeNativeOverlayWidth() applies it per platform.
// It's only the pre-layout fallback — the renderer measures the exact overlay
// width live via the Window Controls Overlay API.
const APP_ICON_PATHS = [
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
@@ -503,25 +524,48 @@ function getWindowBackgroundColor() {
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
}
// Transparent WCO — renderer chrome shows through. rgba(0,0,0,0) can fall back
// to GetFrameColor() on some Electron builds; rgba(1,0,0,0) is the escape hatch.
const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)'
function getTitleBarOverlayOptions() {
if (IS_MAC) {
return { height: TITLEBAR_HEIGHT }
}
if (rendererTitleBarTheme) {
return {
color: rendererTitleBarTheme.background,
height: TITLEBAR_HEIGHT,
symbolColor: rendererTitleBarTheme.foreground
}
// Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden
// titlebar still applies).
if (!IS_WINDOWS && !IS_WSL) {
return false
}
const useDarkColors = nativeTheme.shouldUseDarkColors
return {
color: useDarkColors ? '#111111' : '#f7f7f7',
color: TITLEBAR_OVERLAY_COLOR,
height: TITLEBAR_HEIGHT,
symbolColor: useDarkColors ? '#f7f7f7' : '#242424'
symbolColor:
rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground)
? rendererTitleBarTheme.foreground
: nativeTheme.shouldUseDarkColors
? '#f7f7f7'
: '#242424'
}
}
// Push refreshed overlay options to a live window after a theme/appearance
// change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions()
// returns false; the try/catch additionally guards builds where
// setTitleBarOverlay isn't supported.
function applyTitleBarOverlay(win) {
const options = getTitleBarOverlayOptions()
if (!options || typeof options !== 'object') {
return
}
try {
win?.setTitleBarOverlay?.(options)
} catch {
// Overlay not supported on this platform/build — leave the frameless
// titlebar as-is.
}
}
@@ -744,6 +788,9 @@ let rendererReloadTimes = []
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
// Latched non-bootstrap backend spawn failure — stops getConnection() from
// respawning hermes dashboard children in a tight loop while boot is broken.
let backendStartFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
@@ -1254,6 +1301,36 @@ function isCommandScript(command) {
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
}
function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
if (!IS_WINDOWS || !command || isCommandScript(command)) return null
const resolved = path.resolve(String(command))
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null
const scriptsDir = path.dirname(resolved)
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null
const venvRoot = path.dirname(scriptsDir)
const python = getNoConsoleVenvPython(venvRoot)
if (!fileExists(python)) return null
const root = path.dirname(venvRoot)
return {
label: `existing Hermes no-console Python at ${python}`,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
kind: 'python',
readyFile: true,
shell: false
}
}
function normalizeExecutablePathForCompare(commandPath) {
if (!commandPath) return null
@@ -1474,6 +1551,97 @@ function getVenvPython(venvRoot) {
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
}
function readVenvHome(venvRoot) {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im)
return match ? match[1].trim() : null
} catch {
return null
}
}
function getNoConsoleVenvPython(venvRoot) {
if (!IS_WINDOWS) return getVenvPython(venvRoot)
// Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
// wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
// and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
if (fileExists(venvPythonw)) return venvPythonw
const baseHome = readVenvHome(venvRoot)
if (baseHome) {
const basePythonw = path.join(baseHome, 'pythonw.exe')
if (fileExists(basePythonw)) return basePythonw
}
return venvPythonw
}
function toNoConsolePython(pythonPath) {
if (!IS_WINDOWS || !pythonPath) return pythonPath
const resolved = String(pythonPath)
if (/pythonw\.exe$/i.test(resolved)) return resolved
if (/python\.exe$/i.test(resolved)) {
const pythonw = path.join(path.dirname(resolved), 'pythonw.exe')
if (fileExists(pythonw)) return pythonw
}
return pythonPath
}
function applyWindowsNoConsoleSpawnHints(backend) {
if (!IS_WINDOWS || !backend?.command) return backend
const usesHermesModule =
backend.kind === 'python' ||
(Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
if (!usesHermesModule) return backend
backend.command = toNoConsolePython(backend.command)
if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) {
backend.readyFile = true
}
return backend
}
function getVenvSitePackagesEntries(venvRoot) {
const entries = []
if (!venvRoot) return entries
if (IS_WINDOWS) {
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
if (directoryExists(sitePackages)) entries.push(sitePackages)
return entries
}
const version = (() => {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
return match ? match[1].trim() : null
} catch {
return null
}
})()
if (version) {
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
if (directoryExists(sitePackages)) entries.push(sitePackages)
}
return entries
}
function makeDashboardReadyFile() {
const dir = path.join(app.getPath('userData'), 'backend-ready')
fs.mkdirSync(dir, { recursive: true })
return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`)
}
// resolveGitBinary — locate git.exe on Windows. A fresh installer-driven
// install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on
// PATH), so a bare spawn('git') ENOENTs and self-update checks fail with
@@ -1503,6 +1671,30 @@ function resolveGitBinary() {
return _gitBinaryCache
}
// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH
// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually
// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's
// terminal. Check the common install locations first, then PATH. Cached.
let _ghBinaryCache = null
function resolveGhBinary() {
if (_ghBinaryCache) return _ghBinaryCache
const candidates = []
if (IS_WINDOWS) {
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'))
if (process.env.LOCALAPPDATA) {
candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe'))
}
} else {
const home = app.getPath('home')
candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh'))
}
_ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh'
return _ghBinaryCache
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
@@ -1982,7 +2174,8 @@ async function applyUpdates(opts = {}) {
emitUpdateProgress({
stage: 'restart',
message: 'Updating Hermes — this window will close and the updater will open. Dont reopen Hermes yourself; it restarts automatically when the update finishes.',
message:
'Updating Hermes — this window will close and the updater will open. Dont reopen Hermes yourself; it restarts automatically when the update finishes.',
percent: 100
})
repairMacUpdaterHelper(updater)
@@ -2065,7 +2258,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
rememberLog(
`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`
)
// Same dwell as the in-app update hand-off (#50419): give the updater's
// window time to appear before we vanish, so the recovery doesn't look like
// a crash and provoke a mid-recovery relaunch.
@@ -2590,20 +2785,24 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
const python = findPythonForRoot(root)
if (!python) return null
return {
const venvRoot = path.join(root, 'venv')
const venvPython = getVenvPython(venvRoot)
const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
label,
command: python,
command,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot: path.join(root, 'venv')
venvRoot
}),
root,
bootstrap: Boolean(options.bootstrap),
shell: false
}
})
}
// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the
@@ -2612,11 +2811,12 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
// ensureRuntime() to create / refresh it before launch.
function createActiveBackend(dashboardArgs) {
const venvPython = getVenvPython(VENV_ROOT)
const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
return {
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
command,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
@@ -2626,7 +2826,7 @@ function createActiveBackend(dashboardArgs) {
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
}
})
}
function resolveHermesBackend(dashboardArgs) {
@@ -2687,6 +2887,11 @@ function resolveHermesBackend(dashboardArgs) {
}
if (hermesCommand) {
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs)
if (unwrapped) {
return unwrapped
}
// Smoke-test the candidate before trusting it. A `hermes` shim
// left behind by a half-uninstalled pip install (or a venv
// entry-point pointing at a deleted interpreter) still resolves
@@ -2696,15 +2901,17 @@ function resolveHermesBackend(dashboardArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
return (
unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
)
}
rememberLog(
`Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.`
@@ -2726,15 +2933,15 @@ function resolveHermesBackend(dashboardArgs) {
// failure, fall through to step 6 so the bootstrap runner pulls
// a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv.
if (canImportHermesCli(python)) {
return {
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
label: `installed hermes_cli module via ${python}`,
command: python,
command: toNoConsolePython(python),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: {},
shell: false
}
})
}
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
}
@@ -2768,7 +2975,7 @@ function resolveHermesBackend(dashboardArgs) {
async function ensureRuntime(backend) {
if (!backend.bootstrap) {
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
return backend
return applyWindowsNoConsoleSpawnHints(backend)
}
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
@@ -2784,7 +2991,9 @@ async function ensureRuntime(backend) {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
const handoffError = new Error(
'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.'
)
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
@@ -2908,7 +3117,7 @@ async function ensureRuntime(backend) {
)
}
backend.command = venvPython
backend.command = getNoConsoleVenvPython(VENV_ROOT)
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
updateBootProgress({
phase: 'runtime.ready',
@@ -2917,10 +3126,9 @@ async function ensureRuntime(backend) {
running: true,
error: null
})
return backend
return applyWindowsNoConsoleSpawnHints(backend)
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
@@ -3579,11 +3787,7 @@ function getWindowButtonPosition() {
}
function getNativeOverlayWidth() {
// macOS reports traffic-light coords via windowButtonPosition; the
// titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
// render the native window-controls overlay on the right, so the renderer
// needs to inset its right cluster by this much to clear them.
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL })
}
function getWindowState() {
@@ -4831,6 +5035,7 @@ function resetBootProgressForReconnect() {
function resetHermesConnection() {
connectionPromise = null
backendStartFailure = null
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
@@ -4992,6 +5197,7 @@ async function spawnPoolBackend(profile, entry) {
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -5012,7 +5218,8 @@ async function spawnPoolBackend(profile, entry) {
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
HERMES_WEB_DIST: webDist,
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -5045,7 +5252,10 @@ async function spawnPoolBackend(profile, entry) {
})
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(child), startFailed])
const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
@@ -5158,6 +5368,9 @@ async function startHermes() {
if (bootstrapFailure) {
throw bootstrapFailure
}
if (backendStartFailure) {
throw backendStartFailure
}
if (connectionPromise) return connectionPromise
connectionPromise = (async () => {
@@ -5211,6 +5424,7 @@ async function startHermes() {
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
@@ -5237,7 +5451,8 @@ async function startHermes() {
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
HERMES_WEB_DIST: webDist,
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -5293,12 +5508,19 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
const port = await Promise.race([
waitForDashboardPortAnnouncement(hermesProcess, { readyFile }),
backendStartFailed
])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
backendStartFailure = null
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
@@ -5324,6 +5546,7 @@ async function startHermes() {
}
})().catch(error => {
const message = error instanceof Error ? error.message : String(error)
backendStartFailure = error instanceof Error ? error : new Error(message)
updateBootProgress(
{
error: message,
@@ -5623,7 +5846,7 @@ function createWindow() {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
applyTitleBarOverlay(mainWindow)
})
}
}
@@ -5807,19 +6030,32 @@ ipcMain.handle('hermes:pet-overlay:close', async () => {
return { ok: true }
})
// Drag: the overlay reports a new absolute screen position (it already knows the
// pointer's screen coords), we just move the window.
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
// The window is created non-resizable (no stray edge-drag on the transparent
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
// sizing — so briefly flip resizable on whenever the size actually changes.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
const win = petOverlayWindow
const width = Math.max(80, Math.round(bounds.width))
const height = Math.max(80, Math.round(bounds.height))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
if (resizing) {
win.setResizable(false)
}
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
@@ -5889,6 +6125,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
backendStartFailure = null
bootstrapState = {
active: false,
manifest: null,
@@ -5915,6 +6152,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
backendStartFailure = null
resetHermesConnection()
return { ok: true }
})
@@ -6283,11 +6521,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
ipcMain.handle('hermes:saveClipboardImage', async () => {
const image = clipboard.readImage()
if (!image || image.isEmpty()) {
return ''
if (image && !image.isEmpty()) {
return writeComposerImage(image.toPNG(), '.png')
}
return writeComposerImage(image.toPNG(), '.png')
// WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
// Linux clipboard Electron reads, so a host screenshot looks empty above.
// Pull it straight off the Windows clipboard via PowerShell as a fallback.
if (IS_WSL) {
const png = readWslWindowsClipboardImage()
if (png) {
return writeComposerImage(png, '.png')
}
}
return ''
})
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
@@ -6307,7 +6555,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
background: payload.background,
foreground: payload.foreground
}
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
applyTitleBarOverlay(mainWindow)
})
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
@@ -6596,7 +6844,160 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {

View File

@@ -30,5 +30,8 @@ test('setJsonRequestHeaders does not set Electron-restricted Content-Length', ()
setJsonRequestHeaders(request)
assert.deepEqual(headers, [['Content-Type', 'application/json']])
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
assert.equal(
headers.some(([name]) => name.toLowerCase() === 'content-length'),
false
)
})

View File

@@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
git: {
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
worktreeRemove: (repoPath, worktreePath, options) =>
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
review: {
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
diff: (repoPath, filePath, scope, baseRef, staged) =>
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),

View File

@@ -0,0 +1,11 @@
// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right).
// Live width comes from navigator.windowControlsOverlay in the renderer.
const OVERLAY_FALLBACK_WIDTH = 144
/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */
function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) {
return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0
}
module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth }

View File

@@ -0,0 +1,29 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
// This static reservation is only the pre-layout FALLBACK. Once laid out the
// renderer reads the exact width from navigator.windowControlsOverlay
// (use-window-controls-overlay-width.ts) and uses these values only when the WCO
// API is unavailable.
test('Windows reserves the overlay fallback width', () => {
assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH)
})
test('WSLg paints the same WCO, so it reserves the same fallback width', () => {
// The original bug: WSL fell through to 0, so the right tools sat under the
// controls and the title overran into them.
assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH)
})
test('plain Linux and macOS reserve nothing', () => {
assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0)
assert.equal(nativeOverlayWidth(), 0)
assert.equal(nativeOverlayWidth({}), 0)
})
test('the fallback width is a sane positive pixel value', () => {
assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0)
})

View File

@@ -7,45 +7,81 @@ const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: false
}),
1
)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
isShallow: true, hasMergeBase: false,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'abc',
targetSha: 'abc',
isShallow: true,
hasMergeBase: false
}),
0
)
})
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
assert.equal(resolveBehindCount({
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: true,
}), 3)
assert.equal(
resolveBehindCount({
countStr: '3',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: true
}),
3
)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(resolveBehindCount({
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 7)
assert.equal(
resolveBehindCount({
countStr: '7',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false,
hasMergeBase: true
}),
7
)
})
test('up-to-date full clone reports 0', () => {
assert.equal(resolveBehindCount({
countStr: '0', currentSha: 'x', targetSha: 'x',
isShallow: false, hasMergeBase: true,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '0',
currentSha: 'x',
targetSha: 'x',
isShallow: false,
hasMergeBase: true
}),
0
)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false,
hasMergeBase: true
}),
0
)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
@@ -68,12 +104,24 @@ test('full (non-shallow) clone always runs the count', () => {
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'same', targetSha: 'same',
isShallow: true, hasMergeBase: false,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: false
}),
1
)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'same',
targetSha: 'same',
isShallow: true,
hasMergeBase: false
}),
0
)
})

View File

@@ -62,7 +62,10 @@ test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolv
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
// dev electron
assert.equal(resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), null)
assert.equal(
resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'),
null
)
// empty / missing
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)

View File

@@ -39,7 +39,9 @@ function canonicalGitHubRemote(url) {
}
function isSshRemote(url) {
const value = String(url || '').trim().toLowerCase()
const value = String(url || '')
.trim()
.toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}

View File

@@ -26,7 +26,11 @@ const REQUEST_TIMEOUT_MS = 20_000
const ID_RE = /^[\w-]+\.[\w-]+$/
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
function request(
url,
{ method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
redirectsLeft = MAX_REDIRECTS
) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0
@@ -42,7 +46,13 @@ function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MA
const next = new URL(res.headers.location, url).toString()
res.resume()
// Redirects to the CDN are plain GETs (drop the POST body).
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
resolve(
request(
next,
{ method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes },
redirectsLeft - 1
)
)
return
}

View File

@@ -26,7 +26,16 @@ const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
// ─── sanitizeWindowState ───────────────────────────────────────────────────
test('sanitizeWindowState rejects missing/garbage input', () => {
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
for (const bad of [
null,
undefined,
'nope',
42,
{},
{ width: 'x', height: 800 },
{ width: NaN, height: 800 },
{ width: 1000 }
]) {
assert.equal(sanitizeWindowState(bad), null)
}
})
@@ -112,9 +121,13 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
const d = debounce(() => {
calls += 1
}, 250)
d(); d(); d()
d()
d()
d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
assert.equal(calls, 0)
@@ -125,7 +138,9 @@ test('debounce coalesces a burst into one trailing run', t => {
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
const d = debounce(() => {
calls += 1
}, 250)
d()
d.flush()

View File

@@ -12,7 +12,8 @@ function readElectronFile(name) {
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
const match = needle instanceof RegExp ? needle.exec(source) : null
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
@@ -28,14 +29,28 @@ test('desktop background child processes opt into hidden Windows consoles', () =
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
assert.match(source, /existing Hermes no-console Python at/)
assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/)
assert.match(source, /function toNoConsolePython\(pythonPath\)/)
assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/)
assert.match(source, /function readVenvHome\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/)
assert.match(source, /backendStartFailure/)
assert.match(source, /HERMES_DESKTOP_READY_FILE/)
assert.match(source, /readyFile: true/)
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
})
test('intentional or interactive desktop child processes stay documented', () => {

View File

@@ -21,8 +21,7 @@ const { execFileSync } = require('node:child_process')
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
@@ -47,10 +46,7 @@ function expandWindowsEnvRefs(value, env = process.env) {
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) {
if (platform !== 'win32' || !name) return null
let stdout
try {

View File

@@ -1,21 +1,12 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
const out = ['', 'HKEY_CURRENT_USER\\Environment', ' HERMES_HOME REG_SZ F:\\Hermes\\data', ''].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
@@ -39,10 +30,7 @@ test('parseRegQueryValue returns null when the value line is absent', () => {
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
assert.equal(expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), 'C:\\Users\\jeff\\h')
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {

View File

@@ -14,11 +14,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return false
}
const roots = new Set(
(installRoots ?? [])
.filter(Boolean)
.map(candidate => path.resolve(String(candidate)))
)
const roots = new Set((installRoots ?? []).filter(Boolean).map(candidate => path.resolve(String(candidate))))
for (const root of roots) {
if (resolved === root) {

View File

@@ -13,33 +13,21 @@ const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
const installRoot = path.resolve('/opt/Hermes')
test('isPackagedInstallPath returns false when not packaged', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
false
)
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), false)
})
test('isPackagedInstallPath flags the install root itself', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
true
)
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath flags paths nested under the install root', () => {
const nested = path.join(installRoot, 'resources', 'app.asar')
assert.equal(
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
true
)
assert.equal(isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath ignores paths outside the install root', () => {
const homeProject = path.resolve('/home/user/projects/demo')
assert.equal(
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
false
)
assert.equal(isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), false)
})

View File

@@ -0,0 +1,92 @@
// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg
// bridges text but not images). Returns PNG bytes or null; exec injectable.
const { execFileSync } = require('node:child_process')
// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException
// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
// survives stdout's text decoding intact, and write with [Console]::Out.Write
// to avoid a trailing newline.
const PS_SCRIPT = [
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
'if ($null -eq $img) { exit 0 }',
'$ms = New-Object System.IO.MemoryStream',
'$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)',
'[Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))'
].join('\n')
// PowerShell's -EncodedCommand takes UTF-16LE base64. Encoding the whole script
// this way sidesteps every layer of WSL→Windows quoting (spaces, quotes,
// brackets, newlines) that plain -Command arguments would mangle.
function encodePowerShellCommand(script) {
return Buffer.from(String(script), 'utf16le').toString('base64')
}
// Locate powershell.exe. The bare name resolves through WSL's Windows-interop
// PATH on every standard WSL2 setup; the absolute fallback covers a stripped
// PATH. Returns the first candidate — execFile surfaces ENOENT if it's wrong
// and we fall back to null.
function powershellCandidates() {
return ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
}
function decodeClipboardImageBase64(stdout) {
const b64 = String(stdout || '').trim()
if (!b64) return null
let buffer
try {
buffer = Buffer.from(b64, 'base64')
} catch {
return null
}
// Guard against partial / garbage output: require a real PNG signature.
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
return null
}
return buffer
}
// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or
// null when there's no image, PowerShell is unreachable, or output is invalid.
// Linux-only by contract (caller gates on IS_WSL); never throws.
function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) {
const encoded = encodePowerShellCommand(PS_SCRIPT)
for (const ps of candidates) {
try {
const stdout = exec(
ps,
['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
{
encoding: 'utf8',
windowsHide: true,
timeout: 8000,
// A 4K screenshot base64s to a few MB; give stdout generous headroom.
maxBuffer: 64 * 1024 * 1024,
// PowerShell writes progress/CLIXML noise to stderr — ignore it.
stdio: ['ignore', 'pipe', 'ignore']
}
)
const decoded = decodeClipboardImageBase64(stdout)
if (decoded) return decoded
// Empty stdout = no image on the clipboard; stop, don't try fallbacks.
if (String(stdout || '').trim() === '') return null
} catch {
// This powershell.exe candidate is missing/failed — try the next one.
}
}
return null
}
module.exports = {
decodeClipboardImageBase64,
encodePowerShellCommand,
powershellCandidates,
readWslWindowsClipboardImage
}

View File

@@ -0,0 +1,114 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const {
decodeClipboardImageBase64,
encodePowerShellCommand,
powershellCandidates,
readWslWindowsClipboardImage
} = require('./wsl-clipboard-image.cjs')
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function fakePngBuffer(extraBytes = 16) {
return Buffer.concat([PNG_SIGNATURE, Buffer.alloc(extraBytes, 0x42)])
}
test('encodePowerShellCommand produces UTF-16LE base64 PowerShell can decode', () => {
const encoded = encodePowerShellCommand('Write-Output "hi"')
const roundTripped = Buffer.from(encoded, 'base64').toString('utf16le')
assert.equal(roundTripped, 'Write-Output "hi"')
})
test('decodeClipboardImageBase64 returns a Buffer for valid PNG base64', () => {
const png = fakePngBuffer()
const decoded = decodeClipboardImageBase64(png.toString('base64'))
assert.ok(Buffer.isBuffer(decoded))
assert.ok(decoded.equals(png))
})
test('decodeClipboardImageBase64 trims surrounding whitespace before decoding', () => {
const png = fakePngBuffer()
const decoded = decodeClipboardImageBase64(`\n ${png.toString('base64')} \r\n`)
assert.ok(decoded && decoded.equals(png))
})
test('decodeClipboardImageBase64 returns null for empty / whitespace input', () => {
assert.equal(decodeClipboardImageBase64(''), null)
assert.equal(decodeClipboardImageBase64(' \n '), null)
assert.equal(decodeClipboardImageBase64(null), null)
assert.equal(decodeClipboardImageBase64(undefined), null)
})
test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => {
// Valid base64, but the decoded bytes are not a PNG.
const notPng = Buffer.from('this is not a png at all').toString('base64')
assert.equal(decodeClipboardImageBase64(notPng), null)
})
test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => {
const png = fakePngBuffer()
const calls = []
const exec = (cmd, args) => {
calls.push({ cmd, args })
return png.toString('base64')
}
const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
assert.ok(result && result.equals(png))
assert.equal(calls.length, 1)
assert.equal(calls[0].cmd, 'powershell.exe')
// -STA is mandatory for System.Windows.Forms.Clipboard.
assert.ok(calls[0].args.includes('-STA'))
assert.ok(calls[0].args.includes('-EncodedCommand'))
})
test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => {
let count = 0
const exec = () => {
count += 1
return ''
}
const result = readWslWindowsClipboardImage({
exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
})
assert.equal(result, null)
// Empty stdout means "no image on the clipboard" — don't probe further candidates.
assert.equal(count, 1)
})
test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => {
const png = fakePngBuffer()
const seen = []
const exec = cmd => {
seen.push(cmd)
if (cmd === 'powershell.exe') {
throw Object.assign(new Error('not found'), { code: 'ENOENT' })
}
return png.toString('base64')
}
const result = readWslWindowsClipboardImage({
exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
})
assert.ok(result && result.equals(png))
assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'])
})
test('readWslWindowsClipboardImage returns null when every candidate throws', () => {
const exec = () => {
throw new Error('boom')
}
const result = readWslWindowsClipboardImage({ exec, candidates: ['a', 'b'] })
assert.equal(result, null)
})
test('powershellCandidates lists the bare name first, then the absolute fallback', () => {
const candidates = powershellCandidates()
assert.equal(candidates[0], 'powershell.exe')
assert.ok(candidates.some(c => c.endsWith('WindowsPowerShell/v1.0/powershell.exe')))
})

View File

@@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -51,11 +51,17 @@
"@assistant-ui/react-streamdown": "^0.1.11",
"@audiowave/react": "^0.6.2",
"@chenglou/pretext": "^0.0.6",
"@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "=13.11.1",
"@lezer/highlight": "^1.2.3",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@@ -93,6 +99,7 @@
"remark-math": "^6.0.0",
"remend": "^1.3.0",
"shiki": "^4.0.2",
"simple-git": "^3.36.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
// self-contained file so the nix build doesn't need to ship node_modules/.
//
// `electron` is provided by the runtime; `node-pty` is staged separately
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
// as a separate file and doesn't need bundling.
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { renameSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const entry = resolve(root, 'electron/main.cjs')
const tmp = resolve(root, 'electron/main.bundled.cjs')
await build({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: tmp,
external: ['electron', 'node-pty'],
logLevel: 'info'
})
// Overwrite the original with the bundled version.
renameSync(tmp, entry)
console.log(`bundled ${entry}`)

View File

@@ -3,10 +3,11 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { Codicon } from '@/components/ui/codicon'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import {
@@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
if (tree.length === 0) {
return (
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>

View File

@@ -477,17 +477,20 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [artifacts])
const openArtifact = useCallback(async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
const openArtifact = useCallback(
async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, a.openFailed)
}
} catch (err) {
notifyError(err, a.openFailed)
}
}, [a])
},
[a]
)
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@@ -839,7 +842,8 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
header: (filter, a) =>
filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault,
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},

View File

@@ -2,9 +2,9 @@ import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { ComposerAttachment } from '@/store/composer'
import { AttachmentList } from './attachments'
import type { ComposerAttachment } from '@/store/composer'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
@@ -32,7 +32,10 @@ describe('AttachmentList', () => {
it('renders empty list without error', () => {
renderWithI18n(<AttachmentList attachments={[]} />)
const container = screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
const container =
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
expect(container).toBeDefined()
})
@@ -55,10 +58,7 @@ describe('AttachmentList', () => {
})
it('does not crash when attachments array contains null entries', () => {
const attachments = [
null as unknown as ComposerAttachment,
makeAttachment('a', 'valid.txt')
]
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)

View File

@@ -73,7 +73,11 @@ export function ContextMenu({
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
{c.images}
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
<ContextMenuItem
disabled={!onPasteClipboardImage}
icon={Clipboard}
onSelect={onPasteClipboardImage ? () => void onPasteClipboardImage() : undefined}
>
{c.pasteImage}
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
@@ -167,7 +171,7 @@ interface ContextMenuItemProps {
interface ContextMenuProps {
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void

View File

@@ -59,8 +59,10 @@ function Harness({
}
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
@@ -127,9 +129,11 @@ function Harness({
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
@@ -146,9 +150,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
@@ -165,9 +171,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
@@ -183,9 +191,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
@@ -200,9 +210,18 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
<Harness
disabled
onCancel={vi.fn()}
onDrain={onDrain}
onQueue={vi.fn()}
onSubmit={onSubmit}
queued={['queued-1']}
/>
)
const editor = getByTestId('editor')
await act(async () => {

View File

@@ -10,8 +10,8 @@
* steal focus from the composer effect.
*/
import { RICH_INPUT_SLOT } from './rich-editor'
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@@ -34,8 +34,14 @@ interface InsertRefsDetail {
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
interface SubmitDetail {
target: ComposerTarget
text: string
}
let activeTarget: ComposerTarget = 'main'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
@@ -106,6 +112,23 @@ export const requestComposerInsertRefs = (
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/** Submit a prompt through a composer as if the user typed + sent it. Lets
* external panels (e.g. the review pane's "let the agent ship it" button) hand
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (trimmed) {
dispatch<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
}
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle the active composer's voice conversation — the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })

View File

@@ -33,7 +33,7 @@ export function HelpHint() {
<Section title={c.hotkeys}>
{COMPOSER_HOTKEY_ROWS.map(row => (
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
<HotkeyRow combos={[...row.combos]} description={c.hotkeyDescs[row.id] ?? ''} key={row.id} />
))}
</Section>

View File

@@ -59,7 +59,11 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneStartFailed)
}
export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
export function useMicRecorder(copy: MicRecorderErrorCopy): {
handle: MicRecorderHandle
level: number
recording: boolean
} {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)

View File

@@ -1,19 +1,12 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
POPOUT_ESTIMATED_HEIGHT,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPopoutPosition,
type PopoutPosition,
type PopoutSize
type PopoutSize,
readPopoutBounds,
setComposerPopoutPosition
} from '@/store/composer-popout'
// Floating surface long-press before it becomes draggable (the 5px platform drags
@@ -80,6 +73,7 @@ function dockProximityOf(rect: DOMRect) {
const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
const h =
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
? 1

View File

@@ -98,12 +98,14 @@ export function useSlashCompletions(options: {
const matches = (
needle
? $sessions.get().filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
? $sessions
.get()
.filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
@@ -135,9 +137,7 @@ export function useSlashCompletions(options: {
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length
? catalog.categories
: [{ name: '', pairs: catalog.pairs ?? [] }]
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
@@ -151,10 +151,9 @@ export function useSlashCompletions(options: {
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
text
})
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`

View File

@@ -220,22 +220,25 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const speak = useCallback(async (text: string) => {
setStatus('speaking')
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
}
}
}, [voiceCopy.playbackFailed])
},
[voiceCopy.playbackFailed]
)
const start = useCallback(async () => {
if (!onTranscribeAudio) {
@@ -255,7 +258,14 @@ export function useVoiceConversation({
consumePendingResponse()
pendingStartRef.current = true
await startListening()
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
}, [
consumePendingResponse,
onFatalError,
onTranscribeAudio,
startListening,
voiceCopy.configureSpeechToText,
voiceCopy.unavailable
])
const end = useCallback(async () => {
pendingStartRef.current = false

View File

@@ -45,8 +45,8 @@ import {
$composerPoppedOut,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPoppedOut,
setComposerPopoutPosition
setComposerPopoutPosition,
setComposerPoppedOut
} from '@/store/composer-popout'
import {
$queuedPromptsBySession,
@@ -60,8 +60,10 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { notify } from '@/store/notifications'
import { $previewStatusBySession } from '@/store/preview-status'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
@@ -80,6 +82,7 @@ import {
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest,
onComposerSubmitRequest,
onComposerVoiceToggleRequest
} from './focus'
import { HelpHint } from './help-hint'
@@ -108,6 +111,7 @@ import {
slashChipElement
} from './rich-editor'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -274,14 +278,17 @@ export function ChatBar({
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } =
useComposerPopoutGestures({
composerRef,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
})
const {
dockProximity,
dragging,
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
})
const draftRef = useRef(draft)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
@@ -780,6 +787,16 @@ export function ChatBar({
if (!pastedText) {
event.preventDefault()
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
// the Linux clipboard the DOM paste event reads, so a host screenshot
// arrives as an empty paste (no blobs, no text). Fall back to the main
// process, which pulls the image straight off the Windows clipboard.
// Silent so a genuinely-empty paste doesn't pop a "no image" warning.
if (onPasteClipboardImage) {
triggerHaptic('selection')
void onPasteClipboardImage({ silent: true })
}
return
}
@@ -812,8 +829,7 @@ export function ChatBar({
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty =
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
@@ -840,7 +856,14 @@ export function ChatBar({
id: text,
type: 'slash',
label: text.slice(1),
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
metadata: {
command: slashCommandToken(trigger.query),
display: text,
meta: '',
group: '',
action: '',
rawText: text
}
})
}
@@ -980,10 +1003,7 @@ export function ChatBar({
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
deleteSelectionInEditor(event.currentTarget)
) {
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
@@ -1351,6 +1371,80 @@ export function ChatBar({
}
}, [setComposerText])
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
const openInWorktree = useCallback(
(path: string) => {
const text = draftRef.current
clearDraft()
clearComposerAttachments()
requestStartWorkSession(path, text)
},
[clearDraft]
)
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
// create failure throws back to the row (which toasts) before we touch the
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
const handleBranchOff = useCallback(
async (branch: string, base?: string) => {
const repoPath = cwd?.trim()
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
// anchored there carrying the draft.
const handleConvertBranch = useCallback(
async (branch: string, path?: null | string, isDefault?: boolean) => {
if (path?.trim()) {
openInWorktree(path)
return
}
const repoPath = cwd?.trim()
if (repoPath && isDefault) {
await switchBranchInRepo(repoPath, branch)
openInWorktree(repoPath)
return
}
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
const handleListBranches = useCallback(async () => {
const repoPath = cwd?.trim()
return repoPath ? listRepoBranches(repoPath) : []
}, [cwd])
const handleSwitchBranch = useCallback(
async (branch: string) => {
const repoPath = cwd?.trim()
if (repoPath) {
await switchBranchInRepo(repoPath, branch)
}
},
[cwd]
)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
setComposerText(text)
@@ -1674,6 +1768,43 @@ export function ChatBar({
}
}, [autoDrainNext, busy, queuedPrompts.length])
// Esc cancels the in-flight turn when the CHAT has focus — not just the
// composer input (which has its own handler above). Clicking into the
// transcript and hitting Esc now stops the run, matching the Stop button.
// Intentional only: we bail if (a) the composer/another field already
// handled Esc (defaultPrevented), (b) focus is in any input/textarea/
// contenteditable (you're typing, not stopping), or (c) a dialog/popover is
// open — Esc must close that overlay, never double as canceling the stream
// behind it. A latest-handler ref keeps the listener registered once.
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
return
}
if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) {
return
}
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
@@ -1706,6 +1837,22 @@ export function ChatBar({
.catch(restore)
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. A ref keeps the listener stable
// while always calling the latest dispatchSubmit closure.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useEffect(
() =>
onComposerSubmitRequest(({ target, text }) => {
if (target === 'main' && !inputDisabled) {
dispatchSubmitRef.current(text)
}
}),
[inputDisabled]
)
const submitDraft = () => {
if (disabled) {
return
@@ -2099,7 +2246,7 @@ export function ChatBar({
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
@@ -2114,10 +2261,20 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
/>
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>

View File

@@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement
} from './rich-editor'
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
@@ -159,6 +154,7 @@ export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonl
editor.focus({ preventScroll: true })
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)

View File

@@ -94,13 +94,7 @@ export function ModelPill({
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
disabled={disabled}
type="button"
variant="ghost"
>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>

View File

@@ -0,0 +1,469 @@
import { useStore } from '@nanostores/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DiffCount } from '@/components/ui/diff-count'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
if (branch.checkedOut) {
return copy.branchOpenExisting
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
* draft, so it supplies the orchestration; the row just collects the new
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
onBranchOff?: (branch: string, base?: string) => Promise<void>
/** Check an existing branch out into a fresh worktree + session (no new
* branch). Drives the dialog's "convert a branch" picker. */
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
/** List the repo's local branches for the "convert a branch" picker. */
onListBranches?: () => Promise<HermesGitBranch[]>
/** Open the review pane (changed files + diffs). */
onOpen?: () => void
/** Jump into an existing worktree (open a fresh session anchored there). */
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
}
/**
* The always-on coding-context row, the BASE of the composer status stack:
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
* than the per-turn rows above it (larger branch label, accent glyph), and the
* entry point to the review pane. Hidden when the active session isn't in a
* local git repo (the probe returns null).
*/
export const CodingStatusRow = memo(function CodingStatusRow({
onBranchOff,
onConvertBranch,
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const status = useStore($repoStatus)
const worktrees = useStore($repoWorktrees)
const [branchOpen, setBranchOpen] = useState(false)
const [branchName, setBranchName] = useState('')
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
const [branchPending, setBranchPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!onListBranches) {
return
}
setBranchesLoading(true)
try {
setBranches(await onListBranches())
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [onListBranches])
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
// closing before the dialog grabs focus (Radix focus-trap handoff races
// otherwise).
const startBranch = (base: string | undefined) => {
setBranchBase(base)
setBranchName('')
setConvertMode(false)
setTimeout(() => setBranchOpen(true), 0)
}
const startConvert = () => {
setBranchBase(undefined)
setBranchName('')
setConvertMode(true)
void loadBranches()
setTimeout(() => setBranchOpen(true), 0)
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
const convertBranch = async (branch: HermesGitBranch) => {
if (branchPending || !branch || !onConvertBranch) {
return
}
setBranchPending(true)
try {
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
setBranchOpen(false)
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
// mount or unrelated re-renders.
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return
}
lastWorktreeReqRef.current = worktreeReq
if (!onBranchOff) {
return
}
setBranchBase(undefined)
setBranchName('')
setConvertMode(false)
setBranchOpen(true)
}, [onBranchOff, worktreeReq])
const submitBranch = async () => {
const branch = branchName.trim()
if (branchPending || !branch || !onBranchOff) {
return
}
setBranchPending(true)
try {
await onBranchOff(branch, branchBase)
setBranchOpen(false)
setBranchName('')
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
}
try {
await onSwitchBranch(branch)
} catch (err) {
notifyError(err, s.switchFailed(branch))
}
}
if (!status) {
return null
}
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
// The kebab offers branching off the trunk and/or the current branch. The
// worktree-add bases the new branch on `base` (a branch name; undefined =
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
// back to a plain off-HEAD branch when no trunk is detected.
const current = status.detached ? null : status.branch
const branchTargets: { base: string | undefined; label: string }[] = []
// Current branch first (the 99% "branch off where I am"), then the trunk just
// below it ("New branch from main"), deduped when they're the same.
if (current) {
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
}
if (status.defaultBranch && status.defaultBranch !== current) {
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
}
if (branchTargets.length === 0) {
branchTargets.push({ base: undefined, label: s.newBranch })
}
const switchTarget =
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.
const otherWorktrees = onOpenWorktree
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
: []
const hasLineDelta = status.added > 0 || status.removed > 0
// Untracked files carry no line delta vs HEAD, so surface them as a count when
// they're the only change (otherwise +/- tells the story).
const untrackedOnly = !hasLineDelta && status.untracked > 0
return (
<>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
onActivate={onOpen}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
title={branchLabel}
>
{branchLabel}
</span>
{/* Branch actions kebab — same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
follow opacity so the invisible trigger isn't clickable at rest. */}
{onBranchOff && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
// The row's onActivate also fires on Enter/Space; keep it from
// opening the review pane when the kebab is the focus target.
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
{/* The row sits at the bottom of the screen (above the composer),
so the menu opens upward. */}
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
{branchTargets.map(target => (
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
<span className="truncate">{target.label}</span>
</DropdownMenuItem>
))}
{switchTarget && (
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
<span className="truncate">{s.switchTo(switchTarget)}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
{otherWorktrees.map(worktree => (
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
<span className="truncate">{worktree.branch}</span>
</DropdownMenuItem>
))}
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.startWork}</span>
</DropdownMenuItem>
{/* Check an EXISTING branch out into a worktree (no new branch). */}
{onConvertBranch && (
<DropdownMenuItem onSelect={() => startConvert()}>
<span className="truncate">{p.convertBranch}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden></span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden></span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</StatusRow>
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
{!convertMode && branchBase && (
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
)}
</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
// The branch name is the authoritative key; filter on it directly.
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={branchPending}
key={branch.name}
onSelect={() => void convertBranch(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={branchPending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submitBranch()
} else if (event.key === 'Escape') {
setBranchOpen(false)
}
}}
onValueChange={setBranchName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={branchName}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
{onConvertBranch ? (
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={branchPending || !branchName.trim()}
onClick={() => void submitBranch()}
type="button"
>
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
})

View File

@@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row'
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
// A localhost/loopback preview is only meaningful while its dev server is up, so
// we tie it to a live background process rather than persisting dismissals or
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
// Real codicons per group (no sparkles): a checklist for todos, a bot for
// subagents, a background process glyph for background tasks.
const GROUP_ICON: Record<StatusGroup['type'], string> = {
todo: 'checklist',
subagent: 'hubot',
background: 'server-process'
}
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
if (group.type === 'todo') {
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
@@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
// Drop localhost previews once no dev server is left running — that's what made
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
@@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
// Preview links live as child rows of the background group — a localhost dev
// server and its preview are the same thing — so they no longer float as an
// odd, differently-indented standalone block under the stack.
const previewRows =
visiblePreviews.length > 0 && sessionId
? visiblePreviews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))
: []
const hasBackgroundGroup = groups.some(g => g.type === 'background')
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
key: group.type,
node: (
@@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
icon={
group.type === 'todo' ? (
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
) : undefined
}
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
@@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
{group.type === 'background' && previewRows}
</StatusSection>
)
}))
if (previews.length > 0 && sessionId) {
// No background group to host them (e.g. a standalone on-disk file preview):
// keep the previews as their own row block so they don't disappear.
if (previewRows.length > 0 && !hasBackgroundGroup) {
sections.push({
key: 'preview',
// Not a collapsible group — preview links just sit there, one line each,
// each individually closeable.
node: (
<div className="px-1 py-0.5">
{previews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
)
node: <div className="px-1 py-0.5">{previewRows}</div>
})
}
@@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
@@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
)

View File

@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronRight, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
@@ -76,50 +75,52 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
<StatusRow
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
leading={
<Codicon
aria-hidden
className={cn('text-muted-foreground/70', opening && 'animate-pulse')}
name="globe"
size="0.8rem"
/>
}
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
// in-app preview pane instead. (isOpen still toggles the pane closed.)
onActivate={event => {
if (event.metaKey || event.ctrlKey) {
void togglePreview()
} else {
void openInBrowser()
}
}}
trailing={
<span className="-my-1 flex items-center gap-0.5">
<Tip label={t.preview.openInBrowser}>
<Button
aria-label={t.preview.openInBrowser}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
void openInBrowser()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="link-external" size="0.75rem" />
</Button>
</Tip>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
</span>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
}
trailingVisible
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
{item.label}
</span>
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
</span>
<Tip
label={
<span className="flex flex-col gap-0.5">
<span>{item.target}</span>
<span className="opacity-70">{t.preview.linkHint}</span>
</span>
}
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
</Tip>
</StatusRow>
)
})

View File

@@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUpRight, X } from '@/lib/icons'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
@@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']):
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.9rem] leading-none text-muted-foreground/80"
className="text-[0.85rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
@@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
type="button"
variant="ghost"
>
<X size={12} />
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : canOpen ? (
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
) : undefined
}
>

View File

@@ -11,7 +11,14 @@ function renderPopover(kind: '@' | '/', loading = false) {
const rendered = render(
<I18nProvider configClient={null} initialLocale="zh">
<ComposerTriggerPopover activeIndex={0} items={[]} kind={kind} loading={loading} onHover={onHover} onPick={onPick} />
<ComposerTriggerPopover
activeIndex={0}
items={[]}
kind={kind}
loading={loading}
onHover={onHover}
onPick={onPick}
/>
</I18nProvider>
)

View File

@@ -46,7 +46,7 @@ export interface ChatBarProps {
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage?: () => void
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void

View File

@@ -226,9 +226,10 @@ const attachToMain = (attachment: ComposerAttachment) => {
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [copy.imagePreviewFailed])
}, [])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
@@ -329,35 +330,38 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
const attachImagePath = useCallback(async (filePath: string) => {
if (!filePath) {
return false
}
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
const attachImagePath = useCallback(
async (filePath: string) => {
if (!filePath) {
return false
}
return true
} catch (err) {
notifyError(err, copy.imagePreviewFailed)
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
return true
}
}, [])
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, copy.imagePreviewFailed)
return true
}
},
[copy.imagePreviewFailed]
)
const attachImageBlob = useCallback(
async (blob: Blob) => {
@@ -411,25 +415,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
const pasteClipboardImage = useCallback(async () => {
try {
const path = await window.hermesDesktop?.saveClipboardImage()
const pasteClipboardImage = useCallback(
async ({ silent = false }: { silent?: boolean } = {}) => {
try {
const path = await window.hermesDesktop?.saveClipboardImage()
if (!path) {
notify({
kind: 'warning',
title: copy.clipboard,
message: copy.noClipboardImage
})
if (!path) {
if (!silent) {
notify({
kind: 'warning',
title: copy.clipboard,
message: copy.noClipboardImage
})
}
return
return false
}
await attachImagePath(path)
return true
} catch (err) {
if (!silent) {
notifyError(err, copy.clipboardPasteFailed)
}
return false
}
await attachImagePath(path)
} catch (err) {
notifyError(err, copy.clipboardPasteFailed)
}
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
},
[attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]
)
const attachContextFolderPath = useCallback(
(folderPath: string) => {

View File

@@ -75,7 +75,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage: () => void
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles: () => void
onPickFolders: () => void
onPickImages: () => void
@@ -88,7 +88,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRestoreToMessage?: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
@@ -317,7 +317,12 @@ export function ChatView({
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
!isSecondaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
!selectedSessionId &&
!activeSessionId &&
messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the

View File

@@ -6,7 +6,7 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
@@ -14,15 +14,31 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
import { CodeEditor } from '@/components/chat/code-editor'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import {
desktopFileDiff,
desktopGitRoot,
readDesktopFileDataUrl,
readDesktopFileText,
writeDesktopFileText
} from '@/lib/desktop-fs'
import { Check, Pencil, X } from '@/lib/icons'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { setPreviewDirty } from '@/store/preview-edit'
import { $currentCwd } from '@/store/session'
import { notifyWorkspaceChanged } from '@/store/workspace-events'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
const SOURCE_CHUNK_LINES = 200
const SOURCE_LINE_PX = 20
const SOURCE_OVERSCAN_LINES = 400
type EmptyStateTone = 'neutral' | 'warning'
@@ -126,6 +142,8 @@ interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
diff?: string
error?: string
language?: string
loading: boolean
@@ -133,6 +151,19 @@ interface LocalPreviewState {
truncated?: boolean
}
// True when focus is in a field that should swallow plain keystrokes (so the
// bare-`e` edit shortcut never fires while the user is typing in the composer,
// a search box, or the editor itself).
function isTypableElement(el: Element | null): boolean {
if (!el) {
return false
}
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable
}
function filePathForTarget(target: PreviewTarget) {
if (target.path) {
return target.path
@@ -299,27 +330,92 @@ function MarkdownPreview({ text }: { text: string }) {
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
function PreviewModeSwitcher({
active,
modes,
onSelect,
trailing
}: {
active: PreviewViewMode
modes: PreviewViewMode[]
onSelect: (mode: PreviewViewMode) => void
trailing?: ReactNode
}) {
const { t } = useI18n()
const showModes = modes.length > 1
if (!showModes && !trailing) {
return null
}
const label: Record<PreviewViewMode, string> = {
diff: t.preview.diff,
rendered: t.preview.renderedPreview,
source: t.preview.source
}
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
onClick={onToggle}
type="button"
>
{asSource ? t.preview.renderedPreview : t.preview.source}
</button>
// Fixed height so the header is byte-identical between read and edit modes —
// swapping the trailing controls must never move the body below it.
<div className="flex h-7 shrink-0 items-center justify-end gap-3 border-b border-border/40 px-3">
{showModes &&
modes.map(mode => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
mode === active
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
{label[mode]}
</button>
))}
{trailing && <div className="flex items-center gap-1.5">{trailing}</div>}
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
// Cancel / Save controls rendered as the header's trailing slot (not a bar of
// their own) so edit mode reuses the read-mode header row verbatim.
function EditControls({
dirty,
onCancel,
onSave,
saving
}: {
dirty: boolean
onCancel: () => void
onSave: () => void
saving: boolean
}) {
const { t } = useI18n()
return (
<>
<button
className="flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCancel}
type="button"
>
<X className="size-3" />
{t.common.cancel}
</button>
<button
className="flex items-center gap-1 rounded-md bg-primary px-2 py-0.5 text-[0.625rem] font-bold text-primary-foreground shadow-xs transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={!dirty || saving}
onClick={onSave}
type="button"
>
<Check className="size-3" />
{saving ? t.common.saving : t.common.save}
</button>
</>
)
}
interface LineSelection {
end: number
@@ -337,7 +433,18 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const { t } = useI18n()
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
const lastChunk = chunks.at(-1)
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
overscanRows: SOURCE_OVERSCAN_LINES,
rowPx: SOURCE_LINE_PX,
rowsPerChunk: SOURCE_CHUNK_LINES,
totalRows: totalLines
})
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
@@ -394,69 +501,97 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
}, [filePath, selection])
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
{beforeRows > 0 && <div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />}
{visibleChunks.map(chunk => (
<Fragment key={chunk.start}>
<div className="select-none text-right text-muted-foreground/55">
{chunk.lines.map((_lineText, offset) => {
const line = chunk.start + offset + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
return (
<div
className={cn(
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
</div>
)
})}
</div>
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{chunk.text}
</ShikiHighlighter>
</div>
</Fragment>
))}
{afterRows > 0 && <div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />}
</div>
</div>
)
}
type PreviewViewMode = 'diff' | 'rendered' | 'source'
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const { t } = useI18n()
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
// User-picked view; null = auto (diff when changed, else rendered markdown,
// else source). Reset when the previewed file changes.
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
// Spot-editor state. The editor owns its buffer (keyed by `editorKey`); the
// live draft + the snapshot the user started from live in refs so typing
// never re-renders this (large) component — `dirty` is the only render-worthy
// signal and it flips just once when crossing the clean↔dirty boundary.
// `selfReload` re-runs the load after a save without the parent.
const [editing, setEditing] = useState(false)
const draftRef = useRef('')
const baselineRef = useRef('')
const [dirty, setDirty] = useState(false)
const [editorKey, setEditorKey] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<null | string>(null)
const [conflict, setConflict] = useState(false)
const [selfReload, setSelfReload] = useState(0)
// For the bare-`e` shortcut: the read-view root (to detect focus-within) and a
// hover flag (no state — only the keydown handler reads it).
const readViewRef = useRef<HTMLDivElement>(null)
const hoverRef = useRef(false)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
useEffect(() => {
setUserMode(null)
setEditing(false)
setDirty(false)
setSaving(false)
setSaveError(null)
setConflict(false)
draftRef.current = ''
baselineRef.current = ''
}, [filePath, reloadKey])
// HTML files are rendered as source code, not in a webview - so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
@@ -508,6 +643,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
// Best-effort: fetch the file's working-tree-vs-HEAD diff so the
// preview can offer a DIFF view when there are uncommitted changes.
// Empty (clean file / not a repo / remote) just hides the option.
if (!shouldBlock) {
try {
const root = await desktopGitRoot(filePath)
const diff = root ? await desktopFileDiff(root, filePath) : ''
if (active && diff.trim()) {
setState(prev => (prev.text === result.text ? { ...prev, diff } : prev))
}
} catch {
// No diff available; the preview just shows source.
}
}
}
} catch (error) {
if (active) {
@@ -524,7 +675,188 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, selfReload, target.dataUrl, target.language])
// Editing is only offered for whole, readable text — never images, binaries,
// or files we only loaded the first 512 KB of (saving would drop the tail).
const canEdit =
isText && !isImage && !blockedByTarget && state.text !== undefined && !state.truncated && !state.binary
// Per-keystroke: update the draft ref (no render) and only set `dirty` when it
// actually changes — React bails on an identical value, so a long typing run
// triggers a single re-render at most.
const handleEditorChange = useCallback((value: string) => {
draftRef.current = value
const next = value !== baselineRef.current
setDirty(prev => (prev === next ? prev : next))
}, [])
// Publish the unsaved state to the rail so the tab can show a modified dot.
// Keyed by url; cleared on unmount/tab-change so a stale dot never lingers.
useEffect(() => {
setPreviewDirty(target.url, editing && dirty)
return () => setPreviewDirty(target.url, false)
}, [target.url, editing, dirty])
const beginEdit = () => {
const text = state.text ?? ''
baselineRef.current = text
draftRef.current = text
setDirty(false)
setEditorKey(key => key + 1)
setSaving(false)
setSaveError(null)
setConflict(false)
setEditing(true)
}
// Latest `beginEdit` for the keydown listener, so the listener can stay
// subscribed across renders without recreating itself or going stale.
const beginEditRef = useRef(beginEdit)
beginEditRef.current = beginEdit
// Bare `e` enters edit mode when the file pane is hovered or focused and no
// typable field has focus — a fast, button-free path (double-click felt laggy
// because of the browser's click-disambiguation delay).
useEffect(() => {
if (!canEdit || editing) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'e' || event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (isTypableElement(document.activeElement)) {
return
}
const root = readViewRef.current
const focusWithin = Boolean(root && document.activeElement && root.contains(document.activeElement))
if (!hoverRef.current && !focusWithin) {
return
}
event.preventDefault()
beginEditRef.current()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [canEdit, editing])
const cancelEdit = () => {
setEditing(false)
setSaveError(null)
setConflict(false)
}
const discardAndReload = () => {
setEditing(false)
setConflict(false)
setSaveError(null)
setSelfReload(n => n + 1)
}
const saveEdit = async (force = false) => {
if (saving) {
return
}
setSaving(true)
setSaveError(null)
try {
// Stale-on-disk guard: re-read what's on disk now and compare to the
// snapshot the user started from. If something changed underneath (an
// agent edit, an external save), don't clobber it silently — surface the
// choice. `force` is the user picking "overwrite" from that banner.
if (!force) {
try {
const current = await readTextPreview(filePath)
if (!current.binary && (current.text ?? '') !== baselineRef.current) {
setConflict(true)
setSaving(false)
return
}
} catch {
// Couldn't re-read for the check — fall through and attempt the write.
}
}
await writeDesktopFileText(filePath, draftRef.current)
baselineRef.current = draftRef.current
setDirty(false)
setConflict(false)
setEditing(false)
notifyWorkspaceChanged()
setSelfReload(n => n + 1)
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error))
} finally {
setSaving(false)
}
}
// Rendered before the loading/error branches so a background re-read (file
// watcher, workspace tick) can't unmount the editor and drop the draft. Uses
// the SAME container + fixed-height header as the read view so entering edit
// never shifts the body — only the trailing controls and the body swap.
if (editing) {
return (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<PreviewModeSwitcher
active="source"
modes={[]}
onSelect={() => {}}
trailing={<EditControls dirty={dirty} onCancel={cancelEdit} onSave={() => void saveEdit()} saving={saving} />}
/>
{conflict && (
<div className="shrink-0 border-b border-amber-400/40 bg-amber-50 px-3 py-2 text-[0.7rem] text-amber-900 dark:border-amber-300/30 dark:bg-amber-300/10 dark:text-amber-100">
<div className="font-semibold">{t.preview.diskChangedTitle}</div>
<div className="mt-0.5 leading-relaxed">{t.preview.diskChangedBody}</div>
<div className="mt-1.5 flex gap-3">
<button
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
onClick={() => void saveEdit(true)}
type="button"
>
{t.preview.overwrite}
</button>
<button
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
onClick={discardAndReload}
type="button"
>
{t.preview.discardReload}
</button>
</div>
</div>
)}
{saveError && (
<div className="shrink-0 border-b border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[0.7rem] text-destructive">
{t.preview.saveFailed(saveError)}
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<CodeEditor
filePath={filePath}
initialValue={baselineRef.current}
key={editorKey}
onCancel={cancelEdit}
onChange={handleEditorChange}
onSave={() => void saveEdit()}
/>
</div>
</div>
)
}
if (state.loading) {
return <PageLoader label={t.preview.loading} />
@@ -544,11 +876,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return (
<PreviewEmptyState
body={
binary
? t.preview.binaryBody(target.label)
: t.preview.largeBody(target.label, formatBytes(size))
}
body={binary ? t.preview.binaryBody(target.label) : t.preview.largeBody(target.label, formatBytes(size))}
primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
tone="warning"
@@ -571,29 +899,79 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
const hasDiff = Boolean(state.diff && state.diff.trim())
// Order the toggle reads left→right; default lands on the most useful view.
const modes: PreviewViewMode[] = []
if (isMarkdown) {
modes.push('rendered')
}
modes.push('source')
if (hasDiff) {
modes.push('diff')
}
const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source'
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
return (
<div className="h-full overflow-auto bg-transparent">
<div
className="flex h-full flex-col overflow-hidden bg-transparent"
onMouseEnter={() => {
hoverRef.current = true
}}
onMouseLeave={() => {
hoverRef.current = false
}}
ref={readViewRef}
>
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
{t.preview.truncated}
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
<PreviewModeSwitcher
active={mode}
modes={modes}
onSelect={setUserMode}
trailing={
canEdit ? (
<button
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
onClick={beginEdit}
title={`${t.preview.edit} (e)`}
type="button"
>
<Pencil className="size-3" />
{t.preview.edit}
</button>
) : null
}
/>
<div className="min-h-0 flex-1 overflow-auto">
{mode === 'rendered' ? (
<MarkdownPreview text={state.text} />
) : mode === 'diff' ? (
<FileDiffPanel
className="mx-0 mb-0 h-full max-h-none"
diff={state.diff ?? ''}
fullText={state.text}
path={filePath}
showLineNumbers
/>
) : (
<SourceView
filePath={filePath}
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
text={state.text}
/>
)}
</div>
</div>
)
}
return (
<PreviewEmptyState
body={t.preview.noInlineBody(target.mimeType || '')}
title={t.preview.noInlineTitle}
/>
)
return <PreviewEmptyState body={t.preview.noInlineBody(target.mimeType || '')} title={t.preview.noInlineTitle} />
}

View File

@@ -7,7 +7,9 @@ import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(Date.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})

View File

@@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
@@ -16,10 +25,13 @@ import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeOtherRightRailTabs,
closeRightRail,
closeRightRailTab,
closeRightRailTabsToRight,
type PreviewTarget
} from '@/store/preview'
import { $dirtyPreviewUrls } from '@/store/preview-edit'
import { PreviewPane } from './preview-pane'
@@ -56,12 +68,16 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const { t } = useI18n()
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const panesFlipped = useStore($panesFlipped)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
...(previewTarget
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
: []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
],
[filePreviewTabs, previewTarget, t.preview.tab]
@@ -82,68 +98,109 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
<aside
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
panesFlipped ? 'border-r' : 'border-l'
)}
// Windows/WSLg paint Electron's Window Controls Overlay across our
// titlebar band, so the editor-style tab strip (which normally sits IN that
// band) would land under the fixed titlebar tools. --right-rail-top-inset
// (set by AppShell only when the overlay is present) drops the rail one
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
{tabs.map((tab, index) => {
const active = tab.id === activeTab.id
const hasOthers = tabs.length > 1
const hasTabsToRight = index < tabs.length - 1
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
return (
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.target.path || tab.target.url || tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
{dirty && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
>
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
legible where it overlaps the filename. */}
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
</span>
)}
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
{t.common.close}
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
</ContextMenuItem>
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
{t.preview.closeOthers}
</ContextMenuItem>
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
{t.preview.closeToRight}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>

View File

@@ -0,0 +1,155 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Shared, content-agnostic sidebar chrome — used by both the flat session
// sections and the project/workspace tree, so it lives outside either to keep
// imports one-directional (no index <-> projects cycle).
/** `loaded/total` when there's more on the server, else just the loaded count. */
export const countLabel = (loaded: number, total: number): string =>
total > loaded ? `${loaded}/${total}` : String(loaded)
/** The muted count chip next to a section/workspace label. */
export function SidebarCount({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────
//
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
// stretch to fill the cell and center content internally — never items-center
// on the shell grid, or short clusters (projects) float 12px off sessions.
const rowMinH = 'min-h-[1.625rem]'
const rowPadX = 'pl-2 pr-1'
const rowGap = 'gap-1.5'
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
/** Vertical stack of rows (gap-px, single column). */
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px', className)} {...props} />
}
/** Nested rows (session previews, worktree bodies). */
export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) {
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,
children,
className,
...props
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
return (
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
{children}
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
</div>
)
}
/** Multi-control left cluster (project rows). */
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn(rowInset, className)} {...props} />
}
/** Session row main tap target. */
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
}
/** Tappable label — underline/truncate live on the inner span, not the button. */
export function SidebarRowLink({
className,
labelClassName,
children,
...props
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
return (
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
<span className={cn(rowLabel, labelClassName)}>{children}</span>
</button>
)
}
/** Fixed leading column (dot, icon, drag handle). */
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLead, className)} {...props} />
}
/** Standard row label typography. */
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
return <span className={cn(rowLabel, className)} {...props} />
}
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
export function SidebarRowGrab({
ariaLabel,
children,
className,
dragging = false,
dragHandleProps,
leadClassName
}: {
ariaLabel: string
children: React.ReactNode
className?: string
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
leadClassName?: string
}) {
return (
<SidebarRowLead
{...dragHandleProps}
aria-label={ariaLabel}
className={cn(
'group/handle relative cursor-grab touch-none overflow-hidden active:cursor-grabbing',
leadClassName,
className
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
>
<span className="grid size-full place-items-center transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0">
{children}
</span>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</SidebarRowLead>
)
}
/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */
export function SidebarRowLeadGlyph({
children,
className,
style
}: {
children: React.ReactNode
className?: string
style?: React.CSSProperties
}) {
return (
<span
className={cn('grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none', className)}
style={style}
>
{children}
</span>
)
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
@@ -328,7 +329,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon name="loading" size="0.75rem" spinning />
<GlyphSpinner ariaLabel={c.loading} className="text-[0.75rem]" />
</div>
) : runs.length === 0 ? (
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
@@ -7,24 +8,26 @@ interface SidebarLoadMoreRowProps {
loading?: boolean
}
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
// intentionally identical to workspace "show more" controls (ellipsis button)
// so pagination reads as one interaction everywhere.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
{loading ? (
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
) : (
<Codicon name="ellipsis" size="0.75rem" />
)}
</button>
)
}

View File

@@ -1,3 +1,12 @@
/** New ids first, then ids still present in the persisted order. */
export function reconcileFreshFirst(currentIds: string[], orderIds: string[]): string[] {
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
return [...currentIds.filter(id => !retainedSet.has(id)), ...retained]
}
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
if (!manual || !currentIds.length || !orderIds.length) {
return []
@@ -10,8 +19,5 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str
return []
}
const retainedSet = new Set(retained)
const fresh = currentIds.filter(id => !retainedSet.has(id))
return [...fresh, ...retained]
return reconcileFreshFirst(currentIds, orderIds)
}

View File

@@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -131,7 +132,11 @@ export function ProfileRail() {
const defaultProfile = profiles.find(profile => profile.is_default)
const onDefault = !isAll && activeKey === 'default'
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
const named = sortByProfileOrder(
profiles.filter(profile => !profile.is_default),
order
)
const multiProfile = profiles.length > 1
// distance constraint: a small drag reorders, a tap still selects the profile.
@@ -481,7 +486,11 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
<Codicon name="edit" size="0.875rem" />
<span>{p.rename}</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>{t.common.delete}</span>
</ContextMenuItem>
@@ -494,30 +503,14 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={p.setColor(swatch)}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
{p.autoColor}
</button>
<ColorSwatches
clearIcon="sync"
clearLabel={p.autoColor}
onChange={pickColor}
swatches={PROFILE_SWATCHES}
swatchLabel={p.setColor}
value={color}
/>
</PopoverContent>
</Popover>
)

View File

@@ -0,0 +1,296 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$projectDialog,
addProjectFolder,
closeProjectDialog,
createProject,
generateProjectIdea,
pickProjectFolder,
renameProject
} from '@/store/projects'
// Single dialog mounted once in the sidebar; it renders create / rename /
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
// the native directory picker (reused from the default-project-dir setting).
export function ProjectDialog() {
const { t } = useI18n()
const p = t.sidebar.projects
const state = useStore($projectDialog)
const open = state !== null
const mode = state?.mode ?? 'create'
const [name, setName] = useState('')
const [folders, setFolders] = useState<string[]>([])
const [idea, setIdea] = useState('')
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
const [generatingIdea, setGeneratingIdea] = useState(false)
const [submitting, setSubmitting] = useState(false)
const nameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName(state?.name ?? '')
setFolders([])
setIdea('')
setTemplates(randomIdeaTemplates())
setGeneratingIdea(false)
setSubmitting(false)
if (mode !== 'add-folder') {
window.setTimeout(() => nameRef.current?.select(), 0)
}
}
}, [open, mode, state?.name])
const onOpenChange = (next: boolean) => {
if (!next) {
closeProjectDialog()
}
}
// One submit beat for every flow: guard re-entry, run the write, close on
// success, surface a toast on failure. Callers pass only the write.
const runSubmit = async (write: () => Promise<unknown>) => {
if (submitting) {
return
}
setSubmitting(true)
try {
await write()
closeProjectDialog()
} catch (err) {
notifyError(err, p.createFailed)
} finally {
setSubmitting(false)
}
}
const pickFolder = async () => {
const dir = await pickProjectFolder()
if (!dir) {
return
}
const projectId = state?.projectId
if (mode === 'add-folder' && projectId) {
await runSubmit(() => addProjectFolder(projectId, dir))
return
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
}
const submit = async () => {
const trimmed = name.trim()
const projectId = state?.projectId
if (mode === 'rename' && projectId) {
if (trimmed) {
await runSubmit(() => renameProject(projectId, trimmed))
}
return
}
// A project owns sessions by folder (cwd-prefix), so creation requires at
// least one — a folder-less project couldn't hold a session anyway.
if (mode === 'create' && trimmed && folders.length) {
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
}
}
const generateIdea = async () => {
if (generatingIdea) {
return
}
setGeneratingIdea(true)
try {
const text = await generateProjectIdea(name)
if (text) {
setIdea(text)
}
} finally {
setGeneratingIdea(false)
}
}
const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
</DialogHeader>
{mode !== 'add-folder' && (
<Input
autoFocus
disabled={submitting}
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
placeholder={p.namePlaceholder}
ref={nameRef}
value={name}
/>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
{folders.length === 0 ? (
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.noFolders}</span>
) : (
<ul className="flex flex-col gap-1">
{folders.map((folder, index) => (
<li
className={cn(
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]'
)}
key={folder}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="folder" size="0.75rem" />
<span className="min-w-0 flex-1 truncate" title={folder}>
{folder}
</span>
{index === 0 && (
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
{p.primaryBadge}
</span>
)}
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</li>
))}
</ul>
)}
<Button
className="self-start"
disabled={submitting}
onClick={() => void pickFolder()}
size="sm"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
{p.addFolder}
</Button>
</div>
)}
{mode === 'create' && (
<div className="flex flex-col gap-1.5">
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.ideaLabel}</span>
<div className="relative">
<Textarea
className="min-h-20 pr-8 text-[0.8125rem]"
disabled={submitting}
onChange={event => setIdea(event.target.value)}
placeholder={p.ideaPlaceholder}
value={idea}
/>
<GenerateButton
className="absolute top-1 right-1"
disabled={submitting}
generating={generatingIdea}
generatingLabel={p.ideaGenerating}
label={p.ideaGenerate}
onGenerate={() => void generateIdea()}
/>
</div>
<div className="flex flex-wrap items-center gap-1">
{templates.map(template => (
<button
className="flex items-center gap-1 rounded-full border border-(--ui-stroke-tertiary) px-2 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:opacity-50"
disabled={submitting}
key={template.label}
onClick={() => setIdea(template.idea)}
type="button"
>
<span aria-hidden>{template.emoji}</span>
{template.label}
</button>
))}
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
</div>
</div>
)}
{mode === 'add-folder' && (
<Button disabled={submitting} onClick={() => void pickFolder()} type="button">
<Codicon name="folder-opened" size="0.875rem" />
{p.addFolder}
</Button>
)}
{mode !== 'add-folder' && (
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={submitting || !name.trim() || (mode === 'create' && folders.length === 0)}
onClick={() => void submit()}
type="button"
>
{mode === 'rename' ? t.common.save : p.create}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,275 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { removeWorktreePath } from '@/store/projects'
import { SidebarRowStack } from '../chrome'
import { useWorkspaceNodeOpen } from './model'
import { SidebarWorkspaceGroup } from './workspace-group'
import {
mergeRepoWorktreeGroups,
overlayRepoLanes,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
// The entered project's body. Main-checkout sessions render directly — no
// redundant repo/branch header (the breadcrumb already names the project). Only
// linked worktrees nest, shown by branch. Multi-folder projects keep per-repo
// headers so the folders stay distinguishable.
export function EnteredProjectContent({
project,
renderRows,
onNewSession,
repoWorktrees,
liveSessions,
removedSessionIds
}: {
project: SidebarProjectTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
repoWorktrees?: Record<string, HermesGitWorktree[]>
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
if (!project.repos.length) {
return null
}
const single = project.repos.length === 1
return (
<>
{project.repos.map(repo => (
<RepoFlatSection
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
key={repo.id}
liveSessions={liveSessions}
onNewSession={onNewSession}
removedSessionIds={removedSessionIds}
renderRows={renderRows}
repo={repo}
showHeader={!single}
/>
))}
</>
)
}
function RepoFlatSection({
repo,
showHeader,
renderRows,
onNewSession,
discoveredWorktrees,
liveSessions,
removedSessionIds
}: {
repo: SidebarWorkspaceTree
showHeader: boolean
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
discoveredWorktrees?: HermesGitWorktree[]
liveSessions?: SessionInfo[]
removedSessionIds?: ReadonlySet<string>
}) {
const { t } = useI18n()
const s = t.sidebar
const [open, toggleOpen] = useWorkspaceNodeOpen(repo.id)
const dismissedWorktrees = useStore($dismissedWorktreeIds)
// The repo's session lanes already come fully built from the backend; this
// only injects empty VISUAL lanes from a live `git worktree list`.
const mergedGroups = useMemo(() => mergeRepoWorktreeGroups(repo, discoveredWorktrees), [repo, discoveredWorktrees])
// Optimistic placement runs against the MERGED lane set (backend + visual
// git-worktree lanes) so out-of-tree/sibling worktrees — which exist as visual
// lanes before the snapshot carries their sessions — get the new row. The
// overlay drops lanes it empties, so re-merge to restore still-real worktrees.
const overlaidGroups = useMemo(() => {
if (!(liveSessions?.length || removedSessionIds?.size)) {
return mergedGroups
}
const { groups } = overlayRepoLanes({ ...repo, groups: mergedGroups }, liveSessions ?? [], removedSessionIds)
return mergeRepoWorktreeGroups({ id: repo.id, path: repo.path, groups }, discoveredWorktrees)
}, [repo, mergedGroups, discoveredWorktrees, liveSessions, removedSessionIds])
const discoveredWorktreePaths = useMemo(
() =>
new Set(
(discoveredWorktrees ?? [])
.map(worktree => worktree.path?.trim())
.filter((path): path is string => Boolean(path))
),
[discoveredWorktrees]
)
// Main lanes are always visible; linked worktrees can be user-dismissed.
// A live `git worktree list` hit wins over an old dismissal: if git says the
// worktree exists again (or still exists after "hide from sidebar"), surface it.
const ordered = overlaidGroups.filter(
group =>
group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
)
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
// Removal asks how: actually `git worktree remove` it, or just hide the lane
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
// instead of erroring (those changes are usually throwaway).
const [removeTarget, setRemoveTarget] = useState<null | SidebarSessionGroup>(null)
const [forceTarget, setForceTarget] = useState<null | SidebarSessionGroup>(null)
const removeViaGit = async (group: SidebarSessionGroup, force = false) => {
if (!repo.path || !group.path) {
return
}
try {
await removeWorktreePath(repo.path, group.path, { force })
dismissWorktree(group.id)
} catch (err) {
// git refuses a non-force remove on a dirty/locked worktree — offer force
// rather than dead-ending on an error toast.
if (!force && /force|modified|untracked|dirty|locked|contains/i.test(String((err as Error)?.message ?? ''))) {
setForceTarget(group)
} else {
notifyError(err, s.projects.removeWorktreeFailed)
}
}
}
const body = (
<>
{ordered.map(group => (
<SidebarWorkspaceGroup
group={group}
key={group.id}
// The kanban bucket is read-only: it aggregates many task worktrees, so
// "new session here" and "remove worktree" have no single target.
onNewSession={group.isKanban ? undefined : onNewSession}
onRemove={group.isMain || group.isKanban ? undefined : () => setRemoveTarget(group)}
renderRows={renderRows}
/>
))}
</>
)
// Both removal prompts share the shape (hide-from-sidebar + cancel + a
// destructive action); only the copy and the destructive handler differ.
const worktreeDialog = (
target: null | SidebarSessionGroup,
setTarget: (next: null | SidebarSessionGroup) => void,
description: string,
destructiveLabel: string,
onDestructive: (group: SidebarSessionGroup) => void
) => (
<Dialog onOpenChange={isOpen => !isOpen && setTarget(null)} open={Boolean(target)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`${s.projects.removeWorktree} "${target?.label ?? ''}"?`}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setTarget(null)} variant="ghost">
{t.common.cancel}
</Button>
<Button
onClick={() => {
if (target) {
dismissWorktree(target.id)
}
setTarget(null)
}}
variant="secondary"
>
{s.projects.removeFromSidebar}
</Button>
<Button
onClick={() => {
setTarget(null)
if (target) {
onDestructive(target)
}
}}
variant="destructive"
>
{destructiveLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
const removeDialog = (
<>
{worktreeDialog(
removeTarget,
setRemoveTarget,
s.projects.removeWorktreeConfirm,
s.projects.removeWorktree,
group => void removeViaGit(group)
)}
{worktreeDialog(
forceTarget,
setForceTarget,
s.projects.removeWorktreeDirty,
s.projects.forceRemove,
group => void removeViaGit(group, true)
)}
</>
)
if (!showHeader) {
return (
<>
{body}
{removeDialog}
</>
)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
)
}
count={repoCount}
emphasis
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
label={repo.label}
onToggle={toggleOpen}
open={open}
title={repo.path ?? undefined}
/>
{open && <SidebarRowStack className="pl-2.5">{body}</SidebarRowStack>}
{removeDialog}
</SidebarRowStack>
)
}

View File

@@ -0,0 +1,15 @@
// Public surface of the project/worktree sidebar, consumed by the sidebar root.
export { EnteredProjectContent } from './entered-content'
export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model'
export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
overlayLiveLanes,
overlayLivePreviews,
sessionRecency,
type SidebarProjectTree,
type SidebarSessionGroup,
type SidebarWorkspaceTree
} from './workspace-groups'
export { StartWorkButton } from './workspace-header'

View File

@@ -0,0 +1,135 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { mapPool } from '@/lib/pool'
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $worktreeRefreshToken } from '@/store/projects'
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
// Page size when revealing more already-loaded rows within a workspace group.
export const SIDEBAR_GROUP_PAGE = 5
// Recent sessions previewed under each project in the overview.
export const PROJECT_PREVIEW_COUNT = 3
// Max concurrent `git worktree list` probes when a project spans many repos.
const WORKTREE_PROBE_CONCURRENCY = 4
const pathListKey = (paths: string[]): string =>
paths
.map(path => path.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.join('\n')
// Every session in a project, across its repos/worktrees (order-agnostic).
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions))
export const projectTreeCwd = (project: SidebarProjectTree): null | string =>
project.path || project.repos.find(repo => repo.path)?.path || null
// Overview rows carry their activity stamp from the backend (lanes are empty in
// overview mode), falling back to loaded session times when present.
const projectActivityTime = (project: SidebarProjectTree): number =>
Math.max(
project.lastActive ?? 0,
projectSessions(project).reduce((latest, s) => Math.max(latest, sessionRecency(s)), 0)
)
// The project's most-recent sessions, for the overview preview under each row.
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
export function sortProjectsForOverview(
projects: SidebarProjectTree[],
activeProjectId: null | string
): SidebarProjectTree[] {
return [...projects].sort((a, b) => {
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
if (aActive !== bActive) {
return aActive ? -1 : 1
}
if (!a.isAuto !== !b.isAuto) {
return a.isAuto ? 1 : -1
}
const aHasSessions = a.sessionCount > 0
const bHasSessions = b.sessionCount > 0
if (aHasSessions !== bHasSessions) {
return aHasSessions ? -1 : 1
}
return (
projectActivityTime(b) - projectActivityTime(a) ||
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
})
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so
// linked worktrees still appear even when their sessions aren't in the recents
// payload currently loaded in memory.
export function useRepoWorktreeMap(
repoPaths: string[],
enabled: boolean
): [Record<string, HermesGitWorktree[]>, boolean] {
const [map, setMap] = useState<Record<string, HermesGitWorktree[]>>({})
const [loading, setLoading] = useState(false)
const key = useMemo(() => pathListKey(repoPaths), [repoPaths])
// Refetch when a worktree is added/removed so a new lane shows immediately.
const refreshToken = useStore($worktreeRefreshToken)
useEffect(() => {
const git = window.hermesDesktop?.git
if (!enabled || !repoPaths.length || !git?.worktreeList) {
setMap({})
setLoading(false)
return
}
let cancelled = false
setLoading(true)
// Bounded so a many-repo project doesn't spawn a `git` process per repo at once.
void mapPool(repoPaths, WORKTREE_PROBE_CONCURRENCY, async repoPath => {
try {
return [repoPath, await git.worktreeList(repoPath)] as const
} catch {
return [repoPath, []] as const
}
})
.then(entries => void (cancelled || setMap(Object.fromEntries(entries))))
.finally(() => void (cancelled || setLoading(false)))
return () => {
cancelled = true
}
}, [enabled, key, repoPaths, refreshToken])
return [map, loading]
}
// 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.
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const overridden = collapsed.includes(id)
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
}

View File

@@ -0,0 +1,157 @@
import type * as React from 'react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
SIDEBAR_LEAD_ICON_SIZE,
SidebarRowBody,
SidebarRowCluster,
SidebarRowGrab,
SidebarRowLabel,
SidebarRowLead,
SidebarRowLeadGlyph,
SidebarRowLink,
SidebarRowNest,
SidebarRowShell
} from '../chrome'
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
import { ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
export function projectIcon({ color, icon }: SidebarProjectTree) {
if (color && !icon) {
return (
<SidebarRowLeadGlyph>
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
</SidebarRowLeadGlyph>
)
}
return (
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
)
}
export function ProjectBackRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<SidebarRowShell>
<SidebarRowBody
className="group/back w-full text-(--ui-text-tertiary) opacity-40 hover:text-foreground"
onClick={onClick}
>
<SidebarRowLead>
<SidebarRowLeadGlyph>
<Codicon name="arrow-left" size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
</SidebarRowLead>
<SidebarRowLabel className="text-xs underline-offset-4 group-hover/back:underline">{label}</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
)
}
interface ProjectOverviewRowProps {
project: SidebarProjectTree
onEnter?: (id: string) => void
onNewSession?: (path: null | string) => void
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
activeProjectId?: null | string
previewSessions?: SessionInfo[]
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
ref?: React.Ref<HTMLDivElement>
style?: React.CSSProperties
}
export function ProjectOverviewRow({
project,
onEnter,
onNewSession,
renderRows,
activeProjectId,
previewSessions,
reorderable = false,
dragging = false,
dragHandleProps,
ref,
style
}: ProjectOverviewRowProps) {
const { t } = useI18n()
const s = t.sidebar
const isActive = project.id === activeProjectId
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
// The appearance popover anchors here (the full row) so it opens flush with
// the sidebar's content edge regardless of which side the sidebar is on.
const rowRef = useRef<HTMLDivElement>(null)
const fetched = (previewSessions ?? []).slice(0, PROJECT_PREVIEW_COUNT)
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, PROJECT_PREVIEW_COUNT)) : []
const lead = reorderable ? (
<SidebarRowGrab
ariaLabel={s.projects.reorder(project.label)}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName="overflow-visible"
>
{projectIcon(project)}
</SidebarRowGrab>
) : (
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
)
return (
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
<SidebarRowShell
actions={
<>
{onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
ref={rowRef}
>
<SidebarRowCluster className="min-w-0 flex-1">
{lead}
<SidebarRowLink
aria-label={s.projects.enter(project.label)}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
) : (
<span className="flex-1" />
)}
</SidebarRowCluster>
</SidebarRowShell>
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
</div>
)
}

View File

@@ -0,0 +1,235 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ColorSwatches } from '@/components/ui/color-swatches'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import {
copyPath,
deleteProject,
openProjectAddFolder,
openProjectRename,
revealPath,
setActiveProject,
updateProject
} from '@/store/projects'
import type { SidebarProjectTree } from './workspace-groups'
// Curated codicons for the project glyph (tinted by the chosen color).
const ICONS = [
'folder-library',
'repo',
'rocket',
'beaker',
'flame',
'star-full',
'heart',
'zap',
'target',
'lightbulb',
'tools',
'device-desktop',
'device-mobile',
'terminal',
'dashboard',
'globe',
'broadcast',
'cloud',
'database',
'package',
'book',
'organization',
'bug',
'shield',
'key',
'gift',
'telescope',
'home'
]
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
// — auto projects are dismissed, explicit ones drop their entry). Explicit
// projects additionally get rename / add folder / set active. Hidden until the
// row is hovered (group/workspace), matching the + affordance.
export function ProjectMenu({
project,
isActive,
scoped = false,
onExitScope,
anchorRef
}: {
project: SidebarProjectTree
isActive: boolean
// True when rendered in the entered-project header, so removal can leave the
// now-defunct scope.
scoped?: boolean
onExitScope?: () => void
// Anchor the appearance popover to the whole row instead of the kebab, so it
// opens flush against the sidebar's content-facing edge — otherwise a
// right-side sidebar drags the picker across the entire panel (the kebab
// lives at the row's outer edge). Falls back to the kebab when absent.
anchorRef?: React.RefObject<HTMLElement | null>
}) {
const { t } = useI18n()
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const [appearanceOpen, setAppearanceOpen] = useState(false)
// Open toward the content area: right when the sidebar is on the left, left
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
const removeAuto = () => {
dismissAutoProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const confirmDelete = async () => {
await deleteProject(project.id)
if (scoped) {
onExitScope?.()
}
}
const trigger = (
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:opacity-100',
// In the project header reveal on the whole header hover; in overview
// rows reveal on the row hover.
scoped ? 'group-hover/section:opacity-100' : 'group-hover/workspace:opacity-100'
)}
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
)
return (
<Popover onOpenChange={setAppearanceOpen} open={appearanceOpen}>
{/* Position the appearance popover against the row (when a ref is wired);
the kebab is only the dropdown trigger then. */}
{anchorRef ? <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} /> : null}
<DropdownMenu>
{anchorRef ? trigger : <PopoverAnchor asChild>{trigger}</PopoverAnchor>}
{/* Closing the menu refocuses the trigger (also the popover anchor),
which the appearance popover would read as focus-outside and die on.
Suppress that refocus so it survives. */}
<DropdownMenuContent
align="end"
className="w-48"
onCloseAutoFocus={event => event.preventDefault()}
sideOffset={6}
>
{!project.isAuto && (
<>
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
<Codicon name="edit" size="0.875rem" />
<span>{p.menuRename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>{p.menuAppearance}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
<Codicon name="new-folder" size="0.875rem" />
<span>{p.menuAddFolder}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
<Codicon name="target" size="0.875rem" />
<span>{p.menuSetActive}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{project.isAuto ? (
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{p.removeFromSidebar}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.menuDelete}`}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent
align="start"
className="w-auto p-2"
onClick={event => event.stopPropagation()}
side={panesFlipped ? 'left' : 'right'}
sideOffset={6}
>
<ColorSwatches
clearIcon="circle-slash"
clearLabel={p.noColor}
onChange={color => void updateProject(project.id, { color })}
swatches={PROFILE_SWATCHES}
value={project.color ?? null}
/>
{/* Same 6 columns + gap as the swatch grid so the popover keeps the
profile picker's width (icons flex to fill, not fixed-width). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{ICONS.map(name => (
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
key={name}
onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
))}
</div>
</PopoverContent>
<ConfirmDialog
confirmLabel={p.menuDelete}
description={p.deleteConfirm}
destructive
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={confirmDelete}
open={confirmDeleteOpen}
title={`${p.menuDelete} "${project.label}"?`}
/>
</Popover>
)
}

View File

@@ -0,0 +1,148 @@
import type * as React from 'react'
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
import { countLabel, SidebarRowStack } from '../chrome'
import { SidebarLoadMoreRow } from '../load-more-row'
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import type { SidebarSessionGroup } from './workspace-groups'
import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
interface SidebarWorkspaceGroupProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
// When set (linked worktree rows), shows a remove affordance that runs a real
// `git worktree remove`.
onRemove?: () => void
}
export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemove }: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
// Empty worktree/branch lanes start collapsed — they only show a "No sessions
// yet" placeholder, so defaulting them open just adds noise. Profile lanes and
// lanes that already hold sessions default open.
const defaultOpen = isProfileGroup || group.sessions.length > 0
const [open, toggleOpen] = useWorkspaceNodeOpen(group.id, defaultOpen)
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
// Leading glyph: profile color dot, a home mark for the repo's primary
// checkout (labeled by its live branch), or a branch/kanban mark otherwise.
const leadingIcon = group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : (
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={group.isKanban ? 'checklist' : group.isHome ? 'home' : 'git-branch'}
size="0.75rem"
/>
)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + SIDEBAR_GROUP_PAGE
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
group.onLoadMore?.()
}
}
const handleNewSession = async () => {
if (isProfileGroup) {
newSessionInProfile(group.id)
return
}
if (!onNewSession) {
return
}
// Main-checkout lanes are branch-labeled views over the same repo root path.
// Clicking "+" on `main` should open on `main`, not whatever branch the root
// currently sits on (`test0`, etc.), so explicitly switch first.
if (group.isMain && group.path && group.label) {
try {
await switchBranchInRepo(group.path, group.label)
} catch (err) {
notifyError(err, t.statusStack.coding.switchFailed(group.label))
return
}
}
onNewSession(group.path)
}
return (
<SidebarRowStack>
<WorkspaceHeader
action={
(onNewSession || isProfileGroup || onRemove) && (
<div className="flex items-center">
{(onNewSession || isProfileGroup) && (
<WorkspaceAddButton
label={s.newSessionIn(group.label)}
// Profile groups start a fresh session in that profile but keep
// the all-profiles browse view; workspace groups seed the new
// session's cwd. Main checkout lanes are branch-targeted.
onClick={() => void handleNewSession()}
/>
)}
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
</div>
)
}
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ?? undefined}
/>
{open && (
<>
{visibleSessions.length === 0 ? (
<div className="min-h-7 pl-2 text-[0.75rem] leading-7 text-(--ui-text-quaternary)">{s.noSessions}</div>
) : (
renderRows(visibleSessions)
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
) : (
<WorkspaceShowMoreButton
count={nextCount}
label={group.label}
onClick={() => setVisibleCount(count => count + SIDEBAR_GROUP_PAGE)}
/>
))}
</>
)}
</SidebarRowStack>
)
}

View File

@@ -0,0 +1,716 @@
import { describe, expect, it } from 'vitest'
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import {
baseName,
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
overlayLiveLanes,
overlayLivePreviews,
type SidebarProjectTree,
type SidebarSessionGroup,
sortWorktreeGroups
} from './workspace-groups'
// The grouping itself now lives on the backend (tui_gateway/project_tree.py,
// covered by tests/tui_gateway/test_project_tree.py). This file only covers the
// thin render helpers the desktop still owns + the VISUAL worktree enhancer.
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const lane = (over: Partial<SidebarSessionGroup> & Pick<SidebarSessionGroup, 'id' | 'label'>): SidebarSessionGroup => ({
path: null,
sessions: [],
...over
})
describe('baseName', () => {
it('returns the final path segment, ignoring trailing slashes and separators', () => {
expect(baseName('/www/hermes-agent/')).toBe('hermes-agent')
expect(baseName('C:\\repos\\app')).toBe('app')
expect(baseName('')).toBeUndefined()
})
})
describe('kanbanWorktreeDir', () => {
it('matches a kanban task worktree (t_<hex>) and returns its .worktrees dir', () => {
expect(kanbanWorktreeDir('/repo/.worktrees/t_aaaaaaaa')).toBe('/repo/.worktrees')
})
it('does NOT match a user-named "New worktree" under .worktrees/ (its own lane)', () => {
expect(kanbanWorktreeDir('/repo/.worktrees/test-gui-stuff')).toBeNull()
})
it('returns null for non-kanban paths', () => {
expect(kanbanWorktreeDir('/repo/src')).toBeNull()
expect(kanbanWorktreeDir('/repo')).toBeNull()
})
})
describe('sortWorktreeGroups', () => {
it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => {
const at = (t: number) => [makeSession('/x', { last_active: t })]
const groups = [
lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }),
lane({ id: 'stale', label: 'stale-branch', isMain: true, sessions: at(10) }),
lane({ id: 'wt', label: 'busy-worktree', isMain: false, sessions: at(500) }),
lane({ id: 'main', label: 'main', isMain: true, sessions: at(1) })
]
// main (trunk) first despite being least recent; kanban last despite being
// most recent; busy-worktree ahead of stale-branch by activity.
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['main', 'busy-worktree', 'stale-branch', 'kanban'])
})
it('pins the live home checkout above trunk, even when it has no sessions yet', () => {
const groups = [
lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }),
lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true })
]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['bb/projects-paradigm', 'main'])
})
it('falls back to label order for equally-idle lanes', () => {
const groups = [lane({ id: 'b', label: 'beta', isMain: false }), lane({ id: 'a', label: 'alpha', isMain: false })]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
})
})
describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('injects a linked worktree lane discovered by git that has no sessions yet', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
expect(merged.map(g => g.label)).toEqual(['main', 'feature'])
// The injected lane is empty (visual only — never carries sessions).
expect(merged.find(g => g.label === 'feature')?.sessions).toEqual([])
})
it('never spawns a lane per kanban task worktree', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
{ branch: 'wt/t_bbbbbbbb', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_bbbbbbbb' }
]
expect(mergeRepoWorktreeGroups(repo, discovered).map(g => g.label)).toEqual(['main'])
})
it('does not duplicate a lane already present from the backend (by id/path)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
expect(merged).toHaveLength(1)
// The backend lane keeps its session rows; the enhancer left it untouched.
expect(merged[0].sessions).toHaveLength(1)
})
it('is a no-op when git worktree list is unavailable (remote backend)', () => {
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual([
'main'
])
})
it('does not add a second "main" for a linked worktree checked out on main', () => {
const groups = [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
]
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/main-mirror' }
]
expect(
mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')
).toHaveLength(1)
})
it('surfaces a user-named "New worktree" under .worktrees/ as its own lane', () => {
const discovered: HermesGitWorktree[] = [
{
branch: 'hermes/test-gui-stuff',
detached: false,
isMain: false,
locked: false,
path: '/repo/.worktrees/test-gui-stuff'
}
]
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
expect(merged.map(g => g.label)).toContain('hermes/test-gui-stuff')
})
it('relabels a dir-named linked worktree lane to its live checked-out branch', () => {
// Backend labels the lane by the worktree dir (`hermes-agent-ci`); the live
// `git worktree list` says HEAD there is `bb/ci-affected-only` → branch wins.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
}),
lane({
id: '/repo-ci',
label: 'hermes-agent-ci',
isMain: false,
path: '/repo-ci',
sessions: [makeSession('/repo-ci')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' },
{ branch: 'bb/ci-affected-only', detached: false, isMain: false, locked: false, path: '/repo-ci' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const ci = merged.find(g => g.id === '/repo-ci')
expect(ci?.label).toBe('bb/ci-affected-only')
// The relabel is label-only — the lane keeps its id, path, and sessions.
expect(ci?.path).toBe('/repo-ci')
expect(ci?.sessions).toHaveLength(1)
})
it('re-anchors a lane whose path drifted from git truth back to its branch path', () => {
// The reported bug: a lane is correctly labeled by its branch (`bb/attempts`)
// but its stored PATH points at a stale/old worktree dir. git pins a branch
// to exactly one worktree, so the lane must follow the branch's real path —
// otherwise "reveal in Finder" opens a completely different worktree.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo/.worktrees/attempts',
label: 'bb/attempts',
isMain: false,
path: '/repo/.worktrees/attempts',
sessions: [makeSession('/repo/.worktrees/attempts')]
})
]
}
// git now has `bb/attempts` at a sibling dir, not the stale `.worktrees` one.
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/attempts', detached: false, isMain: false, locked: false, path: '/repo-pr-attempts' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const attempts = merged.filter(g => g.label === 'bb/attempts')
// Exactly one lane, re-pointed at git's real path (label preserved, sessions
// preserved), and NO leftover lane on the stale path.
expect(attempts).toHaveLength(1)
expect(attempts[0].path).toBe('/repo-pr-attempts')
expect(attempts[0].sessions).toHaveLength(1)
expect(merged.some(g => g.path === '/repo/.worktrees/attempts')).toBe(false)
})
it('collapses a re-anchored lane onto the real lane that already holds that path', () => {
// A stale lane (branch label, wrong path) AND the real worktree lane both
// exist. Re-anchoring the stale one onto git's path must not leave a twin —
// keep the richer (more sessions) lane.
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: 'stale', label: 'bb/feature', isMain: false, path: '/repo/.worktrees/old', sessions: [] }),
lane({
id: '/repo-feature',
label: 'bb/feature',
isMain: false,
path: '/repo-feature',
sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/feature', detached: false, isMain: false, locked: false, path: '/repo-feature' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const feature = merged.filter(g => g.path === '/repo-feature')
expect(feature).toHaveLength(1)
expect(feature[0].sessions).toHaveLength(2)
expect(merged.some(g => g.path === '/repo/.worktrees/old')).toBe(false)
})
it('keeps the dir label for a detached-HEAD worktree (no branch to show)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] })
]
}
const discovered: HermesGitWorktree[] = [
{ branch: null, detached: true, isMain: false, locked: false, path: '/repo-ci' }
]
expect(mergeRepoWorktreeGroups(repo, discovered).find(g => g.id === '/repo-ci')?.label).toBe('repo-ci')
})
it('collapses the main checkout into one home lane labeled by the live branch (off-trunk)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
// The repo root is switched to a feature branch. The historical "main"
// sessions fold into ONE home lane labeled by the live branch — no stale
// "main" lane lingering beside it.
const discovered: HermesGitWorktree[] = [
{ branch: 'some-feature', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
expect(merged.filter(g => g.isMain)).toHaveLength(1)
expect(home?.label).toBe('some-feature')
expect(home?.path).toBe('/repo')
expect(home?.sessions).toHaveLength(1)
expect(merged.some(g => g.label === 'main')).toBe(false)
})
it('labels the home lane "main" (still home-flagged) when the root is on trunk', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
]
const home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
expect(home?.label).toBe('main')
expect(home?.isHome).toBe(true)
})
it('folds multiple historical main-checkout branch lanes into the single live home lane', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo', { id: 'a' })]
}),
lane({
id: '/repo::branch::old',
label: 'old-feature',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo', { id: 'b' })]
})
]
}
const discovered: HermesGitWorktree[] = [
{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }
]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
expect(merged.filter(g => g.isMain)).toHaveLength(1)
expect(home?.label).toBe('bb/live')
expect(home?.sessions.map(s => s.id).sort()).toEqual(['a', 'b'])
})
it('leaves main lanes untouched on a remote backend (no git probe)', () => {
const repo = {
id: '/repo',
path: '/repo',
groups: [
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
})
]
}
// No discovered worktrees → no live branch truth → backend label stands.
const merged = mergeRepoWorktreeGroups(repo, undefined)
expect(merged.map(g => g.label)).toEqual(['main'])
expect(merged[0].isHome).toBeFalsy()
})
})
const makeProject = (id: string, folders: string[]): ProjectInfo => ({
archived: false,
board_slug: null,
color: null,
created_at: 0,
description: null,
folders: folders.map((path, i) => ({ added_at: 0, is_primary: i === 0, label: null, path })),
icon: null,
id,
name: id,
primary_path: folders[0] ?? null,
slug: id
})
const projectNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id'>): SidebarProjectTree => ({
label: over.id,
path: over.id,
repos: [],
sessionCount: 0,
...over
})
describe('liveSessionProjectId', () => {
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
})
it('routes a session under an explicit project folder to that project', () => {
const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [
makeProject('p_app', ['/www/app'])
])
expect(id).toBe('p_app')
})
it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => {
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
// Kanban task worktree → folds into the kanban bucket, not a project preview.
expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
// Sibling worktree OUTSIDE the repo root → project can't be derived from the row.
expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
})
it('places an in-tree worktree session under its repo project (the root is in the path)', () => {
// "Convert a branch" / "new worktree" land at `<repoRoot>/.worktrees/<slug>`,
// so they belong to the same auto project as the repo root and must show in
// the overview at once, not wait for the next backend refresh.
expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe(
'/www/app'
)
})
it('routes an in-tree worktree session to the owning explicit project', () => {
const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [
makeProject('p_app', ['/www/app'])
])
expect(id).toBe('p_app')
})
})
describe('overlayLiveLanes', () => {
it('injects a live session into the matching main lane instantly', () => {
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.label === 'main')
expect(lane?.sessions.map(session => session.id)).toContain('fresh')
expect(overlaid.sessionCount).toBe(1)
})
it('injects a session created in a fresh worktree into that worktree lane (no git_repo_root yet)', () => {
// The brand-new session row has only a cwd — no git_repo_root. The entered
// project knows its repo root, so the worktree session still lands in its
// own lane (not kanban, not skipped) optimistically.
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby')
expect(lane?.label).toBe('baby')
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
})
it('folds a kanban-task worktree session into the kanban lane', () => {
const project = projectNode({
id: '/www/app',
isAuto: true,
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.isKanban)
expect(lane?.id).toBe('/www/app::kanban')
expect(lane?.sessions.map(s => s.id)).toEqual(['k'])
})
it('does not duplicate a session already present in a backend lane', () => {
const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })
]
}
]
})
const overlaid = overlayLiveLanes(project, [existing])
expect(overlaid.repos[0].groups.flatMap(g => g.sessions.map(s => s.id))).toEqual(['dup'])
})
it('adds a new session to an existing worktree lane keyed by a divergent id (matches by path)', () => {
// Backend keyed the worktree lane off a branch-style id (no live git probe),
// but the lane PATH is the worktree dir. A new session under that worktree
// must join the existing lane, not spawn a twin.
const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({
id: '/www/app::branch::baby',
label: 'baby',
path: '/www/app/.worktrees/baby',
sessions: [existing]
})
]
}
]
})
const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby')
expect(lanes).toHaveLength(1)
expect(lanes[0].sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
})
it('places a session into an out-of-tree (sibling) worktree lane by its path', () => {
// `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under
// it — repo-root nesting fails, but the existing lane carries its real path.
const existing = makeSession('/www/app-ci', { id: 'old' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [] }),
lane({ id: '/www/app-ci', label: 'app-ci', path: '/www/app-ci', sessions: [existing] })
]
}
]
})
const fresh = makeSession('/www/app-ci', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci')
const main = overlaid.repos[0].groups.find(g => g.label === 'main')
expect(ci?.sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
expect(main?.sessions ?? []).toHaveLength(0)
})
it('places into a visual-only discovered worktree lane after merge', () => {
const discovered = [
{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }
]
const groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
const project = projectNode({
id: '/www/app',
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }]
})
const fresh = makeSession('/www/app-retry', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [fresh])
const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry')
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
})
it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => {
const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' })
const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' })
const project = projectNode({
id: '/www/app',
repos: [
{
id: '/www/app',
label: 'app',
path: '/www/app',
sessionCount: 2,
groups: [
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [a] }),
lane({ id: '/www/app/.worktrees/baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [b] })
]
}
]
})
// No live rows (both deleted from $sessions); only 'gone' is tombstoned.
const overlaid = overlayLiveLanes(project, [a], new Set(['gone']))
expect(overlaid.repos[0].groups.map(g => g.id)).toEqual(['/www/app::branch::main'])
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(overlaid.sessionCount).toBe(1)
})
})
describe('overlayLivePreviews', () => {
it('merges live sessions into a project preview, live first, capped to the limit', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })]
})
const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })]
const previews = overlayLivePreviews([project], live, [], 3)
expect(previews['/www/app'].map(s => s.id)).toEqual(['fresh', 'old'])
})
it('evicts a deleted session from a project preview (snapshot + live)', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [
makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }),
makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })
]
})
const previews = overlayLivePreviews([project], [], [], 3, new Set(['gone']))
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
})
})

View File

@@ -0,0 +1,588 @@
import type { HermesGitWorktree } from '@/global'
import type { ProjectInfo, SessionInfo } from '@/hermes'
// Session grouping is now computed authoritatively on the backend
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
// `projects.project_sessions`). The desktop is a thin renderer: this module
// only holds the render contract (the three tree interfaces) plus a couple of
// pure helpers and the VISUAL-ONLY worktree enhancer that injects empty lanes
// from `git worktree list`. It never decides session membership.
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
// True when this group is a repo's main checkout (vs a linked worktree).
isMain?: boolean
// True for the repo's primary ("home") checkout lane — the single lane that
// collapses all main-checkout sessions, labeled by the worktree's LIVE branch
// (defaulting to `main`). Renders a home glyph and pins to the top.
isHome?: boolean
// True for the synthetic lane that collapses all of a repo's kanban task
// worktrees (`<repo>/.worktrees/t_*`) into one row, so a heavy board doesn't
// spray hundreds of throwaway branch lanes across the sidebar.
isKanban?: boolean
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** A project node: human-named (or repo-derived), holds its repo subtree. */
export interface SidebarProjectTree {
id: string
label: string
path: null | string
color?: null | string
icon?: null | string
archived?: boolean
// A git repo root promoted automatically (not a user-created projects.db row).
// Deletable = dismissable.
isAuto?: boolean
// The synthetic "No project" bucket for cwd-less sessions.
isNoProject?: boolean
repos: SidebarWorkspaceTree[]
sessionCount: number
// Max activity timestamp across the project's sessions (overview sort key).
lastActive?: number
// Up to N most-recent sessions for the overview preview (set by `projects.tree`).
previewSessions?: SessionInfo[]
}
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] =>
path
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.filter(Boolean)
/** A path with trailing separators stripped, for stable equality checks. */
const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '')
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
// The `.worktrees` dir for a KANBAN-TASK worktree path, else null. Only matches
// task worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db mints) so
// the many ephemeral task worktrees collapse into one lane — while user-named
// "New worktree" dirs (`<repo>/.worktrees/<slug>`) stay as their own lanes.
const KANBAN_DIR_RE = /^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$/
export function kanbanWorktreeDir(path: string): null | string {
return path.match(KANBAN_DIR_RE)?.[1] ?? null
}
/** Label for a main-checkout lane whose session recorded no branch. */
export const DEFAULT_BRANCH_LABEL = 'main'
/** The one definition of a main-checkout lane id (must match the backend tree). */
export const branchLaneId = (repoRoot: string, branch?: string): string =>
`${repoRoot}::branch::${(branch ?? '').trim()}`
/** A session's recency stamp (last activity, falling back to creation). */
export const sessionRecency = (session: SessionInfo): number => session.last_active || session.started_at || 0
/** Default-branch names that pin to the top and read as the repo's trunk. */
const TRUNK_BRANCHES = new Set(['main', 'master', 'trunk', 'develop'])
const isTrunkLane = (group: SidebarSessionGroup): boolean =>
Boolean(group.isMain) && TRUNK_BRANCHES.has(group.label.toLowerCase())
/** A lane's recency = its most-recently-active session (empty lanes sink). */
const laneActivity = (group: SidebarSessionGroup): number =>
group.sessions.reduce((max, session) => Math.max(max, sessionRecency(session)), 0)
// Lane tiers (low sorts first): the repo's primary ("home") checkout pins above
// everything (it's "where you are", labeled by its live branch), then trunk,
// then ordinary branches/worktrees, then the kanban aggregate.
const laneRank = (group: SidebarSessionGroup): number =>
group.isHome ? 0 : isTrunkLane(group) ? 1 : group.isKanban ? 3 : 2
/**
* Sort by tier (home → trunk → branches/worktrees → kanban); within a tier, by
* most-recent activity (empty lanes fall last), label as the tiebreak.
*/
function compareWorktreeGroups(a: SidebarSessionGroup, b: SidebarSessionGroup): number {
const byRank = laneRank(a) - laneRank(b)
if (byRank !== 0) {
return byRank
}
const byActivity = laneActivity(b) - laneActivity(a)
return byActivity || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
}
export function sortWorktreeGroups(groups: SidebarSessionGroup[]): SidebarSessionGroup[] {
return [...groups].sort(compareWorktreeGroups)
}
/**
* VISUAL enhancer only: inject empty lanes from a live `git worktree list` so a
* repo shows its branches/worktrees even when they have no Hermes sessions yet.
* The repo's real session lanes already come fully built from the backend
* (`projects.project_sessions`); this never adds or moves session rows, and it
* degrades to a no-op on remote backends (where the Electron probe returns
* nothing). Lanes already present (by id/path) are left untouched.
*/
export function mergeRepoWorktreeGroups(
repo: Pick<SidebarWorkspaceTree, 'groups' | 'id' | 'path'>,
discoveredWorktrees?: HermesGitWorktree[]
): SidebarSessionGroup[] {
// Branch-primary labels: a linked worktree's identity in every git UI (VS
// Code, JetBrains, lazygit, …) is its CHECKED-OUT BRANCH, not the directory it
// happens to live in. The backend labels these lanes by dir/slug; relabel them
// to the live branch from `git worktree list` so the sidebar matches the
// composer's branch strip. Detached worktrees (no branch) keep their dir label.
const liveBranchByPath = new Map<string, string>()
// Inverse: branch → its ONE live worktree path. git guarantees a branch is
// checked out in at most one worktree, so this mapping is a function and can
// re-anchor a lane whose stored path has drifted from git truth.
const livePathByBranch = new Map<string, string>()
for (const worktree of discoveredWorktrees ?? []) {
const wtPath = normalizePath(worktree.path)
const branch = worktree.branch?.trim()
if (wtPath && branch && !worktree.detached) {
liveBranchByPath.set(wtPath, branch)
livePathByBranch.set(branch.toLowerCase(), worktree.path.trim())
}
}
// The primary ("home") checkout's LIVE branch. A repo dir is only ever on ONE
// branch, so every main-checkout session lane (historical branches over the
// same root path) collapses into a single home lane labeled by this live
// branch, defaulting to `main`. Known only when the local git probe ran;
// remote backends keep the backend's recorded-branch main lane untouched.
const mainWorktree = (discoveredWorktrees ?? []).find(w => w.isMain)
const homeBranch = mainWorktree && !mainWorktree.detached ? mainWorktree.branch?.trim() || DEFAULT_BRANCH_LABEL : ''
// Reconcile a LINKED worktree lane against git truth so its label AND path
// describe the SAME worktree. Two repair directions:
// 1. Path git knows → relabel to that path's live branch (git UIs identify a
// worktree by its checked-out branch, not the dir it lives in).
// 2. Path git DOESN'T know but the label IS a live branch → the lane's path
// has gone stale; re-anchor it to that branch's real path, else "reveal"
// opens a different, stale checkout. The home checkout is folded
// separately (below), never here.
const reconcile = (group: SidebarSessionGroup): SidebarSessionGroup => {
if (group.isMain || group.isKanban) {
return group
}
const branchForPath = liveBranchByPath.get(normalizePath(group.path))
if (branchForPath) {
return branchForPath !== group.label ? { ...group, label: branchForPath } : group
}
const livePath = livePathByBranch.get(group.label.trim().toLowerCase())
if (livePath && normalizePath(livePath) !== normalizePath(group.path)) {
return { ...group, id: livePath, path: livePath }
}
return group
}
const dedupeById = (sessions: SessionInfo[]): SessionInfo[] => {
const byId = new Map<string, SessionInfo>()
for (const session of sessions) {
byId.set(session.id, byId.get(session.id) ?? session)
}
return [...byId.values()]
}
// Fold every main-checkout lane into one home lane labeled by the live branch
// (the root dir is only ever on one branch); reconcile the linked worktrees.
// Always shown, even with no sessions on the current branch yet. Remote
// backends (no probe → no homeBranch) keep their main lanes untouched.
const mainGroups = repo.groups.filter(group => group.isMain)
const reconciled = repo.groups.filter(group => !group.isMain).map(reconcile)
if (homeBranch) {
reconciled.push({
id: branchLaneId(repo.id, homeBranch),
label: homeBranch,
path: repo.path,
isMain: true,
isHome: true,
sessions: dedupeById(mainGroups.flatMap(group => group.sessions))
})
} else {
reconciled.push(...mainGroups)
}
// Collapse any duplicate a re-anchor produced (a stale lane re-pointed onto a
// path a real lane already holds) — keep the richer (more sessions) lane.
const byPath = new Map<string, SidebarSessionGroup>()
const merged: SidebarSessionGroup[] = []
for (const group of reconciled) {
const key = !group.isMain && group.path ? normalizePath(group.path) : ''
const existing = key ? byPath.get(key) : undefined
if (existing) {
if (group.sessions.length > existing.sessions.length) {
merged[merged.indexOf(existing)] = group
byPath.set(key, group)
}
continue
}
if (key) {
byPath.set(key, group)
}
merged.push(group)
}
const seenIds = new Set(merged.map(group => group.id))
const seenPaths = new Set(merged.map(group => group.path).filter((path): path is string => Boolean(path)))
// Dedupe by branch label too: a branch shows once even if it's checked out in
// a linked worktree AND already has a session lane.
const seenLabels = new Set(merged.map(group => group.label.toLowerCase()))
for (const worktree of discoveredWorktrees ?? []) {
const wtPath = worktree.path?.trim()
if (!wtPath) {
continue
}
// The home checkout is already the collapsed home lane (above).
if (worktree.isMain && homeBranch) {
continue
}
// Kanban task worktrees never get their own lane — they fold into the
// session-derived `::kanban` bucket. Listing every `git worktree list` entry
// here is exactly what blew the sidebar up to hundreds of empty rows.
if (!worktree.isMain && kanbanWorktreeDir(wtPath)) {
continue
}
const label =
(worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) ||
baseName(wtPath) ||
wtPath
const id = worktree.isMain ? branchLaneId(repo.id, label) : wtPath
const alreadySeen =
seenIds.has(id) || seenLabels.has(label.toLowerCase()) || (!worktree.isMain && seenPaths.has(wtPath))
if (alreadySeen) {
continue
}
merged.push({ id, isMain: worktree.isMain, label, path: wtPath, sessions: [] })
seenIds.add(id)
seenPaths.add(wtPath)
seenLabels.add(label.toLowerCase())
}
return sortWorktreeGroups(merged)
}
// ── Live session overlay ─────────────────────────────────────────────────────
// The backend tree is a snapshot (sessions with >=1 message, refreshed on a
// turn boundary). For parity with the flat Recents list — instant insertion of
// a freshly-created session and the live "working" arc — we overlay the live
// `$sessions` store onto the tree at render time. This is ADDITIVE only: the
// backend still owns membership, structure, counts, and history. The overlay
// just places rows already present in `$sessions` into the project/lane the
// backend would put them in, using the same id scheme. Worktree/kanban folding
// needs the backend common-root probe, so those rows are left for the next
// tree refresh; the common case (a new main-checkout session) overlays here.
/** True when `target` equals `folder` or is nested under it (segment-wise). */
function isPathUnder(folder: string, target: string): boolean {
const f = segments(folder)
const t = segments(target)
if (!f.length || f.length > t.length) {
return false
}
return f.every((seg, i) => seg === t[i])
}
/**
* The project a live session belongs to (overview membership) — explicit project
* by longest-prefix folder, else the repo root (the auto-project id). An IN-TREE
* linked worktree (`<repoRoot>/.worktrees/<slug>`) belongs to the SAME project as
* its repo root (the root is right there in the path), so a freshly-created
* worktree session — e.g. from "convert a branch" / "new worktree" — surfaces in
* 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).
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
if (!cwd || kanbanWorktreeDir(cwd)) {
return null
}
// No persisted repo root yet (brand-new session) → the cwd is the root.
const repoRoot = (session.git_repo_root || '').trim() || cwd
const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`)
if (!underRepo) {
return null
}
let projectId = ''
let bestLen = -1
for (const project of explicitProjects) {
if (project.archived) {
continue
}
for (const folder of project.folders) {
if (isPathUnder(folder.path, cwd) || isPathUnder(folder.path, repoRoot)) {
const len = segments(folder.path).length
if (len > bestLen) {
bestLen = len
projectId = project.id
}
}
}
}
return projectId || repoRoot
}
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => b.started_at - a.started_at)
/**
* The lane a live session belongs to WITHIN a known repo root, by path — the
* entered project already knows its repo roots, so we don't need the session's
* (often-unset, on a fresh row) git_repo_root. Mirrors the backend's lane ids:
* main checkout -> branch lane, `.worktrees/t_<hex>` -> kanban, any other
* `.worktrees/<slug>` -> that worktree's own lane.
*/
function liveLaneForRepo(repoRoot: string, session: SessionInfo): null | SidebarSessionGroup {
const cwd = (session.cwd || '').trim()
if (!cwd || !isPathUnder(repoRoot, cwd)) {
return null
}
const wt = cwd.match(/^(.*[/\\]\.worktrees)[/\\]([^/\\]+)/)
if (wt) {
const [worktreeRoot, worktreesDir, slug] = [wt[0], wt[1], wt[2]]
return /^t_[0-9a-f]+$/.test(slug)
? { id: `${repoRoot}::kanban`, isKanban: true, isMain: false, label: 'kanban', path: worktreesDir, sessions: [] }
: { id: worktreeRoot, isMain: false, label: slug, path: worktreeRoot, sessions: [] }
}
const branch = (session.git_branch || '').trim() || DEFAULT_BRANCH_LABEL
return { id: branchLaneId(repoRoot, branch), isMain: true, label: branch, path: repoRoot, sessions: [] }
}
const NO_REMOVED: ReadonlySet<string> = new Set()
/**
* Reconcile ONE repo's lanes against the live `$sessions` cache: evict
* deleted/archived rows (`removed`) and inject freshly-created ones, so a lane
* mutates exactly like the flat Recents list. The backend snapshot stays the
* datasource for structure and off-page history; this is the optimistic layer
* on top (Apollo-style), reconciled away on the next snapshot refresh. Returns
* the same repo ref when nothing changes (memo-stable).
*/
export function overlayRepoLanes(
repo: SidebarWorkspaceTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarWorkspaceTree {
const repoRoot = normalizePath(repo.path)
let changed = false
// Snapshot lanes minus anything the user just deleted/archived.
const lanes = repo.groups.map(g => {
if (!removed.size) {
return { ...g, sessions: [...g.sessions] }
}
const kept = g.sessions.filter(s => !removed.has(s.id))
changed ||= kept.length !== g.sessions.length
return { ...g, sessions: kept }
})
for (const session of live) {
const cwd = (session.cwd || '').trim()
if (removed.has(session.id) || !cwd) {
continue
}
// (1) Join an EXISTING worktree lane by its own path. A linked worktree can
// live anywhere on disk (often a repo sibling, e.g. `repo-ci`), so nesting
// under the repo root isn't reliable — but the lane carries its real dir.
// Longest match wins; skip the root lane so an in-tree `.worktrees/<slug>`
// session isn't swallowed by main.
let lane: SidebarSessionGroup | undefined
let bestLen = -1
for (const g of lanes) {
const lanePath = normalizePath(g.path)
if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) {
continue
}
const len = segments(lanePath).length
if (len > bestLen) {
bestLen = len
lane = g
}
}
// (2) Else place under the repo root via a computed lane (main / branch /
// in-tree `.worktrees` / kanban). Match by id, then path (the backend may
// key a worktree lane off the git-probed root OR a branch-style id), then
// the main-lane label; create it when the snapshot lacked it.
if (!lane) {
const placed = repo.path ? liveLaneForRepo(repo.path, session) : null
if (!placed) {
continue
}
const placedPath = normalizePath(placed.path)
lane =
lanes.find(g => g.id === placed.id) ??
(placed.isMain
? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase())
: undefined) ??
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
if (!lane) {
lane = { ...placed, sessions: [] }
lanes.push(lane)
}
}
lane.sessions = upsertSession(lane.sessions, session)
changed = true
}
if (!changed) {
return repo
}
// Drop lanes emptied by eviction (the server only emits non-empty lanes; the
// git-worktree enhancer re-adds any still-real worktree as an empty lane).
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0))
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarProjectTree {
let changed = false
const repos = project.repos.map(repo => {
const next = overlayRepoLanes(repo, live, removed)
changed ||= next !== repo
return next
})
if (!changed) {
return project
}
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
}
/** Merge live sessions into per-project overview previews, keyed by project path. */
export function overlayLivePreviews(
projects: SidebarProjectTree[],
live: SessionInfo[],
explicitProjects: ProjectInfo[],
limit: number,
removed: ReadonlySet<string> = new Set()
): Record<string, SessionInfo[]> {
const byProject = new Map<string, SessionInfo[]>()
for (const session of live) {
if (removed.has(session.id)) {
continue
}
const projectId = liveSessionProjectId(session, explicitProjects)
if (!projectId) {
continue
}
const arr = byProject.get(projectId) ?? []
arr.push(session)
byProject.set(projectId, arr)
}
const out: Record<string, SessionInfo[]> = {}
for (const node of projects) {
if (!node.path) {
continue
}
const liveRows = byProject.get(node.id) ?? []
const base = (node.previewSessions ?? []).filter(session => !removed.has(session.id))
if (!liveRows.length && !base.length) {
continue
}
// Live rows take precedence (fresher title/activity/working state).
const map = new Map<string, SessionInfo>()
for (const session of [...liveRows, ...base]) {
if (!map.has(session.id)) {
map.set(session.id, session)
}
}
out[node.path] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
}
return out
}

View File

@@ -0,0 +1,388 @@
import type * as React from 'react'
import { useCallback, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { SidebarCount, SidebarRowLead } from '../chrome'
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
// ellipsize the HEAD instead, so `…context-facts-rpc` and `…context-persona`
// stay distinguishable. Falls back to whole-string for short labels.
function LaneLabel({ label, title }: { label: string; title?: string }) {
const tailLen = Math.min(14, Math.floor(label.length / 2))
const head = label.slice(0, label.length - tailLen)
const tail = label.slice(label.length - tailLen)
return (
<span className="flex min-w-0" title={title}>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>
)
}
interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
if (branch.checkedOut) {
return copy.branchOpenExisting
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
// "+" affordance shared by repo and worktree headers — reveals on header hover.
export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
aria-label={label}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
)
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
export function WorkspaceShowMoreButton({
count,
label,
onClick
}: {
count: number
label: string
onClick: () => void
}) {
const { t } = useI18n()
const text = t.sidebar.showMoreIn(count, label)
return (
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)
}
// Per-worktree actions (linked worktree lanes only), mirroring the session row
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
const { t } = useI18n()
const p = t.sidebar.projects
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
<span>{p.reveal}</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
<Codicon name="copy" size="0.875rem" />
<span>{p.copyPath}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onRemove} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>{`${p.removeWorktree}`}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
// for that branch under the repo (the lightest way) and we open a new session
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
const { t } = useI18n()
const s = t.sidebar
const p = s.projects
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [pending, setPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!repoPath) {
return
}
setBranchesLoading(true)
try {
setBranches(await listRepoBranches(repoPath))
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [repoPath])
const submit = async () => {
const branch = name.trim()
if (pending || !repoPath || !branch) {
return
}
setPending(true)
try {
// Pass the typed value as both the dir slug source and the branch, so the
// branch is exactly what the user named (the dir is slugified git-side).
const result = await startWorkInRepo(repoPath, { branch, name: branch })
if (result) {
onStarted(result.path)
setOpen(false)
setName('')
}
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setPending(false)
}
}
const convert = async (branch: HermesGitBranch) => {
if (pending || !repoPath || !branch) {
return
}
setPending(true)
try {
let result: null | { branch: string; path: string }
if (branch.worktreePath) {
result = { branch: branch.name, path: branch.worktreePath }
} else if (branch.isDefault) {
await switchBranchInRepo(repoPath, branch.name)
result = { branch: branch.name, path: repoPath }
} else {
result = await startWorkInRepo(repoPath, { existingBranch: branch.name })
}
if (result) {
onStarted(result.path)
setOpen(false)
}
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setPending(false)
}
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
return (
<>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => {
setConvertMode(false)
setName('')
setOpen(true)
}}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Dialog onOpenChange={next => !pending && setOpen(next)} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={pending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={pending}
key={branch.name}
onSelect={() => void convert(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
setOpen(false)
}
}}
onValueChange={setName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={name}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={pending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
<div className="flex items-center gap-2">
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
}
// Collapsible header shared by the repo (emphasis) and worktree levels: a toggle
// button with a leading glyph, plus an optional trailing action (the +).
export function WorkspaceHeader({
action,
count,
emphasis = false,
icon,
label,
onToggle,
open,
title
}: {
action?: React.ReactNode
count: React.ReactNode
emphasis?: boolean
icon: React.ReactNode
label: string
onToggle: () => void
open: boolean
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
title?: string
}) {
return (
<div
className={cn(
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
)}
>
<button
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 bg-transparent text-left',
emphasis ? 'hover:text-foreground' : 'hover:text-(--ui-text-secondary)'
)}
onClick={onToggle}
type="button"
>
<SidebarRowLead>{icon}</SidebarRowLead>
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
<span className="shrink-0">
<SidebarCount>{count}</SidebarCount>
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{action}
</div>
)
}

View File

@@ -77,6 +77,7 @@ interface SessionActions {
pinned?: boolean
profile?: string
onPin?: () => void
onBranch?: () => void
onArchive?: () => void
onDelete?: () => void
}
@@ -92,7 +93,16 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
function useSessionActions({
sessionId,
title,
pinned = false,
profile,
onPin,
onBranch,
onArchive,
onDelete
}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
@@ -130,6 +140,15 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void exportSession(sessionId, { profile, title })
}
},
{
disabled: !onBranch,
icon: 'git-branch',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
onBranch?.()
}
},
{
disabled: !sessionId,
icon: 'edit',
@@ -175,6 +194,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
disabled={!sessionId}
errorMessage={r.copyIdFailed}
iconClassName="size-3.5 text-current"
key={r.copyId}
label={r.copyId}
onCopyError={err => notifyError(err, r.copyIdFailed)}

View File

@@ -15,14 +15,18 @@ import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
/** TUI-style tree stem for branched sessions (`└─ ` / `├─ `). */
branchStem?: string
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onBranch?: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -51,10 +55,12 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
export function SidebarSessionRow({
session,
branchStem,
isPinned,
isSelected,
isWorking,
onArchive,
onBranch,
onDelete,
onPin,
onResume,
@@ -75,7 +81,7 @@ export function SidebarSessionRow({
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -84,6 +90,7 @@ export function SidebarSessionRow({
return (
<SessionContextMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
@@ -91,9 +98,38 @@ export function SidebarSessionRow({
sessionId={session.id}
title={title}
>
<div
<SidebarRowShell
actions={
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onBranch={onBranch}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
}
className={cn(
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
'group relative cursor-pointer transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
// Opaque surface while lifted so the dragged row erases what's under
@@ -123,8 +159,8 @@ export function SidebarSessionRow({
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
<SidebarRowBody
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
@@ -150,49 +186,25 @@ export function SidebarSessionRow({
onResume()
}}
type="button"
>
{reorderable ? (
<span
{...dragHandleProps}
aria-label={handleLabel}
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row. Width MUST match the non-reorderable dot
// column (w-3.5) so rows don't shift horizontally when reorder is
// toggled (e.g. scoped → ALL-profiles view).
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
<SidebarRowGrab
ariaLabel={handleLabel}
dragging={dragging}
dragHandleProps={dragHandleProps}
leadClassName={needsInput ? 'overflow-visible' : undefined}
>
<SidebarRowDot
<SessionRowLeadDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
size="0.75rem"
/>
</span>
</SidebarRowGrab>
) : (
<span
className={cn(
'grid w-3.5 shrink-0 place-items-center',
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
@@ -203,41 +215,38 @@ export function SidebarSessionRow({
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
<div className="relative z-2 grid w-[1.375rem] place-items-center">
{!isWorking && (
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon"
title={r.sessionActions}
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</SessionActionsMenu>
</div>
</div>
</SidebarRowLabel>
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>
)
}
function SessionRowLeadDot({
branchStem,
isWorking,
needsInput = false,
className
}: {
branchStem?: string
isWorking: boolean
needsInput?: boolean
className?: 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 isWorking={isWorking} needsInput={needsInput} />
</span>
)
}
function SidebarRowDot({
isWorking,
needsInput = false,

View File

@@ -4,30 +4,35 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
branchStem?: string
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onBranch?: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
reorderable?: boolean
}
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
entries: SidebarSessionEntry[]
onArchiveSession: (sessionId: string) => void
onBranchSession?: (sessionId: string, profile?: string) => void
onDeleteSession: (sessionId: string) => void
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
sessions: SessionInfo[]
sortable: boolean
workingSessionIdSet: Set<string>
}
@@ -38,21 +43,22 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
entries,
onArchiveSession,
onBranchSession,
onDeleteSession,
onResumeSession,
onTogglePin,
pinned,
sessions,
sortable,
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: sessions.length,
count: entries.length,
estimateSize: () => ROW_ESTIMATE_PX,
getItemKey: index => sessions[index]?.id ?? index,
getItemKey: index => entries[index]?.session.id ?? index,
getScrollElement: () => scrollerRef.current,
// jsdom-friendly default; the real rect takes over on first observe.
initialRect: { height: 600, width: 240 },
@@ -65,23 +71,29 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
const rows = virtualItems.map(virtualItem => {
const session = sessions[virtualItem.index]
const entry = entries[virtualItem.index]
if (!session) {
if (!entry) {
return null
}
const { branchStem, session } = entry
const reorderable = sortable && !branchStem
const commonProps: SessionRowCommonProps = {
branchStem,
isPinned: pinned,
isSelected: session.id === activeSessionId,
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id)
onResume: () => onResumeSession(session.id),
reorderable
}
return sortable ? (
return reorderable ? (
<VirtualSortableRow
index={virtualItem.index}
key={session.id}
@@ -104,7 +116,10 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
// just consume that context via useSortable.
return (
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
<div
className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)}
ref={scrollerRef}
>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{rows}
</div>

View File

@@ -1,149 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/types/hermes'
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
describe('workspaceGroupsFor', () => {
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
const groups = workspaceGroupsFor(
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
'No workspace'
)
expect(groups).toHaveLength(2)
})
it('disambiguates colliding basenames by walking up the path', () => {
expect(
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
})
it('leaves a unique basename as its short label', () => {
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
'desktop',
'heval-py'
])
})
it('grows the prefix past one segment when the parent also collides', () => {
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
'x/proj/apps/desktop',
'y/proj/apps/desktop'
])
})
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
const noWorkspace = groups.find(g => g.path === null)
expect(noWorkspace?.label).toBe('No workspace')
})
})
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
branch: null,
isMainWorktree: false,
...over
})
describe('workspaceTreeFor', () => {
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
'No workspace'
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
})
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
const resolver: WorktreeResolver = cwd => {
if (cwd === '/www/hermes-agent') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
}
if (cwd === '/elsewhere/ha-rtl') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
}
return null
}
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
'No workspace',
resolver
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
// The main checkout labels by directory (its branch is transient — using it
// would misattribute old sessions to the currently checked-out branch);
// linked worktrees label by branch.
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
})
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('heval-node')
expect(tree[0].groups).toHaveLength(1)
expect(tree[0].groups[0].label).toBe('heval-node')
})
it('aggregates session counts across a repos worktrees', () => {
const tree = workspaceTreeFor(
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
'No workspace'
)
const parent = tree.find(p => p.label === 'ha')
expect(parent?.sessionCount).toBe(3)
})
it('no-workspace sessions form their own parent', () => {
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('No workspace')
expect(tree[0].path).toBeNull()
})
})
describe('uniqueCwds', () => {
it('dedupes and drops empty/whitespace cwds', () => {
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
})
})

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