Compare commits

...
Author SHA1 Message Date
ethernet 7e24bfcb0b change(tooling): update node to 26 everywhere, keep node version managed 2026-06-12 13:44:29 -04:00
brooklyn! 46d758bb3e feat(desktop): window translucency slider in Appearance settings (#45086)
A see-through-window control (0–100, off by default) that maps to the
native window opacity via setOpacity — the desktop shows through the whole
window, the same effect as the Windows shift-scroll trick. macOS + Windows;
a no-op on Linux (no runtime window opacity).

Renderer owns the value (persisted, nanostore) and mirrors it to the main
process over IPC; main persists it to translucency.json so a cold launch
applies it at window creation before the renderer reports in.
2026-06-12 12:02:38 -05:00
SHL0MS 7d4e60e44a docs(website): redirect old automation-templates URL to automation-blueprints
The Automation Blueprints rebrand (#44470) renamed the guide page from
guides/automation-templates to guides/automation-blueprints, leaving the
old URL 404ing. The site deploys to static hosting, so server-side
redirects aren't available.

Add @docusaurus/plugin-client-redirects (pinned 3.9.2, same as the other
Docusaurus packages) and a redirect entry for the old slug. The plugin
emits a static HTML page at the old path that meta-refresh/JS-redirects
to the new page, preserving query string and hash, with a canonical link
for SEO. Localized routes are handled automatically (zh-Hans verified).
2026-06-12 09:46:27 -07:00
brooklyn! 79c3ed3cc9 fix(desktop): new chat honours the active profile instead of rubberbanding to default (#45057)
The top "New Session" button (and /new, the keyboard shortcut) cleared
$newChatProfile to null, meaning "use the live gateway context". But
createBackendSessionForSend turned a null into an omitted `profile` param on
session.create. In global-remote mode one backend serves every profile, so an
omitted profile silently binds the new chat to the launch (default) profile's
home/state.db — the session "rubberbands back to default" even though the rail
still shows the selected profile. The per-profile "+" worked because it sets
$newChatProfile explicitly.

Resolve a null $newChatProfile to the active gateway profile at the single
session-creation chokepoint so session.create always carries the live profile.
Harmless for single-profile and local-pooled users: a backend resolves its own
launch profile to None (_profile_home), so passing it changes nothing.
2026-06-12 16:38:56 +00:00
brooklyn! d62979a6f3 feat(desktop): composer status stack, live subagent windows, editable prompts (#44630)
* feat(desktop): session-scoped status stack + kill new-window theme flash

Stack subagents, background tasks, and the queue into one collapsible
"sink" above the composer, reusing the queue's chrome so every status
reads as one piece. Extracts shared StatusSection / StatusRow /
TerminalOutput primitives and a unified $statusItemsBySession store
(subagents mirrored, background owned here, merged + grouped for render).
Renames BrailleSpinner → GlyphSpinner now that it drives more than braille.

Separately, fix the white flash on every new/cmd-clicked window: macOS
`vibrancy` paints an NSVisualEffectView that follows the OS appearance and
ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white
until the renderer painted over it. Pin `nativeTheme.themeSource` to the
app theme (persisted to userData so cold launches paint right before the
renderer loads), hold windows with `show:false` until `ready-to-show`, and
pre-paint the themed background via an inline script before the bundle runs.

* feat(desktop): dock the slash popover to the composer via one shared fill var

The slash·@ popover (and ? help) now docks onto the composer's edge with the
same chrome as the queue/status stack — rounded outer corners, fused borderless
edge, no shadow — but keeps its own narrow width.

Surface + drawer paint a single --composer-fill var; the state ladder
(rest / scrolled / focused / drawer-open) lives once in styles.css on
[data-slot='composer-root']. The :has() drawer-open rule is last and forces an
opaque fill, since translucent glass sampling different backdrops (thread vs
fade gradient) can never match. This replaces the focus-within !important
override that repainted the surface behind every previous matching attempt.

Also drop the chevron column from the project file tree — the folder open/closed
icon already carries the expand state.

* feat(desktop): base inset for file tree rows (post-chevron alignment)

* feat(desktop): wire the status stack's background tasks to the real process registry

The background group was UI-only (dev-mock seeded). Now it's live e2e:

- tui_gateway: new session-scoped `process.list` (registry snapshot filtered
  by the session's session_key, plus a 4KB output tail for the inline
  terminal viewer) and `process.kill` (single process, ownership-checked —
  unlike process.stop's kill_all).
- Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store
  layout-stably — rows keep their position when state flips (never re-sort),
  new processes append, unchanged rows keep object identity so memoised rows
  skip re-rendering, and a dismissed-set stops the registry's retained
  finished procs from resurrecting X-ed rows.
- Refresh triggers: session open, terminal/process tool.complete,
  status.update(kind=process) from the gateway's notification poller, and a
  5s poll armed only while a running row is visible (catches silent exits).
- Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side
  with resurrection guard.
- Re-keyed the stack to the RUNTIME session id: it was keyed by the stored
  session id, where neither subagent events nor process.list would ever land.
- Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit.

Reconcile invariants covered in store/composer-status.test.ts.

* feat(desktop): todos + openable subagents in the status stack, self-healing file tree

- todo lists move out of the inline chat panel into the composer status stack
  (checklist icon, dashed ring = pending, spinner = in progress, check = done),
  fed live from todo tool events and seeded from history on session open
- subagent rows carry the child's real session id end-to-end
  (delegate_tool → gateway → renderer) so clicking one opens ITS session window
- status stack publishes its measured height so the thread's bottom clearance
  grows with it; card paints the shared --composer-fill so focused/scrolled
  states match the composer exactly
- file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button,
  and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...)
- composer drag-drop of tree entries inserts inline refs instead of attachments

* fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone

Sessions record their launch cwd; deleted worktrees leave that path dead,
so opening such a session swapped the tree from the default workspace to a
directory that ENOENTs forever — the 3s retry just spun on it. On a root
read error the tree now asks main to sanitize the cwd (prefers the
configured default project dir), displays that fallback, and quietly
re-probes the original path so it switches back if the dir reappears.

* feat(desktop): working restore-checkpoint button on past user prompts

The discard icon on hover of a past user bubble was decorative — clicking
did nothing. It's now a real control: a confirmation dialog explains that
everything after the prompt is removed, then the session rewinds to that
turn and reruns the same prompt (prompt.submit with
truncate_before_user_ordinal, the same mechanism the edit composer uses).
Failures rethrow into the dialog's inline error instead of toasting.

* fix(desktop): show the restore-checkpoint button on the latest user prompt too

Restoring the most recent prompt is just 'retry this turn' — no reason to
exclude it. Stop still takes the slot while the turn is running.

* fix(desktop): finished todo lists clear themselves out of the status stack

A list whose every item is completed/cancelled lingers ~4s so the final
checkmark is visible, then the todo group drops out of the stack. A fresh
active list arriving within the linger cancels the scheduled clear.

* chore(desktop): drop dead editableCheckpoint copy, terser restore confirm

* fix(desktop): rewind clears the abandoned timeline's todos + background

Restoring to (or editing) an earlier prompt rewinds the conversation, but
the todos and background processes spawned by the now-discarded turns kept
showing in the status stack — and the real background processes kept
running. Both rewind paths now clear the session's todo rows and kill +
drop its background processes before the fresh run repopulates them. Also
drops the click-to-edit clamp transition, which flashed a half-expanded
bubble on the way into the edit composer.

* feat(desktop): user messages are always editable; edit/restore revert mid-stream

The bubble is now always click-to-edit — even while a turn streams — instead
of going inert during a run. Sending an edit acts like restore: it rewinds to
that prompt and re-runs with the new text. Both edit and restore can fire
mid-stream now; the gateway refuses prompt.submit while a turn runs (4009
"session busy"), so they interrupt the live turn first and retry the submit
until the cooperative interrupt winds it down. Restore (re-run as-is) shows on
every prompt except the latest running one, which keeps the Stop button.

* fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh"

The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so
selecting text in the file preview pane and hitting it fell through to the
terminal handler — which imported the right text but labelled the composer ref
"zsh:N lines" off the shell name. When the selection isn't an xterm selection,
label it with the previewed file instead.

* fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging

The source preview lets you select lines in the gutter and drag them into the
composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line
selection is active — it drops the identical ref instead of falling through to
the terminal's global handler (which grabbed the native text selection and sent
a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line
selection there's no native selection, so the terminal handler stays out of it.

* chore: gitignore apps/desktop/demo/ scratch output

The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's
throwaway, never part of the app. Ignore it so it stops cluttering git status.

* feat(desktop): subagent watch windows, hard stop, sidebar hygiene

Child-session mirror for live subagent windows, delegate sessions tagged
and excluded from the sidebar, composer focus/stop polish, and WS stall
resilience on the gateway transport.

* refactor: DRY delegate SQL + trim status-stack noise

Extract shared listable-child and delegate-delete helpers in hermes_state,
collapse cancelRun busy release, and cut comment bloat in resume/status paths.

* fix(desktop): hide orphaned subagent sessions in sidebar

Cascade-delete all ephemeral children on parent delete (not just tagged rows),
run v16 backfill to tag legacy orphans, and record new delegates as source=subagent.

* fix: restore orphan contract for untagged children + lazy session eviction

Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy),
walk marker chains recursively with FK-safe orphaning, gate lazy watch
sessions out of the still-starting eviction exemption via an explicit flag,
pass session_id to _make_agent only when resuming, and hide source=subagent
from session search.

* fix(gateway): gate child mirror off upgraded sessions + age out stale run entries

Review findings: the mirror could interleave synthetic events with a real
native stream once a watch window upgrades (prompt.submit builds an agent),
and a lost subagent.complete left _active_child_runs pinning running=true
forever. Mirror now stops when the live session owns an agent; liveness
reads ignore entries older than an hour.

* fix(gateway): reject prompt.submit into a watch session while its child runs

A lazy watch session's running flag is False (the run lives in the parent
turn), so typing mid-run sailed past the busy guard and built a second agent
racing the in-flight child on the same stored session. Busy error until the
run completes; afterwards the submit upgrades into a normal conversation.

* refactor(gateway): DRY watch-resume payload + compose listable-child SQL

Fold the duplicated child-run busy overlay into one _reuse_live_payload
helper across both resume reuse paths, collapse the twin mirror early-returns,
and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it.

* fix(desktop): clip horizontal overflow on sidebar scroll areas

Add overflow-x-hidden alongside overflow-y-auto on session list scrollers
and the shared SidebarContent primitive — vertical scroll unchanged.
2026-06-12 08:30:06 -05:00
y0shualee 9c50521704 fix(desktop): complete backend PATH for Homebrew Codex
macOS Desktop backend processes can still miss Apple Silicon Homebrew paths even after adding Hermes-managed Node and venv bins. That leaves `/codex-runtime on` unable to find a Homebrew-installed `codex` binary at `/opt/homebrew/bin/codex`.

Add a small testable backend env helper that builds the dashboard subprocess environment in one place. It prepends Hermes-managed Node and venv bins, appends missing POSIX sane PATH entries individually, preserves caller precedence without duplicates, and keeps Windows PATH casing/delimiters intact.

Wire both source-checkout and active-install backend descriptors through the helper, and add Node regression coverage to the desktop platform test suite.
2026-06-12 03:03:44 -07:00
Teknium 88dbf95105 fix(dashboard): profile-scope Channels endpoints and seed per-profile .env (#44792)
Two halves of the same community report (dashboard Profile Builder):

1. A fresh dashboard/CLI-created profile got no .env file unless cloned,
   so it silently inherited API keys and messaging tokens from the shell
   environment / root install. create_profile() now seeds a placeholder
   .env (0600) for non-clone profiles, matching the SOUL.md seeding.

2. The Channels endpoints (/api/messaging/platforms GET/PUT/test) were
   not profile-scoped: they read/wrote the dashboard process's own .env
   via load_env()/save_env_value() regardless of the global profile
   switcher. They now accept the standard optional profile param (body
   beats query on the PUT, matching other scoped writes) and run inside
   _profile_scope(). When scoped, the payload no longer falls back to
   os.environ or load_gateway_config()'s env-override layer — both carry
   the ROOT install's credentials and would misreport them as the
   profile's. /api/messaging/platforms added to PROFILE_SCOPED_PREFIXES
   so the sidebar switcher scopes the Channels page automatically.
2026-06-12 02:09:28 -07:00
loongfayandloongfay e20e0bd744 feat(Yuanbao): support wechat forward msg (#43508)
* feat(yuanbao): support wechat forward msg

* feat(yuanbao): support wechat forward msg

---------

Co-authored-by: loongfay <izhaolongfei@gmail.com>
2026-06-12 02:06:47 -07:00
Teknium 0fd34e8c5a fix(teams): cache document/video/audio attachments and classify as DOCUMENT (#44778)
The Teams adapter only handled image/* attachments — documents (the
application/vnd.microsoft.teams.file.download.info consent-free download
payload and any direct-URL non-image attachment) never reached media_urls
at all, so run.py's document-context injection had nothing to surface.
Completes the class-wide sweep from PR #44695 (Signal/Email/SimpleX).

- download.info attachments: fetch the pre-authed SharePoint downloadUrl
  (SSRF-guarded, same guard chain as base.py cache_*_from_url) and route
  through cache_media_bytes
- direct-URL non-image attachments: same fetch + classify path
- skip Teams' text/html message-body mirror and adaptive-card attachments
- DOCUMENT > PHOTO > VIDEO > AUDIO precedence for mixed attachments,
  matching the Email precedence rationale from #44695
2026-06-12 02:05:41 -07:00
Siddharth Balyan 7ba5df0d52 feat(billing): /credits command — balance + portal top-up handoff (#44776)
* feat(billing): /usage → portal top-up browser handoff

Add the terminal side of the billing slice (phase 2a): start a top-up by
throwing the user to the portal billing page with the top-up modal open. The
terminal does not confirm, poll, or track payment — checkout completes in the
browser and the next /usage shows the new balance.

- nous_account.py: parse organisation.slug/name from /api/oauth/account into
  NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned
  {base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy
  {base}/billing?topup=open (never /orgs/None/...).
- portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line
  (Topping up as <email> / org <name>), browser open with printed-URL fallback,
  no-wait closing copy. No polling/confirmation (deferred to 2b).
- account_usage.py: the shared /usage credits block now links the org-pinned
  top-up URL (auto-opens the modal) + points to the command.

Depends on NAS #409 (organisation.slug/name + ?topup=open). Do not merge until
that is live on the target env; until then /api/oauth/account returns
organisation: { id } only and the URL falls back to legacy.

* feat(billing): /credits command for balance + top-up handoff

Replace the standalone `hermes portal topup` subcommand with an in-session
/credits slash command — a focused money surface (balance in, top-up out) that
works in the CLI, TUI, and every messaging platform from one registry entry.

- commands.py: register /credits (Info category). Slack is at its 50-slash cap,
  so /credits is routed via /hermes credits on Slack only (new
  _SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the
  native list and breaking Telegram parity; native everywhere else.
- account_usage.py: build_credits_view() — one portal fetch → balance lines +
  identity line + org-pinned top-up URL + depleted flag, consumed by all
  surfaces. Reuses the same snapshot/URL builder as /usage so numbers match.
- cli.py: _show_credits() — balance block + identity line + 3-button panel
  (Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal.
  ASK, never auto-launch; headless falls back to printing the URL.
- gateway/slash_commands.py: _handle_credits_command() — renders the block +
  tappable top-up URL + no-wait copy; works on button and plain-text platforms.
- /usage credits line now points to /credits.
- Retire `hermes portal topup` (portal_cli.py back to baseline); the engine
  (slug/name parse + nous_portal_topup_url) stays as the shared core.

No polling, no payment confirmation (billing phase 2a). Depends on NAS #409.

* fix(credits): /credits works in the TUI slash-worker (non-interactive)

In the TUI, /credits runs in the slash-worker subprocess where there is no
live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called
the 3-button modal unconditionally, which fell back to reading stdin →
exception → slash.exec rejected → the command produced no output (only the
pre-existing 'Credit access paused' banner showed).

- _show_credits: when self._app is None (TUI worker / piped / non-interactive),
  render the text variant — balance block + tappable top-up URL + no-wait line,
  same affordance as the messaging surfaces — and skip the modal entirely. The
  3-button panel still renders in the interactive CLI.
- Depleted banner copy: 'run /usage for balance' → 'run /credits to top up'
  now that /credits is the dedicated money surface (+ tests).
- Regression tests: _show_credits with self._app=None renders text and never
  invokes the modal; logged-out path.

* feat(tui): credits.view RPC for the /credits tappable top-up button

Add a credits.view JSON-RPC method returning the structured CreditsView
(logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can
render a clickable <Link> top-up button instead of plain text. Account-
independent (portal fetch gated on a logged-in Nous account), fail-open to
{logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern.

Frontend (TUI-local /credits command + Ink component) lands separately.

* feat(tui): /credits command with keyboard-driven top-up confirm

TUI-local /credits: fetches the structured balance via the credits.view RPC,
prints the balance + identity + top-up URL, then arms the EXISTING confirm
overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel).
Reuses ConfirmReq — no new overlay component/state/input handler. Headless
(openExternalUrl returns false) falls back to printing the URL.

- gatewayTypes.ts: CreditsViewResponse.
- commands/credits.ts: the command (mirrors /status's rpc+guarded pattern).
- registry.ts: register creditsCommands.
- test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases).

Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
2026-06-12 08:51:10 +00:00
Teknium 4474873d2c feat(cli): persist resolved approval/clarify prompts in scrollback (#44702)
Modal prompt panels (dangerous-command approval, clarify questions)
live in the prompt_toolkit layout and vanish on the next repaint,
leaving no trace of the question or the decision in chat history.

Emit a dim one-line summary after each prompt resolves:
  ⚠ Approval: <command> → allowed for session
  ? Clarify: <question> → <answer>

Gated on display.persist_prompts (default true). Detail and outcome
are whitespace-collapsed and capped at 120 chars.
2026-06-12 01:14:35 -07:00
Teknium 8e5b7592f8 refactor(agent): hoist MEDIA-directive regex to module level
Avoid recompiling the pattern on every _serialize_for_summary call; name it
beside _PATH_MENTION_RE with the #14665 rationale.
2026-06-12 01:14:28 -07:00
Tranquil-Flow 286ecd26d8 fix(agent): strip MEDIA directives from compressor summarizer input (#14665) 2026-06-12 01:14:28 -07:00
Teknium 8b2a3c9c51 chore: add kdunn926 to AUTHOR_MAP 2026-06-12 01:07:50 -07:00
Teknium 74180ebf0b fix(gateway): classify SimpleX non-image/non-audio files as DOCUMENT
SimpleX tagged unknown files application/octet-stream in media_types
but classification only handled audio/image, leaving msg_type TEXT —
run.py never injected the document context. Same bug class as #12845.
2026-06-12 01:07:50 -07:00
Teknium f03f161b39 fix(gateway): classify email document attachments as DOCUMENT
Email cached document attachments and placed them in media_urls, but
msg_type only flipped on image attachments — documents stayed TEXT and
run.py's document-context injection (gated on MessageType.DOCUMENT)
silently dropped them. Same bug class as Signal #12845. DOCUMENT wins
over PHOTO for mixed attachments since image handling keys off per-path
mime types while document injection gates strictly on message_type.
2026-06-12 01:07:50 -07:00
Teknium 1e29ab38c7 fix(gateway): classify Signal video attachments + catch-all DOCUMENT fallback
Widen the salvaged #12851 fix to match the established classification
pattern (WhatsApp/Slack/BlueBubbles/Mattermost): video/* -> VIDEO, and
any remaining MIME type falls through to DOCUMENT instead of TEXT, so
exotic types still trigger run.py's document-context injection.
2026-06-12 01:07:50 -07:00
Kyle Dunn 8e821cd2f5 test(gateway): verify Signal inbound text attachment sets MessageType.DOCUMENT 2026-06-12 01:07:50 -07:00
Kyle Dunn ffef9da9b7 test(gateway): verify Signal inbound PDF attachment sets MessageType.DOCUMENT 2026-06-12 01:07:50 -07:00
Kyle Dunn 8207ae888d fix(gateway): add Signal message type classification for documents 2026-06-12 01:07:50 -07:00
teknium1 05470aa1b6 feat(messaging): expose action='unreact' in send_message + react dispatch tests
Follow-up for salvaged PR #44486: the adapter shipped remove_reaction but
the tool only exposed 'react'. Generalize _handle_react(remove=) and add
tool-level dispatch tests for react/unreact (missing from the original PR).
2026-06-12 01:07:38 -07:00
underthestars-zhy b4e95a2efe fix(photon): add clarifying comments for Windows-safe os.kill usage 2026-06-12 01:07:38 -07:00
underthestars-zhyandClaude Fable 5 23305cfeab fix(photon): normalize DM chat keys in last-inbound reaction tracker
Inbound events key the tracker by the DM chat GUID (any;-;+1555...),
but home-channel react calls address the same space by bare E.164 —
normalize both to the phone so add_reaction's last-inbound default
resolves regardless of which form the caller uses (mirrors the
sidecar's phoneTargetFromSpaceId).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy 156f4fba92 feat(photon): add agent-facing emoji reaction support
Add `action='react'` to `send_message` tool and expose `add_reaction`/
`remove_reaction` on the Photon adapter.

- Track latest inbound message id per chat (`_last_inbound_by_chat`,
  bounded to 200 entries) so the agent can react without threading
  message ids through tool calls
- New `add_reaction`/`remove_reaction` public methods on PhotonAdapter;
  unlike the lifecycle tapbacks, these are not gated by PHOTON_REACTIONS
- `send_message` gains `action='react'` with `emoji` and optional
  `message_id` params; resolves target via existing channel-directory
  and home-channel logic; requires a live gateway adapter
2026-06-12 01:07:38 -07:00
underthestars-zhy a23c0b378c fix(photon): use per-call httpx client in _sidecar_call
Prevents "Future attached to a different loop" errors when
_sidecar_call is invoked from a worker thread via _run_async in
send_message_tool. The persistent _http_client remains in use for
the inbound streaming loop, which always runs on the gateway's loop.
2026-06-12 01:07:38 -07:00
underthestars-zhy 9bfff6e16c chore(photon): bump spectrum-ts to 3.1.0 2026-06-12 01:07:38 -07:00
underthestars-zhyandClaude Fable 5 a652131c42 fix(photon): stop gateway restarts from orphaning the sidecar on its port
A hard gateway exit (crash, SIGKILL, supervisor restart) left the
detached Node sidecar running with a token the next gateway run doesn't
know, so it could never be told to /shutdown. Every replacement spawn
then died on EADDRINUSE, failing each 30→300s reconnect attempt while
the orphan kept consuming the inbound gRPC stream.

Two layers:
- Lifetime binding: the adapter now holds the sidecar's stdin as a
  pipe, and the sidecar (PHOTON_SIDECAR_WATCH_STDIN=1) shuts down on
  stdin EOF — fired by the OS on any parent death, including SIGKILL.
- Startup reaping: before spawning, the adapter probes the port and
  terminates a stale listener, but only after verifying its command
  line is a Photon sidecar; a foreign listener raises a clear error
  instead of being signalled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhyandClaude Fable 5 573c4e6511 feat(photon): upgrade to spectrum-ts 3.0.0 (pinned) with markdown + reactions
Pin spectrum-ts to exactly 3.0.0 (was ^1.18.0 plus an `npm install
spectrum-ts@latest` on every setup) so breaking SDK majors can't take
down fresh installs silently; `hermes photon setup` now runs `npm ci`.
Upgrade procedure documented in the README.

Migrate resolveSpace to the v3 namespace API: `im.space.create(phone)`
for DMs and `im.space.get(id)` for everything else — group spaces are
now rehydratable from their persisted id after a sidecar restart, which
v1 could not do.

Markdown: replies go out via the v3 `markdown()` builder (iMessage
renders natively; other Spectrum platforms degrade to plain text).
`PHOTON_MARKDOWN=false` reverts to the stripped plain-text path.

Reactions, behind PHOTON_REACTIONS (default off): lifecycle tapbacks
(👀 while processing, 👍/👎 on completion) via new sidecar /react and
/unreact endpoints with per-target reaction-handle tracking, and user
tapbacks on bot-sent messages routed to the agent as synthetic
`reaction:added:<emoji>` events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:07:38 -07:00
underthestars-zhy 0a963d8c9a feat(photon): add telemetry toggle via hermes photon telemetry 2026-06-12 01:07:38 -07:00
Teknium c196269d8d fix(credits): suppress usage gauge when top-up funds exist + add display.credits_notices toggle (#44716)
The subscription-cap usage gauge (50/75/90% bands) ignored purchased
(top-up) credits: a sub user with top-up funds got a sticky warn banner
at 90% of their cap — permanently at >=100%, alongside grant_spent —
despite being fully able to keep inferencing. The cap is the wrong
denominator for an account that can keep spending.

- evaluate_credits_notices: purchased_micros > 0 suppresses the usage
  band (grant_spent already covers the cap-reached + top-up case with
  the remaining balance). A top-up landing mid-session clears any
  showing band; spending top-up down to 0 resumes the gauge.
- New display.credits_notices config (default true): false silences all
  credits notices. State capture and /usage are unaffected. Read once
  per agent (cached) in _emit_credits_notices, fail-open true.
- Docs: configuration.md display block.
2026-06-12 01:06:46 -07:00
ethernet 906bee9cf7 fix(nix): natively compile and correctly stage node-pty for desktop app
- Add ELECTRON_SKIP_BINARY_DOWNLOAD=1 to nix/lib.nix to prevent offline download failures.
- Manually trigger native compilation of node-pty via npm rebuild --build-from-source in buildPhase.
- Run stage-native-deps.cjs to copy the natively compiled binary into build/native-deps.
- Flatten native-deps and install-stamp.json to the root of the output derivation in installPhase, matching electron-builder's extraResources behavior so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty'.
- Add doCheck=true and a strict checkPhase to fail fast if the staged native binary is missing.
2026-06-12 03:55:09 -04:00
kshitij 046f444ddc Merge pull request #44738 from kshitijk4poor/salvage/memory-sync-multimodal-content
fix(memory): flatten multimodal content before provider sync
2026-06-12 00:40:31 -07:00
kshitijk4poor 15439bee47 refactor(memory): reuse _summarize_user_message_for_log instead of forking it
The original fix added agent/memory_manager.py:flatten_message_content, but
that helper was a near-exact duplicate of
agent/codex_responses_adapter.py:_summarize_user_message_for_log — same
None/str/list dispatch, same {text,input_text,output_text}/{image_url,input_image}
part sets, the identical [N image(s)] marker, and the same str() fallback. The
only difference was the join separator (newline for memory vs space for the
log/trajectory previews the existing helper already serves), and that helper is
already imported into agent/turn_finalizer.py — the same file whose call site the
memory fix touches.

Parameterize the existing helper with sep=' ' (default preserves every current
logging/trajectory caller byte-for-byte) and call it with sep='\n' at the memory
boundary; drop the forked flatten_message_content. Repoints the unit tests to the
consolidated helper and adds a case locking the default space-join.

Single source of truth for multimodal-content flattening; no behavior change for
the fix or for existing callers.
2026-06-12 12:49:18 +05:30
Erosika 87893fe4cb fix(memory): flatten multimodal content before provider sync
Multimodal turns carry message content as a list of typed parts
({type: "text"|"image_url", ...}). _sync_external_memory_for_turn
passed that list straight into MemoryManager.sync_all, and providers
feed it to regexes — Honcho's sync_turn calls sanitize_context, where
re.sub raised 'expected string or bytes-like object, got list'. Every
turn with an attached image silently never synced.

Flatten to plain text at the boundary: text parts joined, images noted
as an [N image(s)] marker so the attachment isn't erased from recall.
Fixing here covers all providers instead of patching each plugin.

(cherry picked from commit 705bdb6ffe)
2026-06-12 12:46:28 +05:30
brooklyn! d810f2b262 Merge pull request #44676 from NousResearch/bb/fix-schema-ref-default
fix(tools): strip default from $ref nodes in tool schemas
2026-06-12 01:21:14 -05:00
teknium1 b3f5e17bb9 fix(tui): wrap long approval commands in the Ink overlay
Sibling site of the CLI approval-panel fix: the TUI ApprovalPrompt
rendered each command line with wrap="truncate-end", so a long
single-line command lost its tail at terminal width. Wrap to the
panel width via wrapAnsi before applying the 10-line preview cap.
2026-06-11 23:05:08 -07:00
墨綠BG 81cdbbddc8 🐛 fix(cli): wrap approval preview hints 2026-06-11 23:05:08 -07:00
墨綠BG d6df38bb6b 🐛 fix(cli): wrap long approval commands in prompt 2026-06-11 23:05:08 -07:00
Teknium c7bee8f961 refactor(agent): drop unused tail_start param from _derive_auto_focus_topic
The parameter was reserved-but-unused (del'd immediately); YAGNI. Test
call site updated.
2026-06-11 23:03:52 -07:00
konsisumer 434c684bfa fix(agent): focus automatic compression on recent user turns 2026-06-11 23:03:52 -07:00
Teknium db7714d5f1 Merge pull request #44331 from NousResearch/hermes/hermes-6b48295e
feat(whatsapp): WhatsApp Business Cloud API adapter (salvage #43921)
2026-06-11 22:48:06 -07:00
Kysstaandkyssta-exe 343803b23c fix(cli): use subprocess on Windows for dashboard profile re-exec (#44282) (#44446)
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-11 22:41:39 -07:00
Kysstaandkyssta-exe a942bfd9cc fix(gateway): reset _last_flushed_db_idx when reusing cached agent (#44327) (#44518)
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
2026-06-11 22:41:34 -07:00
kshitij a35b370284 Merge pull request #44674 from kshitijk4poor/fix/slack-reactions-plugin-registry-bookkeeping
fix(plugins,slack): registry bookkeeping fixes + ack reaction events (salvage #42561)
2026-06-11 22:32:59 -07:00
Brooklyn Nicholson b2d151abe2 fix(tools): strip default from $ref nodes in tool schemas
Fireworks-hosted Kimi rejects tool requests when nullable MCP/Pydantic
schemas collapse to {"$ref": "...", "default": null}. Strip that sibling
during global schema sanitization so gateway and CLI calls succeed again.
2026-06-12 00:30:51 -05:00
kshitijk4poor 44bd478039 fix(plugins): credit shared hook/middleware/tool names to every plugin
list_plugins() attribution diffed registry names against all already-loaded
plugins, so when a plugin registered a hook / middleware / tool name an
earlier plugin had already used, the shared name was credited to the first
plugin only and later plugins under-reported (0 hooks) in hermes plugins
list. commands_registered right beside it already attributed correctly by
plugin ownership.

Snapshot per-registry counts before register() and attribute the entries
this plugin's register() actually added (per-registration delta). Add a
regression test: two plugins registering the same hook name are each
credited with 1 hook.
2026-06-12 10:57:25 +05:30
kshitijk4poor 889a13696b fix(plugins): clear _plugin_platform_names on force-rediscover
discover_and_load(force=True) cleared every per-plugin registry except
_plugin_platform_names, which register_platform() populates. A platform
plugin disabled between force-rediscovers left a stale name behind, so the
set diverged from the real platform_registry / _plugins state and never
shrank across repeated force passes.

Add the missing clear() and a regression test that seeds every per-plugin
registry, forces a rediscover, and asserts they all empty (so a future
registry addition can't silently leak across a force pass either).
2026-06-12 10:55:44 +05:30
Veritas-7 82d570165e fix(slack): ack reaction lifecycle events
Register no-op Slack event handlers for inbound reaction_added and reaction_removed events so Slack Bolt does not log unhandled-request warnings for events Hermes does not consume.
2026-06-12 10:54:07 +05:30
kshitij c574170050 Merge pull request #44664 from kshitijk4poor/salvage/slack-plugin-action-handlers
feat(plugins): expose register_slack_action_handler API (salvage #20589)
2026-06-11 22:14:44 -07:00
kshitijk4poor e4c168b1f4 chore: map bcsmith528 contributor email for attribution 2026-06-12 10:39:05 +05:30
Brad SmithandClaude Opus 4.7 08e8bedae8 fix(gateway): keep plugin action wrapper signature to (ack, body, action)
The previous implementation captured loop vars via default arguments::

    async def _wrapped(ack, body, action, _cb=_cb, _plugin_name=_plugin_name):

slack_bolt's ``kwargs_injection`` introspects each listener's signature
via ``inspect.signature`` and passes ``None`` for any parameter name it
doesn't recognise (see ``slack_bolt/kwargs_injection/async_utils.py``
``build_async_required_kwargs``). That clobbered ``_cb`` to ``None`` at
dispatch time, so the wrapped plugin handler became ``NoneType`` —
``await _cb(...)`` then raised ``'NoneType' object is not callable`` and
no plugin action handler ever fired.

Replace the default-arg trick with a small closure factory so the
wrapper's public signature is exactly ``(ack, body, action)``. Add a
regression test that introspects the wrapped function's signature.

Found via real Slack click on a Block Kit button registered through
``ctx.register_slack_action_handler`` — gateway log showed
``[Slack] Plugin 'None' action handler raised: 'NoneType' object is
not callable`` despite the registration log line confirming the
handler was wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:36:14 +05:30
Brad SmithandClaude Opus 4.7 62e937bf2b feat(plugins): expose register_slack_action_handler API
Plugins that post Block Kit messages with interactive elements (buttons,
overflow menus, datepickers, etc.) had no documented way to receive the
resulting click events. The plugin API exposed register_tool, register_hook,
register_command, register_platform, and register_context_engine, but
nothing for slack_bolt action handlers. The only workaround was to
monkey-patch SlackAdapter.connect from inside register(), which is
fragile and breaks on every Hermes update.

This change adds:

* PluginContext.register_slack_action_handler(action_id, callback) —
  validates inputs and queues the handler on the PluginManager.
  action_id accepts whatever slack_bolt.App.action() accepts (literal
  string, compiled re.Pattern, or constraint dict).
* PluginManager.get_slack_action_handlers() — accessor used by the
  Slack adapter at connect time.
* SlackAdapter.connect — after wiring its built-in approval and
  slash-confirm buttons, iterates the plugin-registered handlers
  and registers each via self._app.action(matcher)(callback). Each
  callback is wrapped defensively so a misbehaving plugin cannot
  crash slack_bolt's dispatch loop, with a best-effort ack on
  exception so Slack stops retrying the click.
* Defensive fallback when the plugin layer is unhealthy: a
  RuntimeError from get_plugin_manager() is logged and swallowed
  rather than blocking the gateway from starting.
* Test coverage in tests/gateway/test_slack_plugin_action_handlers.py
  for input validation, multi-plugin registration, the connect-time
  wiring, defensive exception handling, and the plugin-loader-
  failure fallback path.
* Documentation in website/docs/guides/build-a-hermes-plugin.md
  describing the new API alongside the existing register_command /
  dispatch_tool documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 10:36:14 +05:30
teknium1 52c7976f40 fix(whatsapp-cloud): review follow-ups for #43921
- nous_subscription: gate the STT managed-default flip on openai-audio
  entitlement and skip when a local backend (faster-whisper or custom
  command) works; new _local_stt_backend_available() helper + tests
- whatsapp_cloud: WHATSAPP_CLOUD_{DM_POLICY,ALLOW_FROM,GROUP_POLICY,
  GROUP_ALLOW_FROM} env overrides so both adapters can run in parallel;
  normalize allowlist entries (JID/punctuation) to bare wa_id
- whatsapp_cloud: wrap per-message event build in try/except (dedup-marked
  wamids would be silently dropped on Meta's batch retry otherwise)
- whatsapp_cloud: validate media_id before URL/filename interpolation,
  delete transient .ogg after voice upload, FIFO-cap interactive-button
  state dicts and per-chat wamid cache
- whatsapp_common: '# **Title**' headers no longer double-wrap asterisks
- setup wizard: read access token / app secret via getpass on TTYs
- docs: new WHATSAPP_CLOUD_* gating env vars
2026-06-11 07:51:01 -07:00
Teknium 2ecb4e62bb Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e 2026-06-11 07:38:25 -07:00
emozilla bfcc9f92b4 Merge commit '6110aed9b' into feat/whatsapp-cloud-api 2026-06-10 21:39:22 -04:00
emozilla 984e6cb5b8 feat(whatsapp): add WhatsApp Business Cloud API adapter
Add an official, production-grade WhatsApp integration via Meta's
Business Cloud API as a complement to the existing Baileys bridge.
No bridge subprocess, no QR codes, no account-ban risk — at the cost
of a Meta Business account and a public HTTPS webhook URL.

Setup is fully wizard-driven: 'hermes whatsapp-cloud' walks through
every credential with paste-time validation (catches the #1 trap of
pasting a phone number into the Phone Number ID field), generates a
verify token, and ends with copy-paste instructions for the
cloudflared / Meta-dashboard / Business Manager pieces that can't be
automated. The wizard also points users at Meta's Business Manager
for setting the bot's display name and profile picture.

Feature set:

- Inbound: text, images (with native-vision routing), voice notes
  (STT), documents (small text inlined, larger cached), reply context.
- Outbound: text with WhatsApp-flavored markdown conversion, images,
  videos, documents, opus voice notes via ffmpeg with MP3 fallback.
- Native interactive buttons for clarify, dangerous-command approval,
  and slash-command confirmation flows — matches the Telegram /
  Discord UX, graceful degrades to plain text.
- Read receipts (blue double-checkmarks) and typing indicator,
  using Meta's combined endpoint so they fire in a single API call.
- Webhook security: X-Hub-Signature-256 HMAC verification (raw body,
  constant-time), wamid deduplication, group-shaped-message refusal
  (groups deferred to v2 — Baileys still covers them).
- Full integration with the gateway's session, cron, display-tier,
  prompt-hint, and auth-allowlist systems. Cloud and Baileys can run
  side-by-side against different phone numbers.

Also wires STT (speech-to-text) through Nous's managed audio gateway
for Nous subscribers — previously the default stt.provider=local
required a separate faster-whisper install. New subscribers now get
voice-note transcription out of the box.

Docs: 418-line user guide at website/docs/user-guide/messaging/
whatsapp-cloud.md, sidebar entry, environment-variables reference,
ADDING_A_PLATFORM.md updated with the optional interactive-UX
contract for future adapter authors.

Tests: 100 dedicated tests for the adapter, 32 for the setup wizard,
20 for the Nous subscription STT wiring, plus regression coverage
across display_config, prompt_builder, and the cron scheduler.

Known limitations (deferred until clear demand signal):
- Group chats — use the Baileys bridge if you need them.
- Message templates for 24-hour-window outside-conversation sends —
  reactive chat is unaffected; cron / delegate_task with gaps > 24h
  will fail with a clear error. The agent's system prompt warns the
  model about this so it knows to mention it when scheduling delayed
  messages.
2026-05-23 01:07:01 -04:00
230 changed files with 16787 additions and 1968 deletions
@@ -48,7 +48,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 26
cache: npm
- name: Install npm dependencies
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 26
cache: npm
cache-dependency-path: website/package-lock.json
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 26
cache: npm
cache-dependency-path: website/package-lock.json
+6 -7
View File
@@ -10,16 +10,15 @@ on:
jobs:
typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 26
cache: npm
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
- run: npm run --prefix ui-tui typecheck
- run: npm run --prefix web typecheck
- run: npm run --prefix apps/bootstrap-installer typecheck
- run: npm run --prefix apps/desktop typecheck
- run: npm run --prefix apps/shared typecheck
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
node-version: '26'
- name: Build web dashboard
run: cd web && npm ci && npm run build
+4
View File
@@ -132,3 +132,7 @@ scripts/out/
# stores the published notes. They are not a build artifact and must never be
# committed to the repo root. See the hermes-release skill.
RELEASE_v*.md
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
# walkthroughs). Throwaway artifacts, never part of the app.
apps/desktop/demo/
+1
View File
@@ -0,0 +1 @@
26.3.0
+8 -10
View File
@@ -1,12 +1,12 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
# which reached EOL in April 2026 — we copy node + npm + corepack from the
# upstream node:22 image instead so we can stay on a supported LTS without
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
# upstream node:26 image instead so we can stay on the supported node without
# waiting for Debian 15+. Bookworm-based slim image used
# so the produced binary links against glibc 2.36, which runs cleanly on
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
# is a one-line ARG change; see #4977.
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
FROM node:26-bookworm-slim@sha256:3fe807a03a4436e7bc76b7e84e6861899cd75c9028ae99bc00581940141ae150 AS node_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
@@ -90,17 +90,15 @@ RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
# installs from the upstream image. npm and npx are recreated as symlinks
# Node 26: copy the node binary plus the bundled npm JS
# installs from the upstream image. npm and npx are recreated as symlinks
# because they're symlinks in the source image (and need to live on PATH).
# See node_source stage at the top of the file for the version-bump
# rationale (#4977).
COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/
COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
WORKDIR /opt/hermes
@@ -119,7 +117,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# `npm_config_install_links=false` forces npm to install `file:` deps as
# symlinks instead of copies. This is the default since npm 10+, which is
# what the image ships now (via the node:22 source stage). We set it
# what the image ships now (via the node:26 source stage). We set it
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
# 9.x defaulted to install-as-copy, which produced a hidden
# node_modules/.package-lock.json that permanently disagreed with the root
+90 -2
View File
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
account info to show (fail-open: caller just shows nothing).
"""
try:
from hermes_cli.nous_account import nous_portal_billing_url
from hermes_cli.nous_account import nous_portal_topup_url
if account_info is None or not getattr(account_info, "logged_in", False):
return None
@@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
if not windows and not details:
return None
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
return None
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
and callers degrade gracefully.
"""
logged_in: bool
balance_lines: tuple[str, ...] = ()
identity_line: Optional[str] = None
topup_url: Optional[str] = None
depleted: bool = False
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return not_logged_in
except Exception:
return not_logged_in
try:
import concurrent.futures
from hermes_cli.nous_account import (
get_nous_portal_account_info,
nous_portal_topup_url,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
return not_logged_in
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
balance_lines = [
line
for line in rendered
if not line.lstrip().startswith("Top up:")
and not line.lstrip().startswith("(or run")
]
# Identity line — shown before any open (roadmap §4.4).
email = getattr(account, "email", None)
org_name = getattr(account, "org_name", None)
who: list[str] = []
if email:
who.append(str(email))
if org_name:
who.append(f"org {org_name}")
identity_line = ("Topping up as " + " / ".join(who)) if who else None
return CreditsView(
logged_in=True,
balance_lines=tuple(balance_lines),
identity_line=identity_line,
topup_url=nous_portal_topup_url(account),
depleted=getattr(account, "paid_service_access", None) is False,
)
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
+14 -7
View File
@@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
return converted
def _summarize_user_message_for_log(content: Any) -> str:
"""Return a short text summary of a user message for logging/trajectory.
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
"""Flatten message content to a plain-text summary.
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
parts from the API server. Logging, spinner previews, and trajectory
files all want a plain string — this helper extracts the first chunk of
text and notes any attached images. Returns an empty string for empty
lists and ``str(content)`` for unexpected scalar types.
parts from the API server. Several consumers want a plain string:
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
- External memory providers, which feed the text to regexes
(``sanitize_context``) and text APIs — a raw list crashes the sync with
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
so the turn isn't recorded as if the attachment never existed. Returns an
empty string for empty lists and ``str(content)`` for unexpected scalar
types.
"""
if content is None:
return ""
@@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str:
text_bits.append(text)
elif ptype in {"image_url", "input_image"}:
image_count += 1
summary = " ".join(text_bits).strip()
summary = sep.join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
summary = f"{note} {summary}" if summary else note
+45 -2
View File
@@ -143,10 +143,18 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
# become another unbounded transcript copy after the LLM summarizer failed.
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
_FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
# MEDIA delivery directives must not reach the summarizer — if one leaks into
# the summary, the downstream model may re-emit it as an active directive on
# the next turn, triggering bogus attachment sends (#14665).
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
value = value.strip()
@@ -1007,6 +1015,7 @@ class ContextCompressor(ContextEngine):
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1454,7 +1463,7 @@ Use this exact structure:
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
try:
call_kwargs = {
@@ -1623,6 +1632,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@classmethod
def _derive_auto_focus_topic(
cls,
messages: List[Dict[str, Any]],
) -> Optional[str]:
"""Infer a compact focus hint from the most recent real user turns."""
candidates: list[str] = []
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if msg.get("role") != "user":
continue
content = msg.get("content")
if cls._is_context_summary_content(content):
continue
text = redact_sensitive_text(_content_text_for_contains(content).strip())
if not text:
continue
text = " ".join(text.split())
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + ""
candidates.append(text)
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
break
if not candidates:
return None
candidates.reverse()
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + ""
return focus
@classmethod
def _find_latest_context_summary(
cls,
@@ -2070,7 +2112,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
)
# Phase 3: Generate structured summary
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
# If summary generation failed, behavior splits on
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
+11 -1
View File
@@ -286,6 +286,16 @@ def evaluate_credits_notices(
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
if uf >= band[0]:
current_band = band
# Top-up suppression: when the account holds purchased (top-up) credits,
# the subscription-cap gauge is the wrong denominator — warning "90% used"
# at a user sitting on $50 of top-up is noise (and it previously stuck
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
# entirely; the cap-reached case is covered by the grant_spent info notice
# below, which already names the remaining top-up balance. A top-up landing
# mid-session flips current_band → None and the clear path below removes
# any showing band line.
if state.purchased_micros > 0:
current_band = None
grant_cond = (
state.denominator_kind == "subscription_cap"
and uf is not None
@@ -345,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /usage for balance",
text="✕ Credit access paused · run /credits to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
+19 -2
View File
@@ -489,6 +489,23 @@ PLATFORM_HINTS = {
"files arrive as downloadable documents. You can also include image "
"URLs in markdown format ![alt](url) and they will be sent as photos."
),
"whatsapp_cloud": (
"You are on a text messaging communication platform, WhatsApp "
"(via Meta's official Business Cloud API). Standard markdown "
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
"to write in markdown. Tables are NOT supported — prefer bullet "
"lists or labeled key:value pairs. "
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png) become photo attachments, "
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
"messages, other files arrive as documents. Image URLs in markdown "
"format ![alt](url) also work. "
"IMPORTANT: this platform has a 24-hour conversation window — if the "
"user hasn't messaged in 24h, free-form replies are refused by Meta "
"(error 131047). This rarely matters for live chat, but is worth "
"knowing if you're scheduling a delayed message."
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
@@ -1418,13 +1435,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
+101
View File
@@ -0,0 +1,101 @@
const path = require('node:path')
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin'
])
function delimiterForPlatform(platform = process.platform) {
return platform === 'win32' ? ';' : ':'
}
function pathModuleForPlatform(platform = process.platform) {
return platform === 'win32' ? path.win32 : path.posix
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH'
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const seen = new Set()
const ordered = []
for (const entry of entries) {
if (!entry) continue
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
seen.add(part)
ordered.push(part)
}
}
return ordered.join(delimiter)
}
function buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
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 }
)
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
venvRoot,
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
return {
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
[key]: buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath: currentPathValue(currentEnv, platform),
platform,
pathModule
})
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
pathEnvKey
}
@@ -0,0 +1,95 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
pathEnvKey
} = require('./backend-env.cjs')
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
for (const expected of POSIX_SANE_PATH_ENTRIES) {
assert.ok(entries.includes(expected), `${expected} should be present`)
}
})
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
assert.ok(
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
'existing Homebrew bin keeps its precedence over appended missing sane entries'
)
})
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
const env = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
pythonPathEntries: ['/repo/hermes-agent'],
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentEnv: {
PATH: '/usr/bin:/bin',
PYTHONPATH: '/existing/pythonpath'
},
platform: 'darwin',
pathModule: path.posix
})
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
pythonPathEntries: ['C:\\repo\\hermes-agent'],
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
currentEnv: {
Path: 'C:\\Windows\\System32;C:\\Windows',
PYTHONPATH: 'C:\\existing\\pythonpath'
},
platform: 'win32',
pathModule: path.win32
})
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
assert.equal(env.PATH, undefined)
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
assert.ok(env.Path.includes('\\venv\\Scripts;'))
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
})
+9 -1
View File
@@ -1,4 +1,5 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
@@ -142,7 +143,14 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
function resolveRequestedPathForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
const raw = rejectUnsafePathSyntax(filePath, purpose)
let raw = rejectUnsafePathSyntax(filePath, purpose)
// Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely
// arrive as `~/...`. Node's fs has no shell — without expansion the path
// resolves under process.cwd() and every read "ENOENT"s forever.
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
raw = path.join(os.homedir(), raw.slice(1))
}
if (/^file:/i.test(raw)) {
let resolvedPath
+13
View File
@@ -106,6 +106,19 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d
)
})
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
assert.equal(
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
path.resolve(os.homedir(), 'www/project')
)
// `~user` shorthand is NOT expanded — only the caller's own home.
assert.equal(
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
path.resolve(os.tmpdir(), '~other/secret')
)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
+269 -91
View File
@@ -26,19 +26,22 @@ const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const {
buildSessionWindowUrl,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { PortPool } = require('./port-pool.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const {
OFFICIAL_REPO_HTTPS_URL,
isOfficialSshRemote
} = require('./update-remote.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -95,6 +98,7 @@ try {
nodePty = require(nodePtyDir)
}
} catch {
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
nodePty = null
nodePtyDir = null
}
@@ -346,10 +350,110 @@ const APP_ICON_PATHS = [
let rendererTitleBarTheme = null
const terminalSessions = new Map()
// Force the NATIVE window appearance (vibrancy material, titlebar, the
// pre-first-paint window background) to follow the APP theme instead of the
// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that
// tracks the window's effective appearance and ignores `backgroundColor` —
// so a dark-themed app on a light-mode Mac flashes a white material on every
// new window until the renderer covers it. The renderer reports its mode via
// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin
// nativeTheme.themeSource to it and persist the value so cold launches paint
// correctly before the renderer has even loaded.
const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json')
const THEME_SOURCES = new Set(['dark', 'light', 'system'])
function readPersistedThemeSource() {
try {
const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8'))
if (parsed && THEME_SOURCES.has(parsed.themeSource)) {
return parsed.themeSource
}
} catch {
// Missing / malformed → follow the OS like a fresh install.
}
return 'system'
}
function writePersistedThemeSource(mode) {
try {
fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true })
fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[theme] write native theme failed: ${error.message}`)
}
}
nativeTheme.themeSource = readPersistedThemeSource()
// Window translucency (see-through window). One lever, 0100; 0 = off (the
// default). Mapped to the native window opacity so the desktop shows through
// the whole window. Persisted so a cold launch applies it at window creation,
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
// a no-op on Linux. See store/translucency.
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
function clampIntensity(value) {
const n = Math.round(Number(value))
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
}
function readPersistedTranslucency() {
try {
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
} catch {
return 0
}
}
function writePersistedTranslucency(intensity) {
try {
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[translucency] write failed: ${error.message}`)
}
}
let translucencyIntensity = readPersistedTranslucency()
// Map the 0100 lever to a window opacity. Floor at 0.3 so the most see-through
// setting is still usable rather than nearly invisible. 0 → fully opaque.
function windowOpacity() {
return 1 - (translucencyIntensity / 100) * 0.7
}
// Re-apply translucency to a live window (runtime toggle, no recreation).
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
function applyWindowTranslucency(win) {
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
return
}
try {
win.setOpacity(windowOpacity())
} catch (error) {
rememberLog(`[translucency] apply failed: ${error.message}`)
}
}
function isHexColor(value) {
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
}
// Background color to paint a window with BEFORE its renderer loads, so a new
// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme
// the renderer last reported; fall back to the OS preference on first launch.
function getWindowBackgroundColor() {
if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) {
return rendererTitleBarTheme.background
}
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
}
function getTitleBarOverlayOptions() {
if (IS_MAC) {
return { height: TITLEBAR_HEIGHT }
@@ -1162,10 +1266,14 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}))
const out = execFileSync(
pyExe,
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
)
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1300,11 +1408,15 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
}))
const child = spawn(
resolveGitBinary(),
IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args,
hiddenWindowsChildOptions({
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
})
)
let stdout = ''
let stderr = ''
@@ -1741,11 +1853,15 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(command, args, hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
}))
child = spawn(
command,
args,
hiddenWindowsChildOptions({
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
})
)
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -2133,9 +2249,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
label,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: {
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot: path.join(root, 'venv')
}),
root,
bootstrap: Boolean(options.bootstrap),
shell: false
@@ -2154,9 +2272,11 @@ function createActiveBackend(dashboardArgs) {
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: {
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [ACTIVE_HERMES_ROOT],
venvRoot: VENV_ROOT
}),
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
@@ -4563,25 +4683,29 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
HERMES_HOME,
...backend.env,
// Pin the gateway's tool/terminal cwd to the same directory we chose for
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// 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
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
const child = spawn(
backend.command,
backend.args,
hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
HERMES_HOME,
...backend.env,
// Pin the gateway's tool/terminal cwd to the same directory we chose for
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// 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
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
)
entry.process = child
entry.port = port
entry.token = token
@@ -4778,30 +4902,34 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
// resolves to the SAME location our resolveHermesHome() picked. Without
// this pin, Python falls back to ~/.hermes on every platform — fine on
// mac/linux (where our default matches), but on Windows our default is
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
// Mismatch would split config / sessions / .env / logs across two
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// 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
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
hermesProcess = spawn(
backend.command,
backend.args,
hiddenWindowsChildOptions({
cwd: hermesCwd,
env: {
...process.env,
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
// resolves to the SAME location our resolveHermesHome() picked. Without
// this pin, Python falls back to ~/.hermes on every platform — fine on
// mac/linux (where our default matches), but on Windows our default is
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
// Mismatch would split config / sessions / .env / logs across two
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// 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
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
})
)
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -4939,21 +5067,29 @@ function focusWindow(win) {
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId) {
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: 480,
height: 800,
minWidth: 420,
minHeight: 620,
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
backgroundColor: '#f7f7f7',
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
@@ -4968,6 +5104,10 @@ function createSessionWindow(sessionId) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
@@ -4978,7 +5118,8 @@ function createSessionWindow(sessionId) {
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
})
)
@@ -5004,8 +5145,13 @@ function createWindow() {
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
backgroundColor: '#f7f7f7',
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
// `backgroundColor` and follows the OS appearance) can't flash a light
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
@@ -5041,6 +5187,10 @@ function createWindow() {
}
}
mainWindow.once('ready-to-show', () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
})
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
@@ -5152,12 +5302,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
return { ok: true }
})
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
if (typeof sessionId !== 'string' || !sessionId.trim()) {
return { ok: false, error: 'invalid-session-id' }
}
createSessionWindow(sessionId.trim())
createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
return { ok: true }
})
@@ -5565,6 +5715,35 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
ipcMain.on('hermes:native-theme', (_event, mode) => {
if (!THEME_SOURCES.has(mode)) {
return
}
if (nativeTheme.themeSource !== mode) {
nativeTheme.themeSource = mode
writePersistedThemeSource(mode)
}
})
// See-through window translucency. Persist + re-apply opacity to every open
// window at runtime (no recreation, so caching/sessions are untouched).
ipcMain.on('hermes:translucency', (_event, payload) => {
const next = clampIntensity(payload && payload.intensity)
if (next === translucencyIntensity) {
return
}
translucencyIntensity = next
writePersistedTranslucency(next)
for (const win of BrowserWindow.getAllWindows()) {
applyWindowTranslucency(win)
}
})
ipcMain.handle('hermes:openExternal', (_event, url) => {
if (!openExternalUrl(url)) {
throw new Error('Invalid external URL')
@@ -6002,11 +6181,15 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
}))
const child = spawn(
py,
['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'],
hiddenWindowsChildOptions({
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
)
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -6164,7 +6347,7 @@ let _rendererReadyForDeepLink = false
function _extractDeepLink(argv) {
if (!Array.isArray(argv)) return null
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
}
function handleDeepLink(url) {
@@ -6208,9 +6391,7 @@ ipcMain.handle('hermes:deep-link-ready', () => {
_pendingDeepLink = null
handleDeepLink(
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
(Object.keys(queued.params).length
? '?' + new URLSearchParams(queued.params).toString()
: ''),
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
)
}
return { ok: true }
@@ -6221,9 +6402,7 @@ function registerDeepLinkProtocol() {
if (process.defaultApp && process.argv.length >= 2) {
// Dev: register with the electron exec path + entry script so the OS can
// relaunch us with the URL.
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
])
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
} else {
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
}
@@ -6256,7 +6435,6 @@ app.on('open-url', (event, url) => {
handleDeepLink(url)
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
+3 -1
View File
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
+18 -5
View File
@@ -5,22 +5,30 @@
const { pathToFileURL } = require('node:url')
// Secondary windows open at the minimum usable size — a compact side panel for
// subagent watch / cmd-click session pop-out, not a second full desktop.
const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
return `${base}/?win=secondary${route}`
return `${base}/${query}${route}`
}
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
}
// A small registry keyed by sessionId that guarantees one window per chat:
@@ -83,4 +91,9 @@ function createSessionWindowRegistry() {
}
}
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
module.exports = {
buildSessionWindowUrl,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
}
@@ -76,6 +76,12 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
})
test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => {
const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
+22
View File
@@ -9,6 +9,28 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="shortcut icon" href="/apple-touch-icon.png" />
<title>Hermes</title>
<script>
// Pre-paint the themed background before the app bundle loads. Without
// this, the first frame (which is what `ready-to-show` waits for) is the
// UA-default white page, and the real theme only lands once the whole
// module graph has executed — i.e. the "white flash" on every new
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
try {
let bg = localStorage.getItem('hermes-boot-background')
let scheme = localStorage.getItem('hermes-boot-color-scheme')
if (!bg) {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
bg = dark ? '#111111' : '#f7f7f7'
scheme = dark ? 'dark' : 'light'
}
document.documentElement.style.backgroundColor = bg
if (scheme === 'dark' || scheme === 'light') {
document.documentElement.style.colorScheme = scheme
}
} catch {
// localStorage unavailable — keep UA defaults.
}
</script>
</head>
<body>
<div id="root" class="scrollbar-dt"></div>
+4 -3
View File
@@ -8,7 +8,7 @@
"type": "module",
"main": "electron/main.cjs",
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": ">=26.0.0"
},
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
@@ -18,7 +18,8 @@
"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 && node scripts/assert-dist-built.cjs",
"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",
"postbuild": "node scripts/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -35,7 +36,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.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",
"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/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/port-pool.test.cjs electron/session-windows.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",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+6 -4
View File
@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view'
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
if (status === 'running' || status === 'queued') {
return (
<BrailleSpinner
<GlyphSpinner
ariaLabel={a.running}
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
spinner="breathe"
@@ -290,7 +290,7 @@ function StreamLine({
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
{entry.text}
{active ? (
<BrailleSpinner
<GlyphSpinner
ariaLabel={t.agents.streaming}
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
spinner="breathe"
@@ -372,7 +372,9 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
{t.agents.files}
</p>
{fileLines.slice(0, 8).map(line => (
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
{line}
@@ -2,25 +2,21 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
import { composerFusedDockCard } from '@/components/chat/composer-dock'
import { cn } from '@/lib/utils'
export const COMPLETION_DRAWER_BELOW_CLASS = [
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
'w-80 max-w-[calc(100vw-2rem)]',
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-xl border border-(--ui-stroke-secondary)',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
'p-1 text-xs text-popover-foreground shadow-lg',
'backdrop-blur-md'
].join(' ')
// Same docked chrome as the queue/status stack, but its own thing: a narrow,
// left-aligned card (not full width) that fuses to the composer's edge instead
// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative
// margin overlaps the seam so the composer's (now-transparent) edge border reads
// as shared. Fused (opaque) fill — the composer surface swaps to the same fill
// while a drawer is open, so the two paint as one panel.
const DRAWER_SHELL =
'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground'
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top'))
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom'))
export function ComposerCompletionDrawer({
adapter,
@@ -11,6 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -86,7 +87,7 @@ export function ContextMenu({
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
{c.tipPre}
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
<Kbd size="sm">@</Kbd>
{c.tipPost}
</div>
</DropdownMenuContent>
@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -63,7 +64,14 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<span className="inline-flex items-center gap-1.5">
{c.steer}
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
</span>
)
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -75,7 +83,7 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={steerLabel}>
<Tip label={steerTip}>
<Button
aria-label={steerLabel}
className={GHOST_ICON_BTN}
@@ -10,6 +10,7 @@
* steal focus from the composer effect.
*/
import { RICH_INPUT_SLOT } from './rich-editor'
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
@@ -123,3 +124,12 @@ export const focusComposerInput = (el: HTMLElement | null) => {
window.requestAnimationFrame(focus)
window.setTimeout(focus, 0)
}
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */
export const blurComposerInput = () => {
const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null
if (el && document.activeElement === el) {
el.blur()
}
}
@@ -1,11 +1,23 @@
import type { ReactNode } from 'react'
import { KbdCombo } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
const COMPOSER_HOTKEY_ROWS = [
{ id: 'composer.mention', combos: ['@'] },
{ id: 'composer.slash', combos: ['/'] },
{ id: 'composer.help', combos: ['?'] },
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
{ id: 'composer.cancel', combos: ['escape'] },
{ id: 'composer.history', combos: ['up', 'down'] }
] as const
export function HelpHint() {
const { t } = useI18n()
@@ -20,8 +32,8 @@ export function HelpHint() {
</Section>
<Section title={c.hotkeys}>
{HOTKEY_KEYS.map(key => (
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
{COMPOSER_HOTKEY_ROWS.map(row => (
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
))}
</Section>
@@ -57,3 +69,16 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
</div>
)
}
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
return (
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
<span className="flex shrink-0 items-center gap-1">
{combos.map(combo => (
<KbdCombo combo={combo} key={combo} size="sm" />
))}
</span>
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
</div>
)
}
+52 -47
View File
@@ -14,6 +14,7 @@ import {
} from 'react'
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
@@ -48,6 +49,7 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { useTheme } from '@/themes'
@@ -80,12 +82,14 @@ import {
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT,
slashChipElement
} from './rich-editor'
import { ComposerStatusStack } from './status-stack'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -168,6 +172,7 @@ export function ChatBar({
const draft = useAuiState(s => s.composer.text)
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
const scrolledUp = useStore($threadScrolledUp)
const sessionMessages = useStore($messages)
const activeQueueSessionKey = queueSessionKey || sessionId || null
@@ -177,6 +182,17 @@ export function ChatBar({
[activeQueueSessionKey, queuedPromptsBySession]
)
// Status items (subagents, background processes) are keyed by the RUNTIME
// session id — gateway events and process.list both speak that id. Only the
// queue uses the stored-session fallback key (prompts can queue pre-resume).
const statusSessionId = sessionId ?? null
const statusStackVisible = useMemo(
() =>
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
[queuedPrompts.length, statusItemsBySession, statusSessionId]
)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
@@ -602,9 +618,7 @@ export function ChatBar({
// (which drives `hasComposerPayload` → the send button). Shared by the input
// and compositionend paths so committed IME text reaches state through either.
const flushEditorToDraft = (editor: HTMLDivElement) => {
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
}
normalizeComposerEditorDom(editor)
const nextDraft = composerPlainText(editor)
@@ -688,8 +702,7 @@ export function ChatBar({
// already an arg pick (`/personality alice`), so it commits normally.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const expandsToArgs =
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
@@ -1113,11 +1126,8 @@ export function ChatBar({
}
}
const stashAt = (
scope: string | null,
text = draftRef.current,
attachments = $composerAttachments.get()
) => stashSessionDraft(scope, text, attachments)
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
stashSessionDraft(scope, text, attachments)
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
@@ -1669,6 +1679,7 @@ export function ChatBar({
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
data-drag-active={dragActive ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -1696,26 +1707,30 @@ export function ChatBar({
onPick={replaceTriggerWithChip}
/>
)}
{activeQueueSessionKey && queuedPrompts.length > 0 && (
// Out of flow so the queue never inflates the composer's measured
// height (that drives thread bottom padding → chat resizes on
// queue). Overlaps -mb-2 onto the surface's top border for a shared
// edge; capped + scrollable. Overlays the chat instead of pushing it.
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
entries={queuedPrompts}
onDelete={id => {
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
exitQueuedEdit('cancel')
}
}}
onEdit={beginQueuedEdit}
onSendNow={id => void sendQueuedNow(id)}
/>
</div>
)}
{/* Session-scoped status stack (todos, subagents, background tasks,
queue). Out of flow so it never inflates the composer's measured
height; it overlays the chat instead of pushing it, and publishes
its own --status-stack-measured-height so the thread's clearance
accounts for it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
<QueuePanel
busy={busy}
editingId={queueEdit?.entryId ?? null}
entries={queuedPrompts}
onDelete={id => {
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
exitQueuedEdit('cancel')
}
}}
onEdit={beginQueuedEdit}
onSendNow={id => void sendQueuedNow(id)}
/>
) : null
}
sessionId={statusSessionId}
/>
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
@@ -1723,10 +1738,10 @@ export function ChatBar({
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'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',
'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)]',
COMPOSER_DROP_FADE_CLASS,
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
'group-has-data-[state=open]/composer:border-t-transparent',
'group-data-[status-stack]/composer:border-t-transparent',
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-surface"
@@ -1736,20 +1751,14 @@ export function ChatBar({
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
composerFill,
composerSurfaceGlass
)}
/>
<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: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"
>
@@ -1824,12 +1833,8 @@ export function ChatBarFallback() {
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
composerFill,
composerSurfaceGlass
)}
/>
</div>
@@ -3,7 +3,12 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } 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 }
@@ -89,56 +94,102 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null {
if (typeof ref !== 'string') {
return { kind: ref.kind, label: ref.label, rawValue: ref.value }
}
const match = ref.match(/^@([^:]+):(.+)$/)
if (!match) {
return null
}
const refsHtml = refs
.map(ref => {
if (typeof ref !== 'string') {
return refChipHtml(ref.kind, ref.value, ref.label)
}
return { kind: match[1] || 'file', rawValue: match[2] || '' }
}
const match = ref.match(/^@([^:]+):(.+)$/)
function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') {
const slice = range.cloneRange()
slice.selectNodeContents(editor)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
})
.join(' ')
if (edge === 'before') {
slice.setEnd(range.startContainer, range.startOffset)
} else {
slice.setStart(range.endContainer, range.endOffset)
}
const container = document.createElement('div')
container.appendChild(slice.cloneContents())
return composerPlainText(container)
}
function buildRefFragment(
refs: readonly { kind: string; label?: string; rawValue: string }[],
{ needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean }
) {
const fragment = document.createDocumentFragment()
if (needsBeforeSpace) {
fragment.append(document.createTextNode(' '))
}
refs.forEach((ref, index) => {
if (index > 0) {
fragment.append(document.createTextNode(' '))
}
fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label))
})
if (needsAfterSpace) {
fragment.append(document.createTextNode(' '))
}
return fragment
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable<typeof ref> => ref !== null)
if (!parsed.length) {
return null
}
editor.focus({ preventScroll: true })
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
: null
editor.focus({ preventScroll: true })
if (range && selection) {
const beforeText = plainTextInRange(editor, range, 'before')
const afterText = plainTextInRange(editor, range, 'after')
if (range) {
const beforeRange = range.cloneRange()
beforeRange.selectNodeContents(editor)
beforeRange.setEnd(range.startContainer, range.startOffset)
const beforeContainer = document.createElement('div')
beforeContainer.appendChild(beforeRange.cloneContents())
const afterRange = range.cloneRange()
afterRange.selectNodeContents(editor)
afterRange.setStart(range.endContainer, range.endOffset)
const afterContainer = document.createElement('div')
afterContainer.appendChild(afterRange.cloneContents())
const beforeText = composerPlainText(beforeContainer)
const afterText = composerPlainText(afterContainer)
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
range.insertNode(
buildRefFragment(parsed, {
needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText),
needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText)
})
)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
} else {
const current = composerPlainText(editor)
editor.append(
buildRefFragment(parsed, {
needsAfterSpace: true,
needsBeforeSpace: current.length > 0 && !/\s$/.test(current)
})
)
placeCaretEnd(editor)
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
}
normalizeComposerEditorDom(editor)
return composerPlainText(editor)
}
@@ -1,10 +1,7 @@
import { useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@@ -23,108 +20,70 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(true)
if (entries.length === 0) {
return null
}
return (
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
<button
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
<span className="truncate">{c.queued(entries.length)}</span>
</button>
<StatusSection label={c.queued(entries.length)}>
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
{!collapsed && (
<div className="space-y-0.5 px-1 pb-0.5">
{entries.map(entry => {
const isEditing = editingId === entry.id
const attachmentsCount = entry.attachments.length
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
return (
<div
className={cn(
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
key={entry.id}
>
<span
aria-hidden
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer}
</span>
)}
</div>
)}
</div>
<div
className={cn(
'flex shrink-0 items-center gap-0 transition-opacity',
isEditing
? 'opacity-100'
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)}
return (
<StatusRow
className={cn(
'border border-transparent',
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
key={entry.id}
leading={
<span aria-hidden className="size-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
}
trailing={
<>
<Button
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="micro"
type="button"
variant="text"
>
<Tip label={c.editQueued}>
<Button
aria-label={c.editQueued}
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label={sendLabel}>
<Button
aria-label={sendLabel}
className="h-5 w-5 rounded-md"
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label={c.deleteQueued}>
<Button
aria-label={c.deleteQueued}
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
{c.queueEdit}
</Button>
<Button
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="micro"
type="button"
variant="secondary"
>
{busy ? c.queueSendNext : c.queueSend}
</Button>
<Button onClick={() => onDelete(entry.id)} size="micro" type="button" variant="text">
{c.queueDelete}
</Button>
</>
}
trailingVisible={isEditing}
>
<div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
{isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
</StatusRow>
)
})}
</StatusSection>
)
}
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
@@ -16,3 +23,39 @@ describe('renderComposerContents', () => {
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
})
describe('normalizeComposerEditorDom', () => {
it('unwraps a single insertHTML wrapper div so plain text stays one line', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.innerHTML = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
normalizeComposerEditorDom(editor)
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
expect(editor.querySelector(':scope > div')).toBeNull()
})
it('removes a trailing br after a ref chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br'))
normalizeComposerEditorDom(editor)
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`')
expect(editor.querySelector('br')).toBeNull()
})
})
describe('insertInlineRefsIntoEditor', () => {
it('inserts chips without wrapper divs or spurious newlines', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`'])
expect(editor.querySelector(':scope > div')).toBeNull()
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
})
@@ -184,3 +184,36 @@ export function placeCaretEnd(element: HTMLElement) {
selection?.removeAllRanges()
selection?.addRange(range)
}
/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */
export function normalizeComposerEditorDom(editor: HTMLElement) {
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
editor.replaceChildren()
return
}
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
const wrapper = editor.firstChild as HTMLElement
if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
editor.replaceChildren(...Array.from(wrapper.childNodes))
}
}
const last = editor.lastChild
if (last?.nodeName !== 'BR') {
return
}
let prev: ChildNode | null = last.previousSibling
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
prev = prev.previousSibling
}
if ((prev as HTMLElement | null)?.dataset.refText) {
editor.removeChild(last)
}
}
@@ -0,0 +1,194 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
$statusItemsBySession,
type ComposerStatusItem,
dismissBackgroundProcess,
groupStatusItems,
refreshBackgroundProcesses,
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { StatusItemRow } from './status-row'
// Slow safety-net poll for silent exits (processes without notify_on_complete
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
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)
}
return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length)
}
interface ComposerStatusStackProps {
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
* as the last group so it stays fused to the composer like before. */
queue: ReactNode
sessionId: null | string
}
/**
* The status "sink" above the composer: one card (the queue's chrome) holding
* every session-scoped status subagents, background tasks, queue grouped by
* type and separated by light dividers. Collapses to nothing when empty.
*/
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
const { t } = useI18n()
const navigate = useNavigate()
const itemsBySession = useStore($statusItemsBySession)
const scrolledUp = useStore($threadScrolledUp)
const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
[itemsBySession, sessionId]
)
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream.
useEffect(() => {
if (sessionId) {
void refreshBackgroundProcesses(sessionId)
}
}, [sessionId])
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
}
const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS)
return () => clearInterval(timer)
}, [hasRunningBackground, sessionId])
const openAgents = () => navigate(AGENTS_ROUTE)
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
key: group.type,
node: (
<StatusSection
accessory={
group.type === 'subagent' ? (
<Button
className="text-muted-foreground/75 hover:text-foreground/90"
onClick={openAgents}
size="micro"
type="button"
variant="text"
>
{t.statusStack.agents}
</Button>
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
icon={
group.type === 'todo' ? (
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
) : undefined
}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
<StatusItemRow
item={item}
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
</StatusSection>
)
}))
if (queue) {
sections.push({ key: 'queue', node: queue })
}
const visible = sections.length > 0
const stackRef = useRef<HTMLDivElement | null>(null)
// The stack is out of flow (overlays the thread), so the composer's measured
// height never sees it. Publish our own measured height — bucketed like the
// composer's, to avoid style invalidation churn — so the thread's
// last-message clearance can add it and the stack never hides messages.
useLayoutEffect(() => {
const root = document.documentElement
const el = stackRef.current
if (!visible || !el) {
root.style.removeProperty('--status-stack-measured-height')
return
}
let last = -1
const sync = () => {
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
if (bucket !== last) {
last = bucket
root.style.setProperty('--status-stack-measured-height', `${bucket}px`)
}
}
const observer = new ResizeObserver(sync)
observer.observe(el)
sync()
return () => {
observer.disconnect()
root.style.removeProperty('--status-stack-measured-height')
}
}, [visible])
if (!visible) {
return null
}
return (
<div
className="absolute inset-x-0 bottom-full z-6 -mb-[9px] max-h-[40vh] overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur. */}
<div className={cn(composerDockCard('top'), 'mx-1 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>
</div>
)
}
@@ -0,0 +1,155 @@
import { Fragment, memo, type ReactNode, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { TerminalOutput } from '@/components/chat/terminal-output'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
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'
const toolLabel = (name: string) =>
name
.split('_')
.filter(Boolean)
.map(part => part[0]!.toUpperCase() + part.slice(1))
.join(' ') || name
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
// is still open (pending), codicons once it resolves, a live spinner only on
// the in-progress item.
const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon: string; tone: string }> = {
cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' },
completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }
}
// Left slot: braille spinner while running, otherwise a small status dot
// (green = done, red = failed) so the slot is always filled and rows align.
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
if (item.todoStatus === 'pending') {
return (
<span
aria-hidden
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
/>
)
}
if (item.todoStatus && item.todoStatus !== 'in_progress') {
const glyph = TODO_GLYPHS[item.todoStatus]
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
}
if (item.state === 'running') {
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.9rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
}
return (
<span
aria-hidden
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
/>
)
}
interface StatusItemRowProps {
item: ComposerStatusItem
/** Clear a finished background task from the stack. */
onDismiss?: (id: string) => void
/** Open the subagent's own session window, livestreamed by the gateway's
* child-session mirror (Agents view fallback for older gateways). */
onOpen?: () => void
/** Cancel a running background task. */
onStop?: (id: string) => void
}
/**
* Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}.
* Memoised + keyed by id so parent re-renders never remount it (the spinner
* keeps ticking instead of resetting).
*/
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
const { t } = useI18n()
const s = t.statusStack
const [outputOpen, setOutputOpen] = useState(false)
const failed = item.state === 'failed'
const running = item.state === 'running'
const action =
item.type === 'background'
? running
? onStop && { label: s.stop, onClick: () => onStop(item.id) }
: onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) }
: null
const canOpen = item.type === 'subagent' && !!onOpen
const hasOutput = item.type === 'background' && !!item.output
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined
return (
<Fragment>
<StatusRow
leading={leadingGlyph(item, s)}
onActivate={onActivate}
trailing={
action ? (
<Tip label={action.label}>
<Button
aria-label={action.label}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
action.onClick()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
) : canOpen ? (
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
) : undefined
}
>
<span
className={cn(
'min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4',
failed
? 'text-destructive/90'
: item.todoStatus && item.todoStatus !== 'in_progress'
? 'text-muted-foreground/75'
: 'text-foreground/92'
)}
>
{item.title}
</span>
{item.type === 'subagent' && item.currentTool && (
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
{toolLabel(item.currentTool)}
</span>
)}
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
{s.exit(item.exitCode)}
</span>
)}
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
</StatusRow>
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
</Fragment>
)
})
@@ -1,16 +1,12 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
COMPLETION_DRAWER_BELOW_CLASS,
COMPLETION_DRAWER_CLASS,
CompletionDrawerEmpty
} from './completion-drawer'
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
const AT_ICON_BY_TYPE: Record<string, string> = {
diff: 'diff',
@@ -87,7 +83,7 @@ export function ComposerTriggerPopover({
{items.length === 0 ? (
loading ? (
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<GlyphSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
<span>{copy.lookupLoading}</span>
</div>
) : (
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus'
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { useI18n } from '@/i18n'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
@@ -286,6 +287,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
const insertContextPathInlineRef = useCallback(
(path: string, isDirectory = false) => {
if (!path) {
return false
}
const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd)
if (!ref) {
return false
}
requestComposerInsertRefs([ref])
requestComposerFocus('main')
return true
},
[currentCwd]
)
const attachContextFilePath = useCallback(
(filePath: string) => {
if (!filePath) {
@@ -546,6 +567,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
attachDroppedItems,
attachImageBlob,
attachImagePath,
insertContextPathInlineRef,
pasteClipboardImage,
pickContextPaths,
pickImages,
+6 -9
View File
@@ -43,7 +43,7 @@ import {
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
@@ -80,6 +80,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>
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
@@ -124,13 +125,7 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className="min-w-0 flex-1"
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
<div className={titlebarHeaderTitleClass}>
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -141,7 +136,7 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto flex h-6 w-full min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
@@ -176,6 +171,7 @@ export function ChatView({
onThreadMessagesChange,
onEdit,
onReload,
onRestoreToMessage,
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
@@ -362,6 +358,7 @@ export function ChatView({
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId}
sessionKey={threadKey}
/>
@@ -10,12 +10,16 @@ import { useEffect, useMemo, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
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 { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
@@ -357,6 +361,38 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
// ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the
// gutter drag produces — so the keyboard path mirrors dragging the lines into
// the composer. Capture-phase + stopPropagation so it beats the terminal's
// global ⌘L handler (which would otherwise grab the native text selection).
useEffect(() => {
if (!selection) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isAddSelectionShortcut(event)) {
return
}
const lineEnd = selection.end > selection.start ? selection.end : undefined
const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get())
if (!ref) {
return
}
event.preventDefault()
event.stopPropagation()
requestComposerInsertRefs([ref])
requestComposerFocus('main')
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [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">
@@ -168,7 +168,7 @@ export function SidebarCronJobsSection({
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
+12 -10
View File
@@ -39,6 +39,7 @@ import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { comboTokens } from '@/lib/keybinds/combo'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
@@ -108,11 +109,7 @@ const VIRTUALIZE_THRESHOLD = 25
const NON_SESSION_INITIAL_ROWS = 3
const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const NEW_SESSION_KBD = comboTokens('mod+n')
const SIDEBAR_NAV: SidebarNavItem[] = [
{
@@ -144,8 +141,11 @@ const GROUP_DND_ID_PREFIX = 'group:'
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
// Vertical scroll only — never a horizontal bar from glow bleed, long titles, etc.
const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain'
// A non-session group's scroll body: own scroller when tall, flattened when compact.
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
const GROUP_BODY = cn(SCROLL_Y, COMPACT_FLAT)
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -830,8 +830,9 @@ export function ChatSidebar({
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
size="sm"
/>
)}
</>
@@ -857,11 +858,11 @@ export function ChatSidebar({
)}
{contentVisible && showSessionSections && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
{trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
contentClassName={cn('flex min-h-0 flex-1 flex-col gap-px pb-1.75', SCROLL_Y)}
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
@@ -908,7 +909,8 @@ export function ChatSidebar({
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
'flex min-h-0 flex-1 flex-col pb-1.75',
SCROLL_Y,
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px',
@@ -102,7 +102,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
})
const list = (
<div className={cn('relative min-h-0 flex-1 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>
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { KbdCombo } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@@ -38,7 +38,6 @@ import {
Wrench,
Zap
} from '@/lib/icons'
import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
@@ -620,7 +619,6 @@ export function CommandPalette() {
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
const keys = combo ? comboTokens(combo) : null
return (
<CommandItem
@@ -632,10 +630,10 @@ export function CommandPalette() {
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{keys && <KbdGroup className="ml-auto" keys={keys} />}
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
+28 -6
View File
@@ -11,7 +11,6 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
@@ -21,6 +20,7 @@ import {
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { latestSessionTodos } from '../lib/todos'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -76,10 +76,12 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
import { ChatView } from './chat'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import {
ChatPreviewRail,
@@ -141,7 +143,7 @@ const CRON_POLL_INTERVAL_MS = 30_000
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
@@ -273,22 +275,27 @@ export function DesktopController() {
// the shared command handler) creates the job. Signal readiness so a link
// that arrived during boot is flushed exactly once.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
// Tell the main process the renderer is ready to receive deep links.
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
@@ -554,15 +561,27 @@ export function DesktopController() {
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId, storedProfile)
const messages = toChatMessages(latest.messages)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
messages: preserveLocalAssistantErrors(messages, state.messages)
}),
storedSessionId
)
// Seed the status stack's todo group from history — but only while
// the plan is still in flight, so reopening an old chat doesn't pin
// its finished todo list above the composer forever.
const todos = latestSessionTodos(messages)
if (todos && todoListActive(todos)) {
setSessionTodos(runtimeSessionId, todos)
} else {
clearSessionTodos(runtimeSessionId)
}
return
} catch {
// Best-effort fallback when live stream payloads are empty.
@@ -582,6 +601,7 @@ export function DesktopController() {
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
})
@@ -711,6 +731,7 @@ export function DesktopController() {
editMessage,
handleThreadMessagesChange,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
@@ -945,6 +966,7 @@ export function DesktopController() {
onPickImages={() => void composer.pickImages()}
onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage}
onSteer={steerPrompt}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
@@ -990,8 +1012,8 @@ export function DesktopController() {
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onActivateFile={path => composer.insertContextPathInlineRef(path)}
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
onChangeCwd={changeSessionCwd}
/>
</Pane>
@@ -12,6 +12,8 @@ import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
const INDENT = 10
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
const TREE_ROW_INSET = 12
interface ProjectTreeProps {
collapseNonce: number
@@ -200,18 +202,16 @@ function ProjectTreeRow({
event.dataTransfer.setData('text/plain', node.data.id)
}}
ref={dragHandle}
style={style}
style={{
...style,
paddingLeft:
(typeof style.paddingLeft === 'number'
? style.paddingLeft
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
}}
>
{isFolder && !isPlaceholder && (
<span aria-hidden className="flex w-3 items-center justify-center">
<Codicon
className="text-(--ui-text-tertiary)"
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
size="0.75rem"
/>
</span>
)}
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
{/* No chevron column the folder icon (open/closed) already carries the
expand state, so the extra glyph was pure noise. */}
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
{isPlaceholder && !isErrorPlaceholder ? (
<Codicon name="loading" size="0.75rem" spinning />
@@ -221,6 +221,36 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenLastCalledWith('/b')
})
it('falls back to the sanitized workspace dir when the session cwd is gone', async () => {
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/home/me/projects', sanitized: true }))
readDir.mockImplementation(async path => {
if (path === '/deleted/worktree') return { entries: [], error: 'ENOENT' }
if (path === '/home/me/projects') return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }])
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
await waitFor(() => expect(result.current.data.length).toBe(1))
expect(sanitizeWorkspaceCwd).toHaveBeenCalledWith('/deleted/worktree')
expect(result.current.rootError).toBeNull()
expect(result.current.effectiveCwd).toBe('/home/me/projects')
expect(result.current.data[0]?.name).toBe('repo')
})
it('keeps the root error when sanitize offers no usable fallback', async () => {
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/deleted/worktree', sanitized: false }))
readDir.mockResolvedValue({ entries: [], error: 'ENOENT' })
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
await waitFor(() => expect(result.current.rootError).toBe('ENOENT'))
expect(result.current.effectiveCwd).toBe('/deleted/worktree')
})
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
@@ -64,6 +64,10 @@ export interface UseProjectTreeResult {
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
collapseNonce: number
data: TreeNode[]
/** Directory actually displayed differs from the requested cwd when the
* session's recorded cwd no longer exists and we fell back to the default
* workspace dir. */
effectiveCwd: string
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
@@ -80,6 +84,8 @@ interface ProjectTreeState {
loaded: boolean
openState: Record<string, boolean>
requestId: number
/** Directory the displayed entries were read from ('' until first load). */
resolvedCwd: string
rootError: string | null
rootLoading: boolean
}
@@ -91,6 +97,7 @@ const initialState: ProjectTreeState = {
loaded: false,
openState: {},
requestId: 0,
resolvedCwd: '',
rootError: null,
rootLoading: false
}
@@ -100,6 +107,11 @@ const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
// While the root is errored (ENOENT during a session's cwd race, a folder that
// reappears after a checkout, a remote that wasn't ready), keep retrying on a
// slow cadence so the tree self-heals instead of staying "UNREADABLE" forever.
const ROOT_ERROR_RETRY_MS = 3_000
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
}
@@ -110,6 +122,31 @@ function clearProjectTree() {
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
}
/** Sessions record their launch cwd; deleted worktrees and remote-backend
* paths arrive here as directories that don't exist on this machine. Rather
* than bricking the tree, display the sanitized workspace fallback (main
* prefers the configured default project dir). Local connections only
* remote trees are read through the remote bridge. */
async function fallbackRootFor(cwd: string): Promise<string | null> {
if ($connection.get()?.mode === 'remote') {
return null
}
const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
if (!sanitize) {
return null
}
try {
const { cwd: fallback, sanitized } = await sanitize(cwd)
return sanitized && fallback && fallback !== cwd ? fallback : null
} catch {
return null
}
}
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
if (!cwd) {
clearProjectTree()
@@ -138,11 +175,27 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
loaded: false,
openState: current.cwd === cwd ? current.openState : {},
requestId,
resolvedCwd: '',
rootError: null,
rootLoading: true
})
const { entries, error } = await readProjectDir(cwd, cwd)
let resolvedCwd = cwd
let { entries, error } = await readProjectDir(cwd, cwd)
if (error) {
const fallback = await fallbackRootFor(cwd)
if (fallback) {
const retry = await readProjectDir(fallback, fallback)
if (!retry.error) {
resolvedCwd = fallback
entries = retry.entries
error = undefined
}
}
}
setProjectTree(latest => {
if (latest.cwd !== cwd || latest.requestId !== requestId) {
@@ -153,6 +206,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
...latest,
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
loaded: true,
resolvedCwd,
rootError: error || null,
rootLoading: false
}
@@ -230,7 +284,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
}
})
const { entries, error } = await readProjectDir(id, cwd)
const rootPath = $projectTree.get().resolvedCwd || cwd
const { entries, error } = await readProjectDir(id, rootPath)
inflight.delete(id)
@@ -256,19 +311,62 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [connectionKey, cwd])
// Self-heal: an errored root re-probes every few seconds while the tree is
// mounted. Each attempt bumps requestId, so a persistent error re-arms the
// timer; a success clears rootError and stops it.
useEffect(() => {
if (!cwd || state.cwd !== cwd || !state.rootError) {
return
}
const timer = window.setTimeout(() => void loadRoot(cwd, { force: true }), ROOT_ERROR_RETRY_MS)
return () => window.clearTimeout(timer)
}, [cwd, state.cwd, state.requestId, state.rootError])
// While showing the fallback root, quietly re-probe the session's real cwd
// (a worktree re-created, a checkout restored) and switch back when it
// reappears. The probe never touches state, so there's no flicker.
const usingFallback = state.cwd === cwd && Boolean(state.resolvedCwd) && state.resolvedCwd !== cwd
useEffect(() => {
if (!cwd || !usingFallback) {
return
}
let cancelled = false
const timer = window.setInterval(() => {
void readProjectDir(cwd, cwd).then(({ error }) => {
if (!cancelled && !error) {
void loadRoot(cwd, { force: true })
}
})
}, ROOT_ERROR_RETRY_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [cwd, usingFallback])
return useMemo(
() => ({
collapseAll,
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
data: state.cwd === cwd ? state.data : [],
effectiveCwd: state.cwd === cwd && state.resolvedCwd ? state.resolvedCwd : cwd,
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
refreshRoot,
@@ -286,6 +384,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
state.cwd,
state.data,
state.openState,
state.resolvedCwd,
state.rootError,
state.rootLoading
]
+39 -22
View File
@@ -5,7 +5,6 @@ import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
@@ -34,17 +33,11 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: r.noFolderSelected
const {
collapseAll,
collapseNonce,
data,
effectiveCwd,
loadChildren,
openState,
refreshRoot,
@@ -53,11 +46,18 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
setNodeOpen
} = useProjectTree(currentCwd)
const cwdName = hasCwd
? (effectiveCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? effectiveCwd)
: r.noFolderSelected
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? currentCwd : undefined,
defaultPath: hasCwd ? effectiveCwd : undefined,
directories: true,
multiple: false,
title: r.changeCwdTitle
@@ -70,7 +70,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
if (!preview) {
throw new Error(r.couldNotPreview(path))
@@ -97,7 +97,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwd={effectiveCwd}
cwdName={cwdName}
data={data}
error={rootError}
@@ -126,13 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
// base focus ring come from <Button size="icon-xs">. This constant exists
// purely to share the sidebar palette + the hover-reveal behavior below.
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
function FilesystemTab({
canCollapse,
@@ -157,20 +156,20 @@ function FilesystemTab({
const r = t.rightSidebar
return (
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<Tip label={hasCwd ? r.folderTip(cwd) : r.openFolder}>
<div className="peer/project-label flex min-w-0 flex-1">
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
</Tip>
</div>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_CLASS}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
@@ -189,7 +188,7 @@ function FilesystemTab({
</Button>
<Button
aria-label={r.collapseAll}
className={HEADER_ACTION_REVEAL_CLASS}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
@@ -209,6 +208,7 @@ function FilesystemTab({
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
onRetry={onRefresh}
openState={openState}
/>
</div>
@@ -230,6 +230,9 @@ interface FileTreeBodyProps {
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
/** Force-reload the root. The hook also auto-retries while errored, so this
* is the impatient-user path. */
onRetry?: () => void
openState: ReturnType<typeof useProjectTree>['openState']
}
@@ -244,6 +247,7 @@ function FileTreeBody({
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
onRetry,
openState
}: FileTreeBodyProps) {
const { t } = useI18n()
@@ -254,7 +258,20 @@ function FileTreeBody({
}
if (error) {
return <EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
{onRetry && (
<button
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
onClick={onRetry}
type="button"
>
{r.tryAgain}
</button>
)}
</div>
)
}
if (loading && data.length === 0) {
@@ -9,7 +9,7 @@ import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { KbdCombo } from '@/components/ui/kbd'
import { useTerminalSession } from './use-terminal-session'
interface TerminalTabProps {
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{t.rightSidebar.addToChat}
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
<KbdCombo className="ml-1 opacity-70" combo="mod+l" size="sm" />
</Button>
</div>
)}
@@ -99,8 +99,6 @@ export function resolveSurfaceColor(fallback: string): string {
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
@@ -20,6 +21,17 @@ import {
type TerminalStatus = 'closed' | 'open' | 'starting'
// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane
// lands in this handler with no xterm selection. Label those with the previewed
// file's name instead of the shell, so the composer ref reads as a file quote
// rather than a bogus "zsh:N lines".
function previewSelectionLabel(): string {
const target = $filePreviewTarget.get() ?? $previewTarget.get()
const source = target?.path || target?.url || ''
return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || ''
}
const HERMES_PATHS_MIME = 'application/x-hermes-paths'
function readEscapeSequence(data: string, index: number) {
@@ -257,16 +269,20 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
)
const addSelectionToChat = useCallback(() => {
const selectedText = readSelection() || selectionRef.current
const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim()
const selectedText = termSelection || window.getSelection()?.toString() || ''
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
// Terminal selection → shell-anchored label; anything else came from the
// preview pane sharing this global shortcut → label it with the file.
const label = termSelection
? selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
: previewSelectionLabel() || 'selection'
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
@@ -275,7 +291,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
}, [readSelection])
}, [])
// Always listen — gating on the React selection state misses selections the
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
@@ -18,7 +18,9 @@ import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
import { setClarifyRequest } from '@/store/clarify'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@@ -37,6 +39,7 @@ import {
setYoloActive
} from '@/store/session'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
@@ -52,6 +55,7 @@ interface MessageStreamOptions {
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
@@ -67,15 +71,7 @@ interface QueuedStreamDeltas {
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
| 'branch'
| 'cwd'
| 'fast'
| 'model'
| 'personality'
| 'provider'
| 'reasoningEffort'
| 'serviceTier'
| 'yolo'
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
>
>
@@ -253,8 +249,14 @@ export function useMessageStream({
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState
}: MessageStreamOptions) {
const sessionInterrupted = useCallback(
(sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false,
[sessionStateByRuntimeIdRef]
)
// Patch the in-flight assistant message (or seed it). Centralises the
// streamId/groupId bookkeeping every event callback would otherwise repeat.
const mutateStream = useCallback(
@@ -478,6 +480,20 @@ export function useMessageStream({
// a tool part can't jump ahead of the text that preceded it.
flushQueuedDeltas(sessionId)
if (sessionInterrupted(sessionId)) {
return
}
// The composer status stack owns todo display now (no inline panel) —
// mirror every todo state the tool reports into its session store.
if (payload?.name === 'todo') {
const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args)
if (todos) {
setSessionTodos(sessionId, todos)
}
}
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
upsertSubagent(
@@ -496,7 +512,7 @@ export function useMessageStream({
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
)
},
[flushQueuedDeltas, mutateStream]
[flushQueuedDeltas, mutateStream, sessionInterrupted]
)
const completeAssistantMessage = useCallback(
@@ -677,9 +693,11 @@ export function useMessageStream({
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
return
}
const sessionId = explicitSid || activeSessionIdRef.current
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
@@ -875,13 +893,22 @@ export function useMessageStream({
// the sidebar indicator clears as soon as it's answered, not only at
// message.complete.
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
// terminal/process tool calls are the only things that spawn or reap
// background processes — sync the composer status stack right after.
if (
!sessionInterrupted(sessionId) &&
(payload?.name === 'terminal' || payload?.name === 'process')
) {
void refreshBackgroundProcesses(sessionId)
}
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload) {
if (sessionId && payload && !sessionInterrupted(sessionId)) {
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
pruneDelegateFallbackSubagents(sessionId)
}
@@ -987,6 +1014,12 @@ export function useMessageStream({
text: result ? JSON.stringify(result) : ''
})
}
} else if (event.type === 'status.update') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
if (sessionId && payload?.kind === 'process') {
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -1027,6 +1060,7 @@ export function useMessageStream({
flushQueuedDeltas,
queryClient,
refreshHermesConfig,
sessionInterrupted,
updateSessionState,
upsertToolCall
]
@@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
import { $connection, $sessions, setSessions } from '@/store/session'
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
@@ -43,6 +44,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
@@ -57,6 +59,7 @@ function Harness({
refreshSessions,
requestGateway,
resumeStoredSession,
seedMessages,
storedSessionId
}: {
busyRef?: MutableRefObject<boolean>
@@ -65,6 +68,7 @@ function Harness({
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
@@ -73,7 +77,7 @@ function Harness({
}
const localBusyRef = busyRef ?? { current: false }
const stateRef = useRef({
messages: [],
messages: seedMessages ?? [],
busy: false,
awaitingResponse: false,
interrupted: true
@@ -105,10 +109,11 @@ function Harness({
useEffect(() => {
onReady({
cancelRun: actions.cancelRun,
restoreToMessage: actions.restoreToMessage,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
})
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
return null
}
@@ -395,6 +400,125 @@ describe('usePromptActions steerPrompt', () => {
})
})
describe('usePromptActions restoreToMessage', () => {
beforeEach(() => {
$busy.set(false)
$messages.set([
{ id: 'u1', role: 'user', parts: [textPart('first prompt')] },
{ id: 'a1', role: 'assistant', parts: [textPart('first answer')] },
{ id: 'u2', role: 'user', parts: [textPart('second prompt')] },
{ id: 'a2', role: 'assistant', parts: [textPart('second answer')] }
])
})
afterEach(() => {
cleanup()
$busy.set(false)
$messages.set([])
vi.restoreAllMocks()
})
it('rewinds to the target user turn and resubmits its text', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={$messages.get()}
/>
)
await handle!.restoreToMessage('u1')
// Ordinal 0 = "truncate before the first visible user message": the gateway
// drops that turn and everything after, then runs the same text again.
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0
})
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
expect(lastState.busy).toBe(true)
})
it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => {
const requestGateway = vi.fn(async () => {
throw new Error('gateway exploded')
})
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await expect(handle!.restoreToMessage('u2')).rejects.toThrow('gateway exploded')
expect(lastState.busy).toBe(false)
})
it('interrupts the live turn and retries past "session busy" when reverting mid-stream', async () => {
$busy.set(true)
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
submitAttempts += 1
// The cooperative interrupt hasn't wound the turn down yet on the first
// try; the second attempt lands once the gateway reports idle.
if (submitAttempts === 1) {
throw new Error('session busy')
}
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={$messages.get()}
/>
)
await handle!.restoreToMessage('u1')
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
expect(submitAttempts).toBe(2)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0
})
})
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await handle!.restoreToMessage('a1')
await handle!.restoreToMessage('missing')
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
@@ -35,6 +35,7 @@ import {
terminalContextBlocksFromDraft,
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@@ -52,6 +53,8 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
@@ -114,6 +117,18 @@ function isSessionNotFoundError(error: unknown): boolean {
return /session not found/i.test(message)
}
// The gateway refuses prompt.submit while a turn is running (4009 "session
// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then
// retry the submit until the cooperative interrupt has wound the turn down.
const REWIND_INTERRUPT_TIMEOUT_MS = 6_000
const REWIND_RETRY_INTERVAL_MS = 150
function isSessionBusyError(error: unknown): boolean {
return /session busy/i.test(error instanceof Error ? error.message : String(error))
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@@ -523,6 +538,7 @@ export function usePromptActions({
// Images use their base64 preview so the thumbnail renders inline without
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
@@ -540,6 +556,7 @@ export function usePromptActions({
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
return false
}
@@ -652,6 +669,7 @@ export function usePromptActions({
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
@@ -672,6 +690,7 @@ export function usePromptActions({
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
@@ -1234,12 +1253,13 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)
}
setAwaitingResponse(false)
// Interrupting keeps whatever was already generated and just
// stops — no "[interrupted]" marker. A pending/streaming message with no
// body text is dropped entirely so we never leave an empty bubble behind.
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(
@@ -1251,8 +1271,7 @@ export function usePromptActions({
)
if (!sessionId) {
setMutableRef(busyRef, false)
setBusy(false)
releaseBusy()
setMessages(finalizeMessages($messages.get()))
return
@@ -1260,13 +1279,12 @@ export function usePromptActions({
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, streamId)
return {
...state,
messages,
busy: true,
busy: false,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
@@ -1274,8 +1292,13 @@ export function usePromptActions({
}
})
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
try {
await requestGateway('session.interrupt', { session_id: sessionId })
releaseBusy()
} catch (err) {
let stopError = err
@@ -1284,11 +1307,13 @@ export function usePromptActions({
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('session.interrupt', { session_id: recoveredId })
releaseBusy()
return
}
@@ -1297,8 +1322,7 @@ export function usePromptActions({
}
}
setMutableRef(busyRef, false)
setBusy(false)
releaseBusy()
notifyError(stopError, copy.stopFailed)
}
}, [
@@ -1421,13 +1445,116 @@ export function usePromptActions({
[activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
)
// Cursor-style "restore checkpoint": rewind the conversation to a past user
// prompt and run it again from there. Reuses the edit composer's rewind
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
// user turn and everything after it from the session history, then the same
// text is submitted as a fresh turn. Callers confirm before invoking; errors
// are rethrown so the confirmation dialog can surface them inline.
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
// can fire while a turn is streaming, interrupt the live turn first, then
// retry the submit until the gateway stops reporting "session busy" — the
// interrupt is cooperative, so the running turn takes a beat to wind down.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
if (wasRunning) {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort — the busy-retry below still gates the submit.
}
}
const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS
for (;;) {
try {
await requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
return
} catch (err) {
if (isSessionBusyError(err) && Date.now() < deadline) {
await sleep(REWIND_RETRY_INTERVAL_MS)
continue
}
throw err
}
}
},
[requestGateway]
)
const restoreToMessage = useCallback(
async (messageId: string) => {
const sessionId = activeSessionId || activeSessionIdRef.current
if (!sessionId) {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === messageId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
return
}
const text = chatMessageText(source).trim()
if (!text) {
return
}
const wasRunning = $busy.get()
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
// rows (and kill the live processes) before the fresh run repopulates.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: state.messages.slice(0, sourceIndex + 1)
}))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
} catch (err) {
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
throw err
}
},
[activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
return
}
@@ -1439,12 +1566,23 @@ export function usePromptActions({
return
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn, so capture the live state — the submit
// helper interrupts first when a turn is running.
const wasRunning = $busy.get()
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
// Editing rewinds the conversation to this prompt — same as restore — so
// drop the abandoned timeline's todos/background rows (and kill the live
// processes) before the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
@@ -1459,24 +1597,18 @@ export function usePromptActions({
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
const submit = (truncateOrdinal?: number) =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
const isStaleTargetError = (err: unknown) =>
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex))
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
try {
await submit()
// Already interrupted on the first attempt — submit as a plain resend.
await submitRewindPrompt(sessionId, text, undefined, false)
return
} catch (retryErr) {
@@ -1491,7 +1623,7 @@ export function usePromptActions({
notifyError(surfaced, copy.editFailed)
}
},
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState]
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
@@ -1534,6 +1666,7 @@ export function usePromptActions({
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
@@ -0,0 +1,119 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd } from '@/store/session'
import type { ClientSessionState } from '../../types'
import { useSessionActions } from './use-session-actions'
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
deleteSession: vi.fn(),
getSessionMessages: vi.fn(),
listAllProfileSessions: vi.fn(),
setApiRequestProfile: vi.fn(),
setSessionArchived: vi.fn()
}))
const RUNTIME_SESSION_ID = 'rt-new-001'
function Harness({
onReady,
requestGateway
}: {
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})
useEffect(() => {
onReady(actions.createBackendSessionForSend)
}, [actions.createBackendSessionForSend, onReady])
return null
}
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
}
return {} as never
})
$currentCwd.set('')
profileSetup()
let create: ((preview?: string | null) => Promise<string | null>) | null = null
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
await waitFor(() => expect(create).not.toBeNull())
await create!()
return createParams
}
describe('createBackendSessionForSend profile routing', () => {
afterEach(() => {
cleanup()
$newChatProfile.set(null)
$activeGatewayProfile.set('default')
vi.restoreAllMocks()
})
it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => {
// The "rubberband to default" bug: the top New Session button clears
// $newChatProfile to null. In global-remote mode one backend serves every
// profile, so an omitted `profile` lands the chat on the launch (default)
// profile. The session must instead carry the active gateway profile.
const params = await createWith(() => {
$activeGatewayProfile.set('coder')
$newChatProfile.set(null)
})
expect(params).toMatchObject({ profile: 'coder' })
})
it('honours an explicit per-profile "+" selection', async () => {
const params = await createWith(() => {
$activeGatewayProfile.set('coder')
$newChatProfile.set('analyst')
})
expect(params).toMatchObject({ profile: 'analyst' })
})
it('passes the default profile for single-profile users (backend resolves it to launch)', async () => {
const params = await createWith(() => {
$activeGatewayProfile.set('default')
$newChatProfile.set(null)
})
expect(params).toMatchObject({ profile: 'default' })
})
})
@@ -43,6 +43,7 @@ import {
workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
@@ -406,13 +407,17 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
// A plain new session (top "New Session", /new, keybind) leaves
// $newChatProfile null to mean "use the live context"; the per-profile
// "+" sets it explicitly. Resolve null to the active gateway profile so
// session.create always carries it: in global-remote mode one backend
// serves every profile, so an omitted profile param silently lands the
// chat on the launch (default) profile — the "rubberbands back to
// default" bug. This is a no-op for single-profile/local-pooled users:
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
@@ -534,6 +539,7 @@ export function useSessionActions({
if (cachedRuntimeId && cachedState) {
const stored = $sessions.get().find(session => session.id === storedSessionId)
const cachedViewState =
!cachedState.model && stored?.model != null
? {
@@ -606,26 +612,23 @@ export function useSessionActions({
}))
}
let resumedRunning = false
try {
// Load the local snapshot first, then ask the gateway to resume.
// Previously these raced:
// 1. clear messages to []
// 2. local getSessionMessages -> 45 msgs
// 3. a second resume path cleared [] again
// 4. gateway resume -> 43 msgs
// That is the ctrl+R flash chain. Avoid showing an empty thread
// while we already have a route-scoped session id, and don't race the
// local snapshot against gateway resume.
const watchWindow = isWatchWindow()
let localSnapshot = $messages.get()
try {
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
// Watch windows skip REST prefetch — lazy resume attaches the live mirror.
if (!watchWindow) {
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
}
}
}
} catch {
@@ -635,9 +638,7 @@ export function useSessionActions({
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
// Owning profile: in app-global remote mode one backend serves every
// profile, so the gateway opens this profile's state.db + home to
// resume + persist the right session (no-op for single/launch profile).
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
@@ -651,15 +652,7 @@ export function useSessionActions({
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
// Avoid a second visible transcript rebuild on resume/switch.
// `getSessionMessages()` is the stable stored transcript snapshot and
// paints first; `session.resume` can return a slightly different
// runtime-shaped projection (e.g. tool/system coalescing), which was
// causing a second full message-list replacement a second later.
// Keep the already-painted local snapshot for the view/cache when it
// exists; use gateway messages only as a fallback when no local
// snapshot was available.
// Keep the local snapshot when resume would only reshuffle runtime projection.
const preferredMessages =
localSnapshot.length > 0
? localSnapshot
@@ -675,14 +668,16 @@ export function useSessionActions({
patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
resumedRunning = Boolean((resumed as { running?: boolean }).running)
updateSessionState(
resumed.session_id,
state => ({
...state,
...(runtimeInfo ?? {}),
messages: messagesForView,
busy: false,
awaitingResponse: false
busy: resumedRunning,
awaitingResponse: resumedRunning
}),
storedSessionId
)
@@ -701,9 +696,9 @@ export function useSessionActions({
notifyError(err, copy.resumeFailed)
} finally {
if (isCurrentResume()) {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
busyRef.current = resumedRunning
setBusy(resumedRunning)
setAwaitingResponse(resumedRunning)
}
}
},
@@ -9,6 +9,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
@@ -135,6 +136,7 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const translucency = useStore($translucency)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
@@ -183,6 +185,32 @@ export function AppearanceSettings() {
title={a.colorMode}
/>
<ListRow
action={
<div className="flex items-center gap-3">
<input
aria-label={a.translucencyTitle}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={100}
min={0}
onChange={event => {
triggerHaptic('selection')
setTranslucency(Number(event.target.value))
}}
step={5}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={translucency}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{translucency}%
</span>
</div>
}
description={a.translucencyDesc}
title={a.translucencyTitle}
/>
<ListRow
below={
<>
+5 -10
View File
@@ -5,6 +5,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import {
KEYBIND_ACTIONS,
@@ -166,15 +167,11 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
type="button"
>
{capturing ? (
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
<Kbd variant="capturing">{k.pressKey}</Kbd>
) : combos.length > 0 ? (
combos.map(combo => (
<span className="kbd-cap" key={combo}>
{formatCombo(combo)}
</span>
))
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
) : (
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
<Kbd variant="ghost">{k.set}</Kbd>
)}
</button>
@@ -209,9 +206,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<span className="kbd-cap" key={key}>
{formatCombo(key)}
</span>
<KbdCombo combo={key} key={key} />
))}
</div>
<span aria-hidden className="size-6 shrink-0" />
+4 -1
View File
@@ -19,7 +19,10 @@ export const titlebarButtonClass =
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]'
// Title row inside the header — must stay in the flex truncate chain.
export const titlebarHeaderTitleClass = 'min-w-0 flex-1 overflow-hidden'
export const titlebarHeaderShadowClass =
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']"
@@ -6,6 +6,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { KbdCombo } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -229,7 +230,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
<KbdCombo combo="mod+enter" size="sm" />
{copy.shortcutSuffix}
</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button
@@ -216,36 +216,6 @@ function assistantTodoMessage(
} as ThreadMessage
}
function assistantReasoningTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
): ThreadMessage {
return {
id: 'assistant-reasoning-todo-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
result: { todos }
},
{ type: 'text', text: 'Done — fake list created.' }
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function StreamingHarness() {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
@@ -718,7 +688,7 @@ describe('assistant-ui streaming renderer', () => {
expect(container.textContent).toContain('Interim answer.')
})
it('renders live todo rows during a running turn', () => {
it('does not render an inline todo panel — todos live in the composer status stack', () => {
const { container } = render(
<TodoHarness
message={assistantTodoMessage([
@@ -728,52 +698,6 @@ describe('assistant-ui streaming renderer', () => {
/>
)
const ui = within(container)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
expect(ui.getByText('Gather ingredients')).toBeTruthy()
expect(ui.queryByText(/pending/i)).toBeNull()
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
})
it('renders archived todos after turn completion regardless of pending state', () => {
const first = render(
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
)
const ui = within(first.container)
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
first.unmount()
const second = render(
<TodoHarness
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
/>
)
const archivedUi = within(second.container)
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
})
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
const { container } = render(
<TodoHarness
message={assistantReasoningTodoMessage([
{ content: 'Buy oats', id: 'oats', status: 'completed' },
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
])}
/>
)
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
expect(todoPanel).toBeTruthy()
expect(thinkingDisclosure).toBeTruthy()
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
})
})
@@ -58,7 +58,6 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
@@ -70,6 +69,7 @@ import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-p
import { Intro, type IntroProps } from '@/components/chat/intro'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
@@ -136,6 +136,7 @@ export const Thread: FC<{
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
}> = ({
@@ -146,6 +147,7 @@ export const Thread: FC<{
loading,
onBranchInNewChat,
onCancel,
onRestoreToMessage,
sessionId = null,
sessionKey
}) => {
@@ -154,9 +156,9 @@ export const Thread: FC<{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} />
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
}),
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
)
const emptyPlaceholder = intro ? (
@@ -216,7 +218,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
const previewTargets = useMemo(() => {
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
@@ -246,7 +247,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
{/* Todos render in the composer status stack now, not inline. */}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
{previewTargets.length > 0 && (
@@ -737,11 +738,46 @@ const USER_ACTION_ICON_BUTTON_CLASS =
const USER_ACTION_ICON_SIZE = '0.6875rem'
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
// Background-process notifications are injected into the conversation as user
// messages (the agent must react to them, and message-role alternation forbids
// a synthetic system row mid-loop). They are NOT something the human typed, so
// render them as a compact system-style notice instead of a user bubble.
// Shape: see tools/process_registry.py format_process_notification().
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
const newline = body.indexOf('\n')
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
return (
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
<span className="flex items-center gap-1.5">
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
<span className="wrap-anywhere">{headline}</span>
</span>
{detail && (
<details className="pl-[1.3125rem]">
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
output
</summary>
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
{detail}
</pre>
</details>
)}
</div>
)
}
const UserMessage: FC<{
onCancel?: () => Promise<void> | void
}> = ({ onCancel }) => {
onRestoreToMessage?: (messageId: string) => Promise<void> | void
}> = ({ onCancel, onRestoreToMessage }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
@@ -791,15 +827,32 @@ const UserMessage: FC<{
useResizeObserver(measureClamp, clampInnerRef)
// Injected background-process notification, not a human prompt — render the
// compact system-style notice (after all hooks above have run).
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
return (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-stretch"
data-role="user"
data-slot="aui_user-message-root"
>
<ProcessNotificationNote text={messageText.trim()} />
</MessagePrimitive.Root>
)
}
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
const showRestore = !isLatestUser && !threadRunning
// Restore (re-run this exact prompt) is available everywhere the Stop button
// isn't — including mid-stream on older prompts, since the action interrupts
// the live turn before rewinding.
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
const bubbleClassName = cn(
USER_BUBBLE_BASE_CLASS,
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
)
const bubbleContent = (
@@ -828,21 +881,19 @@ const UserMessage: FC<{
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<div className="relative w-full">
{threadRunning ? (
<div className={bubbleClassName}>{bubbleContent}</div>
) : (
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
title={copy.editMessage}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
)}
{/* Always editable clicking opens the edit composer even while a
turn streams; sending the edit reverts (interrupt + rewind). */}
<ActionBarPrimitive.Edit asChild>
<button
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
title={copy.editMessage}
type="button"
>
{bubbleContent}
</button>
</ActionBarPrimitive.Edit>
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
@@ -860,13 +911,20 @@ const UserMessage: FC<{
{StopGlyph}
</button>
) : (
<span
aria-hidden="true"
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
title={copy.editableCheckpoint}
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
setRestoreConfirmOpen(true)
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</span>
</button>
)}
</div>
)}
@@ -894,6 +952,17 @@ const UserMessage: FC<{
</BranchPickerPrimitive.Root>
</div>
</ActionBarPrimitive.Root>
{showRestore && (
<ConfirmDialog
confirmLabel={copy.restoreConfirm}
description={copy.restoreBody}
destructive
onClose={() => setRestoreConfirmOpen(false)}
onConfirm={() => onRestoreToMessage?.(messageId)}
open={restoreConfirmOpen}
title={copy.restoreTitle}
/>
)}
</StickyHumanMessageContainer>
</MessagePrimitive.Root>
)
@@ -1,109 +0,0 @@
import { type FC } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Loader2Icon } from '@/lib/icons'
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
export function todosFromMessageContent(content: unknown): TodoItem[] {
if (!Array.isArray(content)) {
return []
}
let latest: null | TodoItem[] = null
for (const part of content) {
if (!part || typeof part !== 'object') {
continue
}
const row = part as Record<string, unknown>
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
continue
}
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
if (parsed !== null) {
latest = parsed
}
}
return latest ?? []
}
const headerLabel = (todos: readonly TodoItem[]): string =>
todos.find(t => t.status === 'in_progress')?.content ??
todos.find(t => t.status === 'pending')?.content ??
todos.at(-1)?.content ??
'Tasks'
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
if (status === 'in_progress') {
return (
<span
aria-label={`In progress: ${label}`}
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
>
<Loader2Icon className="size-3 animate-spin text-ring" />
</span>
)
}
const checked = status === 'completed'
return (
<Checkbox
aria-label={label}
checked={checked}
className={cn(
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
checked &&
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
status === 'cancelled' && 'border-muted-foreground/40'
)}
disabled
/>
)
}
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (!todos.length) {
return null
}
const label = headerLabel(todos)
return (
<section
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
data-slot="aui_todo-hoisted"
>
<header className="px-3 pt-3 pb-2">
<span
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
title={label}
>
{label}
</span>
</header>
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
{todos.map(todo => (
<li
// Active row at full presence; everything else fades. Opacity on
// the row so the checkbox glyph dims with the text.
className={cn(
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
)}
key={todo.id}
>
<Checkmark label={todo.content} status={todo.status} />
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
</li>
))}
</ul>
</section>
)
}
@@ -12,9 +12,9 @@ import { DiffLines } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
@@ -100,7 +100,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'running') {
return (
<BrailleSpinner
<GlyphSpinner
ariaLabel={copy.statusRunning}
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
@@ -114,10 +114,7 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'warning') {
return (
<AlertCircle
aria-label={copy.statusRecovered}
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
<AlertCircle aria-label={copy.statusRecovered} className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
)
}
@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils'
/**
* The composer surface and everything docked to it (slash·@ popover, `?` help)
* paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled /
* focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`,
* so the two layers can never disagree drawer-open forces an opaque fill via
* `:has()`, because translucent glass sampling different backdrops (thread vs
* fade gradient) renders as different colors even with identical tints.
*/
export const composerFill = 'bg-(--composer-fill)'
/** Backdrop treatment for the composer input surface. Harmless when the fill
* goes opaque (drawer open) nothing shows through to blur. */
export const composerSurfaceGlass = cn(
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12] [-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out'
)
const composerDockEdge = (edge: 'bottom' | 'top') =>
cn('border border-border/65', edge === 'top' ? 'rounded-t-2xl border-b-0' : 'rounded-b-2xl border-t-0')
/** Glassy docked card — the status stack / queue. Paints the SAME
* `--composer-fill` as the surface, so rest / scrolled / focused / drawer-open
* all match the composer by construction. */
export const composerDockCard = (edge: 'bottom' | 'top' = 'top') =>
cn(composerDockEdge(edge), composerFill, composerSurfaceGlass)
/** Fused docked card completion drawers. Shares `--composer-fill` with the
* composer surface, which goes opaque while a drawer is open. */
export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill)
@@ -0,0 +1,68 @@
import { type ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface StatusRowProps {
children: ReactNode
className?: string
/** Leading glyph slot (spinner / status dot / selection circle). */
leading?: ReactNode
/** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y).
* Trailing-slot buttons should `stopPropagation` so they don't also fire it. */
onActivate?: () => void
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
trailing?: ReactNode
trailingVisible?: boolean
}
/**
* Shared row chrome for everything in the composer status stack status items
* (subagents, background) AND queued prompts. Fixed height, a leading glyph
* slot, flexible content, and a trailing actions slot that reveals on hover.
* Hover background matches the session sidebar. Consumers fill the three slots;
* they never re-implement the row container.
*/
export function StatusRow({
children,
className,
leading,
onActivate,
trailing,
trailingVisible = false
}: StatusRowProps) {
return (
<div
className={cn(
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--ui-row-hover-background)',
onActivate && 'cursor-pointer',
className
)}
onClick={onActivate}
onKeyDown={
onActivate
? event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
: undefined
}
role={onActivate ? 'button' : undefined}
tabIndex={onActivate ? 0 : undefined}
>
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
<div className="flex min-w-0 flex-1 items-center gap-2">{children}</div>
{trailing && (
<div
className={cn(
'flex shrink-0 items-center gap-0.5',
!trailingVisible && 'opacity-0 group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100'
)}
>
{trailing}
</div>
)}
</div>
)
}
@@ -0,0 +1,42 @@
import { type ReactNode, useState } from 'react'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
interface StatusSectionProps {
/** Optional right-aligned actions (text links / micro buttons). Pass
* `Button` with `size="micro"` + `variant="text"` or `"link"`. */
accessory?: ReactNode
children: ReactNode
defaultCollapsed?: boolean
/** Optional glyph between the caret and the label (e.g. a `Codicon`). */
icon?: ReactNode
label: ReactNode
}
/**
* One collapsible group inside the composer status stack. Pure chrome header
* (caret + label) + body styled to match the queue exactly so every status
* (queue, subagents, background) reads as one piece. The stack supplies the
* outer card and the dividers between groups; this owns only its own collapse.
*/
export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
return (
<div>
<div className="flex items-center gap-1 pr-1">
<button
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left text-xs font-normal text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)}
type="button"
>
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
<span className="truncate">{label}</span>
</button>
{accessory && <div className="flex shrink-0 items-center gap-1">{accessory}</div>}
</div>
{!collapsed && <div className="px-1 pb-0.5">{children}</div>}
</div>
)
}
@@ -0,0 +1,50 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
interface TerminalOutputProps {
className?: string
text: string
}
const NEAR_BOTTOM_PX = 24
/**
* Tiny read-only terminal viewer: monospace, non-wrapping (long lines scroll
* horizontally), vertical scroll past `max-h`. Jumps to the bottom on mount,
* then tails sticking to the bottom as `text` grows, but only when the user
* is already near the bottom so scrolling up to read earlier output isn't
* interrupted.
*
* Self-contained so any surface (status rows, tool calls, inspectors) can drop
* in a stdout/stderr box without re-implementing the scroll logic.
*/
export function TerminalOutput({ className, text }: TerminalOutputProps) {
const ref = useRef<HTMLDivElement>(null)
// On open: jump straight to the latest output (no animation, before paint).
useLayoutEffect(() => {
const el = ref.current
if (el) {
el.scrollTop = el.scrollHeight
}
}, [])
// On growth: tail only when already pinned near the bottom.
useEffect(() => {
const el = ref.current
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) {
el.scrollTop = el.scrollHeight
}
}, [text])
return (
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
{text}
</pre>
</div>
)
}
@@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
@@ -69,9 +69,7 @@ export function ModelVisibilityDialog({
next.delete(key)
// Check if this was the last real model for this provider.
const remainingForProvider = [...next].some(
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
)
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))
if (!remainingForProvider) {
next.add(sentinel)
@@ -110,7 +108,7 @@ export function ModelVisibilityDialog({
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
{modelOptions.isPending ? <GlyphSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
</div>
) : (
providers.map(provider => {
+9 -3
View File
@@ -4,6 +4,9 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
// Text+icon actions underline the label on hover, not the glyph.
const TEXT_ACTION_ICON = '[&_.codicon]:no-underline [&_svg]:no-underline'
// Text buttons are square (no radius) and sized by padding + line-height — no
// fixed heights — so they stay snug and scale with content. Only icon buttons
// (inherently square) carry the shared 4px radius.
@@ -22,13 +25,13 @@ const buttonVariants = cva(
secondary:
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
link: `text-primary underline-offset-4 decoration-current/20 hover:underline ${TEXT_ACTION_ICON}`,
// Boxless inline-text action (no bg/border). Quiet by default — reads as
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
text: `text-muted-foreground underline-offset-4 hover:text-foreground hover:underline ${TEXT_ACTION_ICON}`,
// Emphasized inline-text action: bold + always-underlined link. Use for
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
textStrong: `font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground ${TEXT_ACTION_ICON}`
},
size: {
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
@@ -39,6 +42,9 @@ const buttonVariants = cva(
// variants when the button must sit inline in a heading or sentence
// (replaces ad-hoc `h-auto px-0 py-0` overrides).
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
// Status-stack headers, table footers — 12px text actions beside a label.
micro:
"h-auto gap-0.5 px-1 py-0 text-xs leading-4 font-normal has-[>svg]:px-0.5 [&_svg:not([class*='size-'])]:size-3",
icon: 'size-9 rounded-[4px]',
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations'
import { cn } from '@/lib/utils'
export type { SpinnerName }
interface NormalisedSpinner {
frames: readonly string[]
interval: number
@@ -10,10 +12,10 @@ interface NormalisedSpinner {
// Some spinners ship multi-character frames. Pull the first cell so each
// frame fits in one monospace box — matches how the TUI uses them.
const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => {
const out = {} as Record<BrailleSpinnerName, NormalisedSpinner>
const FRAMES_BY_NAME: Record<SpinnerName, NormalisedSpinner> = (() => {
const out = {} as Record<SpinnerName, NormalisedSpinner>
for (const name of Object.keys(spinners) as BrailleSpinnerName[]) {
for (const name of Object.keys(spinners) as SpinnerName[]) {
const raw = spinners[name]
out[name] = {
@@ -25,21 +27,21 @@ const FRAMES_BY_NAME: Record<BrailleSpinnerName, NormalisedSpinner> = (() => {
return out
})()
interface BrailleSpinnerProps {
interface GlyphSpinnerProps {
ariaLabel?: string
className?: string
spinner?: BrailleSpinnerName
spinner?: SpinnerName
}
/**
* One-char braille spinner driven by `unicode-animations`. Mirrors the
* spinner used by the Ink TUI so the desktop and terminal experiences
* read the same visually. Renders inside an `inline-flex` cell with
* `leading-none` and `items-center` so it sits vertically centred inside
* its parent's line-box (e.g. the 1.1rem disclosure row).
* One-char glyph spinner driven by `unicode-animations` (braille, orbit, scan,
* etc. pick any `spinner` name). Mirrors the spinner used by the Ink TUI so
* the desktop and terminal experiences read the same visually. Renders inside
* an `inline-flex` cell with `leading-none` and `items-center` so it sits
* vertically centred inside its parent's line-box.
*/
export function BrailleSpinner({ ariaLabel = 'Loading', className, spinner = 'breathe' }: BrailleSpinnerProps) {
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.breathe!
export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) {
const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille!
const [frame, setFrame] = useState(0)
useEffect(() => {
+83 -12
View File
@@ -1,37 +1,108 @@
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
const COMPACT_KEY = /^[\p{L}\p{N}@/?]$/u
const kbdSurface = [
'border-[color-mix(in_srgb,var(--ui-stroke-secondary)_75%,transparent)]',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_94%,var(--dt-foreground)_6%)]',
'text-[color-mix(in_srgb,var(--dt-foreground)_58%,transparent)]',
'shadow-[0_1px_0_0_color-mix(in_srgb,var(--ui-stroke-tertiary)_85%,transparent),0_1px_2px_0_color-mix(in_srgb,var(--dt-foreground)_7%,transparent)]'
]
const kbdVariants = cva(
'inline-flex shrink-0 items-center justify-center border [font-family:var(--dt-font-kbd)] font-normal leading-none select-none',
{
variants: {
variant: {
default: kbdSurface,
ghost: [
...kbdSurface,
'text-[color-mix(in_srgb,var(--dt-foreground)_38%,transparent)]',
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_72%,var(--dt-foreground)_3%)]',
'border-[color-mix(in_srgb,var(--ui-stroke-tertiary)_80%,transparent)]'
],
capturing: [
'border-[color-mix(in_srgb,var(--theme-primary)_50%,var(--ui-stroke-secondary))]',
'bg-[color-mix(in_srgb,var(--theme-primary)_10%,var(--ui-bg-elevated))]',
'text-[color-mix(in_srgb,var(--theme-primary)_88%,transparent)]',
'shadow-none'
],
inverted: [
'border-[color-mix(in_srgb,currentColor_22%,transparent)]',
'bg-[color-mix(in_srgb,currentColor_12%,transparent)]',
'text-[color-mix(in_srgb,currentColor_88%,transparent)]',
'shadow-[0_1px_0_0_color-mix(in_srgb,currentColor_18%,transparent)]'
]
},
size: {
sm: 'rounded-[0.2rem] text-[0.625rem]',
md: 'rounded-[0.25rem] text-[0.6875rem]'
}
},
defaultVariants: {
variant: 'default',
size: 'md'
}
}
)
function kbdShapeClass(label: string, size: 'sm' | 'md' | null | undefined): string {
const compact = COMPACT_KEY.test(label)
if (size === 'sm') {
return compact ? 'size-[1.125rem] px-0' : 'h-[1.125rem] min-w-[1.125rem] px-1'
}
return compact ? 'size-[1.375rem] px-0' : 'h-[1.375rem] min-w-[1.375rem] px-1.5'
}
interface KbdProps extends React.ComponentProps<'kbd'>, VariantProps<typeof kbdVariants> {}
function Kbd({ children, className, size, variant, ...props }: KbdProps) {
const label = typeof children === 'string' ? children : ''
return (
<kbd
className={cn(
'inline-grid h-4 min-w-4 place-items-center rounded-sm border border-border/70 bg-muted/45 px-1 font-mono text-[0.5625rem] font-medium leading-none text-muted-foreground shadow-xs',
className
)}
className={cn(kbdVariants({ size, variant }), kbdShapeClass(label, size), className)}
data-slot="kbd"
{...props}
/>
>
{children}
</kbd>
)
}
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'> {
interface KbdGroupProps extends Omit<React.ComponentProps<'span'>, 'children'>, VariantProps<typeof kbdVariants> {
keys: string[]
}
function KbdGroup({ className, keys, ...props }: KbdGroupProps) {
function KbdGroup({ className, keys, size, variant, ...props }: KbdGroupProps) {
return (
<span
aria-label={keys.join(' ')}
className={cn('inline-flex shrink-0 items-center gap-0.5 opacity-55', className)}
className={cn('inline-flex shrink-0 items-center gap-1', className)}
data-slot="kbd-group"
{...props}
>
{keys.map(key => (
<Kbd key={key}>{key}</Kbd>
{keys.map((key, index) => (
<Kbd key={`${key}-${index}`} size={size} variant={variant}>
{key}
</Kbd>
))}
</span>
)
}
export { Kbd, KbdGroup }
interface KbdComboProps extends Omit<KbdGroupProps, 'keys'> {
combo: string
}
function KbdCombo({ combo, ...props }: KbdComboProps) {
return <KbdGroup keys={comboTokens(combo)} {...props} />
}
export { Kbd, KbdCombo, KbdGroup, kbdVariants }
+1 -1
View File
@@ -339,7 +339,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
data-sidebar="content"
+7 -3
View File
@@ -20,8 +20,10 @@ declare global {
getGatewayWsUrl: (profile?: null | string) => Promise<string>
// Open (or focus) a standalone OS window for a single chat session so
// the user can work with multiple chats side by side. Returns ok:false
// with an error code when the sessionId is empty/invalid.
openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }>
// with an error code when the sessionId is empty/invalid. `watch` opens
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
@@ -52,6 +54,8 @@ declare global {
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
stopPreviewFileWatch: (id: string) => Promise<boolean>
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
setTranslucency?: (payload: { intensity: number }) => void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
@@ -76,7 +80,7 @@ declare global {
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
+32 -15
View File
@@ -296,6 +296,8 @@ export const en: Translations = {
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
toolViewTitle: 'Tool Call Display',
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
translucencyTitle: 'Window Translucency',
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
product: 'Product',
productDesc: 'Human-friendly tool activity with concise summaries.',
technical: 'Technical',
@@ -1184,14 +1186,14 @@ export const en: Translations = {
'/quit': 'exit hermes'
},
hotkeyDescs: {
'@': 'reference files, folders, urls, git',
'/': 'slash command palette',
'?': 'this quick help (delete to dismiss)',
Enter: 'send · Shift+Enter for newline',
'Cmd/Ctrl+Shift+K': 'send next queued turn',
'Cmd/Ctrl+/': 'all keyboard shortcuts',
Esc: 'close popover · cancel run',
'↑ / ↓': 'cycle popover / history'
'composer.mention': 'reference files, folders, urls, git',
'composer.slash': 'slash command palette',
'composer.help': 'this quick help (delete to dismiss)',
'composer.sendNewline': 'send · Shift+Enter for newline',
'composer.sendQueued': 'send next queued turn',
'keybinds.openPanel': 'all keyboard shortcuts',
'composer.cancel': 'close popover · cancel run',
'composer.history': 'cycle popover / history'
},
attachUrlTitle: 'Attach a URL',
attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.',
@@ -1204,10 +1206,10 @@ export const en: Translations = {
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
editingInComposer: 'Editing in composer',
editingQueuedInComposer: 'Editing queued turn in composer',
editQueued: 'Edit queued turn',
sendQueuedNext: 'Send queued turn next',
sendQueuedNow: 'Send queued turn now',
deleteQueued: 'Delete queued turn',
queueEdit: 'Edit',
queueSendNext: 'Next',
queueSend: 'Send',
queueDelete: 'Delete',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,
@@ -1252,6 +1254,17 @@ export const en: Translations = {
}
},
statusStack: {
agents: 'Agents',
background: count => `${count} Background`,
subagents: count => `${count} Subagent${count === 1 ? '' : 's'}`,
todos: (done, total) => `Tasks ${done}/${total}`,
running: 'Running',
stop: 'Stop',
dismiss: 'Dismiss',
exit: code => `exit ${code}`
},
updates: {
stages: {
idle: 'Getting ready…',
@@ -1287,7 +1300,8 @@ export const en: Translations = {
copied: 'Copied',
done: 'Done',
applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when its done.',
applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when its back.',
applyingBodyBackend:
'The remote backend is applying the update and will restart. Hermes reconnects automatically when its back.',
applyingClose: 'Hermes will close to apply the update.',
errorTitle: 'Update didnt finish',
errorBody: 'No worries — nothing was lost. You can try again now.',
@@ -1653,9 +1667,12 @@ export const en: Translations = {
readAloud: 'Read aloud',
editMessage: 'Edit message',
stop: 'Stop',
editableCheckpoint: 'Editable checkpoint',
restorePrevious: 'Restore previous checkpoint',
restoreCheckpoint: 'Restore checkpoint',
restoreFromHere: 'Restore checkpoint — rerun from this prompt',
restoreTitle: 'Restore to this checkpoint?',
restoreBody: 'Everything after this prompt is removed from the conversation, and the prompt runs again from here.',
restoreConfirm: 'Restore & rerun',
restoreNext: 'Restore next checkpoint',
goForward: 'Go forward',
sendEdited: 'Send edited message',
@@ -1681,7 +1698,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcut: '⌘/Ctrl + Enter to send',
shortcutSuffix: ' to send',
back: 'Back',
skip: 'Skip',
send: 'Send'
+52 -27
View File
@@ -210,15 +210,19 @@ export const ja = defineLocale({
colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。',
toolViewTitle: 'ツール呼び出しの表示',
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
translucencyTitle: 'ウィンドウの透過',
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
product: 'プロダクト',
productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
technical: 'テクニカル',
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
themeProfileNote: profile =>
`${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
installTitle: 'VS Code から導入',
installDesc: 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
installDesc:
'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
installPlaceholder: 'publisher.extension',
installButton: 'インストール',
installing: 'インストール中…',
@@ -387,7 +391,8 @@ export const ja = defineLocale({
personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
showReasoning: 'バックエンドが推論内容を提供したときに表示します。'
},
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
timezone:
'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
agent: {
imageInputMode: '画像添付をモデルへ送る方法を制御します。',
maxTurns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。'
@@ -513,15 +518,16 @@ export const ja = defineLocale({
envOverrideDesc:
'保存された設定を使用するには HERMES_DESKTOP_REMOTE_URL と HERMES_DESKTOP_REMOTE_TOKEN の設定を解除してください。',
localTitle: 'ローカルゲートウェイ',
localDesc: 'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。',
localDesc:
'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。',
remoteTitle: 'リモートゲートウェイ',
remoteDesc:
'このデスクトップシェルをリモートの Hermes バックエンドに接続します。ホスト型ゲートウェイは OAuth またはユーザー名とパスワードを使用します。自己ホスト型はセッショントークンを使用する場合があります。',
remoteUrlTitle: 'リモート URL',
remoteUrlDesc: 'リモートダッシュボードバックエンドのベース URL。/hermes などのパスプレフィックスもサポートしています。',
remoteUrlDesc:
'リモートダッシュボードバックエンドのベース URL。/hermes などのパスプレフィックスもサポートしています。',
probing: 'このゲートウェイの認証方法を確認中…',
probeError:
'このゲートウェイにまだ到達できません。URL を確認してください。応答後に認証方法が表示されます。',
probeError: 'このゲートウェイにまだ到達できません。URL を確認してください。応答後に認証方法が表示されます。',
signedIn: 'サインイン済み',
signIn: 'サインイン',
signOut: 'サインアウト',
@@ -529,7 +535,8 @@ export const ja = defineLocale({
authTitle: '認証',
authSignedInPassword:
'このゲートウェイはユーザー名とパスワードを使用します。サインイン済みです。セッションは自動的に更新されます。',
authSignedInOauth: 'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。',
authSignedInOauth:
'このゲートウェイは OAuth を使用します。サインイン済みです。セッションは自動的に更新されます。',
authNeedsPassword:
'このゲートウェイはユーザー名とパスワードを使用します。このデスクトップアプリを承認するにはサインインしてください。',
authNeedsOauth: provider =>
@@ -544,8 +551,7 @@ export const ja = defineLocale({
saveForRestart: '次回起動時のために保存',
saveAndReconnect: '保存して再接続',
diagnostics: '診断',
diagnosticsDesc:
'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。',
diagnosticsDesc: 'ファイルマネージャーで desktop.log を表示します。ゲートウェイの起動に失敗した際に役立ちます。',
openLogs: 'ログを開く',
incompleteTitle: 'リモートゲートウェイの設定が不完全です',
incompleteSignIn: 'リモートに切り替える前にリモート URL を入力してサインインしてください。',
@@ -603,7 +609,8 @@ export const ja = defineLocale({
},
model: {
loading: 'モデル設定を読み込み中...',
appliesDesc: '新しいセッションに適用されます。コンポーザーのモデルピッカーを使ってアクティブなチャットをホットスワップできます。',
appliesDesc:
'新しいセッションに適用されます。コンポーザーのモデルピッカーを使ってアクティブなチャットをホットスワップできます。',
provider: 'プロバイダー',
model: 'モデル',
applying: '適用中...',
@@ -1017,7 +1024,8 @@ export const ja = defineLocale({
notSet: '未設定',
soulDesc: 'このプロファイルに組み込まれたシステムプロンプトとペルソナの指示。',
soulOptional: '省略可能',
soulPlaceholder: mode => `このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`,
soulPlaceholder: mode =>
`このプロファイルのシステムプロンプト / ペルソナ。\n空欄のままにすると ${mode} のデフォルトを使用します。`,
soulPlaceholderCloned: 'クローン済み',
soulPlaceholderEmpty: '空',
unsavedChanges: '未保存の変更',
@@ -1316,14 +1324,14 @@ export const ja = defineLocale({
'/quit': 'hermes を終了'
},
hotkeyDescs: {
'@': 'ファイル、フォルダー、URL、Git を参照',
'/': 'スラッシュコマンドパレット',
'?': 'クイックヘルプ(削除で閉じる)',
Enter: '送信 · 改行は Shift+Enter',
'Cmd/Ctrl+K': '次のキュー済みターンを送信',
'Cmd/Ctrl+L': '再描画',
Esc: 'ポップオーバーを閉じる · 実行をキャンセル',
'↑ / ↓': 'ポップオーバー / 履歴を切り替え'
'composer.mention': 'ファイル、フォルダー、URL、Git を参照',
'composer.slash': 'スラッシュコマンドパレット',
'composer.help': 'クイックヘルプ(削除で閉じる)',
'composer.sendNewline': '送信 · 改行は Shift+Enter',
'composer.sendQueued': '次のキュー済みターンを送信',
'keybinds.openPanel': 'すべてのキーボードショートカット',
'composer.cancel': 'ポップオーバーを閉じる · 実行をキャンセル',
'composer.history': 'ポップオーバー / 履歴を切り替え'
},
attachUrlTitle: 'URL を添付',
attachUrlDesc: 'Hermes がページを取得し、このターンのコンテキストとして含めます。',
@@ -1336,9 +1344,10 @@ export const ja = defineLocale({
attachments: count => `${count} 件の添付`,
editingInComposer: 'コンポーザーで編集中',
editingQueuedInComposer: 'コンポーザーでキュー済みターンを編集中',
editQueued: 'キュー済みターンを編集',
sendQueuedNow: 'キュー済みターンを今すぐ送信',
deleteQueued: 'キュー済みターンを削除',
queueEdit: '編集',
queueSendNext: '次に送信',
queueSend: '送信',
queueDelete: '削除',
previewUnavailable: 'プレビューは利用できません',
previewLabel: label => `${label} のプレビュー`,
couldNotPreview: label => `${label} をプレビューできませんでした`,
@@ -1383,6 +1392,17 @@ export const ja = defineLocale({
}
},
statusStack: {
agents: 'エージェント',
background: count => `バックグラウンド ${count}`,
subagents: count => `サブエージェント ${count}`,
todos: (done, total) => `タスク ${done}/${total}`,
running: '実行中',
stop: '停止',
dismiss: '閉じる',
exit: code => `終了コード ${code}`
},
updates: {
stages: {
idle: '準備中…',
@@ -1407,7 +1427,8 @@ export const ja = defineLocale({
availableBody: '新しいバージョンの Hermes をインストールする準備ができています。',
availableTitleBackend: 'バックエンドの更新があります',
availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。',
availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
availableBodyNoChangelog:
'新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
updateNow: '今すぐ更新',
maybeLater: '後で',
moreChanges: count => `さらに ${count} 件の変更が含まれています。`,
@@ -1430,7 +1451,8 @@ export const ja = defineLocale({
restarting: 'バックエンドが更新を読み込むため再起動しています…',
notAvailable: 'このバックエンドでは更新を利用できません。',
failed: 'バックエンドの更新に失敗しました。',
noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
noReturn:
'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
}
},
@@ -1786,9 +1808,12 @@ export const ja = defineLocale({
readAloud: '読み上げ',
editMessage: 'メッセージを編集',
stop: '停止',
editableCheckpoint: '編集可能なチェックポイント',
restorePrevious: '前のチェックポイントに戻す',
restoreCheckpoint: 'チェックポイントを復元',
restoreFromHere: 'チェックポイントを復元 — このプロンプトから再実行',
restoreTitle: 'このチェックポイントに復元しますか?',
restoreBody: 'このプロンプト以降のメッセージは会話から削除され、ここからプロンプトが再実行されます。',
restoreConfirm: '復元して再実行',
restoreNext: '次のチェックポイントに戻す',
goForward: '進む',
sendEdited: '編集済みメッセージを送信',
@@ -1814,7 +1839,7 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
shortcut: '⌘/Ctrl + Enter で送信',
shortcutSuffix: ' で送信',
back: '戻る',
skip: 'スキップ',
send: '送信'
+22 -6
View File
@@ -213,6 +213,8 @@ export interface Translations {
colorModeDesc: string
toolViewTitle: string
toolViewDesc: string
translucencyTitle: string
translucencyDesc: string
product: string
productDesc: string
technical: string
@@ -919,10 +921,10 @@ export interface Translations {
attachments: (count: number) => string
editingInComposer: string
editingQueuedInComposer: string
editQueued: string
sendQueuedNext: string
sendQueuedNow: string
deleteQueued: string
queueEdit: string
queueSendNext: string
queueSend: string
queueDelete: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string
@@ -951,6 +953,17 @@ export interface Translations {
dropSession: string
}
statusStack: {
agents: string
background: (count: number) => string
subagents: (count: number) => string
todos: (done: number, total: number) => string
running: string
stop: string
dismiss: string
exit: (code: number) => string
}
updates: {
stages: Record<string, string>
checking: string
@@ -1313,9 +1326,12 @@ export interface Translations {
readAloud: string
editMessage: string
stop: string
editableCheckpoint: string
restorePrevious: string
restoreCheckpoint: string
restoreFromHere: string
restoreTitle: string
restoreBody: string
restoreConfirm: string
restoreNext: string
goForward: string
sendEdited: string
@@ -1340,7 +1356,7 @@ export interface Translations {
loadingQuestion: string
other: string
placeholder: string
shortcut: string
shortcutSuffix: string
back: string
skip: string
send: string
+40 -33
View File
@@ -204,6 +204,8 @@ export const zhHant = defineLocale({
colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。',
toolViewTitle: '工具呼叫顯示',
toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。',
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
product: '產品',
productDesc: '易讀的工具活動與精簡摘要。',
technical: '技術',
@@ -503,8 +505,7 @@ export const zhHant = defineLocale({
defaultConnection: '預設連線適用於所有沒有自訂覆寫的設定檔。',
profileConnection: profile => `僅當「${profile}」為作用中設定檔時使用此連線。設為本機可繼承預設連線。`,
envOverrideTitle: '環境變數正在控制此桌面工作階段。',
envOverrideDesc:
'取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。',
envOverrideDesc: '取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。',
localTitle: '本機閘道',
localDesc: '在 localhost 啟動私有 Hermes 後端。這是預設方式,可離線使用。',
remoteTitle: '遠端閘道',
@@ -626,8 +627,7 @@ export const zhHant = defineLocale({
sessions: {
loading: '正在載入已封存工作階段…',
archivedTitle: '已封存工作階段',
archivedIntro:
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
archivedIntro: '已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
emptyArchivedTitle: '暫無封存',
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
unarchive: '取消封存',
@@ -636,8 +636,7 @@ export const zhHant = defineLocale({
restored: '已還原',
deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`,
defaultDirTitle: '預設專案目錄',
defaultDirDesc:
'新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
defaultDirUpdated: '預設專案目錄已更新',
defaultsTo: label => `預設使用 ${label}`,
change: '變更',
@@ -1080,8 +1079,7 @@ export const zhHant = defineLocale({
topOfHour: '每個整點',
everyHourAt: minute => `每小時的 :${minute}`,
newCron: '新排程工作',
emptyDescNew:
'按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
emptyDescNew: '按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
emptyDescSearch: '請嘗試更廣泛的搜尋詞。',
emptyTitleNew: '暫無排程工作',
emptyTitleSearch: '無相符項目',
@@ -1282,14 +1280,14 @@ export const zhHant = defineLocale({
'/quit': '結束 hermes'
},
hotkeyDescs: {
'@': '參照檔案、資料夾、URL、git',
'/': '斜線指令面板',
'?': '此快速說明(刪除以關閉)',
Enter: '傳送 · Shift+Enter 換行',
'Cmd/Ctrl+K': '傳送下一個排隊的回合',
'Cmd/Ctrl+L': '重繪',
Esc: '關閉彈出視窗 · 取消執行',
'↑ / ↓': '循環彈出視窗 / 歷史記錄'
'composer.mention': '參照檔案、資料夾、URL、git',
'composer.slash': '斜線指令面板',
'composer.help': '此快速說明(刪除以關閉)',
'composer.sendNewline': '傳送 · Shift+Enter 換行',
'composer.sendQueued': '傳送下一個排隊的回合',
'keybinds.openPanel': '所有鍵盤快捷鍵',
'composer.cancel': '關閉彈出視窗 · 取消執行',
'composer.history': '循環彈出視窗 / 歷史記錄'
},
attachUrlTitle: '附加 URL',
attachUrlDesc: 'Hermes 將擷取該頁面並作為此回合的脈絡。',
@@ -1302,9 +1300,10 @@ export const zhHant = defineLocale({
attachments: count => `${count} 個附件`,
editingInComposer: '在輸入框中編輯',
editingQueuedInComposer: '在輸入框中編輯排隊回合',
editQueued: '編輯排隊回合',
sendQueuedNow: '立即傳送排隊回合',
deleteQueued: '刪除排隊回合',
queueEdit: '編輯',
queueSendNext: '下一個',
queueSend: '傳送',
queueDelete: '刪除',
previewUnavailable: '預覽不可用',
previewLabel: label => `預覽 ${label}`,
couldNotPreview: label => `無法預覽 ${label}`,
@@ -1349,6 +1348,17 @@ export const zhHant = defineLocale({
}
},
statusStack: {
agents: '代理',
background: count => `${count} 個背景任務`,
subagents: count => `${count} 個子代理`,
todos: (done, total) => `任務 ${done}/${total}`,
running: '執行中',
stop: '停止',
dismiss: '關閉',
exit: code => `結束碼 ${code}`
},
updates: {
stages: {
idle: '準備中…',
@@ -1420,8 +1430,7 @@ export const zhHant = defineLocale({
finishingTitle: '正在收尾',
failedDesc:
'某個安裝步驟失敗。在 Windows 上,如果另一個 Hermes CLI 或桌面執行個體正在執行,可能會出現這種情況。請停止正在執行的 Hermes 執行個體後重試。可查看下方的詳細資訊或 desktop 記錄中的完整記錄。',
activeDesc:
'這是一次性設定。Hermes 安裝程式正在下載相依套件並設定您的電腦。之後啟動會略過此步驟。',
activeDesc: '這是一次性設定。Hermes 安裝程式正在下載相依套件並設定您的電腦。之後啟動會略過此步驟。',
progress: (completed, total) => `${completed}/${total} 個步驟已完成`,
currentStage: stage => ` -- 目前:${stage}`,
fetchingManifest: '正在取得安裝程式 manifest...',
@@ -1487,12 +1496,10 @@ export const zhHant = defineLocale({
copyAuthCode: '複製授權碼並貼到下方。',
pasteAuthCode: '貼上授權碼',
reopenAuthPage: '重新開啟授權頁面',
autoBrowser: provider =>
`已在瀏覽器中開啟 ${provider}。請在那裡授權 Hermes,連線會自動完成,無需複製或貼上。`,
autoBrowser: provider => `已在瀏覽器中開啟 ${provider}。請在那裡授權 Hermes,連線會自動完成,無需複製或貼上。`,
reopenSignInPage: '重新開啟登入頁面',
waitingAuthorize: '等待您授權...',
externalPending: provider =>
`${provider} 透過自己的 CLI 登入。請在終端機執行此指令,然後回來選擇「我已登入」:`,
externalPending: provider => `${provider} 透過自己的 CLI 登入。請在終端機執行此指令,然後回來選擇「我已登入」:`,
signedIn: '我已登入',
deviceCodeOpened: provider => `已在瀏覽器中開啟 ${provider}。請在那裡輸入此代碼:`,
reopenVerification: '重新開啟驗證頁面',
@@ -1707,16 +1714,14 @@ export const zhHant = defineLocale({
showConsole: '顯示預覽主控台',
hideDevTools: '隱藏預覽 DevTools',
openDevTools: '開啟預覽 DevTools',
finishedRestarting: message =>
`Hermes 已完成預覽伺服器重新啟動${message ? `${message}` : ''}`,
finishedRestarting: message => `Hermes 已完成預覽伺服器重新啟動${message ? `${message}` : ''}`,
failedRestarting: message => `伺服器重新啟動失敗:${message}`,
unknownError: '未知錯誤',
restartedTitle: '預覽伺服器已重新啟動',
reloadingNow: '正在重新載入預覽。',
restartFailedTitle: '預覽重新啟動失敗',
restartFailedMessage: 'Hermes 無法重新啟動伺服器。',
stillWorking:
'Hermes 仍在執行,但尚未收到重新啟動結果。伺服器指令可能正在前台執行。',
stillWorking: 'Hermes 仍在執行,但尚未收到重新啟動結果。伺服器指令可能正在前台執行。',
workspaceReloading: '工作區已變更,正在重新載入預覽',
fileChanged: url => `檔案已變更,正在重新載入預覽:${url}`,
filesChanged: (count, url) => `${count} 個檔案變更,正在重新載入預覽:${url}`,
@@ -1747,9 +1752,12 @@ export const zhHant = defineLocale({
readAloud: '朗讀',
editMessage: '編輯訊息',
stop: '停止',
editableCheckpoint: '可編輯的檢查點',
restorePrevious: '還原至上一個檢查點',
restoreCheckpoint: '還原檢查點',
restoreFromHere: '還原檢查點 — 從此提示重新執行',
restoreTitle: '還原至此檢查點?',
restoreBody: '此提示之後的所有訊息將從對話中移除,並從此處重新執行該提示。',
restoreConfirm: '還原並重新執行',
restoreNext: '還原至下一個檢查點',
goForward: '前進',
sendEdited: '傳送編輯後的訊息',
@@ -1775,7 +1783,7 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
shortcut: '⌘/Ctrl + Enter 傳送',
shortcutSuffix: ' 傳送',
back: '返回',
skip: '略過',
send: '傳送'
@@ -1833,8 +1841,7 @@ export const zhHant = defineLocale({
yoloSystem: active => `此工作階段 YOLO ${active ? '已開啟' : '已關閉'}`,
yoloTitle: 'YOLO',
yoloToggleFailed: '無法切換 YOLO',
profileStatus: current =>
`設定檔:${current}。使用 /profile <name> 或「新工作階段」選擇器在其他設定檔中開始聊天。`,
profileStatus: current => `設定檔:${current}。使用 /profile <name> 或「新工作階段」選擇器在其他設定檔中開始聊天。`,
unknownProfile: '未知設定檔',
noProfileNamed: (target, available) => `沒有名為「${target}」的設定檔。可用的:${available}`,
newChatsProfile: name => `新聊天將使用設定檔 ${name}`,
+34 -16
View File
@@ -291,6 +291,8 @@ export const zh: Translations = {
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
toolViewTitle: '工具调用显示',
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
product: '产品',
productDesc: '易读的工具活动与简洁摘要。',
technical: '技术',
@@ -1018,13 +1020,15 @@ export const zh: Translations = {
platformIntro: {
telegram:
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
discord: '打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
discord:
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
bluebubbles:
'在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
@@ -1370,14 +1374,14 @@ export const zh: Translations = {
'/quit': '退出 hermes'
},
hotkeyDescs: {
'@': '引用文件、文件夹、URL、git',
'/': '斜杠命令面板',
'?': '此快速帮助 (删除以关闭)',
Enter: '发送 · Shift+Enter 换行',
'Cmd/Ctrl+K': '发送下一条排队的回合',
'Cmd/Ctrl+L': '重绘',
Esc: '关闭弹窗 · 取消运行',
'↑ / ↓': '循环弹窗 / 历史'
'composer.mention': '引用文件、文件夹、URL、git',
'composer.slash': '斜杠命令面板',
'composer.help': '此快速帮助 (删除以关闭)',
'composer.sendNewline': '发送 · Shift+Enter 换行',
'composer.sendQueued': '发送下一条排队的回合',
'keybinds.openPanel': '所有键盘快捷键',
'composer.cancel': '关闭弹窗 · 取消运行',
'composer.history': '循环弹窗 / 历史'
},
attachUrlTitle: '附加 URL',
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
@@ -1390,10 +1394,10 @@ export const zh: Translations = {
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
editingQueuedInComposer: '正在输入框中编辑排队回合',
editQueued: '编辑排队回合',
sendQueuedNext: '下一个发送排队回合',
sendQueuedNow: '立即发送排队回合',
deleteQueued: '删除排队回合',
queueEdit: '编辑',
queueSendNext: '下一个',
queueSend: '发送',
queueDelete: '删除',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,
@@ -1438,6 +1442,17 @@ export const zh: Translations = {
}
},
statusStack: {
agents: '代理',
background: count => `${count} 个后台任务`,
subagents: count => `${count} 个子代理`,
todos: (done, total) => `任务 ${done}/${total}`,
running: '运行中',
stop: '停止',
dismiss: '关闭',
exit: code => `退出码 ${code}`
},
updates: {
stages: {
idle: '准备中…',
@@ -1832,9 +1847,12 @@ export const zh: Translations = {
readAloud: '朗读',
editMessage: '编辑消息',
stop: '停止',
editableCheckpoint: '可编辑检查点',
restorePrevious: '恢复上一个检查点',
restoreCheckpoint: '恢复检查点',
restoreFromHere: '恢复检查点 — 从此提示重新运行',
restoreTitle: '恢复到此检查点?',
restoreBody: '此提示之后的所有消息将从对话中移除,并从此处重新运行该提示。',
restoreConfirm: '恢复并重新运行',
restoreNext: '恢复下一个检查点',
goForward: '前进',
sendEdited: '发送编辑后的消息',
@@ -1860,7 +1878,7 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcut: '⌘/Ctrl + Enter 发送',
shortcutSuffix: ' 发送',
back: '返回',
skip: '跳过',
send: '发送'
+2
View File
@@ -66,6 +66,8 @@ export type GatewayEventPayload = {
// terminal.read.request (GUI agent reading the in-app terminal pane)
start?: number
count?: number
// status.update (kind=process → background process completion/watch-match)
kind?: string
}
export function textPart(text: string): ChatMessagePart {
+46 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parseTodos } from './todos'
import { latestSessionTodos, parseTodos } from './todos'
describe('parseTodos', () => {
it('parses todo arrays with valid ids, content, and statuses', () => {
@@ -33,3 +33,48 @@ describe('parseTodos', () => {
expect(parseTodos({ message: 'no todos here' })).toBeNull()
})
})
describe('latestSessionTodos', () => {
const todoPart = (todos: unknown, extra: Record<string, unknown> = {}) => ({
type: 'tool-call',
toolCallId: 't1',
toolName: 'todo',
args: { todos },
...extra
})
it('returns the last todo list across the transcript (result beats args)', () => {
const messages = [
{ parts: [todoPart([{ content: 'Old', id: 'a', status: 'pending' }])] },
{ parts: [{ type: 'text', text: 'hi' }] },
{
parts: [
todoPart([{ content: 'Stale', id: 'a', status: 'pending' }], {
result: { todos: [{ content: 'Fresh', id: 'a', status: 'completed' }] }
})
]
}
]
expect(latestSessionTodos(messages)).toEqual([{ content: 'Fresh', id: 'a', status: 'completed' }])
})
it('prefers the live carried `todos` field over args', () => {
const messages = [
{
parts: [
todoPart([{ content: 'Args', id: 'a', status: 'pending' }], {
todos: [{ content: 'Live', id: 'a', status: 'in_progress' }]
})
]
}
]
expect(latestSessionTodos(messages)).toEqual([{ content: 'Live', id: 'a', status: 'in_progress' }])
})
it('returns null when no todo tool calls exist', () => {
expect(latestSessionTodos([{ parts: [{ type: 'text', text: 'hi' }] }])).toBeNull()
expect(latestSessionTodos([])).toBeNull()
})
})
+37
View File
@@ -49,3 +49,40 @@ function parse(value: unknown, depth: number): null | TodoItem[] {
}
export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0)
/** Latest parseable todo list from one message's aui content parts (tool-call
* parts named `todo`; live parts carry `todos`, hydrated ones args/result). */
export function todosFromMessageContent(content: unknown): null | TodoItem[] {
if (!Array.isArray(content)) {
return null
}
let latest: null | TodoItem[] = null
for (const part of content) {
if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') {
continue
}
const parsed = parseTodos(part.todos) ?? parseTodos(part.result) ?? parseTodos(part.args)
if (parsed !== null) {
latest = parsed
}
}
return latest
}
/** Current todo state for a whole transcript — the last list wins. */
export function latestSessionTodos(messages: readonly { parts?: unknown }[]): null | TodoItem[] {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const todos = todosFromMessageContent(messages[i]?.parts)
if (todos !== null) {
return todos
}
}
return null
}
+2
View File
@@ -1,4 +1,6 @@
import './styles.css'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $backgroundStatusBySession, dismissBackgroundProcess, reconcileBackgroundProcesses } from './composer-status'
const SID = 'sess-1'
const running = (id: string, command = `cmd ${id}`) => ({ command, session_id: id, status: 'running' })
const exited = (id: string, exit_code = 0, command = `cmd ${id}`) => ({
command,
exit_code,
session_id: id,
status: 'exited'
})
const items = () => $backgroundStatusBySession.get()[SID] ?? []
describe('reconcileBackgroundProcesses', () => {
beforeEach(() => {
$backgroundStatusBySession.set({})
})
it('maps registry entries to status items', () => {
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)])
expect(items().map(i => [i.id, i.state])).toEqual([
['a', 'running'],
['b', 'done'],
['c', 'failed']
])
expect(items()[2]!.exitCode).toBe(1)
})
it('keeps row order stable when a process flips state or the snapshot reorders', () => {
reconcileBackgroundProcesses(SID, [running('a'), running('b')])
// Snapshot arrives reordered AND `a` has exited — rows must not move.
reconcileBackgroundProcesses(SID, [running('b'), exited('a', 0)])
expect(items().map(i => [i.id, i.state])).toEqual([
['a', 'done'],
['b', 'running']
])
})
it('appends new processes after existing rows', () => {
reconcileBackgroundProcesses(SID, [running('a')])
reconcileBackgroundProcesses(SID, [running('b'), running('a')])
expect(items().map(i => i.id)).toEqual(['a', 'b'])
})
it('preserves object identity for unchanged rows (memo stability)', () => {
reconcileBackgroundProcesses(SID, [running('a'), running('b')])
const [a1] = items()
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0)])
const [a2, b2] = items()
expect(a2).toBe(a1)
expect(b2!.state).toBe('done')
})
it('is a no-op store write when nothing changed', () => {
reconcileBackgroundProcesses(SID, [running('a')])
const before = $backgroundStatusBySession.get()
reconcileBackgroundProcesses(SID, [running('a')])
expect($backgroundStatusBySession.get()).toBe(before)
})
it('never resurrects a dismissed process while the registry still reports it', () => {
reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')])
dismissBackgroundProcess(SID, 'a')
reconcileBackgroundProcesses(SID, [exited('a', 0), running('b')])
expect(items().map(i => i.id)).toEqual(['b'])
})
it('forgets a dismissal once the registry prunes the process', () => {
reconcileBackgroundProcesses(SID, [exited('a', 0)])
dismissBackgroundProcess(SID, 'a')
// Registry pruned it…
reconcileBackgroundProcesses(SID, [])
// …so a future process reusing the id (new spawn) shows again.
reconcileBackgroundProcesses(SID, [running('a')])
expect(items().map(i => i.id)).toEqual(['a'])
})
it('drops the session key entirely when the last row goes away', () => {
reconcileBackgroundProcesses(SID, [running('a')])
reconcileBackgroundProcesses(SID, [])
expect($backgroundStatusBySession.get()).toEqual({})
})
})
+257
View File
@@ -0,0 +1,257 @@
import { atom, computed } from 'nanostores'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
import { $subagentsBySession, type SubagentProgress } from './subagents'
import { $todosBySession } from './todos'
/** Composer status stack feed — merged todos, subagents, background per session. */
export type StatusItemState = 'done' | 'failed' | 'running'
export type StatusItemType = 'background' | 'subagent' | 'todo'
export interface ComposerStatusItem {
/** background: non-zero exit shown inline when failed. */
exitCode?: number
/** subagent: active tool label shown on the right. */
currentTool?: string
id: string
/** background process: captured stdout/stderr tail for the inline viewer. */
output?: string
/** subagent: its own stored session id row click opens that session window
* (livestreamed by the gateway's child-session mirror). */
sessionId?: string
state: StatusItemState
title: string
/** todo: the full four-state status driving the row's checkmark glyph. */
todoStatus?: TodoStatus
type: StatusItemType
}
// Writable source for background work, synced from the gateway's process
// registry (`terminal(background=true)` spawns) via `process.list`.
export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({})
// Rows the user X-ed away. The registry keeps finished processes around for a
// while, so without this every refresh would resurrect a dismissed row.
const dismissedBySession = new Map<string, Set<string>>()
const subToItem = (s: SubagentProgress): ComposerStatusItem => ({
currentTool: s.currentTool,
id: s.id,
sessionId: s.sessionId,
state: 'running',
title: s.goal,
type: 'subagent'
})
const todoToItem = (t: TodoItem): ComposerStatusItem => ({
id: `todo:${t.id}`,
state: t.status === 'in_progress' ? 'running' : 'done',
title: t.content,
todoStatus: t.status,
type: 'todo'
})
// The single thing the stack reads: a typed, merged item list per session.
export const $statusItemsBySession = computed(
[$subagentsBySession, $backgroundStatusBySession, $todosBySession],
(subs, background, todos) => {
const out: Record<string, ComposerStatusItem[]> = {}
const push = (sid: string, items: ComposerStatusItem[]) => {
if (items.length > 0) {
out[sid] = out[sid] ? [...out[sid], ...items] : items
}
}
for (const [sid, list] of Object.entries(todos)) {
push(sid, list.map(todoToItem))
}
for (const [sid, list] of Object.entries(subs)) {
push(sid, list.filter(s => s.status === 'running' || s.status === 'queued').map(subToItem))
}
for (const [sid, list] of Object.entries(background)) {
push(sid, list)
}
return out
}
)
// Fixed render order for the groups in the stack (top → bottom, above queue).
const TYPE_ORDER: readonly StatusItemType[] = ['todo', 'subagent', 'background']
export interface StatusGroup {
items: ComposerStatusItem[]
type: StatusItemType
}
export function groupStatusItems(items: readonly ComposerStatusItem[]): StatusGroup[] {
const byType = new Map<StatusItemType, ComposerStatusItem[]>()
for (const item of items) {
const list = byType.get(item.type)
if (list) {
list.push(item)
} else {
byType.set(item.type, [item])
}
}
return TYPE_ORDER.filter(type => byType.has(type)).map(type => ({ items: byType.get(type)!, type }))
}
const writeBackground = (sid: string, items: ComposerStatusItem[]) => {
const current = $backgroundStatusBySession.get()
const next = { ...current }
if (items.length > 0) {
next[sid] = items
} else {
delete next[sid]
}
$backgroundStatusBySession.set(next)
}
// `tui_gateway` process.list entry (tools/process_registry.list_sessions + output_tail).
interface GatewayProcessEntry {
command?: string
exit_code?: number
output_tail?: string
session_id?: string
status?: string
}
const toBackgroundItem = (proc: GatewayProcessEntry): ComposerStatusItem => {
const exited = proc.status === 'exited'
const exitCode = typeof proc.exit_code === 'number' ? proc.exit_code : undefined
return {
exitCode,
id: proc.session_id ?? '',
output: proc.output_tail || undefined,
state: exited ? (exitCode ? 'failed' : 'done') : 'running',
title: (proc.command ?? '').split('\n')[0]!.trim() || 'background process',
type: 'background'
}
}
const sameItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
a.state === b.state && a.title === b.title && a.output === b.output && a.exitCode === b.exitCode
/**
* Layout-stable sync of the registry snapshot into the store: existing rows
* keep their position (status flips happen in place, never reorder), new
* processes append, dismissed ids stay gone, and unchanged rows keep their
* object identity so memoised rows skip re-rendering.
*/
export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessEntry[]) {
const dismissed = dismissedBySession.get(sid)
const fresh = new Map(
procs
.filter(proc => proc.session_id && !dismissed?.has(proc.session_id))
.map(proc => [proc.session_id!, toBackgroundItem(proc)])
)
const prev = $backgroundStatusBySession.get()[sid] ?? []
const kept = prev.flatMap(old => {
const next = fresh.get(old.id)
fresh.delete(old.id)
return next ? [sameItem(old, next) ? old : next] : []
})
const next = [...kept, ...fresh.values()]
// Dismissals only need remembering while the registry still reports the id.
if (dismissed) {
const reported = new Set(procs.map(proc => proc.session_id))
for (const id of dismissed) {
if (!reported.has(id)) {
dismissed.delete(id)
}
}
}
if (next.length === prev.length && next.every((item, i) => item === prev[i])) {
return
}
writeBackground(sid, next)
}
/** Pull the session's live process snapshot from the gateway. */
export async function refreshBackgroundProcesses(sid: string): Promise<void> {
const gateway = $gateway.get()
if (!sid || !gateway) {
return
}
try {
const result = await gateway.request<{ processes?: GatewayProcessEntry[] }>('process.list', { session_id: sid })
reconcileBackgroundProcesses(sid, result?.processes ?? [])
} catch {
// Transient socket loss — the next trigger (event or poll) retries.
}
}
/** X on a finished row: drop it now and keep it dropped across refreshes. */
export function dismissBackgroundProcess(sid: string, id: string) {
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
dismissed.add(id)
dismissedBySession.set(sid, dismissed)
const list = $backgroundStatusBySession.get()[sid] ?? []
writeBackground(
sid,
list.filter(item => item.id !== id)
)
}
/** X on a running row: kill the process for real, then drop the row. */
export function stopBackgroundProcess(sid: string, id: string) {
void $gateway
.get()
?.request('process.kill', { process_id: id, session_id: sid })
.catch(() => undefined)
dismissBackgroundProcess(sid, id)
}
/**
* Rewind cleanup: a restore/edit discards the turns that spawned these
* processes, so they belong to an abandoned timeline. Kill the live ones and
* drop every row. Ids are marked dismissed so an in-flight `process.list` poll
* (kill is async) can't resurrect them; reconcile garbage-collects those once
* the registry stops reporting them.
*/
export function resetSessionBackground(sid: string) {
if (!sid) {
return
}
const gateway = $gateway.get()
const list = $backgroundStatusBySession.get()[sid] ?? []
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
for (const item of list) {
dismissed.add(item.id)
if (item.state === 'running') {
void gateway?.request('process.kill', { process_id: item.id, session_id: sid }).catch(() => undefined)
}
}
dismissedBySession.set(sid, dismissed)
writeBackground(sid, [])
}
+3
View File
@@ -14,6 +14,8 @@ export interface SubagentProgress {
id: string
parentId: null | string
goal: string
/** The child's own stored session id — lets UIs open its session window. */
sessionId?: string
model?: string
status: SubagentStatus
taskCount: number
@@ -159,6 +161,7 @@ function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined
id: prev?.id ?? idOf(payload),
parentId: str(payload.parent_id) || prev?.parentId || null,
goal: str(payload.goal) || prev?.goal || 'Subagent',
sessionId: str(payload.child_session_id) || prev?.sessionId,
model: str(payload.model) || prev?.model,
status,
taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1,
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TodoItem } from '@/lib/todos'
import { $todosBySession, clearSessionTodos, setSessionTodos } from './todos'
const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status })
describe('setSessionTodos finished-list auto-clear', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
clearSessionTodos('s1')
vi.useRealTimers()
})
it('keeps an in-flight list indefinitely', () => {
setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'in_progress')])
vi.advanceTimersByTime(60_000)
expect($todosBySession.get().s1).toHaveLength(2)
})
it('drops the list shortly after every item completes', () => {
setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'cancelled')])
expect($todosBySession.get().s1).toHaveLength(2)
vi.advanceTimersByTime(5_000)
expect($todosBySession.get().s1).toBeUndefined()
})
it('cancels the pending clear when a new active list arrives', () => {
setSessionTodos('s1', [todo('a', 'completed')])
vi.advanceTimersByTime(2_000)
// The next turn starts a fresh plan before the linger expires.
setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'pending')])
vi.advanceTimersByTime(60_000)
expect($todosBySession.get().s1).toHaveLength(2)
})
})
+64
View File
@@ -0,0 +1,64 @@
import { atom } from 'nanostores'
import type { TodoItem } from '@/lib/todos'
/**
* Live todo list per runtime session, rendered by the composer status stack
* (the inline transcript panel is gone). Fed from two places:
*
* - live `todo` tool events (use-message-stream)
* - stored-session hydration (desktop-controller) but only when the list is
* still in flight, so reopening an old chat doesn't pin its finished plan
* above the composer forever.
*/
export const $todosBySession = atom<Record<string, TodoItem[]>>({})
export const todoListActive = (todos: readonly TodoItem[]) =>
todos.some(t => t.status === 'pending' || t.status === 'in_progress')
// Once a list finishes (every item completed/cancelled), the final state
// lingers just long enough to see the last checkmark land, then the group
// drops out of the stack on its own.
const FINISHED_LINGER_MS = 4_000
const clearTimers = new Map<string, ReturnType<typeof setTimeout>>()
function cancelScheduledClear(sid: string) {
const timer = clearTimers.get(sid)
if (timer !== undefined) {
clearTimeout(timer)
clearTimers.delete(sid)
}
}
export function setSessionTodos(sid: string, todos: TodoItem[]) {
if (!sid) {
return
}
cancelScheduledClear(sid)
$todosBySession.set({ ...$todosBySession.get(), [sid]: todos })
if (!todoListActive(todos)) {
clearTimers.set(
sid,
setTimeout(() => {
clearTimers.delete(sid)
clearSessionTodos(sid)
}, FINISHED_LINGER_MS)
)
}
}
export function clearSessionTodos(sid: string) {
cancelScheduledClear(sid)
const map = $todosBySession.get()
if (!(sid in map)) {
return
}
const { [sid]: _drop, ...rest } = map
$todosBySession.set(rest)
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Window translucency (see-through window).
*
* One lever, 0100. 0 = off (fully opaque, the default). Higher = more of the
* desktop shows through the whole window the main process maps it to the
* native window opacity (`setOpacity`), the same effect as the Windows
* shift-scroll trick. macOS + Windows only; Linux has no runtime window
* opacity, so it's a no-op there.
*
* The renderer owns the value and mirrors it to the main process over IPC.
*/
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
const KEY = 'hermes.desktop.translucency.v1'
const clamp = (n: number): number => Math.min(100, Math.max(0, Math.round(n)))
const read = (): number => {
const n = Number(storedString(KEY))
return Number.isFinite(n) ? clamp(n) : 0
}
export const $translucency = atom<number>(typeof window === 'undefined' ? 0 : read())
export function setTranslucency(intensity: number): void {
$translucency.set(clamp(intensity))
}
if (typeof window !== 'undefined') {
$translucency.subscribe(intensity => {
persistString(KEY, String(intensity))
window.hermesDesktop?.setTranslucency?.({ intensity })
})
}
+11 -1
View File
@@ -71,7 +71,17 @@ describe('openSessionInNewWindow', () => {
await openSessionInNewWindow('s1')
expect(open).toHaveBeenCalledWith('s1')
expect(open).toHaveBeenCalledWith('s1', undefined)
expect(notifyError).not.toHaveBeenCalled()
})
it('forwards the watch flag for spectator (subagent) windows', async () => {
const open = vi.fn().mockResolvedValue({ ok: true })
installBridge(open)
await openSessionInNewWindow('s1', { watch: true })
expect(open).toHaveBeenCalledWith('s1', { watch: true })
expect(notifyError).not.toHaveBeenCalled()
})
+27 -2
View File
@@ -27,6 +27,30 @@ export function isSecondaryWindow(): boolean {
return result
}
let watchWindowCache: boolean | null = null
// A "watch" window spectates a session that is being driven elsewhere (a
// running subagent). It resumes lazily — the gateway registers history + a
// transport for the live mirror without building an agent, so opening it is
// cheap even while the backend is busy running the delegation.
export function isWatchWindow(): boolean {
if (watchWindowCache !== null) {
return watchWindowCache
}
let result = false
try {
result = new URLSearchParams(window.location.search).get('watch') === '1'
} catch {
result = false
}
watchWindowCache = result
return result
}
// True when running inside the Electron desktop shell (the preload bridge is
// present). The "open in new window" affordance is desktop-only.
export function canOpenSessionWindow(): boolean {
@@ -35,13 +59,14 @@ export function canOpenSessionWindow(): boolean {
// Open (or focus) a standalone OS window for a single chat session. No-ops
// gracefully outside Electron so callers can wire it unconditionally.
export async function openSessionInNewWindow(sessionId: string): Promise<void> {
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: boolean }): Promise<void> {
if (!sessionId || !canOpenSessionWindow()) {
return
}
try {
const result = await window.hermesDesktop.openSessionWindow(sessionId)
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
+35 -46
View File
@@ -297,6 +297,8 @@
--dt-font-sans:
'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
/* Key caps always use the native UI face — never theme typography overrides. */
--dt-font-kbd: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--dt-font-mono:
'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
@@ -308,8 +310,10 @@
--radius: 0.75rem;
--radius-scalar: 0.6;
/* Space under last message vs overlay composer — driven by the measured composer height (see composer/index.tsx). */
--thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem);
/* Space under last message vs overlay composer driven by the measured composer height (see composer/index.tsx)
plus the out-of-flow status stack's measured height (see status-stack/index.tsx) when one is showing. */
--status-stack-measured-height: 0px;
--thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem);
--composer-shell-pad-block-end: 0.625rem;
--message-text-indent: 0.75rem;
@@ -890,14 +894,13 @@ canvas {
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
prompt doesn't dominate the viewport. The clamp lifts on focus only (clicking
opens the edit composer, which shows the full text) not on hover, so the
bubble doesn't jump as the pointer passes over it. --human-msg-full is the
measured content height (set in UserMessage) so it animates to the real
height instead of overshooting the cap. */
bubble doesn't jump as the pointer passes over it. No transition: the lift
happens in the same click that swaps in the edit composer, so animating it
just flashes a half-expanded bubble on the way in. */
.sticky-human-clamp {
cursor: pointer;
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
overflow: hidden;
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
}
.sticky-human-clamp[data-clamped='true'] {
@@ -1024,8 +1027,32 @@ canvas {
color: var(--ui-text-tertiary) !important;
}
[data-slot='composer-root']:focus-within [data-slot='composer-surface'] > [aria-hidden='true'] {
background: var(--ui-chat-bubble-background) !important;
/* Composer fill ONE var painted by the surface AND anything docked to it
(slash·@ popover, `?` help). State ladder sets the var; consumers just paint
`background: var(--composer-fill)`, so every state matches by construction.
The :has() rule is last on purpose: while a completion drawer is open it
beats focus/scroll and forces an OPAQUE fill (both mix endpoints solid)
translucent glass can never match across the two layers because they sample
different backdrops. */
:root {
/* Fallback for drawers outside the main composer (e.g. edit-message). */
--composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background));
}
[data-slot='composer-root'] {
--composer-fill: color-mix(in srgb, var(--dt-card) 72%, transparent);
}
[data-slot='composer-root'][data-thread-scrolled-up] {
--composer-fill: color-mix(in srgb, var(--dt-card) 48%, transparent);
}
[data-slot='composer-root']:has([data-slot='composer-surface']:focus-within) {
--composer-fill: var(--ui-chat-bubble-background);
}
[data-slot='composer-root']:has([data-slot='composer-completion-drawer']) {
--composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background));
}
/* Tool/thinking blocks now live at message-text alignment (no leading
@@ -1250,41 +1277,3 @@ canvas {
}
}
/* Keybind panel / edit overlay: small key chips
A quiet `kbd`-style chip shared by the shortcuts panel and the on-screen
editor so both read as the same control. No animation, no glow. */
.kbd-cap {
display: inline-grid;
place-items: center;
min-width: 1.5rem;
height: 1.4rem;
padding: 0 0.4rem;
border-radius: 0.375rem;
font-family: var(--dt-font-mono, ui-monospace, monospace);
font-size: 0.72rem;
font-weight: 500;
line-height: 1;
color: color-mix(in srgb, var(--dt-foreground) 82%, transparent);
background: color-mix(in srgb, var(--ui-bg-elevated) 70%, transparent);
border: 1px solid var(--ui-stroke-secondary);
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--ui-stroke-tertiary) 50%, transparent);
}
/* Unbound slot: a hollow dashed chip inviting a binding. */
.kbd-cap--ghost {
color: color-mix(in srgb, var(--dt-foreground) 42%, transparent);
background: none;
border-style: dashed;
border-color: var(--ui-stroke-tertiary);
box-shadow: none;
font-style: italic;
}
/* Waiting for a keypress: solid accent, no motion. */
.kbd-capturing {
color: var(--theme-primary);
border-color: color-mix(in srgb, var(--theme-primary) 55%, var(--ui-stroke-secondary)) !important;
border-style: solid;
background: color-mix(in srgb, var(--theme-primary) 9%, var(--ui-bg-elevated));
box-shadow: none;
}
+28 -6
View File
@@ -227,6 +227,17 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
foreground: c.foreground
})
// Raw (non-JSON) keys read by the inline pre-paint script in index.html —
// they let a brand-new window paint the themed background on its very first
// frame, before this module has even loaded.
try {
window.localStorage.setItem('hermes-boot-background', c.background)
window.localStorage.setItem('hermes-boot-color-scheme', rendered)
} catch {
// Storage may be unavailable (private mode / quota); the inline script
// falls back to prefers-color-scheme.
}
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
const link = document.createElement('link')
link.rel = 'stylesheet'
@@ -237,13 +248,23 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
}
// Pin Electron's nativeTheme to the app's mode so the NATIVE window chrome
// (macOS vibrancy material, titlebar, pre-paint background) matches the app
// theme instead of the OS appearance. An explicit light/dark pick is forced;
// 'system' stays 'system' so prefers-color-scheme keeps tracking the OS.
const syncNativeTheme = (pref: ThemeMode, rendered: 'light' | 'dark') =>
window.hermesDesktop?.setNativeTheme?.(pref === 'system' ? 'system' : rendered)
// Boot-time paint to avoid a flash before <ThemeProvider> mounts. Use the last
// active profile's appearance so a non-default profile relaunch paints its own
// skin + light/dark mode.
if (typeof window !== 'undefined') {
const profile = readBootProfileKey()
const resolved = resolveMode(modePref.resolve(profile))
applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved)
const pref = modePref.resolve(profile)
const resolved = resolveMode(pref)
const theme = deriveTheme(skinPref.resolve(profile), resolved)
applyTheme(theme, resolved)
syncNativeTheme(pref, renderedModeFor(theme.colors, resolved))
}
// ─── Context ────────────────────────────────────────────────────────────────
@@ -320,13 +341,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
// What actually gets painted (matches the `.dark` class applyTheme toggles).
const renderedMode = useMemo(
() => renderedModeFor(activeTheme.colors, resolvedMode),
[activeTheme, resolvedMode]
)
const renderedMode = useMemo(() => renderedModeFor(activeTheme.colors, resolvedMode), [activeTheme, resolvedMode])
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Keep the native window appearance pinned to the app theme (vibrancy
// material, titlebar, new-window pre-paint background).
useEffect(() => syncNativeTheme(mode, renderedMode), [mode, renderedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
// stay stable across profile switches).
const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get())
+4 -4
View File
@@ -212,7 +212,7 @@ terminal:
# cwd: "/workspace" # Path INSIDE the container (default: /)
# timeout: 180
# lifetime_seconds: 300
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# docker_image: "nikolaik/python-nodejs:python3.11-nodejs26"
# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace
# # Optional: run the container as your host user's uid:gid so files written
# # into bind-mounted dirs are owned by you, not root. Drops SETUID/SETGID
@@ -242,7 +242,7 @@ terminal:
# cwd: "/workspace" # Path INSIDE the container (default: /root)
# timeout: 180
# lifetime_seconds: 300
# singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20"
# singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs26"
# -----------------------------------------------------------------------------
# OPTION 5: Modal cloud execution
@@ -254,7 +254,7 @@ terminal:
# cwd: "/workspace" # Path INSIDE the sandbox (default: /root)
# timeout: 180
# lifetime_seconds: 300
# modal_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# modal_image: "nikolaik/python-nodejs:python3.11-nodejs26"
# -----------------------------------------------------------------------------
# OPTION 6: Daytona cloud execution
@@ -267,7 +267,7 @@ terminal:
# cwd: "~"
# timeout: 180
# lifetime_seconds: 300
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs26"
# container_disk: 10240 # Daytona max is 10GB per sandbox
#
+125 -2
View File
@@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
"busy_input_mode": "interrupt",
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the decision survives the repaint.
"persist_prompts": True,
"skin": "default",
},
@@ -7448,6 +7451,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@@ -8349,6 +8354,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(f" {line}")
return True
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
Interactive CLI: balance block + identity line + a 3-button panel
(Open top-up / Copy link / Cancel). Non-interactive contexts the TUI
slash-worker subprocess and any place without a live prompt_toolkit app
(``self._app is None``) render a text variant (balance + tappable
top-up URL), because the modal would try to read the RPC stdin and crash
the worker. The terminal never confirms or polls payment (billing phase
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
message, never a crash.
"""
from agent.account_usage import build_credits_view
view = build_credits_view()
if not view.logged_in:
print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
print(" Run `hermes portal` to log in, then /credits.")
return
print()
print(" 💳 Nous credits")
print(f" {'' * 41}")
for line in view.balance_lines:
# Drop the helper's own "📈 Nous credits" header — we print our own.
if line.lstrip().startswith("📈"):
continue
print(f" {line}")
print(f" {'' * 41}")
if view.identity_line:
print(f" {view.identity_line}")
if not view.topup_url:
return
# Non-interactive (TUI slash-worker, piped, no live app): the
# prompt_toolkit modal can't run here — it would read the worker's
# JSON-RPC stdin and crash the command. Render the text variant: the
# tappable URL IS the affordance, same as the messaging surfaces.
if not getattr(self, "_app", None):
print()
print(f" Top up: {view.topup_url}")
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
return
choices = [
("open", "Open top-up in browser", "launch the portal billing page"),
("copy", "Copy link", "copy the top-up URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Add credits?",
detail=f"Top-up page:\n{view.topup_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(view.topup_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to top up: {view.topup_url}")
print()
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
elif choice == "copy":
try:
self._write_osc52_clipboard(view.topup_url)
print(f" 📋 Copied: {view.topup_url}")
except Exception:
print(f" Top-up URL: {view.topup_url}")
else:
print(" 🟡 Cancelled. No credits added.")
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag
@@ -9361,6 +9446,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
for line in reqs["details"].split("\n"):
_cprint(f" {line}")
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
"""Print a one-line scrollback summary of a resolved modal prompt.
Modal panels (approval / clarify) live in the prompt_toolkit layout and
vanish on the next repaint, so the question and the decision leave no
trace in the terminal scrollback. When display.persist_prompts is on
(default), emit a dim single line after the prompt resolves so the
decision survives in chat history.
"""
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
return
detail = " ".join(detail.split())
if len(detail) > 120:
detail = detail[:119] + ""
outcome = " ".join(outcome.split())
if len(outcome) > 120:
outcome = outcome[:119] + ""
_cprint(f"\n{_DIM}{icon} {label}: {detail}{outcome}{_RST}")
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@@ -9400,6 +9504,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
@@ -9513,6 +9618,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_outcome_labels = {
"once": "allowed once",
"session": "allowed for session",
"always": "added to allowlist",
"deny": "denied",
}
self._persist_prompt_summary(
"", "Approval", command,
_outcome_labels.get(result, str(result)),
)
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
@@ -9629,7 +9744,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
show_full = state.get("show_full", False)
title = "⚠️ Dangerous Command"
cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
cmd_display = command
choice_labels = {
"once": "Allow once",
"session": "Allow for this session",
@@ -9653,6 +9768,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Pre-wrap the mandatory content — command + choices must always render.
cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(
"… (choose Show full command)",
inner_text_width,
)
# (choice_index, wrapped_line) so we can re-apply selected styling below
choice_wrapped: list[tuple[int, str]] = []
@@ -9702,7 +9822,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
if len(cmd_wrapped) > max_cmd_rows:
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"]
cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
"… (command truncated — use /logs or /debug for full text)",
inner_text_width,
)
# Allocate any remaining rows to description. The extra -1 in full mode
# accounts for the blank separator between choices and description.
+1
View File
@@ -138,6 +138,7 @@ _HOME_TARGET_ENV_VARS = {
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
"qqbot": "QQBOT_HOME_CHANNEL",
"whatsapp": "WHATSAPP_HOME_CHANNEL",
"whatsapp_cloud": "WHATSAPP_CLOUD_HOME_CHANNEL",
}
# Legacy env var names kept for back-compat. Each entry is the current
Generated
+3 -3
View File
@@ -22,11 +22,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"lastModified": 1780749050,
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
"type": "github"
},
"original": {
+3
View File
@@ -142,6 +142,7 @@ class GatewayAuthorizationMixin:
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
@@ -168,6 +169,7 @@ class GatewayAuthorizationMixin:
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS",
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
@@ -401,6 +403,7 @@ class GatewayAuthorizationMixin:
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
+59
View File
@@ -145,6 +145,7 @@ class Platform(Enum):
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
WHATSAPP_CLOUD = "whatsapp_cloud"
SLACK = "slack"
SIGNAL = "signal"
MATTERMOST = "mattermost"
@@ -462,6 +463,9 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
),
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
Platform.WHATSAPP_CLOUD: lambda cfg: bool(
cfg.extra.get("phone_number_id") and cfg.extra.get("access_token")
),
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
@@ -1429,6 +1433,61 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
thread_id=os.getenv("WHATSAPP_HOME_CHANNEL_THREAD_ID") or None,
)
# WhatsApp Cloud API (official Business Platform via Meta).
# Distinct from the Baileys bridge: pure HTTP graph.facebook.com calls
# outbound, public webhook inbound. Both adapters can run in parallel
# against different phone numbers.
whatsapp_cloud_phone_id = os.getenv("WHATSAPP_CLOUD_PHONE_NUMBER_ID")
whatsapp_cloud_token = os.getenv("WHATSAPP_CLOUD_ACCESS_TOKEN")
if whatsapp_cloud_phone_id and whatsapp_cloud_token:
if Platform.WHATSAPP_CLOUD not in config.platforms:
config.platforms[Platform.WHATSAPP_CLOUD] = PlatformConfig()
config.platforms[Platform.WHATSAPP_CLOUD].enabled = True
config.platforms[Platform.WHATSAPP_CLOUD].extra.update({
"phone_number_id": whatsapp_cloud_phone_id,
"access_token": whatsapp_cloud_token,
})
# Optional: app_id / app_secret (signature verification)
wa_cloud_app_id = os.getenv("WHATSAPP_CLOUD_APP_ID")
if wa_cloud_app_id:
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id
wa_cloud_app_secret = os.getenv("WHATSAPP_CLOUD_APP_SECRET")
if wa_cloud_app_secret:
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret
# Optional: WABA id (analytics, future use)
wa_cloud_waba_id = os.getenv("WHATSAPP_CLOUD_WABA_ID")
if wa_cloud_waba_id:
config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = wa_cloud_waba_id
# Webhook verify token — Meta hub.verify_token shared secret
wa_cloud_verify_token = os.getenv("WHATSAPP_CLOUD_VERIFY_TOKEN")
if wa_cloud_verify_token:
config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = wa_cloud_verify_token
# Webhook server bind config (defaults baked into the adapter)
wa_cloud_host = os.getenv("WHATSAPP_CLOUD_WEBHOOK_HOST")
if wa_cloud_host:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host
wa_cloud_port = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PORT")
if wa_cloud_port:
try:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port)
except ValueError:
pass
wa_cloud_path = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PATH")
if wa_cloud_path:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path
# Graph API version override (rarely needed)
wa_cloud_api_version = os.getenv("WHATSAPP_CLOUD_API_VERSION")
if wa_cloud_api_version:
config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version
whatsapp_cloud_home = os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL")
if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms:
config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel(
platform=Platform.WHATSAPP_CLOUD,
chat_id=whatsapp_cloud_home,
name=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_NAME", "Home"),
thread_id=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_THREAD_ID") or None,
)
# Slack
slack_token = os.getenv("SLACK_BOT_TOKEN")
if slack_token:

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