Compare commits

...
Author SHA1 Message Date
Teknium bdbc4086b6 feat(titles): support language-aware title generation
Make auxiliary title prompts match the user language by default, with an optional pinned `auxiliary.title_generation.language` config.
2026-06-13 06:52:13 -07:00
konsisumer 16fb573bae fix(gateway): clear bloated compression binding on compression-exhaustion auto-reset
After compression exhaustion the auto-reset created a fresh session but
discarded reset_session()'s return value and left the Telegram topic
binding pointing at the oversized compressed child. The next inbound
message in that topic healed the binding forward and switch_session'd the
freshly-reset lane back onto the bloated transcript, re-triggering
compression exhaustion in a loop with a new session id each time.

Capture the fresh entry and re-sync the topic binding to it so the next
message starts clean. No-op on non-topic lanes.

Regression of the #9893/#10063 auto-reset fix.

Fixes #35809
2026-06-13 06:38:29 -07:00
Teknium 6f43ff5572 chore(release): map Gemini schema contributor 2026-06-13 06:12:52 -07:00
Henrik Bentel eed61a1251 fix(gemini): add role field to systemInstruction 2026-06-13 06:12:52 -07:00
Teknium 74c5158b10 fix(model): show bare custom endpoints in gateway picker (#45597)
Surface direct model.provider=custom endpoints in /model picker output and keep explicit bare custom switches on the current endpoint instead of requiring a named providers/custom_providers row.
2026-06-13 06:05:30 -07:00
Teknium 6724daa2c2 fix: keep CLI idle timer ticking (#45592) 2026-06-13 05:55:04 -07:00
Teknium aa53a78d67 fix(desktop): hand off Windows bootstrap recovery (#45594) 2026-06-13 05:54:32 -07:00
Teknium 0333a99925 fix: merge session-only model analytics rows (#45582) 2026-06-13 05:52:42 -07:00
Tranquil-Flow 5acd185f7c fix(moonshot): handle union type arrays in tool schemas 2026-06-13 05:51:41 -07:00
Teknium 39a35b784f chore(release): map custom provider resume contributors 2026-06-13 05:51:05 -07:00
Adalsteinn HelgasonandClaude Opus 4.8 2667601c05 fix(tui): keep reasoning-only assistant turns visible on session resume
A thinking-only assistant turn (reasoning present, empty visible text) is
persisted with its reasoning fields and stays recallable from the transcript,
but `_history_to_messages` dropped it as "empty" before its reasoning was
attached. On desktop/TUI resume or reload the turn therefore vanished from the
session view while the agent could still recall it from a fresh session --
exactly the "messages disappear when the LLM uses its thinking block, but a new
session can recall them" symptom reported on #44022.

Keep an assistant turn when it carries reasoning, even with empty text, so the
desktop "Thinking…" disclosure has something to render. Genuinely empty turns
(no text, no reasoning, no tool calls) are still filtered out.

Refs #44022

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Adalsteinn HelgasonandClaude Fable 5 643dc82793 Fix custom provider identity loss in session persistence
_runtime_model_config persisted the live agent's RESOLVED provider into
the session row's model_config JSON. For any named providers:/
custom_providers: entry, agent.provider is the literal string "custom",
so the entry name was lost (and the api_key is deliberately never
persisted). On session.resume or _reset_session_agent the stored
provider="custom" fed resolve_runtime_provider(requested="custom"),
which cannot match a named entry — the rebuild either raised "No LLM
provider configured" or silently resolved placeholder credentials
against the patched-back base_url.

Persist the REQUESTED/entry identity instead: a new reverse lookup
find_custom_provider_identity(base_url) maps the endpoint URL back to
the canonical custom:<name> menu key. _runtime_model_config stores that
key; _make_agent performs the same recovery for rows persisted before
the fix, falling back to passing the stored base_url as
explicit_base_url so the direct-alias branch still targets the
session's endpoint when no entry matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 05:51:05 -07:00
Haozhe Zhang e256f4aae4 fix(gateway): don't restore a bare billing provider as the resumed session's provider
`_stored_session_runtime_overrides` restored the session provider from
`billing_provider` when `model_config` had no explicit provider. For a
`custom:<name>` endpoint that only ran normal turns (no `/model` switch), the
persisted `billing_provider` is the bare billing bucket `"custom"`, which
`agent_init` treats as non-routable, so `session.resume` failed with
"No LLM provider configured" even though new chats and CLI `--resume` work.

Only restore an explicit `model_config.provider`; skip a bare billing bucket
(`auto`/`openrouter`/`custom`) so resume falls back to the configured default,
matching the CLI path.

Fixes #44022
2026-06-13 05:51:05 -07:00
Teknium cb125c2b3f fix(kanban): pin assigned profile toolsets for workers (#45590) 2026-06-13 05:50:09 -07:00
Teknium a59d5e37e8 feat(telegram): make rich messages always on (#45584)
Remove the rich_messages config toggle entirely so Telegram replies always try the Bot API 10.1 rich-message path first, with the existing MarkdownV2 fallback/latch behavior for unsupported endpoints and per-message failures.

Restore the Telegram platform hint to encourage rich Markdown tables/task lists/math now that the rich path is the default, and remove the config/docs surface for the old toggle.
2026-06-13 05:45:11 -07:00
Teknium 4b646bc21e fix(auxiliary): preserve main provider base url (#45587) 2026-06-13 05:44:18 -07:00
Teknium 62b4618e9a fix(dashboard): scope sessions and analytics to selected profile (#45598) 2026-06-13 05:42:38 -07:00
H-Ali13381 2abcae9678 fix(cli): preserve renderer state on resize 2026-06-13 05:40:18 -07:00
xxxigm c814d3d1dd test(installer): regression for unmerged-index update failure
Functional bash test drives install.sh's autostash block against a throwaway
repo with a real conflicted index and asserts the stash now succeeds and the
unmerged entries are cleared (previously `git stash` failed with "could not
write index"). Source-order assertions cover both scripts to ensure the
`git reset` clear runs before `git stash push` (a no-op otherwise).
2026-06-13 05:19:44 -07:00
xxxigm 573b964dc7 fix(installer): clear an unmerged git index before stashing on update
When an existing install at $INSTALL_DIR has an unmerged index (files in a
"needs merge" state left by a previously interrupted update), the update path
ran `git stash` then `git checkout <branch>`. On a conflicted index `git stash`
aborts with "could not write index" and `git checkout` then aborts with "you
need to resolve your current index first" — surfacing to desktop/bootstrap
users as `git checkout main failed (exit 1)` and failing the whole install at
the repository stage.

Mirror the `hermes update` Python path (#4735): detect unmerged entries with
`git ls-files --unmerged` and clear the conflict state with `git reset` before
stashing. Working-tree changes are still captured by the subsequent stash, so
nothing is discarded; only the index-level conflict markers are dropped, which
lets the checkout proceed.

Fixed in both installers (install.sh and install.ps1) so the Windows GUI
installer and the POSIX one share the same recovery behavior.
2026-06-13 05:19:44 -07:00
Teknium aa0798352a fix(auth): self-heal missing Codex access tokens
Recover Codex singleton auth entries that have a refresh token but no access token by adopting a valid Codex CLI token pair, matching the cron-time failure mode before falling back to the credential pool.
2026-06-13 05:15:26 -07:00
Kennedy UmegeandClaude Opus 4.8 311ff967de review: validate refresh_token, path-agnostic recovery log, map author email
Addresses PR review feedback:
- Validate refresh_token (not only access_token) before persisting the
  re-imported Codex token, so a half-token payload can't silently break the
  next refresh cycle.
- Make the recovery log path-agnostic ("Codex CLI auth.json") since
  _import_codex_cli_tokens can read $CODEX_HOME, not only ~/.codex.
- Add regression test: relogin-required + imported token missing refresh_token
  -> re-raise and persist nothing.
- Map kenmege@yahoo.com -> Kenmege in scripts/release.py AUTHOR_MAP
  (fixes the check-attribution job).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Kennedy UmegeandClaude Opus 4.8 bd66e7e3fb fix(auth): self-heal Codex refresh_token rotation by reimporting from ~/.codex
Hermes keeps its own copy of the Codex OAuth token per profile and at the
top level, separate from the Codex CLI's ~/.codex/auth.json. OAuth
refresh_tokens are single-use, so when the Codex CLI (or another Hermes
process) rotates the shared token, the frozen copy's refresh_token goes
stale and refresh_codex_oauth_pure fails with a relogin-required error
(invalid_grant / refresh_token_reused / 401). Today that surfaces as a hard
401 on the turn — idle profiles and desktop sessions 401 "token_expired"
until a manual re-auth — even though ~/.codex/auth.json holds a fresh token.

_refresh_codex_auth_tokens now falls back to _import_codex_cli_tokens() (the
canonical Codex CLI store) when the stored refresh_token is rejected, adopts
and persists the fresh token, and lets the in-flight retry succeed. This
complements PR #6525 (force relogin on 401/403): we attempt automatic
recovery before surfacing a relogin prompt. Transient failures (e.g. 429
quota, relogin_required=False) are never self-healed — the stored token is
still valid there — so they re-raise unchanged, and the happy path is
untouched.

Adds tests/hermes_cli/test_auth_codex_self_heal.py covering: self-heal on
invalid_grant, no self-heal on 429 quota, re-raise when ~/.codex is absent,
and happy-path-unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 05:15:26 -07:00
Teknium 2681c5a12d fix(photon): correct gateway start command (#45566) 2026-06-13 05:14:59 -07:00
xxxigm fa2aba90b4 docs(docker): explain per-profile gateway ports for multi-profile setups
The Multi-profile section never explained how to reach more than one
profile from outside the container, and distinguishes the two surfaces
that people conflate:

- Hermes Desktop's Remote Gateway connects to a `hermes dashboard`
  backend (port 9119), and a single dashboard serves every co-located
  profile via its profile switcher (the target profile is sent per
  request; the backend opens that profile's HERMES_HOME). No per-profile
  port or second connection is needed for Desktop.
- OpenAI-compatible API clients (Open WebUI, LobeChat, /v1) talk to each
  profile's API server, which binds 8642 for every profile with no
  auto-allocation. Reaching a second profile from such a client needs a
  distinct `API_SERVER_PORT` in that profile's own `.env` (and the port
  must NOT go in the container-wide `environment:` block, or every
  profile collides on it).

Adds the create -> set port -> restart flow, the bridge port-publishing
note, and clarifies the default profile's connection is untouched.
2026-06-13 05:13:25 -07:00
xxxigm 5b857201b7 fix(profiles): correct misleading per-profile gateway port docstrings
The s6 profile-gateway docstrings claimed the bind port comes from a
`[gateway] port` key in config.yaml ("the single source of truth"). No such
key exists or is read anywhere — the API server port is resolved by
gateway/config.py from `API_SERVER_PORT` (or `platforms.api_server.extra.port`)
and defaults to 8642. The wrong reference actively misled a Docker user into
setting a non-functional `gateway.port`.

Point both docstrings (`S6ServiceManager._render_run_script`,
`_maybe_register_gateway_service`) at the real knob, and note the practical
consequence: since each supervised profile gateway loads its own HERMES_HOME,
two profiles left at the default both try to bind 8642 — each needs a distinct
`API_SERVER_PORT` in its own `.env`.
2026-06-13 05:13:25 -07:00
Teknium 905ed413d1 fix(doctor): avoid unsafe npm audit fallback
Root-level npm audit fix can crash with isDescendantOf on the same monorepo tree, so workspace audit advisories should explain the lockfile-bump path instead of recommending another manual npm fix command.
2026-06-13 05:09:56 -07:00
xxxigm bea6c1c01f test(doctor): assert audit-fix hint avoids crashing form and explains build-tool advisories 2026-06-13 05:09:56 -07:00
xxxigm a5e9b17ce3 fix(doctor): stop recommending the npm-crashing audit fix, explain build-tool advisories
`hermes doctor` flagged the web/ui-tui workspaces and told the user to run
`npm audit fix --workspace <name>`, which crashes current npm with
"Cannot read properties of null (reading 'edgesOut')" (an arborist bug with
workspace-filtered audit fix). Recommend the root-level `npm audit fix`
instead.

Even the root form can hit a known npm arborist crash (edgesOut /
isDescendantOf) on this monorepo tree, so add a note that these workspace
advisories are build-time tooling (esbuild/vite, etc.) — not runtime code —
and clear via a lockfile bump rather than a manual fix. This keeps doctor
from handing users a command that errors out and from implying a broken
Hermes install.
2026-06-13 05:09:56 -07:00
xxxigm 5d6c16e972 test(desktop): cover the inline command expander on the approval bar
Asserts the full command is absent until the Command toggle is clicked, then
rendered in full — guarding the long-command reveal path.
2026-06-13 05:08:37 -07:00
xxxigm 266b5a19f1 feat(desktop): expand the full command inline from the approval bar
The native desktop approval bar deliberately omits the command because the
pending tool row "already shows it" — but that row only renders a single
truncated line, and a pending row can't be expanded (it has no result yet). So
the full command was only reachable by opening the "Always allow" dropdown,
reading the modal, cancelling, then clicking Run — 4-5 clicks just to see what
you're approving.

Add a "Command" toggle to the approval bar that reveals the full
`request.command` inline (reusing the dialog's pre styling), default collapsed.
Approving a long command is now "expand, Run". Gated on a non-empty command so
zero-command approvals are unaffected.
2026-06-13 05:08:37 -07:00
Black-KylinandHermes 202e318cb1 fix(gateway): sync compression session splits before failures
Salvages PR #25747 by preserving gateway session rotation even when a post-compression model call fails before returning final content.

Co-authored-by: Hermes <127238744+teknium1@users.noreply.github.com>
2026-06-13 04:51:59 -07:00
helix4u 2d474e39c7 fix(acp): preserve memory provider tools 2026-06-13 04:51:44 -07:00
Teknium 2a5dc0ef3d fix(slack): make video attachments available to agents (#45512) 2026-06-13 03:33:27 -07:00
Teknium 197337cc47 fix(gateway): suppress duplicate final stream sends (#45517) 2026-06-13 03:23:44 -07:00
Teknium 8cf9d8689d fix(desktop): keep composer usable during reconnect (#45488)
* feat(cli): add --safe-mode troubleshooting flag

Inspired by Claude Code v2.1.169 (June 2026): run Hermes with all
customizations disabled to isolate setup problems from product bugs.

--safe-mode implies --ignore-user-config and --ignore-rules, and
additionally skips plugin discovery (hermes_cli/plugins.py) and MCP
server loading (tools/mcp_tool.py) via the internal HERMES_SAFE_MODE
env bridge.

* fix(desktop): keep composer usable during reconnect
2026-06-13 02:36:09 -07:00
brooklyn! b62e57b2f4 Merge pull request #45445 from NousResearch/bb/desktop-stick-to-bottom
fix(desktop): stabilize thread scrolling and session switching
2026-06-13 04:14:49 -05:00
Teknium bc060c7c1c fix(models): remove unavailable claude-fable-5 (#45492) 2026-06-13 02:03:50 -07:00
Teknium 3803e5fc28 fix(agent): don't treat custom:<name> pools as cross-provider mismatch (#45289)
Custom endpoints carry two naming conventions for the same provider: the
agent's provider attribute is the generic 'custom' label while the pool
is keyed 'custom:<normalized-name>'. The defensive guard in
recover_with_credential_pool compared them literally, logged
'Credential pool provider mismatch: pool=custom:<name>, agent=custom',
and skipped recovery — so 401 refresh and 429 rotation never ran for
ANY custom-provider user (seen in the field on a Fireworks setup whose
dead key burned full retry cycles every turn with the skip warning on
each one).

Accept the pair only when the agent's CURRENT base_url resolves to the
same pool key via get_custom_provider_pool_key, preserving the guard's
original purpose (#33088/#33163): a fallback provider or a different
custom endpoint still skips pool mutation.
2026-06-13 02:01:09 -07:00
brooklyn! bdd3868b57 fix(desktop): keep profile color picker open from the context menu (#45489)
Right-click → Color flashed open then closed: on dismiss the context menu
refocuses its trigger, which doubles as the popover anchor, so the picker
read it as a focus-outside event and closed itself. Suppress the menu's
close auto-focus so the picker survives. Long-press already worked since
it bypasses the menu lifecycle.
2026-06-13 04:00:09 -05:00
xxxigm b6c7ebf028 fix(tui): honor provider_routing config in the desktop/TUI backend (#44953)
* fix(tui): honor provider_routing config in the desktop/TUI backend

The messaging gateway and classic CLI both read `provider_routing` from
config.yaml and pass the OpenRouter routing prefs (only / ignore / order /
sort / require_parameters / data_collection) into the agent. The tui_gateway
backend that powers the desktop app and TUI never did, so it built agents
with every routing pref left at its default — OpenRouter then selected
providers freely (effectively at random), ignoring the user's config.

Load `provider_routing` in `_make_agent` and forward the same six prefs the
gateway does, restoring parity across CLI / gateway / desktop. Background
subagent kwargs already propagate these from the parent agent, so they now
inherit correctly too.

* test(tui): cover provider_routing forwarding in _make_agent

Asserts the six OpenRouter routing prefs flow from config.yaml into AIAgent,
and that an absent provider_routing section forwards None/False (unchanged
behavior for users who never configured routing).
2026-06-13 02:58:15 -05:00
Brooklyn Nicholson b2bc48cd5e Merge branch 'main' into bb/desktop-stick-to-bottom
# Conflicts:
#	apps/desktop/src/components/assistant-ui/thread.tsx
2026-06-13 02:52:03 -05:00
brooklyn! 9cd3d8a6ac Merge pull request #45466 from NousResearch/bb/fix-image-generation-placement
fix(desktop): keep generated images in the tool slot, not inline
2026-06-13 02:50:59 -05:00
Brooklyn Nicholson b82d2e549f fix(desktop): keep the diffusion placeholder circular at any aspect
Normalise the radial bloom by the shorter side so portrait/square
placeholders aren't squished into an ellipse.
2026-06-13 02:45:34 -05:00
Brooklyn Nicholson b15dc58064 fix(desktop): keep generated images in the tool slot, not inline
The image-generate tool showed a placeholder, then the model echoed a
(often different) image inline in its prose — a second, jarring copy in
the wrong place, dimmed as tool scaffolding, with a misplaced download
button.

Now the generated image lives only in the tool slot:
- Strip every embedded image/media link from the assistant prose of a
  message that produced an image (the model frequently restates the
  remote URL while the result holds the local path), preserving the
  agent's words. Applied on hydration, live deltas, and completion.
- One stable frame sized from the aspect_ratio arg up front, so the
  diffusion placeholder and the decoded image share the same box and
  crossfade with no layout shift; the box derives its height from the
  true ratio on load (no letterboxing).
- Exempt generated images from the tool-block dim-until-hover rule.
- Extract a shared useImageDownload hook + ImageLightbox so the tool
  image and markdown images share one implementation.
2026-06-13 02:42:15 -05:00
Brooklyn Nicholson acd4278c8a fix(nix): use fetchNpmDeps hash from flake check
prefetch-npm-deps returned a different digest than the actual
fetchNpmDeps build; use the CI-reported hash.
2026-06-13 02:34:25 -05:00
Brooklyn Nicholson be6713c536 fix(nix): refresh npm deps hash 2026-06-13 02:16:13 -05:00
Brooklyn Nicholson 77687156b4 fix(desktop): tighten multiline user prompt spacing 2026-06-13 02:16:13 -05:00
Brooklyn Nicholson 45ceee8a32 Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/desktop-stick-to-bottom 2026-06-13 02:08:10 -05:00
brooklyn! 0a7a81835b Merge pull request #45255 from NousResearch/bb/desktop-stuck-tool-rows
fix(desktop): dismiss settled tool rows (persistent, caret-safe)
2026-06-13 02:08:01 -05:00
Brooklyn Nicholson 76b93869d8 fix(desktop): rebuild thread autoscroll on use-stick-to-bottom 2026-06-13 01:57:30 -05:00
brooklyn! a856276124 Merge pull request #45414 from NousResearch/bb/fix-desktop-queue-drain-strand
fix(desktop): stop stranding queued prompts across backend bounces
2026-06-13 00:39:13 -05:00
Gille 1e755ff556 fix(desktop): keep recents sorted unless manually reordered (#45404) 2026-06-13 00:38:10 -05:00
Brooklyn Nicholson 7f302c91b2 chore: uptick 2026-06-13 00:33:44 -05:00
Brooklyn Nicholson 18916376f1 fix(desktop): never surface "session busy" — retry every submit past it
"Session busy" (4009) is the gateway's concurrency guard, not a user-facing
error. The queue already covers the deliberate "type while busy" case, so
the only leak was a submit racing the settle edge. Generalize the rewind
path's busy-retry into a shared `withSessionBusyRetry` and wrap every
`prompt.submit` (fresh send, session-resume resubmit, and rewind) so a
transient busy is ridden out within a bounded deadline and the call lands
silently. The fromQueue swallow stays as a backstop for the pathological
>deadline case.
2026-06-13 00:26:34 -05:00
Brooklyn Nicholson f23a4b7bb3 fix(desktop): keep queued drains quiet on transient "session busy"
A queued drain firing on the settle edge can race a not-yet-wound-down
turn and get a transient 4009 "session busy". Previously that appended a
red "session busy" error bubble (and toast) per attempt. For fromQueue
submits, swallow the busy error: release busy, keep the entry queued, and
let the composer's bounded auto-drain retry on the next idle.
2026-06-13 00:23:51 -05:00
Brooklyn Nicholson bf090deed3 fix(desktop): stop stranding queued prompts across backend bounces
A prompt typed mid-turn ("ghost bubble") could stick forever and never
send when the backend restarted/reconnected during the turn. Two fragile
assumptions in the composer queue drain caused it:

1. Drain fired ONLY on an observed busy true→false edge. A remount/
   reconnect resets `previousBusyRef` to the current busy value, so the
   settle edge is swallowed and the queue never drains. Replace
   `shouldAutoDrainOnSettle` with the edge-independent `shouldAutoDrain`
   (idle + non-empty), driven on the settle edge, on mount/reconnect, and
   after a re-key. The drain lock still serializes sends.

2. The queue is keyed by `queueSessionKey || sessionId`. When a backend
   resume mints a new runtime session id for the same conversation, the
   entry strands under the dead key. Pass the *stable* stored id as
   `queueSessionKey` so the composer can tell runtime churn from a real
   session switch, and `migrateQueuedPrompts` re-keys pending entries on a
   runtime-id change only (never on a deliberate switch).

Also make the drain resilient to a thrown/rejected onSubmit (e.g. a stale-
session 404): the entry stays queued and is retried on the next idle, with
a per-entry attempt cap (MAX_AUTO_DRAIN_ATTEMPTS) to avoid spin-loops and a
quiet toast once it gives up. A manual send clears the backoff.

Tests: composer-queue covers edge-free drain + re-key migration;
use-prompt-actions covers rejected-drain-keeps-entry + idle retry sends.
2026-06-13 00:20:51 -05:00
brooklyn! 7d183f6497 fix(desktop): theme the image-gen placeholder instead of a white square (#45354)
The diffusion placeholder read `--dt-*` tokens via
`getComputedStyle().getPropertyValue()`, but those resolve through `var()`
chains into `color-mix(in srgb, …)` — returned verbatim and unparseable, so
every token fell to a hardcoded light fallback (white card). In dark mode the
placeholder rendered as a white square.

Resolve each token through a throwaway probe element's `color` so the browser
computes it to a concrete color, and teach `parseColor` Chromium's
`color(srgb r g b / a)` serialization. Re-resolve on theme repaint via a
MutationObserver rather than per animation frame.
2026-06-12 21:45:24 -05:00
brooklyn! 492c402774 perf(desktop): cut GUI streaming & interaction lag (#45343)
* perf(desktop): isolate streaming re-renders & cut layout thrash

During a token stream $messages is replaced ~30x/s. Subscribing the whole
chat view to it re-rendered the composer, runtime boundary, and every
message on every delta.

- Derive coarse facts (empty thread? tail is user?) via nanostores
  `computed` atoms so per-token flushes don't re-render their consumers.
- Move the $messages subscription + runtime wiring into a dedicated
  ChatRuntimeBoundary; the composer reads $messages imperatively.
- Drive message rows off stable useAuiState selectors and a lazy
  getMessageText getter instead of eagerly materialized text.
- Feed ResizeObserver entry sizes into measureClamp / FadeText and dedupe
  the style writes, killing the read-write-read reflow cascade.

* perf(desktop): incremental markdown rendering during streams

Re-parsing the full message markdown every reveal frame is O(N^2) over a
long answer and dominated stream CPU.

- Throttle useSmoothReveal commits to ~1 frame (REVEAL_MIN_COMMIT_MS).
- Memoize block parsing with an LRU keyed on source text so only changed
  blocks re-parse.
- Replace Streamdown's full-text parseIncompleteMarkdown with a
  tail-bounded remend: scan to the last top-level boundary outside
  fences/math and repair only the trailing open block. New remend-tail.ts
  is proven render-equivalent to full remend at every streaming prefix
  (remend-tail.test.ts), minus an intentional, documented divergence on
  cross-block dangling openers.

* perf(desktop): faster session resume & warm AudioContext at idle

- Resume: fire the REST transcript prefetch and the session.resume RPC in
  parallel, and skip the redundant message conversion + reconciliation
  when the prefetch already hydrated the transcript.
- Haptics: web-haptics builds its AudioContext lazily on first trigger,
  paying the ~850ms CoreAudio spin-up on the first streamStart haptic as
  the first token paints. Open/close a throwaway context at idle so the
  real one connects to an already-warm audio service.
2026-06-12 21:22:39 -05:00
Brooklyn Nicholson d62e9b7592 build(nix): refresh npmDepsHash for the remend dependency
Adding remend changed package-lock.json, so the flake's pinned npm deps
hash went stale and `nix flake check` failed. Bump it to match.
2026-06-12 21:17:22 -05:00
Brooklyn Nicholson 3cf7d43262 perf(desktop): faster session resume & warm AudioContext at idle
- Resume: fire the REST transcript prefetch and the session.resume RPC in
  parallel, and skip the redundant message conversion + reconciliation
  when the prefetch already hydrated the transcript.
- Haptics: web-haptics builds its AudioContext lazily on first trigger,
  paying the ~850ms CoreAudio spin-up on the first streamStart haptic as
  the first token paints. Open/close a throwaway context at idle so the
  real one connects to an already-warm audio service.
2026-06-12 21:07:40 -05:00
Brooklyn Nicholson edc36f3a45 perf(desktop): incremental markdown rendering during streams
Re-parsing the full message markdown every reveal frame is O(N^2) over a
long answer and dominated stream CPU.

- Throttle useSmoothReveal commits to ~1 frame (REVEAL_MIN_COMMIT_MS).
- Memoize block parsing with an LRU keyed on source text so only changed
  blocks re-parse.
- Replace Streamdown's full-text parseIncompleteMarkdown with a
  tail-bounded remend: scan to the last top-level boundary outside
  fences/math and repair only the trailing open block. New remend-tail.ts
  is proven render-equivalent to full remend at every streaming prefix
  (remend-tail.test.ts), minus an intentional, documented divergence on
  cross-block dangling openers.
2026-06-12 21:07:36 -05:00
Brooklyn Nicholson 7c226cc57f perf(desktop): isolate streaming re-renders & cut layout thrash
During a token stream $messages is replaced ~30x/s. Subscribing the whole
chat view to it re-rendered the composer, runtime boundary, and every
message on every delta.

- Derive coarse facts (empty thread? tail is user?) via nanostores
  `computed` atoms so per-token flushes don't re-render their consumers.
- Move the $messages subscription + runtime wiring into a dedicated
  ChatRuntimeBoundary; the composer reads $messages imperatively.
- Drive message rows off stable useAuiState selectors and a lazy
  getMessageText getter instead of eagerly materialized text.
- Feed ResizeObserver entry sizes into measureClamp / FadeText and dedupe
  the style writes, killing the read-write-read reflow cascade.
2026-06-12 21:07:33 -05:00
brooklyn! a86b7b314b Merge pull request #45273 from NousResearch/bb/sidebar-workspace-dedup
feat(desktop): worktree-aware sidebar grouping + composer/sidebar UX fixes
2026-06-12 20:03:43 -05:00
Brooklyn Nicholson d14f6c9563 fix(desktop): stop streaming autoscroll bounce; move attachments below user bubble
Streaming auto-follow chased content growth while parked at the bottom,
which rubber-banded — the tail pin and the virtualizer's own measurement
adjustments fought for scrollTop. Drop it; the one-time new-turn jump
already lands a fresh message in view and the viewport stays put after.

Attachments rendered inside the editable user bubble and were collapsed
via an IntersectionObserver + [data-stuck] CSS hack while the bubble was
pinned. Render them as a flow sibling BELOW the sticky bubble instead, so
they scroll away behind it naturally — no observer, no collapse. Image
refs still render as thumbnails, file refs as chips; no border. Removes
the now-unused useStuckToTop hook and its CSS.
2026-06-12 19:58:25 -05:00
Brooklyn Nicholson a1c6349c1f Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/sidebar-workspace-dedup 2026-06-12 19:40:24 -05:00
Brooklyn Nicholson 78ce91750e fix(desktop): crisp terminal text via opaque xterm canvas
The terminal looked soft/heavy on every platform because the xterm
Terminal was built with allowTransparency: true, which drops the WebGL
renderer's opaque fast-path and bakes glyphs as grayscale-alpha coverage
for compositing over a see-through canvas. Our surface (--ui-bg-chrome)
is opaque and withSurface already paints it, so transparency was pure
blur for no benefit — VS Code keeps it off too. Also drop the Medium
(500) base weight for normal/bold (400/700) to match VS Code's metrics,
and remove the now-unused JetBrains Mono Medium face + woff2.
2026-06-12 19:36:30 -05:00
Brooklyn Nicholson 1a3cd3d436 refactor(desktop): collapse sidebar drag-reorder into one generic ReorderableList
Every reorderable surface (repos, worktrees, sessions, pins) now drops in a
single ReorderableList that owns its own DndContext, so a drag only ever
collides with that list's own items — nesting "just works" without leaking
into the lists around or inside it. This replaces the shared DndContext +
id-prefix dispatch (parent:/group:) whose closestCenter collisions resolved
to a different-typed droppable and silently no-op'd worktree/repo drags.

- Delete groupDndId/parentDndId/parse* helpers and the monolithic
  handleAgentDragEnd/handlePinnedDragEnd; each list persists its new id order
  via a direct typed write (reorderParents/reorderWorktree/reorderSessions/
  reorderPinned).
- Sessions inside repos/worktrees are date-ordered and static (no drag),
  matching the "never reorder on new messages" rule.
- Add setPinnedSessionOrder; drop now-unused reorderPinnedSession.
2026-06-12 18:59:54 -05:00
Teknium 9688c1a94f chore: add Kimi K2.7 code catalog slug (#45283) 2026-06-12 16:55:40 -07:00
Teknium 7e46533d9f test: compressed-summary metadata flag set in-process, stripped on wire 2026-06-12 16:47:15 -07:00
kyssta-exe 956af7f3c3 fix(agent): add metadata flag to context compression summary messages (#38389)
Summary messages (standalone insertion and merge-into-tail) now carry a
metadata flag so frontends (CLI, Desktop, gateway, TUI) can distinguish
them from real assistant/user messages without content-prefix heuristics.

Re-applied from PR #38434 onto current main (conflicted with the
_SUMMARY_END_MARKER hoist). Key renamed from the PR's
'is_compressed_summary' to '_compressed_summary': the wire sanitizers
strip underscore-prefixed message keys, so the flag stays in-process and
can never reach strict gateways (Fireworks/Mistral/Kimi reject unknown
keys with 'Extra inputs are not permitted').
2026-06-12 16:47:15 -07:00
helix4u 1899c8f507 fix(skills): run youtube transcript helper through uv 2026-06-12 16:33:46 -07:00
Brooklyn Nicholson dd12a5403d refactor(desktop): extract shared WorkspaceHeader for repo + worktree rows
The repo and worktree header rows were ~identical after the handle move.
Fold them into one WorkspaceHeader (emphasis flag for the repo level) plus
a small WorkspaceAddButton, so the toggle/handle/count/+ wiring lives in
one place.
2026-06-12 18:30:49 -05:00
Teknium 8905ee6b8a fix(agent): rewind flush cursor exactly when repair compacts before the cursor
Follow-up to the #44837 clamp: a min() clamp only fixes cursor overshoot
past the new end of the list. When repair_message_sequence drops/merges
messages at indexes below the cursor, the clamp leaves the cursor pointing
past unflushed rows and the turn-end flush silently skips them.

Extract repair_message_sequence_with_cursor(): snapshot the flushed prefix
by object identity before repair, then recompute the cursor as the count
of surviving flushed messages. Falls back to the clamp when no snapshot is
available. Keeps the safety guard in _flush_messages_to_session_db.

Adds targeted tests for overshoot, before-cursor compaction, no-repair,
bare-agent, and the flush guard.
2026-06-12 16:29:01 -07:00
kyssta-exe 5d0408d9fe fix(agent): clamp flush cursor after repair_message_sequence compaction (#44837) 2026-06-12 16:29:01 -07:00
konsisumer aec38855b5 fix(agent): preserve recent turns during compression 2026-06-12 16:26:58 -07:00
Brooklyn Nicholson 0595af0ad1 feat(desktop): move workspace/worktree drag handle into the leading icon
Mirror the session row: the repo/worktree header's leading glyph (repo
mark, or a new git-branch mark for worktrees) swaps to a grabber on
hover/drag instead of carrying a separate handle on the right — freeing
header width for the label and + button.
2026-06-12 18:26:38 -05:00
Brooklyn Nicholson e90672696e feat(desktop): worktree-aware sidebar grouping + composer/sidebar UX fixes
Group recents as parent-repo → worktree → sessions using local git
metadata (probed over IPC, with a path-name heuristic fallback for
remote backends). Single-worktree repos collapse to one level. Sessions
order by creation time and never reshuffle on new messages.

Also: fuse the status stack to the composer border, restore icon actions
in the queue panel, fix sidebar label truncation and drag styling, hide
sticky-message attachments while pinned, and bump the terminal font.
2026-06-12 18:18:39 -05:00
brooklyn! bbf020e709 feat(desktop): follow streaming output at bottom + jump-to-bottom button (#45263)
Strict sticky-bottom autoscroll for the chat thread: while the viewport is
parked at the bottom, the tail follows content growth (streaming tokens, late
measurement, Shiki re-highlight) via a useLayoutEffect keyed on the
virtualizer's own size signal, pinned in the same pre-paint pass as its
scrollToFn so the two never rubber-band. The gate is a single boolean — one
upward pixel (scroll/wheel/touch) disarms follow until the user returns to the
bottom.

Adds a floating jump-to-bottom control that appears once scrolled ~10px away
(above the dim threshold so a sub-pixel settle never flashes it), positioned
above the composer with respect to the status stack, with a subtle
scale + slide in/out animation that honours prefers-reduced-motion. The button
bridges to the virtualizer's re-arm + pin path through a small nanostore
emitter.

Supersedes #43624.
2026-06-12 23:00:11 +00:00
Teknium 135fe90166 fix(profiles): backfill .env for pre-existing profiles on hermes update (#45247)
Profiles created before #44792 have no .env. Now that the Channels/Keys
endpoints are profile-scoped (no os.environ fallback), those profiles
would show everything as unconfigured. hermes update now copies the
default install's .env into each named profile that lacks one (0600,
never overwrites, placeholder fallback when the root has no .env), so
existing users keep the credentials they were effectively running with.
2026-06-12 15:42:14 -07:00
xxxigm 68536d4375 test(compressor): regression coverage for assistant-tail anchor + compaction rollup (#29824)
21 cases pinning the new ``_ensure_last_assistant_message_in_tail``
anchor and its interaction with the existing tail-cut path:

* ``TestFindLastAssistantMessageIdx`` — helper contract: prefers a
  content-bearing assistant message, skips ``tool_calls``-only
  stubs, multimodal text-block content counts, falls back to
  "any assistant" when no content-bearing reply exists, honours
  ``head_end``, returns -1 when there's none.

* ``TestEnsureLastAssistantMessageInTail`` — direct: no-op when
  already in the tail, walks ``cut_idx`` back when the reply is
  in the compressed middle, never crosses into the head region,
  re-aligns through a preceding ``tool_call`` / ``tool_result``
  group instead of orphaning it.

* ``TestFindTailCutByTokensAnchorsAssistant`` — integration:
  reporter repro (long tool-output run after the visible reply)
  now preserves the reply; user and assistant anchors compose
  in a single tail-cut call; a soft-ceiling-overrunning oversized
  tool result no longer strands the prior reply.

* ``TestCompactionRollupReproduction`` — end-to-end through
  ``compress()`` with a stubbed ``_generate_summary``: the
  visible reply text survives either as its own standalone
  assistant message (normal path) or concatenated onto the
  merged summary tail (double-collision path the WebUI then
  re-splits). The standalone-summary case is asserted strictly
  (exactly one summary row, exactly one separate assistant
  row carrying the reply) — that's the dominant path and any
  drift there reintroduces the original bug.

* ``TestSourceGuardrail`` — static asserts on
  ``agent/context_compressor.py``: the helper exists, the
  anchor is wired into ``_find_tail_cut_by_tokens`` AFTER the
  user-message anchor (so chaining is monotonic), the
  content-bearing preference is preserved, and the issue
  number is referenced so future bisects can find this fix.
2026-06-12 15:41:57 -07:00
xxxigm 2fef3e2df2 fix(webui): split merge-into-tail compaction so reply renders as its own bubble (#29824)
The compressor has a "double-collision" fallback path: when the
chosen ``summary_role`` collides with the first tail message AND
the flipped role would collide with the last head message, it can't
emit a standalone summary turn (consecutive same-role messages
break Anthropic and friends). It instead prepends the summary +
end-of-summary marker to the first tail message's content via
``_merge_summary_into_tail``.

With the matching anchor from the previous commit, that first tail
message is now usually the user's previously-visible assistant
reply — so the persisted assistant turn ends up shaped as
``[CONTEXT COMPACTION ...] ... --- END OF CONTEXT SUMMARY --- ...
THE ACTUAL REPLY``. Without splitting it, the session viewer
renders one big "Context handoff" bubble and the reply text is
buried inside the metadata blob — which is exactly the
"can't see the last reply" experience #29824 reports, just one
layer deeper.

Added ``splitCompactionContent`` that detects the merge marker
(kept in sync with ``--- END OF CONTEXT SUMMARY — respond to the
message below, not the summary above ---`` in
``agent/context_compressor.py``) and ``MessageBubble`` now
recurses on the two halves: the prefix half renders as the muted
"Context handoff" row, the remainder half renders with the
original assistant styling. Pure (non-merged) summary messages
hit the no-remainder branch and still render as a single
"Context handoff" row, preserving the original behaviour.
2026-06-12 15:41:57 -07:00
xxxigm 691ff7c188 fix(compressor): keep last visible assistant reply out of compaction summary + label handoffs in WebUI (#29824)
Two-pronged fix for the WebUI "context compaction block in place of
last assistant response" regression.

Agent layer (the real fix). ``_find_tail_cut_by_tokens`` already had
``_ensure_last_user_message_in_tail`` to keep the most recent user
request out of the compressed middle (#10896), but no symmetric
anchor for the assistant side. When the conversation has an
oversized recent tool result or a long stretch of tool-call/result
pairs *after* the assistant's last visible reply, the token-budget
walk can stop with the previously-visible reply on the wrong side
of ``cut_idx``. The summariser then rolls it into the single
``[CONTEXT COMPACTION — REFERENCE ONLY]`` block persisted as
``role="user"`` or ``role="assistant"``, and from the operator's
perspective the WebUI session viewer
(``web/src/pages/SessionsPage.tsx``) and the TUI chat panel both
suddenly show the opaque "Context compaction" block in the slot
where they were just reading the actual answer:

    User:  "i cant see the output of the last message you sent,
            i did see it previously, however now see 'context
            compaction'"

Added ``_ensure_last_assistant_message_in_tail`` mirror of the
user-side anchor. It looks for the most recent assistant message
with non-empty text content (skipping tool-call-only assistant
"stubs" which the UI renders as small "calling tool X" indicators
rather than a readable bubble) and walks ``cut_idx`` back through
the standard ``_align_boundary_backward`` so we don't split a
tool_call/result group that immediately precedes it. The two
anchors are chained — each only walks ``cut_idx`` backward, so
the tail can only grow.

Falls back to "most recent assistant of any kind" only when no
content-bearing reply exists in the compressible region (fresh
multi-step tool sequence with no prior reply) — in that case the
agent-side fix is effectively a no-op and the existing
user-message anchor carries the load.

WebUI layer (clarity). Added ``isCompactionMessage`` detector that
recognises the ``[CONTEXT COMPACTION — REFERENCE ONLY]`` (current)
and ``[CONTEXT SUMMARY]:`` (legacy) prefixes from
``agent/context_compressor.py``, and a new ``compaction`` entry
in ``MessageBubble``'s ``ROLE_STYLES`` map. Compaction blocks
now render as muted, italicised system-style rows labelled
``Context handoff`` — clearly metadata, not the assistant's
actual reply — so an operator scrolling back through a long
session can't mistake the summary for a real answer.

Keeping the detected prefixes inline (rather than importing them)
because the WebUI bundle has no Python interop. A guardrail comment
points readers at the source-of-truth constants in
``agent/context_compressor.py``.
2026-06-12 15:41:57 -07:00
Teknium 7a318aae22 fix(profiles): exclude session history, backups, and snapshots from --clone-all (#45246)
--clone-all copied the source profile's state.db, sessions/, backups/,
state-snapshots/, and checkpoints/ into the new profile. These are
per-profile history: a 49GB copy in practice (15GB snapshots + 11GB
backup archives + 16GB state.db + 6.4GB sessions), and restoring a
copied backup inside the clone would resurrect the SOURCE profile's
state. A clone is a fresh workspace; history stays with the source.

New _CLONE_ALL_HISTORY_EXCLUDE_ROOT set, applied at root level for ANY
source profile (named profiles accumulate the same artifacts), unlike
the default-gated infrastructure excludes. Nested same-name dirs still
copy. Docs and the post-create CLI message updated to match; profile
export / hermes backup remain the full-history paths.
2026-06-12 15:41:50 -07:00
Brooklyn Nicholson b16e22b8f2 fix(desktop): persist tool-row dismissal across virtualization; keep caret hittable
Salvage of #45240. The dismiss-settled-tool-rows affordance was correct in
intent but had two issues against current main:

- The thread is virtualized, so a row's component unmounts/remounts as it
  scrolls. Component-local `useState` dismissal was forgotten on remount and
  the row popped back. Move dismissal into a session-scoped nanostore keyed by
  the stable disclosure id (mirrors $toolDisclosureOpen), so a dismissed row
  stays gone while scrolling but a reload restores real history instead of
  permanently rewriting it.
- The dismiss button lived in DisclosureRow's absolute `trailing` slot — the
  exact "opacity-0-but-clickable control fights the caret" pattern the trailing
  comment warns against. Add an in-flow `action` slot that lays out at the far
  right so an interactive control never overlaps the caret's hit-target,
  regardless of title length, and move the dismiss button into it.

Adds a remount regression test alongside the existing dismissal coverage.
2026-06-12 17:34:48 -05:00
helix4u 2e874ef879 fix(desktop): allow dismissing settled tool rows 2026-06-12 17:22:30 -05:00
Teknium 0db5cb8e75 refactor(agent): hoist summary end marker to _SUMMARY_END_MARKER; strip it on rehydration
Follow-up to the #33346 cherry-pick:
- the marker string was duplicated at both insertion sites (standalone +
  merged-into-tail); hoist to a module constant
- _strip_summary_prefix now also strips a trailing end marker so a
  rehydrated handoff body doesn't leak the boundary directive into the
  iterative-update summarizer prompt (it is re-appended on insertion)
2026-06-12 15:05:00 -07:00
Tranquil-Flow 749b7219c4 fix(compression): always append END OF CONTEXT SUMMARY marker to standalone summaries regardless of role
When the compression summary lands as an assistant-role message (head ends
with user), the end marker was not appended. Models may regurgitate the
summary text as their own visible output when there's no clear boundary
signal (#33256).

The end marker was already appended for user-role summaries (#11475, #14521)
but the assistant-role path was missed in the original fix. This ensures ALL
standalone summary messages carry the boundary marker, preventing summary
text from leaking into user-visible chat output.
2026-06-12 15:05:00 -07:00
Teknium a118b94a85 fix(dashboard): skill installs from the dashboard silently auto-cancel (#45150)
The dashboard's /api/skills/hub/install (and the new-profile hub_skills
path) spawned `hermes skills install <id>` with stdin=DEVNULL but
without --yes. do_install()'s 'Confirm [y/N]' prompt hit EOF, defaulted
to 'n', and printed 'Installation cancelled.' into a background log the
user never sees — every dashboard install no-opped.

Pass --yes on both spawn sites, matching the uninstall endpoint which
already passed --yes. The dashboard install button is the explicit user
consent, same as the TUI/slash-command skip_confirm rationale.

Repro: spawned the exact argv with stdin=DEVNULL against a temp
HERMES_HOME — without --yes it cancels, with --yes the skill installs.
2026-06-12 12:58:36 -07:00
Teknium bba9b519aa fix(delegation): remove the default subagent wall-clock timeout (#45149)
Subagents doing legitimate heavy work (deep code reviews, research
fan-outs, slow reasoning models) were routinely killed at the blanket
600s child_timeout_seconds cap while making steady progress (e.g. 36
API calls completed when the axe fell). Failures should come from what
the child is actually doing — API errors, tool errors, iteration
budget — not a delegation-level stopwatch.

- DEFAULT_CHILD_TIMEOUT: 600 -> None; Future.result(timeout=None)
  blocks until the child finishes
- config default delegation.child_timeout_seconds: 600 -> 0
  (0/negative = disabled; positive opts back in, floor 30s unchanged)
- stuck-child protection unchanged: the heartbeat staleness monitor
  still stops refreshing parent activity so the gateway inactivity
  timeout fires on a truly wedged worker; the 0-API-call diagnostic
  dump still works when a cap is configured
- docs updated (EN + zh-Hans)
2026-06-12 12:58:25 -07:00
Teknium 9b01c4d193 fix(update): never spawn an interactive polkit prompt when restarting a system-scope gateway (#45145)
When hermes update restarts a hermes-gateway system service as a
non-root user, the systemctl reset-failed/start/restart calls trigger
polkit's org.freedesktop.systemd1.manage-units TTY authentication
agent. That prompt runs inside a captured subprocess with a 10-15s
timeout, so it flashes and dies before the user can answer, and the
resulting TimeoutExpired was swallowed silently by the loop's blanket
except — the restart phase just vanished with no output.

- Resolve a manage-units command prefix up front: plain systemctl as
  root, sudo -n systemctl as non-root (with a targeted reset-failed
  probe so least-privilege sudoers entries scoped to hermes-gateway*
  qualify), or None when no non-interactive privilege path exists.
- Add --no-ask-password to every manage-units call in the update
  restart path so polkit can never prompt inside a captured subprocess.
- When unprivileged: after a graceful drain, rely on systemd's own
  RestartSec auto-restart (needs no privileges) with a message about
  the wait; skip the force-restart fallback with clear manual
  instructions instead of racing a doomed polkit prompt.
- Surface TimeoutExpired in the restart loop instead of passing
  silently, and add sudo to the system-scope recovery hints.
- Docs: headless-VM note recommending user service + enable-linger,
  or sudo updates / a scoped NOPASSWD sudoers entry for system
  services.
2026-06-12 12:38:15 -07:00
Teknium fca84fe20b test: regression guard for Nous 429 fallback re-entry; AUTHOR_MAP entry 2026-06-12 12:21:29 -07:00
Aðalsteinn Helgason 2714fc8396 fix(agent): re-enter retry loop on genuine Nous 429 so fallback guard runs
The genuine-rate-limit branch set retry_count = max_retries before
continue, intending the top-of-loop Nous guard to handle fallback or
bail cleanly. But the loop condition is retry_count < max_retries, so
the guard never ran: no fallback activation, no clean rate-limit
message — just the generic retry-exhaustion error.

Set retry_count = max(0, max_retries - 1) so the loop body runs exactly
once more and the guard sees the breaker state recorded moments earlier.

Extracted from the #44061 bugfix rollup by @AIalliAI.
2026-06-12 12:21:29 -07:00
Teknium dc467488a7 test: assert typing-stop-before-callback as an invariant, not a call count
The shared _stop_typing_refresh cleanup makes up to two bounded
stop_typing attempts; the old assertion pinned exactly one
typing-stopped event before callback-start.
2026-06-12 12:02:41 -07:00
Teknium c2326bc3be chore: add itsflownium to AUTHOR_MAP 2026-06-12 12:02:41 -07:00
Flownium 331cb38e21 fix: stop Discord typing after replies 2026-06-12 12:02:41 -07:00
Teknium fa5e98facb fix(send): helpful error when --file gets a binary; document MEDIA: attachments (#45116)
A user passing an image to `hermes send --file` got a raw
UnicodeDecodeError ('utf-8 codec can't decode byte 0x89...') with no
hint that media delivery goes through the MEDIA:<path> directive.

- send_cmd: catch UnicodeDecodeError separately and print a usage error
  explaining --file is for text bodies, with copy-pasteable MEDIA: and
  [[as_document]] examples using the user's own path
- --file help text + epilog now mention MEDIA:
- docs: new 'Sending images and other media' section on the hermes send
  reference page
2026-06-12 11:48:06 -07:00
Teknium 652dd9c9f2 fix: rich messages follow-ups — reply_parameters, send latch, opt-in default
- Use reply_parameters per the sendRichMessage spec instead of the
  undocumented reply_to_message_id scalar (silently ignored -> reply
  anchor quietly dropped).
- Latch rich sends off after an endpoint-capability failure (old PTB /
  server without sendRichMessage) so every later reply doesn't pay a
  doomed extra roundtrip; per-message BadRequests do NOT latch.
- Default rich_messages to OFF (opt-in) while the day-old Bot API 10.1
  endpoint is validated live; revert the prompt-hint table guidance
  until the default flips on.
- Tests: reply_parameters shape, send-latch behavior, BadRequest
  non-latch; rich tests opt in explicitly via extra.
2026-06-12 11:47:54 -07:00
ITheEqualizer 05b9c84ca4 Add Telegram Bot API 10.1 rich message support
Introduce opportunistic support for Telegram Bot API 10.1 rich messages by sending raw agent Markdown via sendRichMessage and streaming previews via sendRichMessageDraft. Implements a rich-path fast‑path in gateway/platforms/telegram.py (RICH_MESSAGE_MAX_BYTES=32768, feature gate platforms.telegram.extra.rich_messages, bot capability checks, routing/thread handling, and conservative fallback rules: permanent/capability errors fall back to the legacy MarkdownV2 path, transient/network errors are surfaced without legacy-resend). Also add a latch for draft capability failures (_rich_draft_disabled) and preserve legacy chunking and draft behavior when needed. Update agent prompt hints (telegram encourages rich Markdown/tables), add CLI config example option, update English and Chinese docs to describe rich messages and fallbacks, and add/adjust tests for rich send and draft behavior.
2026-06-12 11:47:54 -07:00
teknium1 6b4073648e fix(tui): config.yaml wins over env model seed in per-turn sync
Hosted instances set HERMES_INFERENCE_MODEL as a provision-time seed in
the container env. _config_model_target() previously went through
_resolve_model() (env-first), so on hosted VPS the sync target stayed
pinned to the seed and dashboard model changes never reached an open
chat -- the exact scenario the sync exists to fix. The sync target now
reads config.yaml first and only falls back to the env vars when config
has no model. Startup resolution (_resolve_model) is unchanged.
2026-06-12 11:03:44 -07:00
IAvecilla bc3f4ed70f Skip redundant model switch 2026-06-12 11:03:44 -07:00
IAvecilla 8c3c08c50b Update implementation to make it cleaner 2026-06-12 11:03:44 -07:00
IAvecilla c61815232a Update model correctly when updating from dashboard 2026-06-12 11:03:44 -07:00
ethernet 1e25358a8f refactor(desktop): use port 0 for ephemeral port discovery instead of PortPool reservation
Replace the PortPool-based port reservation system (9120-9199 range) with OS-assigned ephemeral ports via --port 0.

Before: Desktop probed a hardcoded port range, reserved ports in-process to close TOCTOU races, and passed the chosen port to the dashboard via CLI arg.

After: Desktop spawns dashboard with --port 0, parses the actual port from a stdout announcement line (HERMES_DASHBOARD_READY port=<N>), and uses that for WebSocket connections.

Changes:
- web_server.py: add --port 0 support with SO_REUSEADDR pre-bind + announcement; add EADDRINUSE preflight for explicit ports
- main.cjs: remove PortPool, PORT_FLOOR/CEILING, pickPort(), isPortAvailable(); add waitForDashboardPort() stdout parser
- Delete port-pool.cjs and port-pool.test.cjs (106 lines removed)

Net effect: eliminates the entire TOCTOU-mitigation reservation infrastructure and arbitrary port range constraints. OS handles port allocation natively.
2026-06-12 14:02:19 -04:00
ethernet 8044bf0206 fix(ci): only save test durations when tests pass
The save-durations job used `if: always()` which meant it would
run even when the test matrix failed, potentially caching duration
data from a failed/incomplete run. Changed to check
needs.test.result == 'success' so durations are only cached when
all test slices pass cleanly.
2026-06-12 13:50:52 -04:00
ethernet 4d68984ec7 fix(tests): remove no-longer-needed forensics 2026-06-12 13:42:42 -04:00
ethernet 6ff39c31ad fix(tests): guard against real 'hermes update' subprocess spawns in conftest
Extends _live_system_guard in tests/conftest.py to block any subprocess
call that would run 'hermes update' (or 'python -m hermes_cli.main update')
against the real checkout.

These commands run git fetch origin + git pull, overwriting repo files
like pyproject.toml mid-test-run and corrupting every subsequent
subprocess that reads them. The spawned process uses setsid /
start_new_session=True so it's invisible to pytest's process tree
(PPid=1) — the corruption was essentially undetectable without
explicit inotify/SHA watchdogs.

Root cause of #43703 CI failures: tests in TestUpdateCommandPlatformGate
called _handle_update_command() with HERMES_MANAGED='' and no Popen mock,
causing the code to fall through and spawn a real 'hermes update --gateway'
that overwrote pyproject.toml with origin/main's content (which still
had '--timeout=30 --timeout-method=thread' in addopts while the PR had
already removed pytest-timeout).

The guard covers all three invocation patterns:
- 'hermes update' / 'hermes update --gateway' (direct or via setsid bash -c)
- 'python -m hermes_cli.main update --gateway'
- '.venv/bin/hermes update' (absolute path variant)

Does not false-positive on: git update-index, apt-get update,
pip install --upgrade, or any command lacking 'hermes'/'hermes_cli'.
2026-06-12 13:42:42 -04:00
ethernet c41a6534cf fix(tests): mock subprocess.Popen in all _handle_update_command tests 2026-06-12 13:42:42 -04:00
ethernet 2f9d18711f fix(ci): remove pytest-timeout, use per-file timeout only
fix(ci): write a new cache for test durations every time
change(ci): rip out error 4 retries because we found the real bug
2026-06-12 13:42:42 -04:00
193 changed files with 10925 additions and 2799 deletions
+2 -2
View File
@@ -90,7 +90,7 @@ jobs:
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
# shard would otherwise reach the session-scoped ``built_image``
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
# ``docker build`` — guaranteed to
# die in fixture setup.
#
# Piggybacking here avoids a second image build: the smoke test
@@ -114,7 +114,7 @@ jobs:
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
+19 -15
View File
@@ -4,13 +4,13 @@ on:
push:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
- "**/*.md"
- "docs/**"
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
- "**/*.md"
- "docs/**"
permissions:
contents: read
@@ -30,13 +30,17 @@ jobs:
slice: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore duration cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
# Single stable key. main always overwrites, PRs always find it.
# main always writes a new suffix, but jobs pick the latest one with the same prefix
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
# If there are no exact matches, the action searches for partial matches of the restore keys.
# When the action finds a partial match, the most recent cache is restored to the path directory.
key: test-durations
- name: Install ripgrep (prebuilt binary)
@@ -54,7 +58,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
@@ -115,7 +119,7 @@ jobs:
NOUS_API_KEY: ""
- name: Upload per-slice durations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice }}
path: test_durations.json
@@ -125,11 +129,11 @@ jobs:
# (including PRs) get balanced slicing.
save-durations:
needs: test
if: always() && github.ref == 'refs/heads/main'
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-durations-slice-*
path: durations
@@ -149,17 +153,17 @@ jobs:
"
- name: Save merged duration cache
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
key: test-durations
key: test-durations-${{ github.run_id }}
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install ripgrep (prebuilt binary)
run: |
@@ -176,7 +180,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
+17
View File
@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
try:
from model_tools import get_tool_definitions
from agent.memory_manager import inject_memory_provider_tools
enabled_toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
}
inject_memory_provider_tools(state.agent)
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
if callable(invalidate):
invalidate()
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
def _cmd_tools(self, args: str, state: SessionState) -> str:
try:
from model_tools import get_tool_definitions
from types import SimpleNamespace
from agent.memory_manager import inject_memory_provider_tools
toolsets = _expand_acp_enabled_toolsets(
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
)
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
tool_view = SimpleNamespace(
tools=list(tools or []),
valid_tool_names={
tool.get("function", {}).get("name")
for tool in tools or []
if isinstance(tool, dict)
},
enabled_toolsets=toolsets,
_memory_manager=getattr(state.agent, "_memory_manager", None),
)
inject_memory_provider_tools(tool_view)
tools = tool_view.tools
if not tools:
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]
+2 -32
View File
@@ -1193,38 +1193,8 @@ def init_agent(
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
agent._memory_manager = None
# Inject memory provider tool schemas into the tool surface.
# Skip tools whose names already exist (plugins may register the
# same tools via ctx.register_tool(), which lands in agent.tools
# through _ra().get_tool_definitions()). Duplicate function names cause
# 400 errors on providers that enforce unique names (e.g. Xiaomi
# MiMo via Nous Portal).
#
# Respect the platform's enabled_toolsets configuration (#5544):
# enabled_toolsets is None → no filter, inject (backward compat)
# "memory" in enabled_toolsets → user opted in, inject
# otherwise (incl. []) → user excluded memory, skip injection
#
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
# penalty on local models and a frequent trigger of tool-call loops.
if agent._memory_manager and agent.tools is not None and (
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
):
_existing_tool_names = {
t.get("function", {}).get("name")
for t in agent.tools
if isinstance(t, dict)
}
for _schema in agent._memory_manager.get_all_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
if _tname:
agent.valid_tool_names.add(_tname)
_existing_tool_names.add(_tname)
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
_inject_memory_provider_tools(agent)
# Skills config: nudge interval for skill creation reminders
agent._skill_nudge_interval = 10
+66 -6
View File
@@ -445,6 +445,45 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
return repairs
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
"""Run :func:`repair_message_sequence` and keep the SessionDB flush
cursor consistent with the compacted list (#44837).
``repair_message_sequence`` merges/drops messages in place, shrinking
the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
that list, so after compaction it can point past the new end — the
turn-end flush would then skip the assistant/tool chain entirely — or
past unflushed messages shifted to lower indexes.
Repair preserves object identity for surviving messages, so counting
the survivors from the previously-flushed prefix gives the exact new
cursor even when messages are dropped/merged at indexes *before* the
cursor — a plain ``min()`` clamp would silently skip that many
unflushed rows. Falls back to the clamp when no prefix snapshot is
available.
Returns the number of repairs made (same as ``repair_message_sequence``).
"""
pre_repair_flushed_ids = None
flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
if isinstance(flush_cursor, int) and flush_cursor > 0:
pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
repairs = repair_message_sequence(agent, messages)
if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
if pre_repair_flushed_ids is not None:
agent._last_flushed_db_idx = sum(
1 for m in messages if id(m) in pre_repair_flushed_ids
)
else:
agent._last_flushed_db_idx = min(
agent._last_flushed_db_idx, len(messages)
)
return repairs
def strip_think_blocks(agent, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text.
@@ -579,12 +618,33 @@ def recover_with_credential_pool(
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
if current_provider and pool_provider and current_provider != pool_provider:
_ra().logger.warning(
"Credential pool provider mismatch: pool=%s, agent=%s"
"skipping pool mutation to avoid cross-provider contamination",
pool_provider, current_provider,
)
return False, has_retried_429
# Custom endpoints use two naming conventions for the SAME provider:
# the agent carries the generic ``custom`` label while the pool is
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
# compare treats them as a mismatch and skips recovery for every
# custom-provider user — 401s/429s then burn the full retry cycle
# with no rotation or refresh. Accept the pair as matching only when
# the agent's CURRENT base_url actually resolves to this pool key,
# so a fallback provider (or a different custom endpoint) still
# triggers the guard.
_custom_match = False
if current_provider == "custom" and pool_provider.startswith("custom:"):
try:
from agent.credential_pool import get_custom_provider_pool_key
_agent_base = (getattr(agent, "base_url", "") or "").strip()
_custom_match = bool(_agent_base) and (
(get_custom_provider_pool_key(_agent_base) or "").strip().lower()
== pool_provider
)
except Exception:
_custom_match = False
if not _custom_match:
_ra().logger.warning(
"Credential pool provider mismatch: pool=%s, agent=%s"
"skipping pool mutation to avoid cross-provider contamination",
pool_provider, current_provider,
)
return False, has_retried_429
effective_reason = classified_reason
if effective_reason is None:
+1 -1
View File
@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
explicit_base_url = None
explicit_base_url = runtime_base_url or None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"
+191 -23
View File
@@ -69,6 +69,31 @@ SUMMARY_PREFIX = (
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Metadata key added to context compression summary messages so that frontends
# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
# messages and filter or render them appropriately without content-prefix
# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
#
# Underscore-prefixed ON PURPOSE: the wire sanitizers
# (agent/transports/chat_completions.py convert_messages and the summary-path
# mirror in agent/chat_completion_helpers.py) strip every top-level message
# key starting with "_" before the request leaves the process. Strict
# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
# reject payloads carrying unknown keys with "Extra inputs are not permitted",
# poisoning every subsequent request in the session — a bare key like
# "is_compressed_summary" would reach the wire and trip exactly that.
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
# Appended to every standalone summary message (and to the merged-into-tail
# prefix) so the model has an unambiguous "summary ends here" boundary.
# Without it, weak models read the verbatim "## Active Task" quote as fresh
# user input (#11475, #14521) or regurgitate an assistant-role summary as
# their own output (#33256).
_SUMMARY_END_MARKER = (
"--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---"
)
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
@@ -146,6 +171,11 @@ _FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
# Keep a short run of recent messages verbatim even when the token budget is
# already exhausted. The public ``protect_last_n`` default is intentionally
# high for small/light tails, but using all 20 as a hard floor here would bring
# back the old large-tool-output case where nothing can be compacted.
_MAX_TAIL_MESSAGE_FLOOR = 8
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
@@ -1616,7 +1646,13 @@ This compaction should PRIORITISE preserving all information related to the focu
text = (summary or "").strip()
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
if text.startswith(prefix):
return text[len(prefix):].lstrip()
text = text[len(prefix):].lstrip()
break
# Strip the trailing end marker too — a rehydrated handoff body that
# keeps it would leak the boundary directive into the iterative-update
# summarizer prompt (and the marker is re-appended on insertion anyway).
if text.endswith(_SUMMARY_END_MARKER):
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
return text
@classmethod
@@ -1632,6 +1668,19 @@ This compaction should PRIORITISE preserving all information related to the focu
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@staticmethod
def _has_compressed_summary_metadata(message: Any) -> bool:
"""Return True if *message* carries the compressed-summary flag.
Callers (frontends, CLI, gateway) can use this to distinguish context
compaction summaries from real assistant or user messages without
relying on content-prefix heuristics. The flag is in-process only —
the wire sanitizers strip underscore-prefixed keys before API calls.
"""
if not isinstance(message, dict):
return False
return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
@classmethod
def _derive_auto_focus_topic(
cls,
@@ -1817,6 +1866,105 @@ This compaction should PRIORITISE preserving all information related to the focu
return i
return -1
def _find_last_assistant_message_idx(
self, messages: List[Dict[str, Any]], head_end: int
) -> int:
"""Return the index of the last user-visible assistant reply at or
after *head_end*, or -1.
A "user-visible reply" is an assistant message with non-empty
textual content — i.e. one that the WebUI / TUI / SessionsPage
rendered as a bubble the operator could read. We deliberately
skip assistant messages that contain only ``tool_calls`` (and
no text), because those render as small "calling tool X"
indicators and aren't what the reporter means by "the output
of the last message you sent" (#29824).
Falling back to the most recent assistant message of ANY kind
only kicks in when no content-bearing assistant message exists
in the compressible region — typically a fresh session that
just started a multi-step tool sequence with no prior reply
to anchor. In that case the agent fix is a no-op and the
existing user-message anchor carries the load.
"""
last_any = -1
for i in range(len(messages) - 1, head_end - 1, -1):
msg = messages[i]
if msg.get("role") != "assistant":
continue
if last_any < 0:
last_any = i
content = msg.get("content")
if isinstance(content, str) and content.strip():
return i
if isinstance(content, list):
# Multimodal / Anthropic-style content: look for any
# text block with non-empty text.
for part in content:
if isinstance(part, dict):
text = part.get("text") or part.get("content")
if isinstance(text, str) and text.strip():
return i
return last_any
def _ensure_last_assistant_message_in_tail(
self,
messages: List[Dict[str, Any]],
cut_idx: int,
head_end: int,
) -> int:
"""Guarantee the most recent assistant message is in the protected tail.
WebUI / TUI / SessionsPage bug (#29824). Without this anchor,
``_find_tail_cut_by_tokens`` can leave the user's most recent
visible assistant response inside the compressed middle region —
especially when the conversation has a single oversized tool
result or a long stretch of tool-call/result pairs after the
last assistant reply. The summariser then rolls that reply up
into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block
persisted as ``role="user"`` or ``role="assistant"``. From the
operator's perspective the WebUI session viewer
(``web/src/pages/SessionsPage.tsx``) and the TUI chat panel
both suddenly show the opaque "Context compaction" block in the
slot where they were just reading the assistant's actual reply:
User: "i cant see the output of the last message you
sent, i did see it previously, however now see
'context compaction'"
Mirror of ``_ensure_last_user_message_in_tail`` but anchors on
the last assistant-role message. Re-runs the tool-group
alignment so we don't split a ``tool_call`` / ``tool_result``
group that immediately precedes the anchored message — orphaned
tool messages would otherwise be removed by
``_sanitize_tool_pairs`` and trigger the same data-loss symptom
we're trying to prevent.
"""
last_asst_idx = self._find_last_assistant_message_idx(messages, head_end)
if last_asst_idx < 0:
# No assistant message in the compressible region — nothing
# to anchor (single-turn pre-reply state, etc.).
return cut_idx
if last_asst_idx >= cut_idx:
# Already in the tail — the token-budget walk did the right
# thing on its own.
return cut_idx
# Pull cut_idx back to the assistant message, then re-align so
# we don't split a tool group that immediately precedes it
# (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` →
# ``assistant(final reply)`` sequence would otherwise leave the
# ``tool`` orphan when cut lands at the final reply).
new_cut = self._align_boundary_backward(messages, last_asst_idx)
if not self.quiet_mode:
logger.debug(
"Anchoring tail cut to last assistant message at index %d "
"(was %d, aligned to %d) to keep the previously-visible "
"reply out of the compaction summary (#29824)",
last_asst_idx, cut_idx, new_cut,
)
# Safety: never go back into the head region.
return max(new_cut, head_end + 1)
def _ensure_last_user_message_in_tail(
self,
messages: List[Dict[str, Any]],
@@ -1875,11 +2023,12 @@ This compaction should PRIORITISE preserving all information related to the focu
derived from ``summary_target_ratio * context_length``, so it
scales automatically with the model's context window.
Token budget is the primary criterion. A hard minimum of 3 messages
is always protected, but the budget is allowed to exceed by up to
1.5x to avoid cutting inside an oversized message (tool output, file
read, etc.). If even the minimum 3 messages exceed 1.5x the budget
the cut is placed right after the head so compression still runs.
Token budget is the primary criterion. A bounded message-count floor
keeps a short run of recent turns verbatim even when the budget is
exhausted, but the budget is allowed to exceed by up to 1.5x to avoid
cutting inside an oversized message (tool output, file read, etc.). If
even that floor exceeds 1.5x the budget, the cut is placed right after
the head so compression still runs.
Never cuts inside a tool_call/result group. Always ensures the most
recent user message is in the tail (see ``_ensure_last_user_message_in_tail``).
@@ -1887,8 +2036,19 @@ This compaction should PRIORITISE preserving all information related to the focu
if token_budget is None:
token_budget = self.tail_token_budget
n = len(messages)
# Hard minimum: always keep at least 3 messages in the tail
min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
# Hard minimum: always keep a bounded recent-message floor in the tail.
# ``protect_last_n`` remains a minimum up to the cap; the cap avoids
# preserving a whole run of bulky tool outputs on every compaction.
available_tail = max(0, n - head_end - 1)
min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
# Leave at least two non-head messages available to summarize on short
# transcripts; otherwise compression can replace a tiny middle with a
# summary and save no messages at all.
compressible_tail_cap = max(3, available_tail - 2)
min_tail = (
min(min_tail_floor, compressible_tail_cap, available_tail)
if available_tail > 1 else 0
)
soft_ceiling = int(token_budget * 1.5)
accumulated = 0
cut_idx = n # start from beyond the end
@@ -1960,6 +2120,13 @@ This compaction should PRIORITISE preserving all information related to the focu
# active task is never lost to compression (fixes #10896).
cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)
# Ensure the most recent assistant message is always in the tail
# so the previously-visible reply isn't silently rolled into the
# ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824).
# Each anchor only walks ``cut_idx`` backward, so chaining them is
# monotonic — the tail can only grow, never shrink.
cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)
return max(cut_idx, head_end + 1)
# ------------------------------------------------------------------
@@ -2193,32 +2360,33 @@ This compaction should PRIORITISE preserving all information related to the focu
# When the summary lands as a standalone role="user" message,
# weak models read the verbatim "## Active Task" quote of a past
# user request as fresh input (#11475, #14521). Append the explicit
# end marker — the same one used in the merge-into-tail path — so
# the model has a clear "summary above, not new input" signal.
if not _merge_summary_into_tail and summary_role == "user":
summary = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---"
)
# user request as fresh input (#11475, #14521).
# When it lands as role="assistant", models may regurgitate the
# summary text as their own output (#33256). In both cases, append
# the explicit end marker so the model has a clear "summary ends
# here, respond to the message below" signal.
if not _merge_summary_into_tail:
summary = summary + "\n\n" + _SUMMARY_END_MARKER
if not _merge_summary_into_tail:
compressed.append({"role": summary_role, "content": summary})
compressed.append({
"role": summary_role,
"content": summary,
COMPRESSED_SUMMARY_METADATA_KEY: True,
})
for i in range(compress_end, n_messages):
msg = messages[i].copy()
if _merge_summary_into_tail and i == compress_end:
merged_prefix = (
summary
+ "\n\n--- END OF CONTEXT SUMMARY — "
"respond to the message below, not the summary above ---\n\n"
)
merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
msg["content"] = _append_text_to_content(
msg.get("content"),
merged_prefix,
prepend=True,
)
# Mark the merged message so frontends can identify it as
# containing a compression summary prefix.
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
_merge_summary_into_tail = False
compressed.append(msg)
+12 -5
View File
@@ -595,7 +595,11 @@ def run_conversation(
# landed after an orphan tool result). Most providers return
# empty content on malformed sequences, which would otherwise
# retrigger the empty-retry loop indefinitely.
repaired_seq = agent._repair_message_sequence(messages)
# repair_message_sequence_with_cursor also recomputes the SessionDB
# flush cursor (_last_flushed_db_idx) when repair compacts the list,
# so the turn-end flush doesn't skip the assistant/tool chain (#44837).
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
repaired_seq = repair_message_sequence_with_cursor(agent, messages)
if repaired_seq > 0:
request_logger.info(
"Repaired %s message-alternation violations before request (session=%s)",
@@ -2631,10 +2635,13 @@ def run_conversation(
except Exception:
pass
if _genuine_nous_rate_limit:
# Skip straight to max_retries -- the
# top-of-loop guard will handle fallback or
# bail cleanly.
retry_count = max_retries
# Re-enter the loop exactly once so the
# top-of-loop Nous guard handles fallback or
# bails cleanly. (Setting retry_count to
# max_retries would make the while condition
# false immediately and the guard would never
# run -- no fallback, generic exhaustion error.)
retry_count = max(0, max_retries - 1)
continue
# Upstream capacity 429: fall through to normal
# retry logic. A different model (or the same
+1 -1
View File
@@ -330,7 +330,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
system_instruction = None
joined_system = "\n".join(part for part in system_text_parts if part).strip()
if joined_system:
system_instruction = {"parts": [{"text": joined_system}]}
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
return contents, system_instruction
+60
View File
@@ -44,6 +44,66 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
existing_tool_names = {
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
}
if (
"memory" not in existing_tool_names
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
):
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
valid_tool_names = getattr(agent, "valid_tool_names", None)
if valid_tool_names is None:
valid_tool_names = set()
agent.valid_tool_names = valid_tool_names
added = 0
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
existing_tool_names.add(tool_name)
added += 1
return added
# ---------------------------------------------------------------------------
# Context fencing helpers
# ---------------------------------------------------------------------------
+8 -1
View File
@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
if "type" in node and node["type"] not in {None, ""}:
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next(
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
"string",
)
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
return node
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
+8 -5
View File
@@ -508,13 +508,16 @@ PLATFORM_HINTS = {
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram has NO table syntax — prefer bullet lists or labeled "
"key: value pairs over pipe tables (any tables you do emit are "
"auto-rewritten into row-group bullets, which you can produce "
"directly for cleaner output). "
"Telegram supports rich Markdown, so when it improves clarity you may "
"use headings, tables (pipe `| col | col |` syntax), task lists "
"(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, "
"footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, "
"subscript/superscript, marked (highlighted) text, and anchors. Prefer "
"real Markdown tables and task lists over hand-built bullet substitutes "
"when presenting structured data. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
+26 -1
View File
@@ -22,9 +22,31 @@ TitleCallback = Callable[[str], None]
_TITLE_PROMPT = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in the same language the user is writing in. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
_TITLE_PROMPT_PINNED_LANGUAGE = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in {language}. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
try:
from hermes_cli.config import load_config
return str(
((load_config() or {}).get("auxiliary") or {})
.get("title_generation", {})
.get("language", "")
).strip()
except Exception:
return ""
def generate_title(
user_message: str,
@@ -48,8 +70,11 @@ def generate_title(
user_snippet = user_message[:500] if user_message else ""
assistant_snippet = assistant_response[:500] if assistant_response else ""
language = _title_language()
prompt = _TITLE_PROMPT_PINNED_LANGUAGE.format(language=language) if language else _TITLE_PROMPT
messages = [
{"role": "system", "content": _TITLE_PROMPT},
{"role": "system", "content": prompt},
{"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
]
@@ -3,8 +3,9 @@
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
//!
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
//! shim is free; otherwise `hermes update` aborts with exit code 2),
//! 1. wait for the old Hermes desktop process to fully exit (so both the
//! venv shim and packaged app.asar are free; otherwise `hermes update`
//! or repair bootstrap can race locked files),
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim
/// before giving up and letting `hermes update`'s own guard decide.
/// How long to wait for the old desktop process to release files under the
/// install tree before giving up and letting `hermes update`'s own guard decide.
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
// ---- pre-step: wait for the old desktop to die -----------------------
// The desktop exec'd us then called app.exit(), but process teardown is
// async on Windows. If it still holds the venv shim, `hermes update`
// aborts with exit 2. Give it a bounded window to clear.
wait_for_venv_free(&install_root, &app).await;
// aborts with exit 2. If it still holds the packaged app.asar,
// install.ps1's repair/re-clone path cannot move/remove the install tree.
// Give both handles a bounded window to clear.
wait_for_install_locks_free(&install_root, &app, "update").await;
// ---- stage 1: hermes update -----------------------------------------
// Pass --branch so `hermes update` targets the branch this installer was
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
vec!["update".into(), "--yes".into(), "--gateway".into()];
// --force skips `hermes update`'s Windows running-exe guard (which would
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
// already exited and waited for the venv shim to unlock before launching
// us, and wait_for_venv_free below force-kills any straggler — so by the
// already exited and waited for the install locks to clear before launching
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
// time `hermes update` runs there is no legitimate hermes.exe to protect,
// and the guard would only produce a false "Hermes is still running" stop.
update_args.push("--force".into());
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
Ok(())
}
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
/// elapses. On non-Windows this is a short fixed grace since file locking
/// isn't the failure mode there.
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
/// grace since file locking isn't the failure mode there.
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
let lock_targets = install_lock_probe_paths(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
let locked = locked_paths(&lock_targets);
if locked.is_empty() {
return;
}
if Instant::now() >= deadline {
// Last resort: a backend hermes.exe (or a grandchild it spawned)
// is still holding the shim. The desktop should have reaped its
// tree before handing off, but SIGTERM races / detached
// grandchildren / AV handles can leave a straggler. Rather than
// "proceed anyway" straight into uv's "Access is denied", force-kill
// every hermes.exe except ourselves, then give the OS a beat to
// unload the image.
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
// itself) is still holding one of the update-sensitive files. The
// desktop should have reaped its tree before handing off, but
// SIGTERM races / detached grandchildren / AV handles can leave a
// straggler. Rather than "proceed anyway" straight into uv's
// "Access is denied" or install.ps1's locked app.asar failure,
// force-kill every Hermes.exe except ourselves, then give the OS a
// beat to unload the image.
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] Hermes still holding the venv shim; force-killing stragglers…",
&format!(
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
format_locked_paths(&locked)
),
);
force_kill_other_hermes();
tokio::time::sleep(Duration::from_millis(800)).await;
if !is_locked(&shim) {
let locked_after_kill = locked_paths(&lock_targets);
if locked_after_kill.is_empty() {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim freed after force-kill",
"[handoff] install files freed after force-kill",
);
} else {
emit_log(
app,
Some("update"),
Some(stage),
LogStream::Stdout,
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
&format!(
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
format_locked_paths(&locked_after_kill)
),
);
}
return;
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
}
}
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
let mut paths = vec![venv_hermes(install_root)];
paths.extend(desktop_app_payload_paths(install_root));
paths
}
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
let release = install_root.join("apps").join("desktop").join("release");
if cfg!(target_os = "windows") {
vec![
release.join("win-unpacked").join("resources").join("app.asar"),
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
]
} else if cfg!(target_os = "macos") {
vec![
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
]
} else {
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
}
}
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
paths.iter().filter(|p| is_locked(p)).cloned().collect()
}
fn format_locked_paths(paths: &[PathBuf]) -> String {
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
}
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
/// target "the backend" by PID here — the desktop already exited and we never
/// knew its children — so we kill the whole `hermes.exe` image tree via
/// taskkill, excluding our own PID.
///
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
@@ -891,6 +934,29 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(
probes.iter().any(|p| p == &venv_hermes(root)),
"venv shim remains part of the update lock probe"
);
assert!(
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
#[test]
fn locked_paths_ignores_missing_payloads() {
let root = Path::new("/nonexistent/hermes-agent");
let probes = install_lock_probe_paths(root);
assert!(locked_paths(&probes).is_empty());
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(
+66
View File
@@ -0,0 +1,66 @@
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
/**
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
* line that web_server.py prints after uvicorn binds its socket.
*
* Returns the parsed port. Rejects if:
* - the child exits before emitting the line
* - the child emits an `error` event
* - no line arrives within the timeout
*
* A single `cleanup()` tears down every listener (data/exit/error/timeout)
* on every terminal path resolve, reject, or timeout so repeated
* backend spawns don't leak listener slots on the child.
*/
function waitForDashboardPort(child, timeoutMs = 45_000) {
return new Promise((resolve, reject) => {
let buf = ''
let done = false
function cleanup() {
if (done) return
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
child.off('exit', onExit)
child.off('error', onError)
}
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.stdout.on('data', onData)
child.on('exit', onExit)
child.on('error', onError)
})
}
module.exports = { waitForDashboardPort }
+174
View File
@@ -0,0 +1,174 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}
+65 -57
View File
@@ -35,12 +35,13 @@ const {
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 { waitForDashboardPort } = require('./backend-ready.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 { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const {
buildPosixCleanupScript,
@@ -111,12 +112,6 @@ if (USER_DATA_OVERRIDE) {
app.setPath('userData', resolvedUserData)
}
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
// In-process port reservations that close the pickPort() TOCTOU window where
// two concurrent backend spawns could be handed the same port. See
// port-pool.cjs for the full rationale.
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -1840,6 +1835,44 @@ async function applyUpdates(opts = {}) {
}
}
async function handOffWindowsBootstrapRecovery(reason) {
if (!IS_WINDOWS || !IS_PACKAGED) return false
const updater = resolveUpdaterBinary()
if (!updater) return false
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
const branch = directoryExists(path.join(updateRoot, '.git'))
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
: configuredBranch || DEFAULT_UPDATE_BRANCH
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
const child = spawn(updater, updaterArgs, {
cwd: HERMES_HOME,
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
},
detached: true,
stdio: 'ignore',
windowsHide: false
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
setTimeout(() => {
app.quit()
}, 600)
return true
}
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
// the install we're updating, fall back to `hermes` on PATH.
function resolveHermesCliBinary(updateRoot) {
@@ -2437,6 +2470,14 @@ async function ensureRuntime(backend) {
if (backend.kind === 'bootstrap-needed') {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
throw handoffError
}
// Eagerly flip the bootstrap UI state to 'active' so the renderer
// shows the install overlay BEFORE the runner finishes fetching the
// manifest (which on slow networks can take tens of seconds and would
@@ -2566,24 +2607,6 @@ async function ensureRuntime(backend) {
return backend
}
function isPortAvailable(port) {
return new Promise(resolve => {
const server = net.createServer()
server.once('error', () => resolve(false))
server.once('listening', () => {
server.close(() => resolve(true))
})
server.listen(port, '127.0.0.1')
})
}
async function pickPort() {
const port = await portPool.reserve(isPortAvailable)
if (port === null) {
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
return port
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
@@ -4661,25 +4684,14 @@ async function spawnPoolBackend(profile, entry) {
}
}
const port = await pickPort()
const token = crypto.randomBytes(32).toString('base64url')
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
let backend
let hermesCwd
let webDist
try {
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
hermesCwd = resolveHermesCwd()
webDist = resolveWebDist()
} catch (error) {
// These run before the child exists / its exit handler is attached, so a
// throw here would otherwise leak the reservation and slowly exhaust the
// 9120-9199 range across switch cycles in one app session.
portPool.release(port)
throw error
}
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -4707,7 +4719,6 @@ async function spawnPoolBackend(profile, entry) {
})
)
entry.process = child
entry.port = port
entry.token = token
child.stdout.on('data', rememberLog)
@@ -4721,13 +4732,11 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
portPool.release(port)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
@@ -4735,6 +4744,10 @@ async function spawnPoolBackend(profile, entry) {
}
})
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(child), startFailed])
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
@@ -4762,7 +4775,6 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4848,11 +4860,6 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
// Hoisted so the outer .catch can release a port reserved by pickPort() when
// a throw (e.g. ensureRuntime failing) happens before the child's exit
// handler is attached. Stays null on the remote path (no port picked).
let reservedPort = null
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4880,11 +4887,9 @@ async function startHermes() {
}
}
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
// Pin the desktop's chosen profile via the global --profile flag. This is
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
@@ -4951,7 +4956,6 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4959,7 +4963,6 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4980,6 +4983,10 @@ async function startHermes() {
}
})
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
@@ -5019,7 +5026,6 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
portPool.release(reservedPort)
throw error
})
@@ -5995,6 +6001,8 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
-73
View File
@@ -1,73 +0,0 @@
'use strict'
/**
* In-process port reservation pool for the desktop backend launcher.
*
* pickPort() probes a localhost port with a throwaway server and closes it
* before the real bind happens in a separate Python child. Between that probe
* and the child's bind there is a TOCTOU window: a second concurrent spawn
* (the primary backend racing a pool backend) can be handed the SAME port, and
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
* destroyed" boot loop). Reserving the chosen port in THIS process until the
* child exits closes that window.
*
* The OS bind remains the source of truth; this only deconflicts racers inside
* this process it can't stop a foreign squatter, which the probe + the
* EADDRINUSE self-heal still cover.
*
* The pool is dependency-injected (the availability probe is passed in) and
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
* (see port-pool.test.cjs).
*/
class PortPool {
/**
* @param {number} floor inclusive lowest port to hand out
* @param {number} ceiling inclusive highest port to hand out
*/
constructor(floor, ceiling) {
this.floor = floor
this.ceiling = ceiling
this._reserved = new Set()
}
/** @returns {boolean} whether `port` is currently reserved in-process. */
has(port) {
return this._reserved.has(port)
}
/** Release a previously reserved port. No-op if it was not reserved. */
release(port) {
this._reserved.delete(port)
}
/** Drop all reservations. */
clear() {
this._reserved.clear()
}
/** @returns {number} count of currently reserved ports. */
get size() {
return this._reserved.size
}
/**
* Reserve and return the lowest port in [floor, ceiling] that is neither
* already reserved in-process nor rejected by `isAvailable(port)`, or null
* if every port is taken. `isAvailable` may be sync (boolean) or async
* (Promise<boolean>); it is awaited either way.
*
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
* @returns {Promise<number|null>}
*/
async reserve(isAvailable) {
for (let port = this.floor; port <= this.ceiling; port += 1) {
if (this._reserved.has(port)) continue
if (!(await isAvailable(port))) continue
this._reserved.add(port)
return port
}
return null
}
}
module.exports = { PortPool }
-77
View File
@@ -1,77 +0,0 @@
/**
* Tests for electron/port-pool.cjs.
*
* Run with: node --test electron/port-pool.test.cjs
*
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
* window. These cover selection order, skipping reserved/unavailable ports,
* release/reuse, exhaustion, and async probes without real sockets.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { PortPool } = require('./port-pool.cjs')
const allFree = () => true
test('reserve returns the lowest free port and reserves it', async () => {
const pool = new PortPool(9120, 9199)
const port = await pool.reserve(allFree)
assert.equal(port, 9120)
assert.ok(pool.has(9120))
assert.equal(pool.size, 1)
})
test('reserve skips ports already reserved in-process', async () => {
const pool = new PortPool(9120, 9199)
const first = await pool.reserve(allFree)
const second = await pool.reserve(allFree)
assert.equal(first, 9120)
assert.equal(second, 9121)
})
test('reserve skips ports the probe rejects', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120, 9121])
const port = await pool.reserve(p => !busy.has(p))
assert.equal(port, 9122)
})
test('reserve returns null when every port is taken', async () => {
const pool = new PortPool(9120, 9121)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(await pool.reserve(allFree), null)
})
test('release frees a reserved port for reuse', async () => {
const pool = new PortPool(9120, 9120)
assert.equal(await pool.reserve(allFree), 9120)
assert.equal(await pool.reserve(allFree), null) // exhausted
pool.release(9120)
assert.ok(!pool.has(9120))
assert.equal(await pool.reserve(allFree), 9120) // reusable
})
test('release is a no-op for an unreserved port', () => {
const pool = new PortPool(9120, 9199)
pool.release(9120)
assert.equal(pool.size, 0)
})
test('reserve awaits an async probe', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120])
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
assert.equal(port, 9121)
})
test('clear drops all reservations', async () => {
const pool = new PortPool(9120, 9199)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(pool.size, 2)
pool.clear()
assert.equal(pool.size, 0)
})
+1
View File
@@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /handOffWindowsBootstrapRecovery/)
assert.match(source, /'--repair', '--branch'/)
assert.match(source, /'--update', '--branch'/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
+3 -1
View File
@@ -36,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-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",
"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/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",
@@ -90,6 +90,7 @@
"react-router-dom": "^7.17.0",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
"remend": "^1.3.0",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
@@ -98,6 +99,7 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.6",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},
@@ -24,6 +24,7 @@ afterEach(cleanup)
// state stays stale while the DOM already holds the text.
function Harness({
busy = false,
disabled = false,
queued = [],
onSubmit,
onQueue,
@@ -31,6 +32,7 @@ function Harness({
onDrain
}: {
busy?: boolean
disabled?: boolean
queued?: readonly string[]
onSubmit: (text: string) => void
onQueue: (text: string) => void
@@ -52,6 +54,10 @@ function Harness({
}
const submitDraft = () => {
if (disabled) {
return
}
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
@@ -84,6 +90,10 @@ function Harness({
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
return
}
if (!busy && !hasLivePayload && queued.length > 0) {
onDrain()
@@ -186,4 +196,23 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onDrain).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'draft while reconnecting'
fireEvent.input(editor)
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(editor.textContent).toBe('draft while reconnecting')
expect(onDrain).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
})
+107 -40
View File
@@ -43,13 +43,16 @@ import {
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
MAX_AUTO_DRAIN_ATTEMPTS,
migrateQueuedPrompts,
promoteQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
shouldAutoDrain,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { notify } from '@/store/notifications'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { useTheme } from '@/themes'
@@ -174,7 +177,6 @@ export function ChatBar({
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
const scrolledUp = useStore($threadScrolledUp)
const sessionMessages = useStore($messages)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = useMemo(
@@ -197,11 +199,14 @@ export function ChatBar({
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
activeQueueSessionKeyRef.current = activeQueueSessionKey
const prevQueueKeyRef = useRef(activeQueueSessionKey)
const drainingQueueRef = useRef(false)
// Per-entry auto-drain failure counts; bounds retries so a persistent 404
// can't spin-loop. Cleared on success; reset naturally on remount/reconnect.
const drainFailuresRef = useRef(new Map<string, number>())
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@@ -242,6 +247,8 @@ export function ChatBar({
const gatewayState = useStore($gatewayState)
const newSessionPlaceholders = t.composer.newSessionPlaceholders
const followUpPlaceholders = t.composer.followUpPlaceholders
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
const inputDisabled = disabled && !reconnecting
// Resting placeholder: a starter for brand-new sessions, a continuation for
// existing ones. Picked once and only re-rolled when we genuinely move to a
@@ -272,11 +279,13 @@ export function ChatBar({
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the bar is disabled it's because the gateway isn't open. Distinguish a
// cold start ("Starting Hermes...") from a dropped connection we're trying to
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
// When the transport is disabled it's because the gateway isn't open.
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
// we're trying to restore. During reconnect, keep the textbox editable so a
// flaky network doesn't block drafting; only submit/backend actions stay
// disabled until the gateway is open again.
const placeholder = disabled
? gatewayState === 'closed' || gatewayState === 'error'
? reconnecting
? t.composer.placeholderReconnecting
: t.composer.placeholderStarting
: restingPlaceholder
@@ -318,13 +327,13 @@ export function ChatBar({
)
useEffect(() => {
if (!disabled) {
if (!inputDisabled) {
focusInput()
}
}, [disabled, focusInput, focusKey, focusRequestId])
}, [focusInput, focusKey, focusRequestId, inputDisabled])
useEffect(() => {
if (disabled) {
if (inputDisabled) {
return undefined
}
@@ -344,7 +353,7 @@ export function ChatBar({
offFocus()
offInsert()
}
}, [appendExternalText, disabled])
}, [appendExternalText, inputDisabled])
// Keep draftRef in sync with the assistant-ui composer state for callers
// that read the latest text outside the React render cycle. We don't push
@@ -866,7 +875,9 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
const history = deriveUserHistory($messages.get(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
@@ -891,7 +902,7 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(sessionMessages, chatMessageText)
const history = deriveUserHistory($messages.get(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
@@ -927,6 +938,10 @@ export function ChatBar({
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (disabled) {
return
}
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
void drainNextQueued()
@@ -1325,6 +1340,7 @@ export function ChatBar({
return false
}
drainFailuresRef.current.delete(entry.id)
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
@@ -1336,16 +1352,17 @@ export function ChatBar({
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
)
const drainNextQueued = useCallback(
() =>
runDrain(entries => {
const skip = queueEdit?.entryId
const pickDrainHead = useCallback(
(entries: QueuedPromptEntry[]) => {
const skip = queueEditRef.current?.entryId
return skip ? entries.find(e => e.id !== skip) : entries[0]
}),
[queueEdit, runDrain]
return skip ? entries.find(e => e.id !== skip) : entries[0]
},
[] // reads the edit id off a ref so the lock-holder always sees the latest
)
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
const sendQueuedNow = useCallback(
(id: string) => {
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
@@ -1363,30 +1380,76 @@ export function ChatBar({
return true
}
// A manual send clears the auto-drain backoff so a stuck entry the user
// taps gets a fresh attempt (and re-enables auto-retry on success).
drainFailuresRef.current.delete(id)
return runDrain(entries => entries.find(e => e.id === id))
},
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
)
// Auto-drain on busy → false (turn settled). Queued turns always flow once
// the session is idle again — whether the turn finished naturally or the
// user interrupted it. Interrupting to reach a queued message is the whole
// point of the queue, so we never suppress the drain. To cancel queued
// turns, the user deletes them from the panel.
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
wasBusy
})
) {
void drainNextQueued()
// Edge-independent auto-drain: send the head whenever the session is idle and
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
// drain lock serializes sends; a remount/reconnect resets the failure counts.
const autoDrainNext = useCallback(() => {
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
return
}
}, [busy, drainNextQueued, queuedPrompts.length])
const entry = pickDrainHead(queuedPrompts)
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
return
}
const onFail = () => {
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
drainFailuresRef.current.set(entry.id, fails)
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
notify({
id: 'composer-queue-stuck',
kind: 'error',
title: t.composer.queueStuckTitle,
message: t.composer.queueStuckBody
})
}
}
void runDrain(() => entry)
.then(sent => {
if (!sent) {
onFail()
}
})
.catch(onFail)
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
// never churns, so a change there is a real session switch and must NOT
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
// sessionId) churns on a backend bounce/resume of the same conversation.
useEffect(() => {
const prev = prevQueueKeyRef.current
prevQueueKeyRef.current = activeQueueSessionKey
if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) {
return
}
migrateQueuedPrompts(prev, activeQueueSessionKey)
}, [activeQueueSessionKey, queueSessionKey])
// Queued turns flow whenever the session is idle — on the busy→false settle
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
// strand them. To cancel queued turns, the user deletes them from the panel.
useEffect(() => {
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
autoDrainNext()
}
}, [autoDrainNext, busy, queuedPrompts.length])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
@@ -1421,6 +1484,10 @@ export function ChatBar({
}
const submitDraft = () => {
if (disabled) {
return
}
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
@@ -1601,6 +1668,7 @@ export function ChatBar({
const input = (
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-disabled={inputDisabled ? true : undefined}
aria-label={t.composer.message}
autoCapitalize="off"
autoCorrect="off"
@@ -1611,7 +1679,7 @@ export function ChatBar({
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
)}
contentEditable={!disabled}
contentEditable={!inputDisabled}
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
@@ -1741,7 +1809,6 @@ export function ChatBar({
'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-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"
@@ -1,7 +1,9 @@
import { StatusRow } from '@/components/chat/status-row'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
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'
@@ -38,32 +40,46 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
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"
>
{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>
<Tip label={c.queueEdit}>
<Button
aria-label={c.queueEdit}
className="size-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label={busy ? c.queueSendNext : c.queueSend}>
<Button
aria-label={busy ? c.queueSendNext : c.queueSend}
className="size-5 rounded-md"
disabled={isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label={c.queueDelete}>
<Button
aria-label={c.queueDelete}
className="size-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
</>
}
trailingVisible={isEditing}
@@ -170,14 +170,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
className="absolute inset-x-0 bottom-full z-6 -mb-[9px] max-h-[40vh] overflow-y-auto"
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
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')}>
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
+169 -101
View File
@@ -35,7 +35,9 @@ import {
$gatewayState,
$introPersonality,
$introSeed,
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$selectedStoredSessionId,
$sessions,
sessionPinId
@@ -53,8 +55,9 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
@@ -125,7 +128,13 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className={titlebarHeaderTitleClass}>
<div
className={titlebarHeaderTitleClass}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -136,7 +145,7 @@ function ChatHeader({
title={title}
>
<Button
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]"
className="pointer-events-auto flex h-6 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"
>
@@ -149,105 +158,42 @@ function ChatHeader({
)
}
export function ChatView({
className,
gateway,
onToggleSelectedPin,
onDeleteSelectedSession,
interface ChatRuntimeBoundaryProps {
busy: boolean
children: React.ReactNode
onCancel: () => Promise<void> | void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
/** Route points at an unloaded session render empty until resume swaps in
* the new transcript, so the previous session's messages don't linger. */
suppressMessages: boolean
}
const NO_MESSAGES: ChatMessage[] = []
/**
* Owns the $messages subscription and the assistant-ui external-store runtime.
*
* Isolated from ChatView so the per-token delta flush (which replaces the
* $messages atom ~30×/s during streaming) only re-renders this component and
* the runtime provider. The children (Thread, ChatBar) are created by
* ChatView, whose render output is stable across flushes so React bails out
* of re-rendering them by element identity and the stream's render cost stays
* confined to the streaming message's own subtree.
*/
function ChatRuntimeBoundary({
busy,
children,
onCancel,
onAddContextRef,
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit,
onThreadMessagesChange,
onEdit,
onReload,
onRestoreToMessage,
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
const selectedSessionId = useStore($selectedStoredSessionId)
onThreadMessagesChange,
suppressMessages
}: ChatRuntimeBoundaryProps) {
const storeMessages = useStore($messages)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
model: currentModel,
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
quickModels
},
tools: {
enabled: true,
label: 'Add context',
suggestions: contextSuggestions
},
voice: {
enabled: true,
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
@@ -297,6 +243,120 @@ export function ChatView({
onReload
})
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
}
export function ChatView({
className,
gateway,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
onAddContextRef,
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSteer,
onSubmit,
onThreadMessagesChange,
onEdit,
onReload,
onRestoreToMessage,
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
// every streaming delta flush (~30×/s) and a subscription here re-renders
// the entire chat shell (header, chat bar, thread wrapper) per token. The
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
// this component only needs streaming-stable derivations.
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
// direct nav). Derived in render so the swap reads instantly: the same frame
// the id changes we drop the old transcript and show the loader, instead of
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
model: currentModel,
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
quickModels
},
tools: {
enabled: true,
label: 'Add context',
suggestions: contextSuggestions
},
voice: {
enabled: true,
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
// Drop files anywhere in the conversation area, not just on the composer
// input. In-app drags (project tree / gutter) carry workspace-relative paths
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
@@ -349,7 +409,14 @@ export function ChatView({
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
{...dropHandlers}
>
<AssistantRuntimeProvider runtime={runtime}>
<ChatRuntimeBoundary
busy={busy}
onCancel={onCancel}
onEdit={onEdit}
onReload={onReload}
onThreadMessagesChange={onThreadMessagesChange}
suppressMessages={routeSessionMismatch}
>
<Thread
clampToComposer={showChatBar}
cwd={currentCwd}
@@ -384,13 +451,14 @@ export function ChatView({
onSteer={onSteer}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
queueSessionKey={selectedSessionId || activeSessionId}
queueSessionKey={selectedSessionId}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
)}
</AssistantRuntimeProvider>
</ChatRuntimeBoundary>
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
@@ -0,0 +1,58 @@
import { useStore } from '@nanostores/react'
import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
/**
* Floating "jump to bottom" control. Sits centered just above the composer,
* clearing the out-of-flow status stack via the same measured-height CSS vars
* the thread's bottom clearance uses (`--composer-measured-height` +
* `--status-stack-measured-height`), so it never overlaps the queue / subagent
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
* Enter/exit motion lives in styles.css under `.thread-jump-button` a
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
* `in`/`out` only swap once it has actually appeared.
*/
export function ScrollToBottomButton() {
const { t } = useI18n()
const visible = useStore($threadJumpButtonVisible)
const hasShownRef = useRef(false)
if (visible) {
hasShownRef.current = true
}
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
return (
<button
aria-hidden={!visible}
aria-label={t.assistant.thread.scrollToBottom}
className={cn(
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
!visible && 'pointer-events-none'
)}
data-state={state}
onClick={() => {
triggerHaptic('selection')
requestScrollToBottom()
}}
style={{
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.625rem)'
}}
tabIndex={visible ? 0 : -1}
type="button"
>
<Codicon name="arrow-down" size="1rem" />
</button>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { resolveManualSessionOrderIds } from './order'
describe('resolveManualSessionOrderIds', () => {
it('clears legacy auto-seeded order until the user manually reorders sessions', () => {
expect(resolveManualSessionOrderIds(['newest', 'older'], ['older', 'newest'], false)).toEqual([])
})
it('keeps a manual order and surfaces newly seen sessions first', () => {
expect(resolveManualSessionOrderIds(['newest', 'older', 'oldest'], ['oldest', 'older'], true)).toEqual([
'newest',
'oldest',
'older'
])
})
it('clears manual order when none of the saved ids still exist', () => {
expect(resolveManualSessionOrderIds(['newest'], ['gone'], true)).toEqual([])
})
})
@@ -0,0 +1,17 @@
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
if (!manual || !currentIds.length || !orderIds.length) {
return []
}
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
if (!retained.length) {
return []
}
const retainedSet = new Set(retained)
const fresh = currentIds.filter(id => !retainedSet.has(id))
return [...fresh, ...retained]
}
@@ -467,6 +467,10 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
aria-label={p.actionsFor(label)}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
// Menu close refocuses the trigger — which doubles as the popover
// anchor — so the picker reads it as focus-outside and dies on open.
// Suppress the refocus and the picker survives.
onCloseAutoFocus={event => event.preventDefault()}
>
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
@@ -96,7 +96,9 @@ export function SidebarSessionRow({
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
isSelected && 'bg-(--ui-row-active-background)',
isWorking && 'text-foreground',
dragging && 'z-10 cursor-grabbing opacity-60 shadow-sm',
// Opaque surface while lifted so the dragged row erases what's under
// it (translucency let the rows below bleed through).
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
className
)}
data-working={isWorking ? 'true' : undefined}
@@ -1,7 +1,7 @@
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useMemo, useRef } from 'react'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
@@ -48,7 +48,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
const virtualizer = useVirtualizer({
count: sessions.length,
@@ -101,21 +100,16 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
)
})
const list = (
// When sortable, the caller wraps this in a ReorderableList that owns the
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
// just consume that context via useSortable.
return (
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{rows}
</div>
</div>
)
return sortable ? (
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{list}
</SortableContext>
) : (
list
)
}
interface VirtualSortableRowProps {
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/types/hermes'
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
describe('workspaceGroupsFor', () => {
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
const groups = workspaceGroupsFor(
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
'No workspace'
)
expect(groups).toHaveLength(2)
})
it('disambiguates colliding basenames by walking up the path', () => {
expect(
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
})
it('leaves a unique basename as its short label', () => {
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
'desktop',
'heval-py'
])
})
it('grows the prefix past one segment when the parent also collides', () => {
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
'x/proj/apps/desktop',
'y/proj/apps/desktop'
])
})
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
const noWorkspace = groups.find(g => g.path === null)
expect(noWorkspace?.label).toBe('No workspace')
})
})
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
branch: null,
isMainWorktree: false,
...over
})
describe('workspaceTreeFor', () => {
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
'No workspace'
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
})
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
const resolver: WorktreeResolver = cwd => {
if (cwd === '/www/hermes-agent') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
}
if (cwd === '/elsewhere/ha-rtl') {
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
}
return null
}
const tree = workspaceTreeFor(
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
'No workspace',
resolver
)
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('hermes-agent')
// The main checkout labels by directory (its branch is transient — using it
// would misattribute old sessions to the currently checked-out branch);
// linked worktrees label by branch.
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
})
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('heval-node')
expect(tree[0].groups).toHaveLength(1)
expect(tree[0].groups[0].label).toBe('heval-node')
})
it('aggregates session counts across a repos worktrees', () => {
const tree = workspaceTreeFor(
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
'No workspace'
)
const parent = tree.find(p => p.label === 'ha')
expect(parent?.sessionCount).toBe(3)
})
it('no-workspace sessions form their own parent', () => {
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
expect(tree).toHaveLength(1)
expect(tree[0].label).toBe('No workspace')
expect(tree[0].path).toBeNull()
})
})
describe('uniqueCwds', () => {
it('dedupes and drops empty/whitespace cwds', () => {
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
})
})
@@ -0,0 +1,326 @@
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
export interface SidebarSessionGroup {
id: string
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
const NO_WORKSPACE_ID = '__no_workspace__'
/** Path split into segments, ignoring trailing slashes and mixed separators. */
const segments = (path: string): string[] => path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean)
/** Last path segment. */
export const baseName = (path: string): string | undefined => segments(path).pop()
/** The segments above the basename. */
const parentSegments = (path: string): string[] => segments(path).slice(0, -1)
interface Labelable {
id: string
label: string
path: null | string
}
/**
* Disambiguate groups whose basename collides (worktrees all end in the same
* `apps/desktop`, sibling repos share a folder name, etc.) by walking up the
* path and prepending parent segments until each colliding label is unique
* e.g. `hermes-agent/desktop` vs `hermes-agent-wt-rtl/desktop`. Groups with a
* unique basename keep their short label untouched.
*/
function disambiguateLabels(groups: Labelable[]): void {
const byLabel = new Map<string, Labelable[]>()
for (const group of groups) {
const bucket = byLabel.get(group.label)
if (bucket) {
bucket.push(group)
} else {
byLabel.set(group.label, [group])
}
}
for (const bucket of byLabel.values()) {
if (bucket.length < 2) {
continue
}
// Only groups backed by a real path can grow a prefix; the synthetic
// "No workspace" group has no path and stays as-is.
const pathed = bucket.filter(group => group.path)
if (pathed.length < 2) {
continue
}
const parents = new Map(pathed.map(group => [group.id, parentSegments(group.path!)]))
let depth = 1
// Grow the prefix one parent segment at a time until every label in the
// bucket is distinct, or we run out of parent segments to add.
while (depth <= Math.max(...pathed.map(g => parents.get(g.id)!.length))) {
const labels = new Map<string, number>()
for (const group of pathed) {
const segs = parents.get(group.id)!
const prefix = segs.slice(-depth).join('/')
const base = baseName(group.path!) ?? group.path!
group.label = prefix ? `${prefix}/${base}` : base
labels.set(group.label, (labels.get(group.label) ?? 0) + 1)
}
if ([...labels.values()].every(count => count === 1)) {
break
}
depth += 1
}
}
}
export function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
const path = session.cwd?.trim() || ''
const id = path || NO_WORKSPACE_ID
const label = baseName(path) || path || noWorkspaceLabel
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session)
groups.set(id, group)
}
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const result = [...groups.values()]
disambiguateLabels(result)
return result
}
/**
* A worktree's main repo and all its linked worktrees collapse into ONE parent
* (keyed by the repo root); each worktree is a child group; sessions hang off
* the worktree they ran in. `parent → worktree → sessions`.
*/
export interface SidebarWorkspaceTree {
id: string
label: string
path: null | string
groups: SidebarSessionGroup[]
sessionCount: number
}
/** Resolves a session cwd to git-worktree identity (from the local fs probe). */
export type WorktreeResolver = (cwd: string) => HermesWorktreeInfo | null | undefined
interface WorkspacePlacement {
parentKey: string
parentLabel: string
parentPath: string
worktreeKey: string
worktreeLabel: string
worktreePath: string
}
/** Replace a path's final segment, preserving its prefix + separators. */
const withBaseName = (path: string, name: string): string =>
path.replace(/[/\\]+$/, '').replace(/[^/\\]+$/, name)
/**
* Path-only fallback for when git metadata is unavailable (remote backends,
* unreadable paths). Mirrors the git layout: a `<repo>-wt-<branch>` directory
* nests under its sibling `<repo>`; any other directory is its own repo root.
*/
function placeByHeuristic(path: string): WorkspacePlacement | null {
const base = baseName(path)
if (!base) {
return null
}
const worktreeMatch = base.match(/^(.+)-wt-(.+)$/)
if (worktreeMatch) {
const repo = worktreeMatch[1]
const repoPath = withBaseName(path, repo)
return {
parentKey: repoPath,
parentLabel: repo,
parentPath: repoPath,
worktreeKey: path,
worktreeLabel: worktreeMatch[2],
worktreePath: path
}
}
return {
parentKey: path,
parentLabel: base,
parentPath: path,
worktreeKey: path,
worktreeLabel: base,
worktreePath: path
}
}
function placeWorkspace(path: string, resolver?: WorktreeResolver): WorkspacePlacement | null {
const info = resolver?.(path)
if (info?.repoRoot && info.worktreeRoot) {
const dirLabel = baseName(info.worktreeRoot) || info.worktreeRoot
return {
parentKey: info.repoRoot,
parentLabel: baseName(info.repoRoot) ?? info.repoRoot,
parentPath: info.repoRoot,
worktreeKey: info.worktreeRoot,
// The main checkout's branch is transient — it changes as you work, so a
// branch label would misattribute every past session to whatever branch
// is checked out *now*. Label it by directory. Linked worktrees are
// per-branch by construction, so branch is the clearest label there.
worktreeLabel: info.isMainWorktree ? dirLabel : info.branch || dirLabel,
worktreePath: info.worktreeRoot
}
}
return placeByHeuristic(path)
}
/** Unique, non-empty session cwds — the batch to probe for worktree info. */
export function uniqueCwds(sessions: SessionInfo[]): string[] {
const seen = new Set<string>()
for (const session of sessions) {
const path = session.cwd?.trim()
if (path) {
seen.add(path)
}
}
return [...seen]
}
/**
* Build the `parent → worktree → sessions` tree. Parents keep recency order
* (first-seen in the recency-sorted input); worktree groups within a parent do
* too, while rows inside a worktree sort by creation time (stable muscle memory,
* matching `workspaceGroupsFor`).
*/
export function workspaceTreeFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
resolver?: WorktreeResolver,
options: { preserveSessionOrder?: boolean } = {}
): SidebarWorkspaceTree[] {
interface WorktreeEntry {
group: SidebarSessionGroup
parentKey: string
parentLabel: string
parentPath: string
}
const worktrees = new Map<string, WorktreeEntry>()
const noWorkspace: SessionInfo[] = []
for (const session of sessions) {
const path = session.cwd?.trim() || ''
if (!path) {
noWorkspace.push(session)
continue
}
const placement = placeWorkspace(path, resolver)
if (!placement) {
noWorkspace.push(session)
continue
}
let entry = worktrees.get(placement.worktreeKey)
if (!entry) {
entry = {
group: { id: placement.worktreeKey, label: placement.worktreeLabel, path: placement.worktreePath, sessions: [] },
parentKey: placement.parentKey,
parentLabel: placement.parentLabel,
parentPath: placement.parentPath
}
worktrees.set(placement.worktreeKey, entry)
}
entry.group.sessions.push(session)
}
if (!options.preserveSessionOrder) {
for (const entry of worktrees.values()) {
entry.group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
const parents = new Map<string, SidebarWorkspaceTree>()
for (const entry of worktrees.values()) {
let parent = parents.get(entry.parentKey)
if (!parent) {
parent = { id: entry.parentKey, label: entry.parentLabel, path: entry.parentPath, groups: [], sessionCount: 0 }
parents.set(entry.parentKey, parent)
}
parent.groups.push(entry.group)
parent.sessionCount += entry.group.sessions.length
}
const result = [...parents.values()]
if (noWorkspace.length) {
result.push({
id: NO_WORKSPACE_ID,
label: noWorkspaceLabel,
path: null,
groups: [{ id: NO_WORKSPACE_ID, label: noWorkspaceLabel, path: null, sessions: noWorkspace }],
sessionCount: noWorkspace.length
})
}
// Parents that collide on basename grow a path prefix; worktree labels that
// collide inside a parent do the same.
disambiguateLabels(result)
for (const parent of result) {
disambiguateLabels(parent.groups)
}
return result
}
+7 -2
View File
@@ -3,9 +3,14 @@ import type { ChatMessage } from '@/lib/chat-messages'
export type ThreadLoadingState = 'response' | 'session'
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
const lastVisible = [...messages].reverse().find(message => !message.hidden)
// Allocation-free reverse scan — runs in a hot $messages computed.
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (!messages[i].hidden) {
return messages[i].role === 'user'
}
}
return lastVisible?.role === 'user'
return false
}
export function threadLoadingState(
@@ -118,6 +118,10 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
// "Go to session id" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@@ -413,6 +417,24 @@ export function CommandPalette() {
const result: PaletteGroup[] = []
// Paste a raw session id → jump straight to it, even if it predates the
// recent-200 window the lists below are built from.
const directId = search.trim()
if (SESSION_ID_RE.test(directId)) {
result.push({
items: [
{
icon: MessageCircle,
id: `goto-${directId}`,
keywords: ['session', 'id', 'go to', directId],
label: `${t.commandCenter.goToSession} ${directId}`,
run: go(sessionRoute(directId))
}
]
})
}
if (sessions.length > 0) {
result.push({
heading: t.commandCenter.sections.sessions,
@@ -328,13 +328,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const term = new Terminal({
allowProposedApi: true,
allowTransparency: true,
// Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes
// glyphs as grayscale-alpha for compositing over a see-through canvas, which
// reads soft on every platform; VS Code keeps it off and our surface
// (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid.
allowTransparency: false,
convertEol: true,
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontSize: 11,
fontWeight: '400',
fontWeightBold: '700',
// VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium
// (500) as the base, which reads a touch heavy at this size.
fontWeight: 'normal',
fontWeightBold: 'bold',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
@@ -617,8 +623,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
startSession()
}
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
// fonts.ready settles only already-requested faces; the regular (400),
// bold (700) and italic aren't asked for until styled output paints (past
// atlas init), so warm them up front — otherwise the WebGL atlas bakes a
// fallback face and the terminal renders thin until a repaint.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
@@ -16,6 +16,11 @@ import {
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import {
dedupeGeneratedImageEchoesInParts,
generatedImageEchoSources,
stripGeneratedImageEchoes
} from '@/lib/generated-images'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { parseTodos } from '@/lib/todos'
@@ -343,7 +348,7 @@ export function useMessageStream({
if (queued.assistant) {
mutateStream(
id,
parts => appendAssistantTextPart(parts, queued.assistant),
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
() => [assistantTextPart(queued.assistant)]
)
}
@@ -507,7 +512,7 @@ export function useMessageStream({
mutateStream(
sessionId,
parts => upsertToolPart(parts, payload, phase),
parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)),
() => upsertToolPart([], payload, phase),
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
)
@@ -540,9 +545,11 @@ export function useMessageStream({
const finalText = renderMediaTags(text).trim()
const completionError = completionErrorText(finalText)
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const dedupeReference = normalize(finalText)
const replaceTextPart = (parts: ChatMessagePart[]) => {
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
const dedupeReference = normalize(visibleFinalText)
const kept = parts.filter(part => {
if (part.type === 'text') {
return false
@@ -557,7 +564,7 @@ export function useMessageStream({
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
})
return finalText ? [...kept, assistantTextPart(finalText)] : kept
return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept
}
const completeMessage = (message: ChatMessage): ChatMessage =>
@@ -325,6 +325,81 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
})
it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
// A stale-session 404 must not strand the queued entry: submitPrompt returns
// false on failure so the composer keeps it, and the edge-independent
// auto-drain re-attempts once the session is idle again. storedSessionId is
// null so the session.resume recovery path is skipped and the error surfaces.
let attempt = 0
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
attempt += 1
if (attempt === 1) {
throw new Error('404: {"detail":"Session not found"}')
}
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
const first = await handle!.submitText('please send me', { fromQueue: true })
expect(first).toBe(false)
const second = await handle!.submitText('please send me', { fromQueue: true })
expect(second).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
session_id: RUNTIME_SESSION_ID,
text: 'please send me'
})
})
it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => {
// A submit racing the settle edge can hit a transient 4009 before the turn
// has fully wound down. It must be invisible: retried in place until the
// gateway accepts, never a red "session busy" bubble.
let attempt = 0
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'prompt.submit') {
attempt += 1
if (attempt === 1) {
throw new Error('4009: session busy')
}
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
expect(await handle!.submitText('sent while settling')).toBe(true)
expect(attempt).toBe(2) // rode past the busy on the second try
// No assistant-error message was appended for the transient busy.
expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe(
false
)
})
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
const busyRef = { current: true }
const requestGateway = vi.fn(async () => ({}) as never)
@@ -801,7 +876,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session busy')
throw new Error('gateway exploded')
}
return {} as never
})
@@ -118,10 +118,12 @@ function isSessionNotFoundError(error: unknown): boolean {
}
// 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
// busy"). It's a transient concurrency guard, never a user-facing error: a
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
// turn still surfaces eventually.
const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
const SESSION_BUSY_RETRY_INTERVAL_MS = 150
function isSessionBusyError(error: unknown): boolean {
return /session busy/i.test(error instanceof Error ? error.message : String(error))
@@ -129,6 +131,26 @@ function isSessionBusyError(error: unknown): boolean {
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
// Retry a gateway call across transient "session busy" so it never reaches the
// user — the turn settles within the deadline and the call lands.
async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
for (;;) {
try {
return await call()
} catch (err) {
if (isSessionBusyError(err) && Date.now() < deadline) {
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
continue
}
throw err
}
}
}
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@@ -683,7 +705,7 @@ export function usePromptActions({
let submitErr: unknown = null
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text }))
} catch (firstErr) {
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
@@ -695,7 +717,7 @@ export function usePromptActions({
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('prompt.submit', { session_id: recoveredId, text })
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }))
} else {
submitErr = firstErr
}
@@ -714,9 +736,17 @@ export function usePromptActions({
return true
} catch (err) {
releaseBusy()
// A queued drain that raced a not-yet-settled turn gets a transient
// "session busy" (4009). Don't surface an error bubble/toast — the entry
// stays queued and the composer's bounded auto-drain retries when idle.
if (options?.fromQueue && isSessionBusyError(err)) {
return false
}
const message = inlineErrorMessage(err, copy.promptFailed)
releaseBusy()
updateSessionState(sessionId, state => ({
...state,
messages: [
@@ -1452,9 +1482,8 @@ export function usePromptActions({
// 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.
// can fire while a turn is streaming, interrupt the live turn first the
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
if (wasRunning) {
@@ -1465,27 +1494,13 @@ export function usePromptActions({
}
}
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
}
}
await withSessionBusyRetry(() =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
)
},
[requestGateway]
)
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -12,7 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$messages,
@@ -236,18 +236,42 @@ async function resolveStoredSession(storedSessionId: string): Promise<SessionInf
return cached
}
// Direct by-id on the live backend — one row lookup, no list scan. Covers
// single-profile users and any id on the active profile (e.g. an old session
// past the sidebar's recent window). 404 just means it's not on this profile.
try {
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
const session = await getSession(storedSessionId)
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
upsertResolvedSession(session, storedSessionId)
return resolved
return session
} catch {
return undefined
// Not on the active profile — fall through to the cross-profile probe.
}
// Multi-profile only: probe each other profile by id (still one cheap lookup
// each) rather than pulling every profile's recent sessions. The first hit
// carries its owning `profile`, which routes the resume to the right backend.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
const otherProfiles = $profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
for (const profile of otherProfiles) {
try {
const session = await getSession(storedSessionId, profile)
upsertResolvedSession(session, storedSessionId)
return session
} catch {
// Not on this profile; try the next.
}
}
return undefined
}
type SessionRuntimeStatePatch = Partial<
@@ -523,8 +547,31 @@ export function useSessionActions({
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
// Paint the click before the profile-resolve / gateway-swap awaits below,
// so there's zero dead air: highlight the row instantly (the sidebar reads
// $selectedStoredSessionId) and, for a cold target, drop the previous
// transcript so the thread shows its loader instead of the old session
// lingering until resume lands. A warm-cached target keeps its transcript —
// the cached fast-path repaints it this same tick. Setting the ref here is
// also what use-route-resume's self-heal assumes ("set synchronously at
// resume entry").
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
}
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
// id loads as fast as a sidebar click instead of hanging on a list scan.
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
@@ -618,10 +665,26 @@ export function useSessionActions({
const watchWindow = isWatchWindow()
let localSnapshot = $messages.get()
// REST transcript prefetch and the gateway resume RPC are independent
// — run them concurrently so a big session's wall time is
// max(prefetch, resume) instead of their sum. The prefetch paints the
// transcript as soon as it lands; the RPC binds the runtime id.
// Watch windows skip the prefetch — lazy resume attaches the live mirror.
const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile)
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
// The rejection is consumed by the `await` below; this guard only
// keeps it from surfacing as unhandled while the prefetch settles.
resumePromise.catch(() => undefined)
try {
// Watch windows skip REST prefetch — lazy resume attaches the live mirror.
if (!watchWindow) {
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
if (prefetchPromise) {
const storedMessages = await prefetchPromise
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
@@ -635,12 +698,7 @@ export function useSessionActions({
// Non-fatal: gateway resume below can still hydrate the session.
}
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(watchWindow ? { lazy: true } : {}),
...(sessionProfile ? { profile: sessionProfile } : {})
})
const resumed = await resumePromise
if (!isCurrentResume()) {
return
@@ -648,17 +706,22 @@ export function useSessionActions({
const currentMessages = $messages.get()
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
// Keep the local snapshot when resume would only reshuffle runtime projection.
// Keep the local snapshot when resume would only reshuffle runtime
// projection. When the REST prefetch already hydrated the transcript,
// skip converting/reconciling the resume payload entirely — on a
// 1000+-message session that second conversion plus the deep
// equivalence compare costs over a second of main-thread time.
const preferredMessages =
localSnapshot.length > 0
? localSnapshot
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
? currentMessages
: resumedMessages
: (() => {
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
})()
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
@@ -2,12 +2,22 @@
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import {
parseMarkdownIntoBlocks,
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps
} from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import {
type ComponentProps,
memo,
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
@@ -26,6 +36,7 @@ import {
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { tailBoundedRemend } from '@/lib/remend-tail'
import { cn } from '@/lib/utils'
// Math rendering plugin (KaTeX). Configured once at module scope — the
@@ -42,6 +53,51 @@ import { cn } from '@/lib/utils'
// LLM convention). The default false-setting only accepts `$$...$$`.
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
return tailBoundedRemend(preprocessMarkdown(text))
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed
// on the text — but the same text is re-lexed every time a message REMOUNTS
// (virtualizer scroll, session switch) and whenever multiple surfaces render
// the same content (deferred + smooth reveal republish). A small module-level
// LRU keyed by the exact source string removes all of those repeat parses
// with zero correctness risk (same input → same output). Streaming tail
// growth misses the cache by design (every flush is a new string) — that
// single lex is the irreducible cost.
const BLOCK_CACHE_MAX = 64
const BLOCK_CACHE_MIN_LENGTH = 1024
const blockCache = new Map<string, string[]>()
function parseMarkdownIntoBlocksCached(markdown: string): string[] {
if (markdown.length < BLOCK_CACHE_MIN_LENGTH) {
return parseMarkdownIntoBlocks(markdown)
}
const hit = blockCache.get(markdown)
if (hit) {
// Refresh recency (Map iteration order is insertion order).
blockCache.delete(markdown)
blockCache.set(markdown, hit)
return hit
}
const blocks = parseMarkdownIntoBlocks(markdown)
blockCache.set(markdown, blocks)
if (blockCache.size > BLOCK_CACHE_MAX) {
blockCache.delete(blockCache.keys().next().value as string)
}
return blocks
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path
@@ -241,6 +297,13 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
// keeps draining its tail instead of snapping.
const REVEAL_DRAIN_MS = 500
const REVEAL_MAX_CHARS_PER_FRAME = 30
// Floor between reveal commits. Each commit republishes the text context and
// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark
// on the open block) over the full accumulated text — at raw rAF cadence
// that's 60 full parses/second and was the dominant streaming cost for
// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while
// halving the parse work.
const REVEAL_MIN_COMMIT_MS = 33
function useSmoothReveal(text: string, isRunning: boolean): string {
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
@@ -273,10 +336,27 @@ function useSmoothReveal(text: string, isRunning: boolean): string {
const tick = () => {
const now = performance.now()
const dt = now - lastTickRef.current
// Skip this frame if the floor hasn't elapsed — the backlog math below
// is dt-proportional, so delayed commits reveal proportionally more.
if (dt < REVEAL_MIN_COMMIT_MS) {
frameRef.current = requestAnimationFrame(tick)
return
}
lastTickRef.current = now
const remaining = targetRef.current.length - shownRef.current.length
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
const add = Math.min(
remaining,
// dt-scaled so the per-commit cap stays equivalent to the old
// per-frame cap at any commit cadence.
Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7),
Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))
)
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
setDisplayed(shownRef.current)
@@ -460,17 +540,20 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
containerProps={containerProps}
lineNumbers={false}
mode="streaming"
// Always auto-close incomplete fences — even during streaming.
// Without this, an unclosed ```python ... ``` whose body contains
// `$` (very common: shell snippets, JS template strings, dollar
// amounts) leaks those dollars out to the math parser and they
// get rendered as broken inline math until the closing fence
// arrives. Shiki is independently deferred via `defer={isStreaming}`
// on the SyntaxHighlighter component, so we don't pay code-block
// tokenization on every token even with this set.
parseIncompleteMarkdown
// Incomplete-markdown repair is handled by `preprocessWithTailRepair`
// below (tail-bounded remend) instead of Streamdown's built-in pass,
// which re-runs remend over the ENTIRE message on every flush — ~18%
// of streaming script time on 50KB+ messages. The repair itself stays
// always-on (even between flushes / for completed messages): an
// unclosed ```python ... ``` whose body contains `$` (shell snippets,
// JS template strings, dollar amounts) would otherwise leak those
// dollars to the math parser and render broken inline math. Shiki is
// independently deferred via `defer={isStreaming}` on the
// SyntaxHighlighter component.
parseIncompleteMarkdown={false}
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached}
plugins={plugins}
preprocess={preprocessMarkdown}
preprocess={preprocessWithTailRepair}
/>
)
}
@@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
} as unknown as Animation
}
// jsdom returns 0 for offset*; the virtualizer reads those to size its
// jsdom returns 0 for offset*; some layout code reads those to size the
// viewport. Fall through to client* (which tests can override) or a sane
// default so virtualized items render.
// default so message rows render with non-zero dimensions.
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
@@ -216,6 +216,32 @@ function assistantTodoMessage(
} as ThreadMessage
}
function assistantImageMessage(running = false): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'image-1',
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
}
],
status: running ? { type: 'running' } : { 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)
@@ -254,20 +280,6 @@ function StreamingHarness() {
)
}
function StaticThreadHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage('complete response', false)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function TodoHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@@ -409,222 +421,11 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not auto-follow idle layout shifts', async () => {
const { container } = render(<StaticThreadHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not follow streaming content growth even while parked at the bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let clientHeight = 200
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', {
configurable: true,
get: () => clientHeight
})
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
// Park the user at the bottom of the current content.
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
clientHeight = 240
await act(async () => {
viewport.scrollTop = 760
fireEvent.scroll(viewport)
})
// Content grows as tokens stream in. Streaming auto-follow is removed, so
// the viewport must NOT chase the new bottom — it stays where the user
// last left it.
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(760)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(650)
// Completion re-measures (Shiki highlight) and grows the content. The
// post-run bottom lock is removed, so the viewport stays put instead of
// snapping to the new bottom.
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(800)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
await wait(650)
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
// layout, spring animation via rAF) only produces brittle change-detector
// tests. The rendering/streaming-content tests below remain the contract.
it('renders an incomplete streaming fenced code block as a code card', async () => {
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
@@ -640,14 +441,19 @@ describe('assistant-ui streaming renderer', () => {
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
const { container } = render(<RunningReasoningHarness />)
const ui = within(container)
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
fireEvent.click(thinkingToggle)
}
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
})
expect(container.textContent).not.toContain('```ts')
})
@@ -700,4 +506,16 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
})
it('renders completed image generation results in the tool slot', async () => {
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
'https://cdn.example/cat.png'
)
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
})
})
@@ -0,0 +1,307 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useStickToBottom } from 'use-stick-to-bottom'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; weight: number } & (
| { index: number; kind: 'standalone' }
| { indices: number[]; kind: 'turn' }
)
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
// assistant message folds every tool call into a part, so heavy sessions are
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
// count. "Show earlier" prepends another page; whole turns stay intact so the
// sticky human bubble never loses its turn. This is the long-session perf lever
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
// fight use-stick-to-bottom (the single scroll owner).
const RENDER_BUDGET = 300
interface ThreadMessageListProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
// Group each user message with the assistant turn(s) that follow it so the
// human bubble can `position: sticky` against the scroller across its whole
// turn (see StickyHumanMessageContainer in thread.tsx).
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role, weight] = row.split(':')
return { id, index: Number(index), role, weight: Number(weight) || 1 }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
continue
}
const indices = [message.index]
let weight = message.weight
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
weight += messages[++i].weight
indices.push(messages[i].index)
}
groups.push({ id: message.id, indices, kind: 'turn', weight })
}
return groups
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
.join('\n')
)
const { t } = useI18n()
const groups = buildGroups(messageSignature)
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
// spring can't tell live-token growth from a session-switch bulk relayout, and
// chasing the latter reads as the view scrolling to random spots before
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
initial: 'instant',
resize: 'instant'
})
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
// Walk turns newest-first, summing their part weights until the budget is met;
// everything before that first kept turn is hidden.
let firstVisible = groups.length
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (weight >= renderBudget) {
break
}
}
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])
// Inline edit grows a sticky bubble. Escape before focus/layout so the
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
const beginEditHold = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
endEditHold()
stopScroll()
el.setAttribute('data-editing', 'true')
}, [endEditHold, scrollRef, stopScroll])
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
useEffect(() => () => endEditHold(), [endEditHold])
// New run → snap to the latest turn.
useAuiEvent('thread.runStart', () => void scrollToBottom())
// Reset the cap and pin to bottom on mount + every session switch (messages
// swap in place on a long-lived runtime, so sessionKey is the only signal).
// The swap is multi-step and lays out over many frames; letting the library
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
// Instead: quiet it, glue to the true bottom until the height holds steady,
// then hand back locked. Live streaming afterward uses the normal resize follow.
useLayoutEffect(() => {
setRenderBudget(RENDER_BUDGET)
const el = scrollRef.current
if (!el) {
return
}
stopScroll()
el.scrollTop = el.scrollHeight
let frame = 0
let stableFrames = 0
let lastHeight = el.scrollHeight
const settle = () => {
const node = scrollRef.current
if (!node) {
return
}
const height = node.scrollHeight
stableFrames = height === lastHeight ? stableFrames + 1 : 0
lastHeight = height
node.scrollTop = height
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
if (stableFrames >= 5 || ++frame > 90) {
void scrollToBottom('instant')
return
}
rafId = requestAnimationFrame(settle)
}
let rafId = requestAnimationFrame(settle)
return () => cancelAnimationFrame(rafId)
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
// Prepend an older page while preserving the on-screen position. The user is
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
// won't fight this manual restore.
const showEarlier = useCallback(() => {
const el = scrollRef.current
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
setRenderBudget(budget => budget + RENDER_BUDGET)
}, [scrollRef])
useLayoutEffect(() => {
const el = scrollRef.current
if (el && restoreFromBottomRef.current != null) {
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
restoreFromBottomRef.current = null
}
}, [scrollRef, renderBudget])
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}
data-slot="aui_thread-viewport"
ref={scrollRef as React.RefCallback<HTMLDivElement>}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
{hiddenCount > 0 && (
<button
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
onClick={showEarlier}
type="button"
>
{t.assistant.thread.showEarlier}
</button>
)}
{visibleGroups.map(group => (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
key={group.id}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
))}
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const ThreadMessageList = memo(ThreadMessageListInner)
@@ -1,469 +0,0 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react'
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
interface VirtualizedThreadProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role] = row.split(':')
return { id, index: Number(index), role }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
continue
}
const indices = [message.index]
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
indices.push(messages[++i].index)
}
groups.push({ id: message.id, indices, kind: 'turn' })
}
return groups
}
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const isRunning = useAuiState(s => s.thread.isRunning)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const scrollerRef = useRef<HTMLDivElement | null>(null)
// Shared ref so scrollToFn can check whether the user is parked at the
// bottom without needing a ref from inside useThreadScrollAnchor.
const stickyBottomRef = useRef(true)
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
getItemKey: index => groups[index]?.id ?? index,
getScrollElement: () => scrollerRef.current,
// Seed the rect so the initial range mounts something before
// `observeElementRect` reports the real layout (it overrides this).
initialRect: { height: 600, width: 800 },
overscan: OVERSCAN,
// When the virtualizer adjusts scroll due to item measurement changes,
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
// adjust creates a feedback loop where the two fight each other,
// producing visible rubber-banding (the view snaps to the composer
// then jumps back up).
scrollToFn: (offset, _options, instance) => {
const el = instance.scrollElement
if (!el) {
return
}
if (stickyBottomRef.current) {
const maxScroll = el.scrollHeight - el.clientHeight
const distFromBottom = maxScroll - el.scrollTop
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
return
}
}
;(el as HTMLElement).scrollTo(0, offset)
}
})
useThreadScrollAnchor({
enabled: !renderEmpty,
groupCount: groups.length,
isRunning,
scrollerRef,
sessionKey: sessionKey ?? null,
stickyBottomRef,
virtualizer
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems[0]?.start ?? 0
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-slot="aui_thread-viewport"
ref={scrollerRef}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
>
{/* Natural-flow virtualization: mounted items render as normal
flex siblings so `position: sticky` on the human bubble
resolves against the scroller without transform interference.
Padding spacers reserve scroll space for unmounted items. */}
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{virtualItems.map(virtualItem => {
const group = groups[virtualItem.index]
if (!group) {
return null
}
return (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
data-index={virtualItem.index}
key={virtualItem.key}
ref={virtualizer.measureElement}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})}
</div>
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const VirtualizedThread = memo(VirtualizedThreadInner)
function scrollElementToBottom(el: HTMLDivElement) {
el.scrollTop = el.scrollHeight
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
isRunning: boolean
scrollerRef: React.RefObject<HTMLDivElement | null>
sessionKey: string | null
stickyBottomRef: React.MutableRefObject<boolean>
virtualizer: Virtualizer<HTMLDivElement, Element>
}
function useThreadScrollAnchor({
enabled,
groupCount,
isRunning,
scrollerRef,
sessionKey,
stickyBottomRef,
virtualizer
}: ScrollAnchorOptions) {
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
// user-driven upward scroll; re-armed when they reach bottom again.
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
// measurement adjustments from fighting our pinToBottom.
const lastTopRef = useRef(0)
const lastHeightRef = useRef(0)
const lastClientHeightRef = useRef(0)
// Counter that tracks how many scroll events we expect to be ours rather
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
// async `scroll` event; without this guard the on-scroll handler can race
// with the programmatic write (because content also grew, the *resulting*
// scrollTop can be lower than `lastTopRef` from the previous frame) and
// misread the programmatic pin as the user scrolling up — which disarms
// sticky-bottom and the user's just-submitted message slides above the
// fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro
// (distFromBottom 0 → 49 within one frame, sticking forever).
const programmaticScrollPendingRef = useRef(0)
const prevSessionKeyRef = useRef(sessionKey)
const prevGroupCountRef = useRef(0)
const pinToBottom = useCallback(() => {
const el = scrollerRef.current
if (!el) {
return
}
// Already parked at the bottom: writing `scrollTop` is a no-op and the
// browser fires NO scroll event, so arming the programmatic gate here would
// leave it permanently set. Repeated pins (streaming heartbeats, the
// post-run lock loop) then accumulate the gate, and the next genuine user
// scroll-up is misread as one of our programmatic scrolls — re-arming
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
return
}
// Hold the disarm gate across the scroll event the next line will fire.
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
// single scroll event, so a counter > 1 can never drain and would swallow a
// later real user scroll.
programmaticScrollPendingRef.current = 1
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
setMutableRef(stickyBottomRef, true)
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
}
})
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
useEffect(() => () => setThreadScrolledUp(false), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
useEffect(() => {
const el = scrollerRef.current
if (!el) {
return undefined
}
const disarm = () => {
setMutableRef(stickyBottomRef, false)
programmaticScrollPendingRef.current = 0
}
const onScroll = () => {
const top = el.scrollTop
// If this scroll event is the consequence of `pinToBottom` writing
// `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin
// loop will re-pin on the next frame if the browser clamped us
// short of bottom (because content grew in the same frame).
// Without this guard the post-pin scrollTop gets misread as the
// user scrolling up, disarming sticky-bottom permanently and
// leaving the just-submitted message below the fold.
if (programmaticScrollPendingRef.current > 0) {
programmaticScrollPendingRef.current -= 1
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
setMutableRef(stickyBottomRef, true)
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
setThreadScrolledUp(!atBottom)
return
}
// Disarm only when `scrollTop` decreases while both content height and
// viewport height are stable. A bare `top < lastTopRef.current` check is
// unsafe: virtualizer measurement, streaming markdown, composer resizing,
// window resizing, and toolbar/status updates can all move scrollTop as a
// layout side effect. Wheel-up and touchmove still disarm immediately via
// their own listeners below, so real user intent remains covered.
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
setMutableRef(stickyBottomRef, false)
}
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
setMutableRef(stickyBottomRef, true)
}
setThreadScrolledUp(!atBottom)
}
const onWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
disarm()
}
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', disarm, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', disarm)
}
}, [scrollerRef, stickyBottomRef])
// Intentionally NO streaming auto-follow. Earlier builds ran a
// ResizeObserver here that re-pinned the viewport to the bottom on every
// content growth while a turn was running, so the chat tracked tokens as
// they streamed. That behavior is removed by request: once a turn is in
// flight the viewport stays exactly where the user left it. The viewport
// is still moved to the bottom ONCE per user submit / new turn / session
// change (see the layout effect and the session-change effect below) so a
// freshly submitted message lands in view — but it does not chase the
// stream afterward.
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
useEffect(() => {
const sessionChanged = prevSessionKeyRef.current !== sessionKey
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
prevSessionKeyRef.current = sessionKey
prevGroupCountRef.current = groupCount
if (enabled && (sessionChanged || becameNonEmpty)) {
jumpToBottom()
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
// from the user submit or assistant turn start), pin BEFORE the browser
// commits the layout to screen. Using useLayoutEffect rather than useEffect
// so this runs synchronously after React commits the DOM mutation but before
// the browser paints. Without this, there's a ~50ms visual window where the
// new message sits below the fold.
//
// We pin TWICE in this critical path — once synchronously, then once on
// the next rAF. The second pin catches the case where React mounts the
// new message in the second commit (after our layout effect ran), which
// grows scrollHeight again; without the rAF pin the user briefly sees a
// ~15 px gap below the new message. This fires once per user submit / new
// turn arrival — it is NOT streaming-token follow (that path is removed
// above), so a turn that streams a long response after this initial jump
// will not chase the bottom.
const prevGroupCountForLayoutRef = useRef(groupCount)
useLayoutEffect(() => {
if (!enabled) {
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
// scroll-up during streaming can race with this effect: the wheel
// event hasn't fired yet so stickyBottomRef is still true, and the
// immediate pinToBottom() would snap the viewport back to bottom
// against the user's intent.
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
}
})
}
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
// chase final Shiki re-highlight measurement. With streaming follow gone,
// re-pinning at completion would yank the viewport back to the bottom even
// though the user is reading earlier content — the opposite of what's
// wanted. The one-time submit / new-turn jump already covers landing a
// fresh message in view.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
prevIsRunningForLayoutRef.current = isRunning
}, [isRunning])
useAuiEvent('thread.runStart', jumpToBottom)
}
@@ -7,7 +7,8 @@ import {
MessagePrimitive,
type ToolCallMessagePartProps,
useAui,
useAuiState
useAuiState,
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { IconPlayerStopFilled } from '@tabler/icons-react'
@@ -52,20 +53,24 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
isImagePath,
partitionDroppedFiles
} from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
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 { ThreadMessageList } from '@/components/assistant-ui/thread-list'
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'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { Intro, type IntroProps } from '@/components/chat/intro'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
@@ -94,13 +99,18 @@ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
interface MessageActionProps {
messageId: string
messageText: string
/** Lazy accessor reads the live message text at action time. Passing the
* text itself as a prop forces the whole footer to re-render on every
* streaming delta flush (the text changes ~30×/s), which profiling showed
* was a large slice of per-token script time on long transcripts. */
getMessageText: () => string
onBranchInNewChat?: (messageId: string) => void
}
@@ -128,6 +138,28 @@ function messageContentText(content: unknown): string {
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
// Cheap streaming-stable "does this message have visible text" check: returns
// on the first non-whitespace text part without concatenating the whole
// message. Used as a useAuiState selector so its boolean output stays stable
// across token flushes (flips false→true once per turn).
function contentHasVisibleText(content: unknown): boolean {
if (typeof content === 'string') {
return content.trim().length > 0
}
if (!Array.isArray(content)) {
return false
}
for (const part of content) {
if (partText(part).trim().length > 0) {
return true
}
}
return false
}
export const Thread: FC<{
clampToComposer?: boolean
cwd?: string | null
@@ -168,18 +200,16 @@ export const Thread: FC<{
) : undefined
return (
<GeneratedImageProvider>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<VirtualizedThread
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
</div>
</GeneratedImageProvider>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadMessageList
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
</div>
)
}
@@ -216,20 +246,39 @@ const CenteredThreadSpinner: FC = () => {
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const messageRuntime = useMessageRuntime()
// PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across
// token flushes (booleans, status strings, '' while running), so the
// 30 Hz delta stream only re-renders the markdown part and the tiny
// StreamStallIndicator leaf — not the footer/preview/root subtree.
const messageStatus = useAuiState(s => s.message.status?.type)
const isRunning = messageStatus === 'running'
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
// Preview targets only materialize once the turn completes — while running
// the selector returns '' (stable), so per-token flushes skip the regex
// scan and the re-render it would cause.
const completedText = useAuiState(s =>
s.message.status?.type === 'running' ? '' : messageContentText(s.message.content)
)
const previewTargets = useMemo(() => {
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) {
return []
}
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
}, [messageText])
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
}, [completedText])
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
const getMessageText = useCallback(
() => messageContentText(messageRuntime.getState().content),
[messageRuntime]
)
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
if (isPlaceholder) {
return null
@@ -240,7 +289,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
data-streaming={messageStatus === 'running' ? 'true' : undefined}
data-streaming={isRunning ? 'true' : undefined}
ref={enterRef}
>
<div
@@ -249,7 +298,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
>
{/* Todos render in the composer status stack now, not inline. */}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
{isRunning && <StreamStallIndicator />}
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
@@ -266,8 +315,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{messageText.trim().length > 0 && (
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
{hasVisibleText && (
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
)
@@ -308,10 +357,28 @@ const STREAM_STALL_S = 2
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
// provider stall) nothing signals that work continues. Watch a per-render
// provider stall) nothing signals that work continues. Watch a per-flush
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
// dither + a timer counting from the last activity.
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
//
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
// so that per-token updates re-render only this leaf, not the whole
// AssistantMessage subtree.
const StreamStallIndicator: FC = () => {
const activity = useAuiState(s => {
let textLength = 0
for (const part of s.message.content) {
const text = (part as { text?: unknown }).text
if (typeof text === 'string') {
textLength += text.length
}
}
return `${s.message.content.length}:${textLength}`
})
const [stalled, setStalled] = useState(false)
useEffect(() => {
@@ -335,21 +402,12 @@ const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
)
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const generatedImage = useGeneratedImageContext()
const running = result === undefined
useEffect(() => {
generatedImage?.setPending(running)
}, [generatedImage, running])
if (!running) {
return null
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
return (
<div className="mt-1.5">
<ImageGenerationPlaceholder />
<GeneratedImage aspectRatio={aspectRatio} result={result} />
</div>
)
}
@@ -579,7 +637,7 @@ function formatMessageTimestamp(
return SHORT_FMT.format(date)
}
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
@@ -600,7 +658,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
)}
data-slot="aui_msg-actions"
>
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<Codicon name="refresh" />
@@ -618,7 +676,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
<GitBranchIcon />
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem messageId={messageId} text={messageText} />
<ReadAloudItem getText={getMessageText} messageId={messageId} />
</DropdownMenuContent>
</DropdownMenu>
</ActionBarPrimitive.Root>
@@ -626,7 +684,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
)
}
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
@@ -640,6 +698,8 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
const read = useCallback(async () => {
const text = getText()
if (!text || $voicePlayback.get().status !== 'idle') {
return
}
@@ -649,11 +709,11 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
} catch (error) {
notifyError(error, copy.readAloudFailed)
}
}, [copy.readAloudFailed, messageId, text])
}, [copy.readAloudFailed, getText, messageId])
return (
<DropdownMenuItem
disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
onSelect={e => {
e.preventDefault()
void (isSpeaking ? stopVoicePlayback() : read())
@@ -707,15 +767,22 @@ function messageAttachmentRefs(value: unknown): string[] {
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
return (
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
// and attachments are flow siblings so the bubble pins against the scroller
// while attachments below it scroll away.
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
{attachments}
</>
)
}
@@ -808,8 +875,10 @@ const UserMessage: FC<{
// changes, not on every frame while the outer max-height animates open.
const clampInnerRef = useRef<HTMLDivElement | null>(null)
const [bodyClamped, setBodyClamped] = useState(false)
const lastClampHeightRef = useRef(-1)
const lineHeightRef = useRef(0)
const measureClamp = useCallback(() => {
const measureClamp = useCallback((entries: readonly ResizeObserverEntry[]) => {
const inner = clampInnerRef.current
const outer = inner?.parentElement
@@ -817,12 +886,28 @@ const UserMessage: FC<{
return
}
const styles = getComputedStyle(inner)
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
const fullHeight = inner.scrollHeight
// Prefer the size the ResizeObserver already computed — reading
// `scrollHeight` outside RO timing forces a synchronous layout, and with
// many user bubbles observed at once those reads interleave with the
// style write below into a read-write-read reflow cascade.
const entryHeight = entries.find(entry => entry.target === inner)?.borderBoxSize?.[0]?.blockSize
const fullHeight = Math.ceil(entryHeight ?? inner.scrollHeight)
if (fullHeight === lastClampHeightRef.current) {
return
}
lastClampHeightRef.current = fullHeight
// Line-height is stable for the life of the bubble (font settings don't
// change under it) — resolve the computed style once.
if (!lineHeightRef.current) {
const styles = getComputedStyle(inner)
lineHeightRef.current = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
}
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
setBodyClamped(fullHeight > lineHeight * 2 + 1)
setBodyClamped(fullHeight > lineHeightRef.current * 2 + 1)
}, [])
useResizeObserver(measureClamp, clampInnerRef)
@@ -855,29 +940,34 @@ const UserMessage: FC<{
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
)
const bubbleContent = (
<>
{attachmentRefs.length > 0 && (
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
<DirectiveContent text={attachmentRefs.join(' ')} />
</span>
)}
{hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)}
</>
const bubbleContent = hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
clicking to edit can't grow the bubble by a sub-pixel and reflow the
turn 1px. */}
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer>
<StickyHumanMessageContainer
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
// it. Image refs render as thumbnails, file refs as chips; no border.
attachmentRefs.length > 0 ? (
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
<DirectiveContent text={attachmentRefs.join(' ')} />
</div>
) : null
}
>
<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">
@@ -888,6 +978,7 @@ const UserMessage: FC<{
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
@@ -1077,6 +1168,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })
useEffect(() => () => notifyThreadEditClose(), [])
const focusEditor = useCallback(() => {
const editor = editorRef.current
@@ -1330,7 +1423,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
const remote = $connection.get()?.mode === 'remote'
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {
@@ -1599,9 +1695,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
@@ -1,9 +1,10 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { clearDismissedToolRows } from '@/store/tool-dismiss'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
@@ -104,6 +105,84 @@ function groupedPendingMessage(): ThreadMessage {
} as ThreadMessage
}
function pendingOnlyMessage(): ThreadMessage {
return {
id: 'assistant-pending-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-only',
toolName: 'terminal',
args: { command: 'sleep 10' },
argsText: JSON.stringify({ command: 'sleep 10' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function completedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-completed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-only',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function failedOnlyMessage(): ThreadMessage {
return {
id: 'assistant-failed-only',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'term-failed',
toolName: 'terminal',
args: { command: 'exit 1' },
argsText: JSON.stringify({ command: 'exit 1' }),
isError: true,
result: { stderr: 'boom' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@@ -122,12 +201,14 @@ beforeEach(() => {
clearAllPrompts()
$activeSessionId.set('sess-1')
$toolDisclosureStates.set({})
clearDismissedToolRows()
})
afterEach(() => {
cleanup()
clearAllPrompts()
$activeSessionId.set(null)
clearDismissedToolRows()
})
describe('flat tool list approval surfacing', () => {
@@ -155,4 +236,64 @@ describe('flat tool list approval surfacing', () => {
expect(bar?.closest('[hidden]')).toBeNull()
})
})
it('lets completed tool rows be dismissed', async () => {
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1)
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
// The thread virtualizes, so a row's component unmounts/remounts as it
// scrolls. Dismissal must persist across that — component-local state would
// forget it and the row would pop back. Simulate the remount by unmounting
// and rendering the same message fresh.
const first = render(<GroupHarness message={completedOnlyMessage()} />)
fireEvent.click(await screen.findByLabelText('Dismiss'))
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
first.unmount()
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
it('lets failed tool rows be dismissed', async () => {
render(<GroupHarness message={failedOnlyMessage()} />)
const dismiss = await screen.findByLabelText('Dismiss')
fireEvent.click(dismiss)
await waitFor(() => {
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
it('does not show dismiss for pending tool rows', async () => {
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
await waitFor(() => {
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
})
expect(screen.queryByLabelText('Dismiss')).toBeNull()
})
})
@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {
expect($approvalRequest.get()).toBeNull()
})
it('reveals the full command inline when the Command toggle is clicked', () => {
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
setRequest(longCommand)
render(<PendingToolApproval part={part('terminal')} />)
// Collapsed by default: the full command is not in the DOM yet.
expect(screen.queryByText(longCommand)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
expect(screen.getByText(longCommand)).toBeTruthy()
})
it('sends choice "deny" on Reject', async () => {
const request = mockGateway()
setRequest()
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
// The pending tool row only shows a single truncated line of the command, and
// a pending row can't be expanded (no result yet), so the full command was
// previously only reachable via the "Always allow" modal. Let the user reveal
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
const [showCommand, setShowCommand] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const hasCommand = request.command.trim().length > 0
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}, [confirmAlways, respond])
return (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
<div className="flex items-center gap-2.5">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('once')}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasCommand && (
<Button
aria-expanded={showCommand}
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => setShowCommand(value => !value)}
size="xs"
variant="ghost"
>
{copy.command}
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
</Button>
)}
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
{showCommand && hasCommand && (
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
<DialogDescription>
{copy.alwaysDescription(request.description)}
</DialogDescription>
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
</DialogHeader>
{request.command.trim() && (
@@ -12,16 +12,20 @@ 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 { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
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 { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './tool-approval'
@@ -193,13 +197,16 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
function ToolEntry({ part }: ToolEntryProps) {
const { t } = useI18n()
const copy = t.assistant.tool
const statusCopy = t.statusStack
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
const toolViewMode = useStore($toolViewMode)
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
const dismissed = useStore($toolRowDismissed(disclosureId))
const open = useDisclosureOpen(disclosureId)
const isPending = messageRunning && part.result === undefined
const canDismiss = !isPending && !embedded
// Only animate entries that mount while their message is actively
// streaming — historical sessions mount with `messageRunning === false`,
// so they paint statically without a settle cascade. The wrapping group
@@ -282,9 +289,33 @@ function ToolEntry({ part }: ToolEntryProps) {
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? (
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : undefined
isPending && !embedded ? <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> : undefined
// Once a turn has settled, a hover/focus-revealed dismiss lets the user clear
// a completed/failed row that would otherwise sit at the tail of the chat.
// It goes in the in-flow `action` slot (not `trailing`) so it can't overlap
// the disclosure caret's hit-target — see the comment above `trailing`.
const dismissAction = canDismiss ? (
<Tip label={statusCopy.dismiss}>
<Button
aria-label={statusCopy.dismiss}
className="size-5 rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:text-(--ui-text-primary) hover:opacity-100 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80"
onClick={event => {
event.stopPropagation()
dismissToolRow(disclosureId)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : undefined
if (dismissed) {
return null
}
return (
<div
@@ -297,6 +328,7 @@ function ToolEntry({ part }: ToolEntryProps) {
>
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
<DisclosureRow
action={dismissAction}
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
trailing={trailing}
@@ -14,12 +14,19 @@ import { cn } from '@/lib/utils'
// title text, NOT the full row — and reaches just past the chevron with
// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
// stretching to the message edge.
// - `trailing` overlays the right edge (absolute) and must stay
// non-interactive (e.g. a duration timer) — an opacity-0-but-clickable
// control there steals clicks from the caret. Interactive controls go in
// `action`, which lays out *in flow* at the far right so it never sits on
// top of the caret's hit-target, no matter how long the title is.
export function DisclosureRow({
action,
children,
onToggle,
open,
trailing
}: {
action?: ReactNode
children: ReactNode
onToggle?: () => void
open: boolean
@@ -55,6 +62,11 @@ export function DisclosureRow({
</span>
)}
</button>
{action && (
<span className="ml-auto flex h-(--conversation-line-height) shrink-0 items-center self-start pl-1.5">
{action}
</span>
)}
{trailing && (
<span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>
)}
@@ -1,19 +0,0 @@
'use client'
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
type Value = {
isPending: boolean
setPending: (pending: boolean) => void
}
const Ctx = createContext<Value | null>(null)
export function GeneratedImageProvider({ children }: { children: ReactNode }) {
const [isPending, setPending] = useState(false)
const value = useMemo(() => ({ isPending, setPending }), [isPending])
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export const useGeneratedImageContext = () => useContext(Ctx)
@@ -0,0 +1,174 @@
'use client'
import { type FC, useEffect, useState } from 'react'
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl, mediaName } from '@/lib/media'
import { cn } from '@/lib/utils'
// Aspect hint from the tool args sizes the frame *before* the image loads, so
// the placeholder and the resolved image occupy the same box — no layout shift.
const ASPECT_HINTS: Record<string, number> = {
landscape: 16 / 9,
square: 1,
portrait: 9 / 16
}
function hintedRatio(aspectRatio?: string): number {
return ASPECT_HINTS[String(aspectRatio ?? '').toLowerCase().trim()] ?? ASPECT_HINTS.landscape
}
function isInlineSrc(path: string): boolean {
return /^(?:https?|data):/i.test(path)
}
async function resolveImageSrc(path: string): Promise<string> {
if (isInlineSrc(path)) {
return path
}
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({ aspectRatio, result }) => {
const { t } = useI18n()
const copy = t.desktop
const image = result === undefined ? null : generatedImageFromResult(result)
const pending = result === undefined
const [ratio, setRatio] = useState(() => hintedRatio(aspectRatio))
const [src, setSrc] = useState(() => (image && isInlineSrc(image) ? image : ''))
const [loaded, setLoaded] = useState(false)
const [canvasGone, setCanvasGone] = useState(false)
const [failed, setFailed] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const { download, saving } = useImageDownload(src)
useEffect(() => setRatio(hintedRatio(aspectRatio)), [aspectRatio])
// Resolve the deliverable path (local read / gateway proxy / remote URL). The
// <img> stays mounted under the placeholder and only fades in once it decodes,
// so the frame keeps its hinted size and never jumps.
useEffect(() => {
let cancelled = false
setFailed(false)
setLoaded(false)
setCanvasGone(false)
setSrc(image && isInlineSrc(image) ? image : '')
if (!image || isInlineSrc(image)) {
return
}
void resolveImageSrc(image)
.then(resolved => !cancelled && setSrc(resolved))
.catch(() => !cancelled && setFailed(true))
return () => {
cancelled = true
}
}, [image])
// Completed but no usable image (generation failed): the agent's prose carries
// the explanation, so render nothing here.
if (!pending && !image) {
return null
}
if (failed && image) {
return (
<a
className="mt-2 inline-block font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(mediaExternalUrl(image))
}}
>
{copy.openImage}: {mediaName(image)}
</a>
)
}
return (
<>
<span
aria-label={pending ? t.assistant.tool.renderingImage : undefined}
aria-live={pending ? 'polite' : undefined}
className="group/image relative block max-w-full overflow-hidden rounded-2xl transition-[width,height] duration-300 ease-out"
data-slot="aui_generated-image"
role={pending ? 'status' : undefined}
style={{
aspectRatio: ratio,
// Width is capped so the derived height (width / ratio) never exceeds
// --image-preview-height; the box then matches the image exactly with
// no letterboxing.
width: `min(calc(var(--image-preview-height) * ${ratio}), var(--image-preview-max-width), 100%)`
}}
>
{!canvasGone && (
<div
className={cn('absolute inset-0 transition-opacity duration-500 ease-out', loaded && 'opacity-0')}
onTransitionEnd={() => loaded && setCanvasGone(true)}
>
<DiffusionCanvas />
</div>
)}
{src && (
<button
className="absolute inset-0 block size-full cursor-zoom-in"
onClick={() => setLightboxOpen(true)}
title={copy.openImage}
type="button"
>
<img
alt="Generated image"
className={cn(
'absolute inset-0 size-full object-contain opacity-0 transition-opacity duration-500 ease-out',
loaded && 'opacity-100'
)}
draggable={false}
onError={() => setFailed(true)}
onLoad={event => {
const { naturalHeight, naturalWidth } = event.currentTarget
if (naturalWidth && naturalHeight) {
setRatio(naturalWidth / naturalHeight)
}
setLoaded(true)
}}
src={src}
/>
</button>
)}
{loaded && src && (
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
)}
</span>
{src && (
<ImageLightbox
alt="Generated image"
copy={copy}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
saving={saving}
src={src}
/>
)}
</>
)
}
@@ -1,7 +1,6 @@
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
type Rgb = { r: number; g: number; b: number }
@@ -24,19 +23,26 @@ const smoothstep = (edge0: number, edge1: number, value: number) => {
}
const parseColor = (value: string, fallback: Rgb): Rgb => {
const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
const v = value.trim()
const hex = v.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (hex) {
return {
r: Number.parseInt(hex[1], 16),
g: Number.parseInt(hex[2], 16),
b: Number.parseInt(hex[3], 16)
}
return { r: Number.parseInt(hex[1], 16), g: Number.parseInt(hex[2], 16), b: Number.parseInt(hex[3], 16) }
}
const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
const rgb = v.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i)
return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback
if (rgb) {
return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) }
}
// Chromium serialises `color-mix(in srgb, …)` as `color(srgb r g b / a)` with 01 floats.
const srgb = v.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i)
return srgb
? { r: Math.round(Number(srgb[1]) * 255), g: Math.round(Number(srgb[2]) * 255), b: Math.round(Number(srgb[3]) * 255) }
: fallback
}
const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({
@@ -82,17 +88,22 @@ const fbm = (x: number, y: number) => {
return value
}
const readTheme = () => {
const styles = getComputedStyle(document.documentElement)
type Theme = Record<keyof typeof FALLBACKS, Rgb>
return {
card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card),
muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted),
foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground),
primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary),
ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring)
}
}
const TOKENS = Object.keys(FALLBACKS) as (keyof typeof FALLBACKS)[]
// `--dt-*` resolve through `var()` chains into `color-mix()`, which
// getPropertyValue hands back verbatim — unreadable. Bouncing each token through
// a probe's `color` lets the browser compute it to a concrete color we can
// parse, so the canvas tracks the live theme instead of a hardcoded fallback.
const readTheme = (probe: HTMLElement): Theme =>
Object.fromEntries(
TOKENS.map(key => {
probe.style.color = `var(--dt-${key})`
return [key, parseColor(getComputedStyle(probe).color, FALLBACKS[key])]
})
) as Theme
const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
const rect = canvas.getBoundingClientRect()
@@ -107,8 +118,13 @@ const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) =>
return { width, height }
}
const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => {
const theme = readTheme()
const drawAsciiDiffusion = (
ctx: CanvasRenderingContext2D,
theme: Theme,
width: number,
height: number,
time: number
) => {
const bg = ctx.createLinearGradient(0, 0, width, height)
bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1))
bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1))
@@ -125,6 +141,9 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
const cellHeight = fontSize * 1.28
const cols = Math.ceil(width / cellWidth)
const rows = Math.ceil(height / cellHeight)
// Normalise both axes by the shorter side so the radial bloom stays a circle
// (not a squished ellipse) when the frame isn't landscape.
const short = Math.min(width, height)
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
const timestep = Math.floor(time * 1.15)
@@ -138,10 +157,10 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
for (let col = -1; col <= cols + 1; col += 1) {
const x = col * cellWidth + cellWidth * 0.5
const y = row * cellHeight + cellHeight * 0.5
const nx = x / width
const ny = y / height
const dx = (nx - centerX) * 1.2
const dy = (ny - centerY) * 0.95
const sx = (x - centerX * width) / short
const sy = (y - centerY * height) / short
const dx = sx * 1.2
const dy = sy * 0.95
const radius = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
@@ -152,7 +171,7 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
const contour =
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
const stem = Math.exp(-((sx + 0.05) ** 2 / 0.004 + (sy - 0.25) ** 2 / 0.08)) * 0.46
const latent = clamp(bloom + contour + stem, 0, 1)
const staticA = hash2(col + timestep * 19, row - timestep * 11)
@@ -224,9 +243,10 @@ const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height
ctx.fillRect(0, 0, width, height)
}
const DiffusionCanvas: FC = () => {
export const DiffusionCanvas: FC = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sizeRef = useRef({ width: 0, height: 0 })
const themeRef = useRef<Theme>(FALLBACKS)
const fitToContainer = useCallback(() => {
const canvas = canvasRef.current
@@ -241,6 +261,28 @@ const DiffusionCanvas: FC = () => {
useResizeObserver(fitToContainer, canvasRef)
useEffect(() => {
const probe = document.createElement('span')
probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none'
document.documentElement.appendChild(probe)
const sync = () => {
themeRef.current = readTheme(probe)
}
sync()
// Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and
// rewrites inline custom props on the root) instead of per animation frame.
const observer = new MutationObserver(sync)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-hermes-mode'] })
return () => {
observer.disconnect()
probe.remove()
}
}, [])
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
@@ -254,7 +296,7 @@ const DiffusionCanvas: FC = () => {
let frame = requestAnimationFrame(function draw(now) {
const { width, height } = sizeRef.current
ctx.clearRect(0, 0, width, height)
drawAsciiDiffusion(ctx, width, height, now / 1000)
drawAsciiDiffusion(ctx, themeRef.current, width, height, now / 1000)
frame = requestAnimationFrame(draw)
})
@@ -265,15 +307,3 @@ const DiffusionCanvas: FC = () => {
return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} />
}
export const ImageGenerationPlaceholder: FC = () => {
const { t } = useI18n()
return (
<div aria-label={t.assistant.tool.renderingImage} aria-live="polite" className="w-full max-w-136 self-start" role="status">
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
<DiffusionCanvas />
</div>
</div>
)
}
@@ -51,7 +51,9 @@ export function StatusRow({
role={onActivate ? 'button' : undefined}
tabIndex={onActivate ? 0 : undefined}
>
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
{leading !== 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
@@ -3,55 +3,17 @@
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
const { pathname } = new URL(src, window.location.href)
return pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
}
interface ImageActionCopy {
export interface ImageActionCopy {
downloadImage: string
savingImage: string
}
@@ -59,70 +21,10 @@ interface ImageActionCopy {
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const { t } = useI18n()
const copy = t.desktop
const [saving, setSaving] = useState(false)
const { download, saving } = useImageDownload(src)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
async function handleDownload() {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({
kind: 'info',
title: copy.downloadStarted,
message: copy.restartToUseSaveImage
})
} catch (fallbackError) {
notifyError(fallbackError, copy.restartToSaveImages)
}
return
}
notifyError(error, copy.imageDownloadFailed)
} finally {
setSaving(false)
}
}
const lightbox = src ? (
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
<DialogContent
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxOpen(false)}
src={src}
/>
<ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<span
@@ -138,30 +40,79 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && <ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="inline" />}
{src && (
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
)}
</span>
{lightbox}
{src && (
<ImageLightbox
alt={alt}
copy={copy}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
saving={saving}
src={src}
/>
)}
</>
)
}
function ImageActionButton({
export function ImageLightbox({
alt,
copy,
onClick,
onOpenChange,
open,
saving,
variant
src
}: {
alt?: string
copy: ImageActionCopy
onClick: () => void
onOpenChange: (open: boolean) => void
open: boolean
saving: boolean
src: string
}) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
showCloseButton={false}
>
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => onOpenChange(false)}
src={src}
/>
<ImageActionButton className="group-hover/lightbox:opacity-100" copy={copy} onClick={onClick} saving={saving} />
</div>
</DialogContent>
</Dialog>
)
}
export function ImageActionButton({
className,
copy,
onClick,
saving
}: {
className?: string
copy: ImageActionCopy
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
<button
aria-label={saving ? copy.savingImage : copy.downloadImage}
className={cn(
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
className
)}
disabled={saving}
onClick={event => {
@@ -3,23 +3,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $desktopOnboarding } from '@/store/onboarding'
import { $gatewayState, setGatewayState } from '@/store/session'
import { setGatewayState } from '@/store/session'
import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.
//
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
// INITIAL boot() throws. After the first successful connect (bootCompleted),
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
// against the dead remote and never sets boot.error. So gatewayState sits at
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
// never appears, settings unreachable.
// against the dead remote. So gatewayState sits at 'closed'/'error' with
// boot.error null. The fix keeps the initial-boot overlay out of post-boot
// reconnects, leaving chat/settings usable while the reconnect loop runs.
function resetStores() {
setGatewayState('idle')
@@ -75,7 +75,7 @@ describe('connecting overlay vs recovery surface', () => {
expect(isConnectingShown()).toBe(false)
})
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
const { rerender } = render(
@@ -97,14 +97,14 @@ describe('connecting overlay vs recovery surface', () => {
</>
)
// The connecting overlay reappears and latches...
expect(isConnectingShown()).toBe(true)
// ...with NO recovery surface, because boot.error was never set.
// The initial-boot connecting overlay stays out of the way, so settings and
// the composer remain reachable during the reconnect loop.
expect(isConnectingShown()).toBe(false)
expect(isRecoveryShown()).toBe(false)
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
// closed → error → closed, boot.error never gets set. The user is
// pinned on CONNECTING with no path to Settings indefinitely.
// 3. Reconnect loops against the dead remote: gatewayState bounces closed
// → error → closed. Until the escalation path sets boot.error, the app
// remains usable instead of modal-blocked.
setGatewayState('error')
rerender(
<>
@@ -113,7 +113,7 @@ describe('connecting overlay vs recovery surface', () => {
</>
)
expect($desktopBoot.get().error).toBeNull()
expect(isConnectingShown()).toBe(true)
expect(isConnectingShown()).toBe(false)
expect(isRecoveryShown()).toBe(false)
})
@@ -52,7 +52,13 @@ export function GatewayConnectingOverlay() {
const [tail, setTail] = useState(TAIL)
const [phase, setPhase] = useState<Phase>('live')
const connecting = gatewayState !== 'open' && !boot.error
// The full-screen connecting overlay is for initial boot only. After a
// healthy boot, flaky networks / sleep-wake can drop the socket and flip the
// gateway state back to closed/error while the app reconnects. Do not cover
// the chat then — users should still be able to type drafts, open settings,
// and recover instead of staring at a modal CONNECTING screen.
const initialBootActive = boot.visible || boot.running || boot.progress < 100
const connecting = gatewayState !== 'open' && !boot.error && initialBootActive
// Latches once we've actually shown the overlay, so the brief frame where
// gatewayState flips to "open" (connecting -> false) before the exit phase
// kicks in doesn't unmount us and cause a flash.
@@ -15,5 +15,29 @@ export function HapticsProvider({ children }: { children: ReactNode }) {
return () => registerHapticTrigger(null)
}, [muted, trigger])
// web-haptics builds its AudioContext lazily inside the first trigger(), and
// the process's first AudioContext pays the CoreAudio spin-up (~850ms stall
// in profiles) — which landed on the first streamStart haptic as the first
// token painted. Open/close a throwaway context at idle so the real one
// connects to an already-warm audio service in single-digit ms.
useEffect(() => {
if (typeof requestIdleCallback !== 'function' || typeof AudioContext === 'undefined') {
return undefined
}
const id = requestIdleCallback(
() => {
try {
void new AudioContext().close().catch(() => undefined)
} catch {
// No audio device (headless CI) — nothing to warm.
}
},
{ timeout: 2000 }
)
return () => cancelIdleCallback(id)
}, [])
return <>{children}</>
}
+9 -2
View File
@@ -34,14 +34,21 @@ function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest
const ref = useRef<HTMLSpanElement>(null)
const [overflowing, setOverflowing] = useState(false)
const measureOverflow = useCallback(() => {
const measureOverflow = useCallback((entries: readonly ResizeObserverEntry[]) => {
const el = ref.current
if (!el) {
return
}
setOverflowing(el.scrollWidth - el.clientWidth > 1)
// `clientWidth` from the RO entry when available (already computed);
// `scrollWidth` is unavoidable — content width isn't part of the entry —
// but inside RO timing layout is already clean so the read is cheap.
const clientWidth = entries.find(entry => entry.target === el)?.contentRect?.width ?? el.clientWidth
// setState is identity-stable: React bails out when the boolean doesn't
// change, so repeated RO fires with the same answer don't re-render.
setOverflowing(el.scrollWidth - clientWidth > 1)
}, [])
useResizeObserver(measureOverflow, ref)
+16
View File
@@ -69,6 +69,10 @@ declare global {
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
// Resolve git-worktree identity for a batch of session cwds, reading git's
// on-disk metadata locally. Returns null per cwd that isn't inside a
// checkout (or can't be read — e.g. a remote backend's path).
worktrees?: (cwds: string[]) => Promise<Record<string, HermesWorktreeInfo | null>>
terminal: {
dispose: (id: string) => Promise<boolean>
onData: (id: string, callback: (payload: string) => void) => () => void
@@ -441,6 +445,18 @@ export interface HermesPreviewWatch {
path: string
}
export interface HermesWorktreeInfo {
// Main repo root — the shared grouping key for a checkout and all its linked
// worktrees.
repoRoot: string
// This cwd's own worktree root.
worktreeRoot: string
// True when this is the repo's primary checkout (.git is a directory).
isMainWorktree: boolean
// Current branch (or short detached-HEAD sha), null when unreadable.
branch: null | string
}
export interface HermesReadDirEntry {
name: string
path: string
+13
View File
@@ -210,6 +210,19 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
})
}
// Resolves a single session row by id on one backend (the active profile, or
// the given `profile`). The backend resolves exact ids and unique prefixes and
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
// cross-profile list scan when locating an unknown id's owner.
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionInfo>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
})
}
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for
@@ -0,0 +1,85 @@
import { useCallback, useState } from 'react'
import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
export function imageFilename(src?: string): string {
if (!src) {
return 'image'
}
try {
return new URL(src, window.location.href).pathname.split('/').filter(Boolean).pop() || 'image'
} catch {
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
}
}
function isMissingIpcHandler(error: unknown): boolean {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
}
async function startBrowserDownload(src: string) {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`Could not fetch image: ${response.status}`)
}
const blobUrl = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = blobUrl
link.download = imageFilename(src)
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}
/** Save an image to disk via the desktop IPC bridge, falling back to a browser
* download when the handler is unavailable (older shell / web preview). */
export function useImageDownload(src?: string) {
const { t } = useI18n()
const copy = t.desktop
const [saving, setSaving] = useState(false)
const download = useCallback(async () => {
if (!src || saving) {
return
}
setSaving(true)
try {
if (window.hermesDesktop?.saveImageFromUrl) {
if (await window.hermesDesktop.saveImageFromUrl(src)) {
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
}
return
}
await startBrowserDownload(src)
} catch (error) {
if (isMissingIpcHandler(error)) {
try {
await startBrowserDownload(src)
notify({ kind: 'info', title: copy.downloadStarted, message: copy.restartToUseSaveImage })
} catch (fallbackError) {
notifyError(fallbackError, copy.restartToSaveImages)
}
return
}
notifyError(error, copy.imageDownloadFailed)
} finally {
setSaving(false)
}
}, [copy, saving, src])
return { download, saving }
}
+13 -4
View File
@@ -1,17 +1,26 @@
import { type RefObject, useLayoutEffect, useRef } from 'react'
export function useResizeObserver(onResize: () => void, ...refs: readonly RefObject<Element | null>[]) {
/**
* Observe element resizes. The callback receives the ResizeObserver entries
* (empty on the initial synchronous call and in non-RO environments) so
* callers can read the observed size off the entry instead of forcing a
* fresh layout read.
*/
export function useResizeObserver(
onResize: (entries: readonly ResizeObserverEntry[]) => void,
...refs: readonly RefObject<Element | null>[]
) {
const refsRef = useRef(refs)
refsRef.current = refs
useLayoutEffect(() => {
if (typeof ResizeObserver === 'undefined') {
onResize()
onResize([])
return
}
const observer = new ResizeObserver(() => onResize())
const observer = new ResizeObserver(entries => onResize(entries))
let observed = false
for (const ref of refsRef.current) {
@@ -31,7 +40,7 @@ export function useResizeObserver(onResize: () => void, ...refs: readonly RefObj
return
}
onResize()
onResize([])
return () => observer.disconnect()
}, [onResize])
@@ -0,0 +1,68 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { uniqueCwds, type WorktreeResolver } from '@/app/chat/sidebar/workspace-groups'
import type { HermesWorktreeInfo } from '@/global'
import type { SessionInfo } from '@/hermes'
import { desktopFsCacheKey, desktopWorktrees } from '@/lib/desktop-fs'
type WorktreeMap = Record<string, HermesWorktreeInfo | null>
/**
* Probe the local filesystem for the git-worktree identity of each session cwd
* and return a resolver the grouping uses to build `parent → worktree`. Results
* are cached per cwd (and reset when the backend connection changes), so a probe
* runs once per directory. Unresolved cwds (probe pending, remote backend, or
* non-git dirs) fall back to the path-name heuristic in `workspaceTreeFor`.
*/
export function useWorktreeInfo(sessions: SessionInfo[], enabled: boolean): WorktreeResolver {
const [map, setMap] = useState<WorktreeMap>({})
const cacheRef = useRef<{ data: WorktreeMap; key: string }>({ data: {}, key: '' })
useEffect(() => {
if (!enabled) {
return
}
const key = desktopFsCacheKey()
if (cacheRef.current.key !== key) {
cacheRef.current = { data: {}, key }
setMap({})
}
const missing = uniqueCwds(sessions).filter(cwd => !(cwd in cacheRef.current.data))
if (!missing.length) {
return
}
let cancelled = false
void desktopWorktrees(missing)
.then(result => {
if (cancelled) {
return
}
// Record every probed cwd (null when absent) so we never re-probe it.
const next: WorktreeMap = { ...cacheRef.current.data }
for (const cwd of missing) {
next[cwd] = result[cwd] ?? null
}
cacheRef.current = { data: next, key }
setMap(next)
})
.catch(() => {
// Bridge unavailable / probe failed — leave cwds unresolved so the
// heuristic fallback handles them.
})
return () => {
cancelled = true
}
}, [sessions, enabled])
return useMemo<WorktreeResolver>(() => (cwd: string) => map[cwd], [map])
}
+6
View File
@@ -643,6 +643,7 @@ export const en: Translations = {
back: 'Back',
searchPlaceholder: 'Search sessions, views, and actions',
goTo: 'Go to',
goToSession: 'Go to session',
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
@@ -1210,6 +1211,8 @@ export const en: Translations = {
queueSendNext: 'Next',
queueSend: 'Send',
queueDelete: 'Delete',
queueStuckTitle: 'Queued message not sent',
queueStuckBody: 'A queued turn kept failing to send. It is still in the queue — try sending it again.',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,
@@ -1653,6 +1656,7 @@ export const en: Translations = {
assistant: {
thread: {
loadingSession: 'Loading session',
showEarlier: 'Show earlier messages',
loadingResponse: 'Hermes is loading a response',
thinking: 'Thinking',
today: time => `Today, ${time}`,
@@ -1666,6 +1670,7 @@ export const en: Translations = {
stopReading: 'Stop reading',
readAloud: 'Read aloud',
editMessage: 'Edit message',
scrollToBottom: 'Scroll to bottom',
stop: 'Stop',
restorePrevious: 'Restore previous checkpoint',
restoreCheckpoint: 'Restore checkpoint',
@@ -1682,6 +1687,7 @@ export const en: Translations = {
gatewayDisconnected: 'Hermes gateway is not connected',
sendFailed: 'Could not send approval response',
run: 'Run',
command: 'Command',
moreOptions: 'More approval options',
allowSession: 'Allow this session',
alwaysAllowMenu: 'Always allow…',
+5
View File
@@ -773,6 +773,7 @@ export const ja = defineLocale({
back: '戻る',
searchPlaceholder: 'セッション、ビュー、アクションを検索',
goTo: '移動',
goToSession: 'セッションへ移動',
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
@@ -1348,6 +1349,8 @@ export const ja = defineLocale({
queueSendNext: '次に送信',
queueSend: '送信',
queueDelete: '削除',
queueStuckTitle: 'キュー内のメッセージを送信できません',
queueStuckBody: 'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
previewUnavailable: 'プレビューは利用できません',
previewLabel: label => `${label} のプレビュー`,
couldNotPreview: label => `${label} をプレビューできませんでした`,
@@ -1794,6 +1797,7 @@ export const ja = defineLocale({
assistant: {
thread: {
loadingSession: 'セッションを読み込み中',
showEarlier: '以前のメッセージを表示',
loadingResponse: 'Hermes が応答を読み込み中',
thinking: '考え中',
today: time => `今日 ${time}`,
@@ -1823,6 +1827,7 @@ export const ja = defineLocale({
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
sendFailed: '承認応答を送信できませんでした',
run: '実行',
command: 'コマンド',
moreOptions: 'その他の承認オプション',
allowSession: 'このセッションで許可',
alwaysAllowMenu: '常に許可…',
+6
View File
@@ -540,6 +540,7 @@ export interface Translations {
back: string
searchPlaceholder: string
goTo: string
goToSession: string
commandCenter: string
appearance: string
settings: string
@@ -925,6 +926,8 @@ export interface Translations {
queueSendNext: string
queueSend: string
queueDelete: string
queueStuckTitle: string
queueStuckBody: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string
@@ -1312,6 +1315,7 @@ export interface Translations {
assistant: {
thread: {
loadingSession: string
showEarlier: string
loadingResponse: string
thinking: string
today: (time: string) => string
@@ -1325,6 +1329,7 @@ export interface Translations {
stopReading: string
readAloud: string
editMessage: string
scrollToBottom: string
stop: string
restorePrevious: string
restoreCheckpoint: string
@@ -1341,6 +1346,7 @@ export interface Translations {
gatewayDisconnected: string
sendFailed: string
run: string
command: string
moreOptions: string
allowSession: string
alwaysAllowMenu: string
+5
View File
@@ -748,6 +748,7 @@ export const zhHant = defineLocale({
back: '返回',
searchPlaceholder: '搜尋工作階段、檢視和動作',
goTo: '前往',
goToSession: '前往工作階段',
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
@@ -1304,6 +1305,8 @@ export const zhHant = defineLocale({
queueSendNext: '下一個',
queueSend: '傳送',
queueDelete: '刪除',
queueStuckTitle: '佇列訊息未送出',
queueStuckBody: '佇列中的對話多次傳送失敗。它仍在佇列中,請重試傳送。',
previewUnavailable: '預覽不可用',
previewLabel: label => `預覽 ${label}`,
couldNotPreview: label => `無法預覽 ${label}`,
@@ -1738,6 +1741,7 @@ export const zhHant = defineLocale({
assistant: {
thread: {
loadingSession: '正在載入工作階段',
showEarlier: '顯示較早的訊息',
loadingResponse: 'Hermes 正在載入回覆',
thinking: '思考中',
today: time => `今天,${time}`,
@@ -1767,6 +1771,7 @@ export const zhHant = defineLocale({
gatewayDisconnected: 'Hermes 閘道未連線',
sendFailed: '無法傳送核准回應',
run: '執行',
command: '指令',
moreOptions: '更多核准選項',
allowSession: '允許本工作階段',
alwaysAllowMenu: '一律允許…',
+6
View File
@@ -835,6 +835,7 @@ export const zh: Translations = {
back: '返回',
searchPlaceholder: '搜索会话、视图与操作',
goTo: '前往',
goToSession: '前往会话',
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
@@ -1398,6 +1399,8 @@ export const zh: Translations = {
queueSendNext: '下一个',
queueSend: '发送',
queueDelete: '删除',
queueStuckTitle: '排队消息未发送',
queueStuckBody: '排队的对话多次发送失败。它仍在队列中,请重试发送。',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,
@@ -1833,6 +1836,7 @@ export const zh: Translations = {
assistant: {
thread: {
loadingSession: '正在加载会话',
showEarlier: '显示更早的消息',
loadingResponse: 'Hermes 正在加载回复',
thinking: '思考中',
today: time => `今天,${time}`,
@@ -1846,6 +1850,7 @@ export const zh: Translations = {
stopReading: '停止朗读',
readAloud: '朗读',
editMessage: '编辑消息',
scrollToBottom: '滚动到底部',
stop: '停止',
restorePrevious: '恢复上一个检查点',
restoreCheckpoint: '恢复检查点',
@@ -1862,6 +1867,7 @@ export const zh: Translations = {
gatewayDisconnected: 'Hermes 网关未连接',
sendFailed: '无法发送审批响应',
run: '运行',
command: '命令',
moreOptions: '更多审批选项',
allowSession: '允许本会话',
alwaysAllowMenu: '始终允许…',
@@ -95,6 +95,38 @@ describe('toChatMessages', () => {
)
})
it('keeps the generated image on the tool row while preserving agent prose', () => {
const [message] = toChatMessages([
{
content: '',
role: 'assistant',
timestamp: 1,
tool_calls: [{ id: 'img-1', function: { name: 'image_generate', arguments: '{"prompt":"draw a cat"}' } }]
},
{
content: '{"success":true,"image":"https://cdn.example/cat.png"}',
role: 'tool',
timestamp: 2,
tool_call_id: 'img-1',
tool_name: 'image_generate'
},
{
content: 'Here you go.\n\n![Generated image](https://cdn.example/cat.png)',
role: 'assistant',
timestamp: 3
}
])
const toolPart = message.parts.find(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName === 'image_generate'
)
expect(toolPart?.result).toMatchObject({ image: 'https://cdn.example/cat.png', success: true })
// The duplicated image is stripped, but the agent's words survive.
expect(chatMessageText(message)).toBe('Here you go.')
})
it('coerces non-string message content without throwing', () => {
const [message] = toChatMessages([
{
+6 -1
View File
@@ -1,5 +1,6 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { parseTodos } from '@/lib/todos'
import type { SessionMessage, UsageStats } from '@/types/hermes'
@@ -811,8 +812,12 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
})
flushPendingTools(messages.length)
const withoutGeneratedImageEchoes = result.map(message =>
message.role === 'assistant' ? { ...message, parts: dedupeGeneratedImageEchoesInParts(message.parts) } : message
)
return withUniqueToolCallIds(
result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
withoutGeneratedImageEchoes.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
)
}
+20 -2
View File
@@ -1,7 +1,12 @@
import type {
HermesConnection,
HermesReadDirResult,
HermesReadFileTextResult,
HermesSelectPathsOptions,
HermesWorktreeInfo
} from '@/global'
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
@@ -75,6 +80,19 @@ export async function desktopGitRoot(path: string): Promise<string | null> {
return result.root
}
// Worktree detection runs against the LOCAL filesystem (the electron main
// process). For a remote backend the session cwds live on another machine, so
// we can't resolve them here — callers fall back to the path-name heuristic.
export async function desktopWorktrees(cwds: string[]): Promise<Record<string, HermesWorktreeInfo | null>> {
if (isDesktopFsRemoteMode()) {
return {}
}
const desktop = bridge()
return desktop.worktrees ? desktop.worktrees(cwds) : {}
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
dedupeGeneratedImageEchoesInParts,
generatedImageEchoSources,
generatedImageFromResult,
stripGeneratedImageEchoes
} from './generated-images'
describe('generatedImageFromResult', () => {
it('prefers the host-visible image path', () => {
expect(
generatedImageFromResult({
agent_visible_image: '/container/cache/cat.png',
host_image: '/Users/me/.hermes/cache/images/cat.png',
image: '/Users/me/.hermes/cache/images/cat.png',
success: true
})
).toBe('/Users/me/.hermes/cache/images/cat.png')
})
it('ignores failed image generation results', () => {
expect(generatedImageFromResult({ image: 'https://cdn.example/cat.png', success: false })).toBeNull()
})
})
describe('stripGeneratedImageEchoes', () => {
it('removes repeated generated image markdown without removing prose', () => {
expect(
stripGeneratedImageEchoes('Here you go.\n\n![Generated image](https://cdn.example/cat.png)', [
'https://cdn.example/cat.png'
])
).toBe('Here you go.')
})
it('removes media links for generated local image paths', () => {
expect(
stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])
).toBe('Saved image:')
})
})
describe('generatedImageEchoSources', () => {
it('collects every path variant the model might restate', () => {
expect(
generatedImageEchoSources([
{
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
toolName: 'image_generate',
type: 'tool-call'
}
])
).toEqual(['/host/cat.png', '/sandbox/cat.png'])
})
})
describe('dedupeGeneratedImageEchoesInParts', () => {
it('keeps the agent prose while removing the duplicated image', () => {
expect(
dedupeGeneratedImageEchoesInParts([
{ text: 'Here is your peacock! ![peacock](/host/p.png) Enjoy.', type: 'text' },
{ result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
])
).toEqual([
{ text: 'Here is your peacock! Enjoy.', type: 'text' },
{ result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
])
})
it('strips a sandbox path the model restated instead of the host path', () => {
expect(
dedupeGeneratedImageEchoesInParts([
{ text: '![cat](/sandbox/cat.png)', type: 'text' },
{
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
toolName: 'image_generate',
type: 'tool-call'
}
])
).toEqual([
{
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
toolName: 'image_generate',
type: 'tool-call'
}
])
})
it('leaves pending generations untouched so the agent prose survives', () => {
const parts = [
{ text: 'Another peacock, coming up!', type: 'text' },
{ result: undefined, toolName: 'image_generate', type: 'tool-call' }
]
expect(dedupeGeneratedImageEchoesInParts(parts)).toEqual(parts)
})
})
+116
View File
@@ -0,0 +1,116 @@
type ToolLike = {
result?: unknown
toolName?: unknown
type?: unknown
}
type TextLike = {
text?: unknown
type?: unknown
}
// Path-ish result fields the model may echo into its prose. Display prefers the
// host path (gateway-deliverable); stripping must catch every variant so a
// sandbox path the model restated doesn't slip through as a duplicate image.
const DISPLAY_KEYS = ['host_image', 'image'] as const
const ECHO_KEYS = ['host_image', 'image', 'agent_visible_image'] as const
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== 'string' || !value.trim()) {
return null
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null
} catch {
return null
}
}
function stringFields(record: Record<string, unknown>, keys: readonly string[]): string[] {
return keys.map(key => record[key]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
}
function regexEscape(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function unique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]
}
function imageResult(part: ToolLike): Record<string, unknown> | null {
if (part.type !== 'tool-call' || part.toolName !== 'image_generate') {
return null
}
const record = recordFromUnknown(part.result)
return record && record.success !== false ? record : null
}
/** Display source for a completed `image_generate` result (host path wins). */
export function generatedImageFromResult(result: unknown): string | null {
const record = recordFromUnknown(result)
if (!record || record.success === false) {
return null
}
return stringFields(record, DISPLAY_KEYS)[0] ?? null
}
/** Every path/URL a generated image might appear as in prose, for de-duping. */
export function generatedImageEchoSources(parts: readonly ToolLike[]): string[] {
return unique(parts.flatMap(part => stringFields(imageResult(part) ?? {}, ECHO_KEYS)))
}
/** Strip a generated image out of prose so it only ever shows in the tool slot.
* Once a generation succeeded (`sources` is non-empty) we drop every embedded
* image and media link from that message the model frequently restates the
* remote URL while the result holds the local path, so matching the exact
* source is not enough. Bare occurrences of the known paths/URLs are removed
* too. Surrounding prose is preserved. */
export function stripGeneratedImageEchoes(text: string, sources: readonly string[]): string {
if (!text || sources.length === 0) {
return text
}
let next = text
.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '')
.replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
for (const source of unique([...sources])) {
next = next.replace(new RegExp(String.raw`(^|[\s([{])<?${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1')
}
return next
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim()
}
/** Strip generated-image echoes from text parts, dropping any part left empty.
* The image lives in the tool slot; prose keeps the agent's actual words. */
export function dedupeGeneratedImageEchoesInParts<T extends TextLike & ToolLike>(parts: readonly T[]): T[] {
const sources = generatedImageEchoSources(parts)
if (!sources.length) {
return [...parts]
}
return parts
.map(part =>
part.type === 'text' && typeof part.text === 'string'
? { ...part, text: stripGeneratedImageEchoes(part.text, sources) }
: part
)
.filter(part => part.type !== 'text' || (typeof part.text === 'string' && part.text.trim().length > 0))
}
+105
View File
@@ -0,0 +1,105 @@
import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
import remend from 'remend'
import { describe, expect, it } from 'vitest'
import { findRemendWindowStart, tailBoundedRemend } from './remend-tail'
const CORPUS = `# Heading one
Intro paragraph with **bold**, *italic*, \`inline code\`, and a [link](https://example.com).
## Code
\`\`\`python
def main():
cost = "$5"
print(f"total: $\{cost}")
\`\`\`
Some text after the fence with $x^2 + y^2$ inline math.
$$
\\int_0^1 f(x) dx
$$
- list item one with **bold**
- list item two
| col a | col b |
| ----- | ----- |
| 1 | 2 |
~~~js
const s = \`template \${value}\`
~~~
Final paragraph with ~~strike~~ and unfinished [link text](https://exa
`
/**
* Render-equivalence oracle: full-text remend and tail-bounded remend may
* differ in raw string output ONLY in ways that cannot affect rendering
* i.e. after block splitting, every block must be identical. (Streamdown
* renders blocks independently, so block-level equality IS render equality.)
*/
function blocksOf(text: string): string[] {
return parseMarkdownIntoBlocks(text)
}
describe('tailBoundedRemend', () => {
it('matches full remend block output at every streaming prefix', () => {
for (let end = 1; end <= CORPUS.length; end++) {
const prefix = CORPUS.slice(0, end)
const full = blocksOf(remend(prefix))
const tail = blocksOf(tailBoundedRemend(prefix))
expect(tail, `prefix length ${end}: ${JSON.stringify(prefix.slice(-60))}`).toEqual(full)
}
})
it('repairs an unclosed fence opened early in a long message', () => {
const text = `intro\n\n\`\`\`python\n${'x = 1\n'.repeat(500)}print("$dollar")`
const repaired = tailBoundedRemend(text)
expect(blocksOf(repaired)).toEqual(blocksOf(remend(text)))
// the window must reach back to the fence opener
expect(findRemendWindowStart(text)).toBe(text.indexOf('```python'))
})
it('bounds the window to the tail paragraph when no fence is open', () => {
const text = `para one\n\npara two\n\npara three with **bold`
const start = findRemendWindowStart(text)
expect(start).toBe(text.indexOf('para three'))
expect(tailBoundedRemend(text)).toBe(remend(text))
})
it('widens the window across an open $$ math block', () => {
const text = `before\n\n$$\n\\frac{a}{b}`
const start = findRemendWindowStart(text)
expect(start).toBeLessThanOrEqual(text.indexOf('$$'))
expect(blocksOf(tailBoundedRemend(text))).toEqual(blocksOf(remend(text)))
})
it('handles closed constructs without modification', () => {
const text = `done **bold** and \`code\`\n\n\`\`\`js\nconst a = 1\n\`\`\`\n\nlast line.`
expect(tailBoundedRemend(text)).toBe(text)
})
it('intentionally diverges from full remend on cross-block dangling openers', () => {
// Full remend scans the whole document and appends `**` for an opener
// left dangling in an EARLIER block, dumping stray asterisks into the
// unrelated tail block ("|**"). Because Streamdown splits into blocks
// after the repair, that opener never renders as bold either way — the
// tail-bounded result is the cleaner of the two. This test documents
// the divergence so a future remend upgrade that changes the behavior
// gets noticed.
const text = `- item with **dangling\n- item two\n\n|`
expect(remend(text).endsWith('|**')).toBe(true)
expect(tailBoundedRemend(text).endsWith('|')).toBe(true)
expect(tailBoundedRemend(text).endsWith('|**')).toBe(false)
})
})
+108
View File
@@ -0,0 +1,108 @@
import remend from 'remend'
// Tail-bounded incomplete-markdown repair.
//
// Streamdown's built-in `parseIncompleteMarkdown` runs `remend` over the whole
// accumulated message on every streaming flush (~18% of script time on 50KB+
// messages). But repairs only ever matter in the trailing block: inline
// constructs can't cross a blank line, and Streamdown splits into blocks AFTER
// the repair, so a dangling opener in an earlier block can't reach the tail.
// We run `remend` on just that block instead.
const BACKTICK = 96 // `
const TILDE = 126 // ~
const SPACE = 32
const TAB = 9
const BACKSLASH = 92
const isSpace = (c: number) => c === SPACE || c === TAB
/**
* Index of the last top-level block start the char after the most recent
* blank line that sits outside any open code fence or `$$` math block. An
* unclosed fence/math always begins after that blank, so it stays wholly
* inside the window without separate tracking. One cheap char pass, no regex.
*/
export function findRemendWindowStart(text: string): number {
const n = text.length
let inFence = false
let fenceChar = 0
let fenceRun = 0
let inMath = false
let boundary = 0
let pending = -1 // a blank line, committed to `boundary` once content follows
for (let lineStart = 0; lineStart <= n; ) {
let lineEnd = text.indexOf('\n', lineStart)
if (lineEnd === -1) {
lineEnd = n
}
let i = lineStart
while (i < lineEnd && isSpace(text.charCodeAt(i))) {
i += 1
}
const first = i < lineEnd ? text.charCodeAt(i) : -1
let marker = false
// Fence open/close (``` or ~~~, ≤3 spaces indent).
if ((first === BACKTICK || first === TILDE) && i - lineStart <= 3) {
let run = i
while (run < lineEnd && text.charCodeAt(run) === first) {
run += 1
}
if (run - i >= 3) {
marker = true
if (!inFence) {
inFence = true
fenceChar = first
fenceRun = run - i
} else if (first === fenceChar && run - i >= fenceRun && onlyWhitespace(text, run, lineEnd)) {
inFence = false
}
}
}
// Toggle `$$` math state on plain lines ($$ inside a fence is literal).
if (!inFence && !marker) {
for (let s = text.indexOf('$$', lineStart); s !== -1 && s < lineEnd - 1; s = text.indexOf('$$', s + 2)) {
if (s === 0 || text.charCodeAt(s - 1) !== BACKSLASH) {
inMath = !inMath
}
}
}
if (first === -1 && !inFence && !inMath) {
pending = lineEnd + 1
} else if (pending !== -1) {
boundary = pending
pending = -1
}
lineStart = lineEnd + 1
}
return boundary
}
function onlyWhitespace(text: string, from: number, to: number): boolean {
for (let i = from; i < to; i += 1) {
if (!isSpace(text.charCodeAt(i))) {
return false
}
}
return true
}
export function tailBoundedRemend(text: string): string {
const start = findRemendWindowStart(text)
return start <= 0 ? remend(text) : text.slice(0, start) + remend(text.slice(start))
}
+25
View File
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { persistStringArray, storedStringArray } from './storage'
describe('string array storage', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('removes the key for an empty array', () => {
window.localStorage.setItem('test.order', JSON.stringify(['a']))
persistStringArray('test.order', [])
expect(window.localStorage.getItem('test.order')).toBeNull()
expect(storedStringArray('test.order')).toEqual([])
})
it('persists non-empty arrays', () => {
persistStringArray('test.order', ['a', 'b'])
expect(window.localStorage.getItem('test.order')).toBe(JSON.stringify(['a', 'b']))
expect(storedStringArray('test.order')).toEqual(['a', 'b'])
})
})
+5 -1
View File
@@ -58,7 +58,11 @@ export function storedStringArray(key: string): string[] {
export function persistStringArray(key: string, value: string[]) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
if (value.length === 0) {
window.localStorage.removeItem(key)
} else {
window.localStorage.setItem(key, JSON.stringify(value))
}
} catch {
// Pins are a local preference; restricted storage should not break chat.
}
+40 -18
View File
@@ -7,9 +7,10 @@ import {
dequeueQueuedPrompt,
enqueueQueuedPrompt,
getQueuedPrompts,
migrateQueuedPrompts,
promoteQueuedPrompt,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
shouldAutoDrain,
updateQueuedPrompt,
updateQueuedPromptText
} from './composer-queue'
@@ -117,32 +118,53 @@ describe('composer queue store', () => {
})
})
describe('shouldAutoDrainOnSettle', () => {
const base = { isBusy: false, queueLength: 1, wasBusy: true }
it('drains the next queued prompt when a turn settles', () => {
expect(shouldAutoDrainOnSettle(base)).toBe(true)
describe('migrateQueuedPrompts', () => {
beforeEach(() => {
window.localStorage.removeItem(QUEUE_STORAGE_KEY)
$queuedPromptsBySession.set({})
})
it('drains after an interrupt — the settle edge is the same', () => {
// Interrupting to reach a queued message is the point of the queue; the
// gateway emits the same settle whether the turn finished or was stopped.
expect(shouldAutoDrainOnSettle(base)).toBe(true)
it('moves entries from a dead runtime key onto the live one', () => {
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'stranded' })
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(true)
expect(getQueuedPrompts('rt-old')).toEqual([])
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['stranded'])
// The dead key is dropped from the store entirely.
expect($queuedPromptsBySession.get()['rt-old']).toBeUndefined()
})
it('does not drain when the queue is empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
it('appends after existing target entries (FIFO preserved)', () => {
enqueueQueuedPrompt('rt-new', { attachments: [], text: 'already here' })
enqueueQueuedPrompt('rt-old', { attachments: [], text: 'migrated' })
migrateQueuedPrompts('rt-old', 'rt-new')
expect(getQueuedPrompts('rt-new').map(e => e.text)).toEqual(['already here', 'migrated'])
})
it('ignores steady busy state (no true → false transition)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
it('is a no-op when source is empty or keys match', () => {
expect(migrateQueuedPrompts('rt-old', 'rt-new')).toBe(false)
expect(migrateQueuedPrompts('rt-x', 'rt-x')).toBe(false)
})
})
it('ignores busy entry (false → true, not a settle)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
describe('shouldAutoDrain', () => {
it('drains whenever idle with a non-empty queue', () => {
expect(shouldAutoDrain({ isBusy: false, queueLength: 1 })).toBe(true)
})
it('ignores steady idle state (was not busy)', () => {
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
it('drains on mount/reconnect with no observed busy edge', () => {
// The whole point of dropping the edge: a remount resets the busy ref, so an
// edge-gated drain would strand the entry. Idle + non-empty must still fire.
expect(shouldAutoDrain({ isBusy: false, queueLength: 2 })).toBe(true)
})
it('does not drain mid-turn', () => {
expect(shouldAutoDrain({ isBusy: true, queueLength: 1 })).toBe(false)
})
it('does not drain an empty queue', () => {
expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false)
})
})
+47 -20
View File
@@ -209,31 +209,58 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
writeSession(sid, [])
}
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
export interface AutoDrainSettleInput {
wasBusy: boolean
/**
* Move pending entries from a dead session key onto a live one, preserving FIFO
* (existing target entries first, migrated entries appended). A backend bounce /
* resume can mint a fresh runtime session id for the *same* conversation; the
* entries enqueued under the old id would otherwise be stranded under a key
* nothing reads anymore. No-op unless both keys resolve and differ.
*/
export const migrateQueuedPrompts = (
fromKey: string | null | undefined,
toKey: string | null | undefined
): boolean => {
const from = sidOf(fromKey)
const to = sidOf(toKey)
if (!from || !to || from === to) {
return false
}
const pending = queueFor(from)
if (pending.length === 0) {
return false
}
const next = { ...$queuedPromptsBySession.get() }
delete next[from]
next[to] = [...queueFor(to), ...pending]
$queuedPromptsBySession.set(next)
save(next)
return true
}
/** Inputs to {@link shouldAutoDrain}. */
export interface AutoDrainInput {
isBusy: boolean
queueLength: number
}
/**
* Decide whether the composer should auto-drain the next queued prompt when a
* turn settles (busy transitions true false).
* Decide whether the composer should auto-drain the next queued prompt.
*
* Queued turns always advance once the session is idle again, whether the turn
* finished naturally or the user interrupted it. Interrupting to reach a queued
* message is the whole point of the queue, so we never suppress the drain. The
* gateway guarantees a settle (message.complete + session.info running:false)
* even after an interrupt, so this single edge reliably advances the queue. To
* cancel queued turns the user deletes them from the panel.
* Edge-independent on purpose: the queue must advance whenever the session is
* idle and has pending entries, NOT only on an observed busy true false edge.
* A backend bounce / websocket reconnect remounts the composer and resets the
* busy ref to the current value, swallowing the settle edge an edge-gated
* drain would then strand the entry forever. The caller's drain lock
* (`drainingQueueRef`) serializes sends so being edge-free can't double-submit.
*/
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
const { isBusy, queueLength, wasBusy } = params
export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0
// Only react to a true → false transition; ignore steady state and entry.
if (isBusy || !wasBusy) {
return false
}
return queueLength > 0
}
/** Auto-drain attempts for one entry before we stop retrying and toast. The
* entry stays queued for a manual send; a remount/reconnect resets the count. */
export const MAX_AUTO_DRAIN_ATTEMPTS = 4
+29 -8
View File
@@ -25,7 +25,9 @@ const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorksp
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY = 'hermes.desktop.sessionOrder.manual'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
@@ -57,7 +59,11 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY))
export const $sidebarSessionOrderManual = atom(storedBoolean(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, false))
export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY))
// Order of the top-level repo "parent" groups in the worktree tree (worktrees
// within a parent reuse $sidebarWorkspaceOrderIds).
export const $sidebarWorkspaceParentOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY))
export const $sidebarPinsOpen = atom(true)
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
// true the whole time it's a floating overlay (not just while shown) so the
@@ -84,7 +90,11 @@ $pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
$sidebarMessagingOpenIds.subscribe(ids => persistStringArray(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [...ids]))
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
$sidebarSessionOrderManual.subscribe(manual => persistBoolean(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, manual))
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
$sidebarWorkspaceParentOrderIds.subscribe(ids =>
persistStringArray(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY, [...ids])
)
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
@@ -163,12 +173,24 @@ export function setSidebarSessionOrderIds(ids: string[]) {
}
}
export function setSidebarSessionOrderManual(manual: boolean) {
if ($sidebarSessionOrderManual.get() !== manual) {
$sidebarSessionOrderManual.set(manual)
}
}
export function setSidebarWorkspaceOrderIds(ids: string[]) {
if (!arraysEqual($sidebarWorkspaceOrderIds.get(), ids)) {
$sidebarWorkspaceOrderIds.set(ids)
}
}
export function setSidebarWorkspaceParentOrderIds(ids: string[]) {
if (!arraysEqual($sidebarWorkspaceParentOrderIds.get(), ids)) {
$sidebarWorkspaceParentOrderIds.set(ids)
}
}
export function setSidebarResizing(resizing: boolean) {
$isSidebarResizing.set(resizing)
}
@@ -191,16 +213,15 @@ export function unpinSession(sessionId: string) {
}
}
export function reorderPinnedSession(sessionId: string, targetIndex: number) {
// Replace the whole pinned order at once (drag-reorder hands back the new order
// rather than a single move). Keep only ids that are actually pinned so a stale
// row can't smuggle an unpinned id into the store.
export function setPinnedSessionOrder(ids: string[]) {
const prev = $pinnedSessionIds.get()
const pinned = new Set(prev)
const next = ids.filter(id => pinned.has(id))
if (!prev.includes(sessionId)) {
return
}
const next = insertUniqueId(prev, sessionId, targetIndex)
if (!arraysEqual(prev, next)) {
if (next.length === prev.length && !arraysEqual(prev, next)) {
$pinnedSessionIds.set(next)
}
}
+11 -1
View File
@@ -1,5 +1,6 @@
import { atom } from 'nanostores'
import { atom, computed } from 'nanostores'
import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages'
@@ -195,6 +196,15 @@ export const $workingSessionIds = atom<string[]>([])
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
export const $messages = atom<ChatMessage[]>([])
// Streaming-stable derivations of $messages. During a token stream the array
// is replaced ~30×/s; components that only care about coarse facts (is the
// thread empty? is the tail a user message?) subscribe to these instead of
// $messages so per-token flushes don't re-render them — nanostores' `computed`
// only notifies when the derived VALUE changes.
export const $messagesEmpty = computed($messages, messages => messages.length === 0)
export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageIsUser)
export const $freshDraftReady = atom(false)
export const $busy = atom(false)
export const $awaitingResponse = atom(false)
+59 -6
View File
@@ -1,11 +1,64 @@
import { atom } from 'nanostores'
import { atom, type WritableAtom } from 'nanostores'
// "Is the thread parked at the bottom" is owned by use-stick-to-bottom inside
// ThreadMessageList (the scroll container). That state lives only in that
// subtree, so ThreadMessageList mirrors it into these atoms for the composer,
// status stack, and floating jump button — all of which render OUTSIDE the thread.
//
// `$threadScrolledUp` dims the composer / status stack; `$threadJumpButtonVisible`
// shows the floating jump control. Both track `!isAtBottom` today, but stay
// separate so their thresholds can diverge again without touching consumers.
export const $threadScrolledUp = atom(false)
export const $threadJumpButtonVisible = atom(false)
export function setThreadScrolledUp(value: boolean) {
if ($threadScrolledUp.get() === value) {
return
// Skip no-op writes so subscribers don't churn on every scroll tick.
const setter = (target: WritableAtom<boolean>) => (value: boolean) => {
if (target.get() !== value) {
target.set(value)
}
$threadScrolledUp.set(value)
}
const setScrolledUp = setter($threadScrolledUp)
const setJumpButtonVisible = setter($threadJumpButtonVisible)
export const setThreadAtBottom = (isAtBottom: boolean) => {
setScrolledUp(!isAtBottom)
setJumpButtonVisible(!isAtBottom)
}
export const resetThreadScroll = () => setThreadAtBottom(true)
// Cross-component bridge: the jump button lives by the composer, the viewport's
// `scrollToBottom` lives inside the thread. The bridge registers a handler; the
// button fires it. Mirrors the composer focus/insert emitter pattern.
const handlers = new Set<() => void>()
export const onScrollToBottomRequest = (handler: () => void) => {
handlers.add(handler)
return () => void handlers.delete(handler)
}
export const requestScrollToBottom = () => handlers.forEach(handler => handler())
// Inline edit grows a sticky human bubble. Fire on pointerdown so the viewport
// escapes stick-to-bottom before focus/layout; close clears the edit flag when
// the inline composer unmounts.
const editOpenHandlers = new Set<() => void>()
const editCloseHandlers = new Set<() => void>()
export const onThreadEditOpen = (handler: () => void) => {
editOpenHandlers.add(handler)
return () => void editOpenHandlers.delete(handler)
}
export const notifyThreadEditOpen = () => editOpenHandlers.forEach(handler => handler())
export const onThreadEditClose = (handler: () => void) => {
editCloseHandlers.add(handler)
return () => void editCloseHandlers.delete(handler)
}
export const notifyThreadEditClose = () => editCloseHandlers.forEach(handler => handler())
+45
View File
@@ -0,0 +1,45 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
type DismissedToolRows = Record<string, true>
// Tool rows the user has locally hidden via a row's dismiss control. This is a
// *view-only* hide: the underlying tool call still lives in the stored chat
// history, but once a turn has settled the user can clear a completed/failed
// row out of the way so it stops sitting at the tail of the conversation.
//
// Kept in module memory (not localStorage, unlike $toolDisclosureStates) on
// purpose: the thread is virtualized, so a dismissed row's component unmounts
// and remounts as it scrolls — component-local state would forget the dismissal
// and the row would pop back. Storing it here survives those remounts for the
// life of the app session, while a reload restores every row in place rather
// than permanently rewriting history from a stray click.
export const $dismissedToolRows = atom<DismissedToolRows>({})
const dismissedCache = new Map<string, ReadableAtom<boolean>>()
export function $toolRowDismissed(id: string): ReadableAtom<boolean> {
let cached = dismissedCache.get(id)
if (!cached) {
cached = computed($dismissedToolRows, rows => Boolean(rows[id]))
dismissedCache.set(id, cached)
}
return cached
}
export function dismissToolRow(id: string) {
if (!id || $dismissedToolRows.get()[id]) {
return
}
$dismissedToolRows.set({ ...$dismissedToolRows.get(), [id]: true })
}
export function clearDismissedToolRows() {
if (Object.keys($dismissedToolRows.get()).length === 0) {
return
}
$dismissedToolRows.set({})
}
+91 -23
View File
@@ -419,6 +419,10 @@
body,
#root {
height: 100%;
/* App shell, not a document: the window itself never scrolls on either axis
(panes own their own scroll). Belt to the auto-scroll axis-lock in the
sidebar reorder DnD nothing can drag the whole shell sideways. */
overflow: hidden;
}
html {
@@ -433,7 +437,6 @@
font-size: 0.8125rem;
line-height: var(--dt-line-height, 1.55);
letter-spacing: var(--dt-letter-spacing, 0);
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-font-smoothing: antialiased;
@@ -888,18 +891,25 @@ canvas {
[data-slot='aui_user-message-root'],
[data-slot='aui_edit-composer-root'] {
--human-msg-line-height: 1.3;
font-size: var(--conversation-text-font-size);
}
[data-slot='aui_user-inline-text'] {
line-height: var(--human-msg-line-height);
}
[data-slot='aui_edit-composer-root'] [data-slot='composer-rich-input'] {
line-height: var(--human-msg-line-height);
}
/* 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. 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. */
prompt doesn't dominate the viewport. The clamp lifts only in the edit
composer; expanding on read-only :focus-within ran on mousedown (before the
swap) and fought stick-to-bottom when parked at the bottom. */
.sticky-human-clamp {
cursor: pointer;
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
max-height: calc(4 * var(--human-msg-line-height) * var(--conversation-text-font-size) + 0.15rem);
overflow: hidden;
}
@@ -908,25 +918,18 @@ canvas {
mask-image: linear-gradient(to bottom, #000 55%, transparent);
}
.composer-human-message:focus-within .sticky-human-clamp {
max-height: min(var(--human-msg-full, 24rem), 24rem);
overflow-y: auto;
-webkit-mask-image: none;
mask-image: none;
}
/* The thread renders items in natural document flow (padding spacers, not
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
when an off-screen turn is measured and its real height differs from the
220px estimate. The browser's native scroll anchoring (overflow-anchor:
auto) would adjust scrollTop for that SAME size delta, so the two
double-correct and the view lurches most visibly on Windows mouse wheels,
whose coarse notches mount/measure several under-estimated turns per tick.
Opt out of native anchoring so only the virtualizer compensates. */
/* Stick-to-bottom owns scrollTop while following. Once escaped, native anchoring
is safe and keeps sticky human edits from shoving the viewport; data-editing
enables that path before React swaps in the inline editor. */
[data-slot='aui_thread-viewport'] {
overflow-anchor: none;
}
[data-slot='aui_thread-viewport'][data-following='false'],
[data-slot='aui_thread-viewport'][data-editing='true'] {
overflow-anchor: auto;
}
[data-slot='aui_thread-content'] {
max-width: var(--composer-width);
padding-inline: 1.5rem;
@@ -1154,6 +1157,10 @@ canvas {
margin-block-end: 0 !important;
}
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead {
border-bottom-color: var(--dt-border) !important;
}
/* Tool / thinking blocks are scaffolding around the model's reply, so we
keep them transparent and fade them slightly. The reading column (prose)
stays at full strength; scaffolding lifts back to full opacity on
@@ -1173,6 +1180,12 @@ canvas {
opacity: 1;
}
/* A generated image is the deliverable, not scaffolding keep it at full
strength instead of dimming it until hover. */
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:has([data-slot='aui_generated-image']) {
opacity: 1;
}
/* Conversation block rhythm. assistant-ui renders each range as a direct child
of the message content with no per-part wrapper, so adjacency rules cover
every pairing first block needs no reset, nested tool rows are untouched.
@@ -1255,6 +1268,62 @@ canvas {
}
}
/* Floating "jump to bottom" control (see scroll-to-bottom-button.tsx).
Directional scale: it contracts toward 1 as it arrives (from 1.1) and keeps
contracting to 0.9 as it leaves always shrinking in the direction of
travel, so the motion reads as a soft settle / recede rather than a pop. The
X half-offset stays baked into every transform so `left-1/2` centering holds
through the animation. */
.thread-jump-button {
opacity: 0;
transform: translateX(-50%) translateY(0.3rem) scale(0.9);
}
.thread-jump-button[data-state='in'] {
animation: thread-jump-in 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.thread-jump-button[data-state='out'] {
animation: thread-jump-out 180ms ease-in forwards;
}
@keyframes thread-jump-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(0.3rem) scale(1.1);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
@keyframes thread-jump-out {
from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateX(-50%) translateY(0.3rem) scale(0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.thread-jump-button[data-state='in'],
.thread-jump-button[data-state='out'] {
animation: none;
transition: opacity 120ms linear;
}
.thread-jump-button[data-state='in'] {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
@keyframes code-card-stream-glow {
from {
border-color: color-mix(in srgb, var(--dt-ring) 18%, var(--ui-stroke-tertiary));
@@ -1276,4 +1345,3 @@ canvas {
animation: none;
}
}
+67 -27
View File
@@ -2827,6 +2827,53 @@ def _strip_leaked_terminal_responses(text: str) -> str:
return cleaned
def _estimate_tui_input_height(
lines: list[str] | tuple[str, ...],
prompt_text: str,
terminal_columns: int,
*,
max_height: int = 8,
) -> int:
"""Estimate classic prompt_toolkit input rows using live terminal cells.
The TextArea prompt is injected with prompt_toolkit's BeforeInput
processor, which means it consumes cells only on logical line 0. After a
narrow resize, that first row can leave only one input cell beside an icon
prompt such as `` ``, while continuation rows use the full terminal width.
Never substitute a fake wide fallback here: under- or over-allocating the
TextArea height leaves stale prompt/input cells visible at the bottom of the
terminal.
"""
try:
from prompt_toolkit.utils import get_cwidth
except Exception:
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
try:
columns = int(terminal_columns or 0)
except (TypeError, ValueError):
columns = 0
columns = max(1, columns)
prompt_width = max(0, get_cwidth(prompt_text or ""))
visual_lines = 0
for index, line in enumerate(lines or [""]):
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
# applies only to logical line 0. Wrapped continuation rows, and later
# logical lines, use the full terminal width. Count the display cells
# after that same transformation rather than subtracting the prompt from
# every wrapped row.
line_width = get_cwidth(line or "")
display_width = line_width + (prompt_width if index == 0 else 0)
if display_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-display_width // columns))
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
"""Collect local image attachments for single-query CLI flows."""
message = query or ""
@@ -3689,9 +3736,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
startup UI and ``_replay_output_history`` cannot reconstruct it
(the banner was never added to ``_OUTPUT_HISTORY``).
Instead we just reset prompt_toolkit's renderer cache so the next
incremental redraw starts from a clean slate, then let
``original_on_resize`` recalculate layout for the new size.
Let prompt_toolkit's own resize path run with its renderer cursor
cache intact. Its Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which needs the cached
cursor position to move back to the live prompt origin before
erase_down(). Resetting the renderer before that erase loses the
origin and can leave stale prompt glyphs after a narrow resize.
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
status bar and input separator rules stay hidden until the next user
@@ -3702,14 +3752,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
next prompt restores the bar cleanly.
"""
self._status_bar_suppressed_after_resize = True
try:
app.renderer.reset(leave_alternate_screen=False)
except Exception:
pass
try:
app.invalidate()
except Exception:
pass
original_on_resize()
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
@@ -12004,26 +12046,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
terminal_columns = get_app().output.get_size().columns
except Exception:
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
# Each logical line takes at least 1 visual row; long lines wrap.
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width)) # ceil division
return min(max(visual_lines, 1), 8)
terminal_columns = shutil.get_terminal_size((80, 24)).columns
return _estimate_tui_input_height(
doc.lines,
self._get_tui_prompt_text(),
terminal_columns,
)
except Exception:
return 1
@@ -12765,6 +12798,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
style=style,
full_screen=False,
mouse_support=False,
# The status bar contains wall-clock read-outs (live prompt elapsed
# and idle-since-last-turn). Once a turn finishes there may be no
# further events to invalidate the app, so prompt_toolkit would keep
# rendering the first post-turn value (usually ``✓ 0s``) forever.
# A low-rate refresh keeps the clock honest without reintroducing a
# custom repaint thread or touching conversation state.
refresh_interval=1.0,
# Erase the live bottom chrome (status bar, input box, separator
# rules) on exit instead of freezing a final copy into scrollback.
# Without this, prompt_toolkit's render_as_done teardown repaints
+44 -20
View File
@@ -3163,6 +3163,38 @@ class BasePlatformAdapter(ABC):
pass
self._typing_paused.discard(chat_id)
async def _stop_typing_refresh(
self,
chat_id: str,
typing_task: asyncio.Task | None = None,
*,
timeout: float = 0.5,
stop_attempts: int = 2,
) -> None:
"""Stop the refresh task and platform typing state as one operation."""
self._typing_paused.add(chat_id)
try:
if typing_task is not None and not typing_task.done():
typing_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(typing_task), timeout=timeout)
except (asyncio.CancelledError, asyncio.TimeoutError):
# The task is cancelled; don't let a slow adapter-specific
# cleanup block response delivery or shutdown.
pass
if not hasattr(self, "stop_typing"):
return
attempts = max(1, stop_attempts)
for attempt in range(attempts):
try:
await self.stop_typing(chat_id)
except Exception:
pass
if attempt < attempts - 1:
await asyncio.sleep(0)
finally:
self._typing_paused.discard(chat_id)
def pause_typing_for_chat(self, chat_id: str) -> None:
"""Pause typing indicator for a chat (e.g. during approval waits).
@@ -4092,14 +4124,10 @@ class BasePlatformAdapter(ABC):
)
async def _stop_typing_task() -> None:
typing_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
except (asyncio.CancelledError, asyncio.TimeoutError):
# Cancellation cleanup must not block adapter shutdown. The
# typing task is already cancelled; if the parent task is also
# cancelling, let this message-processing task unwind now.
pass
await self._stop_typing_refresh(
event.source.chat_id,
typing_task,
)
try:
await self._run_processing_hook("on_processing_start", event)
@@ -4486,11 +4514,6 @@ class BasePlatformAdapter(ABC):
# callbacks may perform platform I/O; a stuck callback must not
# leave the typing refresh task running indefinitely.
await _stop_typing_task()
try:
if hasattr(self, "stop_typing"):
await self.stop_typing(event.source.chat_id)
except Exception:
pass
# Fire any one-shot post-delivery callback registered for this
# session (e.g. deferred background-review notifications).
#
@@ -4524,13 +4547,14 @@ class BasePlatformAdapter(ABC):
)
except (asyncio.TimeoutError, Exception):
pass
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
if hasattr(self, "stop_typing"):
await self.stop_typing(event.source.chat_id)
except Exception:
pass
# Some adapters keep platform-level typing tasks. If callback
# work or a late refresh recreated one, make one final bounded stop
# before releasing the session guard.
await self._stop_typing_refresh(
event.source.chat_id,
None,
stop_attempts=1,
)
# Final drain/release boundary: force-flush any timer that missed
# the in-band drain before deciding whether the guard can clear.
await self._flush_text_debounce_now(session_key)
+113 -1
View File
@@ -45,10 +45,12 @@ from gateway.platforms.base import (
ProcessingOutcome,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
SUPPORTED_VIDEO_TYPES,
is_host_excluded_by_no_proxy,
resolve_proxy_url,
safe_url_for_log,
cache_document_from_bytes,
cache_video_from_bytes,
)
@@ -880,7 +882,7 @@ class SlackAdapter(BasePlatformAdapter):
# doesn't log noisy 404 "unhandled request" warnings.
@self._app.event("file_shared")
async def handle_file_shared(event, say):
pass
await self._handle_slack_file_shared(event)
@self._app.event("file_created")
async def handle_file_created(event, say):
@@ -2173,6 +2175,84 @@ class SlackAdapter(BasePlatformAdapter):
self._cache_assistant_thread_metadata(metadata)
self._seed_assistant_thread_session(metadata)
async def _handle_slack_file_shared(self, event: dict) -> None:
"""Fallback for Slack file shares that do not arrive as message.files.
Slack documents ``file_shared`` as a file-ID-only event; callers must
fetch ``files.info`` to get the file object. Keep this intentionally
narrow: normal image/audio/document uploads already arrive on the
message event, but some video shares have only been observed through
this lifecycle event.
"""
channel_id = event.get("channel_id") or event.get("channel") or ""
file_id = event.get("file_id") or (event.get("file") or {}).get("id") or ""
if not channel_id or not file_id:
return
team_id = event.get("team_id") or event.get("team") or ""
try:
client = self._team_clients.get(team_id) if team_id else None
info_resp = await (client or self._get_client(channel_id)).files_info(
file=file_id
)
except Exception as exc:
response = getattr(exc, "response", None)
detail = self._describe_slack_api_error(response, file_obj={"id": file_id})
logger.warning("[Slack] files.info error for file_shared %s: %s", file_id, detail or exc)
return
if not info_resp.get("ok"):
detail = self._describe_slack_api_error(info_resp, file_obj={"id": file_id})
logger.warning(
"[Slack] files.info failed for file_shared %s: %s",
file_id,
detail or info_resp.get("error"),
)
return
file_obj = info_resp.get("file") or {}
if not str(file_obj.get("mimetype", "")).startswith("video/"):
return
share = None
for bucket in (file_obj.get("shares") or {}).values():
if not isinstance(bucket, dict):
continue
channel_shares = bucket.get(channel_id)
if channel_shares:
share = channel_shares[0]
break
if share is None:
for shares in bucket.values():
if shares:
share = shares[0]
break
share = share or {}
ts = share.get("ts") or event.get("event_ts") or ""
thread_ts = share.get("thread_ts") or ""
# Give Slack's normal message.file_share event a chance to arrive first.
# If it does, _handle_slack_message records the same share ts and this
# fallback skips instead of duplicating the user turn.
await asyncio.sleep(0.75)
if ts and self._dedup.is_duplicate(ts):
return
fallback_event = {
"type": "message",
"subtype": "file_share",
"text": "",
"user": event.get("user_id") or file_obj.get("user", ""),
"channel": channel_id,
"channel_type": "im" if channel_id.startswith("D") else "channel",
"team": team_id,
"ts": "", # already recorded above; avoid tripping our own dedup guard
"files": [file_obj],
}
if thread_ts and thread_ts != ts:
fallback_event["thread_ts"] = thread_ts
await self._handle_slack_message(fallback_event)
async def _handle_slack_message(self, event: dict) -> None:
"""Handle an incoming Slack message event."""
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
@@ -2572,6 +2652,36 @@ class SlackAdapter(BasePlatformAdapter):
e,
exc_info=True,
)
elif mimetype.startswith("video/") and url:
try:
original_filename = f.get("name", "")
_, ext = os.path.splitext(original_filename)
ext = ext.lower()
if ext not in SUPPORTED_VIDEO_TYPES:
mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
ext = mime_to_ext.get(
mimetype.split(";", 1)[0].lower(), ".mp4"
)
raw_bytes = await self._download_slack_file_bytes(
url, team_id=team_id
)
cached_path = cache_video_from_bytes(raw_bytes, ext=ext)
media_urls.append(cached_path)
media_types.append(SUPPORTED_VIDEO_TYPES.get(ext, mimetype))
logger.debug("[Slack] Cached user video: %s", cached_path)
except Exception as e: # pragma: no cover - defensive logging
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning(
"[Slack] Failed to cache video from %s: %s",
url,
e,
exc_info=True,
)
elif url:
# Try to handle as a document attachment
try:
@@ -2666,6 +2776,8 @@ class SlackAdapter(BasePlatformAdapter):
if msg_type != MessageType.COMMAND and media_types:
if any(m.startswith("image/") for m in media_types):
msg_type = MessageType.PHOTO
elif any(m.startswith("video/") for m in media_types):
msg_type = MessageType.VIDEO
elif any(m.startswith("audio/") for m in media_types):
msg_type = MessageType.VOICE
else:
+308 -7
View File
@@ -9,6 +9,7 @@ Uses python-telegram-bot library for:
import asyncio
import dataclasses
import inspect
import json
import logging
import os
@@ -347,6 +348,9 @@ class TelegramAdapter(BasePlatformAdapter):
# Telegram message limits
MAX_MESSAGE_LENGTH = 4096
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
# Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
# UTF-8 bytes. Content above this is sent via the legacy chunking path.
RICH_MESSAGE_MAX_BYTES = 32768
# Threshold for detecting Telegram client-side message splits.
# When a chunk is near this limit, a continuation is almost certain.
_SPLIT_THRESHOLD = 4000
@@ -412,6 +416,13 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
# with the raw agent markdown so tables/task lists/etc. render natively.
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
self._rich_send_disabled: bool = False
self._rich_draft_disabled: bool = False
# Buffer rapid/album photo updates so Telegram image bursts are handled
# as a single MessageEvent instead of self-interrupting multiple turns.
self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8"))
@@ -902,6 +913,267 @@ class TelegramAdapter(BasePlatformAdapter):
return {"link_preview_options": LinkPreviewOptions(is_disabled=True)}
return {"disable_web_page_preview": True}
# ------------------------------------------------------------------
# Bot API 10.1 Rich Messages (sendRichMessage)
#
# Final / new-message replies opportunistically use sendRichMessage with
# the RAW agent markdown so richer constructs (tables, task lists,
# collapsible details, math, ...) render natively. The legacy MarkdownV2
# send() path stays as the fallback for unsupported/oversized content and
# older PTB/clients. Streaming edits/drafts are intentionally untouched —
# Telegram exposes no rich-edit method.
# ------------------------------------------------------------------
def _content_fits_rich_limits(self, content: str) -> bool:
"""Cheap pre-check for the one hard rich limit we can count locally.
Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API
rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are
not pre-counted; if exceeded Telegram returns a BadRequest, which
:meth:`_is_rich_fallback_error` classifies as permanent so the send
degrades to the legacy chunking path.
"""
return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
def _bot_supports_rich(self) -> bool:
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
Gates on ``do_api_request`` being an *async* callable. The real
``telegram.Bot.do_api_request`` is a coroutine function; test doubles
that opt into rich set it to an ``AsyncMock`` (also a coroutine
function). Plain ``MagicMock`` bots expose a *sync* auto-child and
``SimpleNamespace`` bots lack the attribute entirely both resolve to
``False`` here, so the legacy path is used unchanged.
"""
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
def _should_attempt_rich(self, content: str) -> bool:
return bool(
not getattr(self, "_rich_send_disabled", False)
and content
and content.strip()
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
def _rich_message_payload(
self, content: str, *, skip_entity_detection: bool = False
) -> Dict[str, Any]:
"""Build the ``InputRichMessage`` object from RAW markdown.
Never pass ``format_message(content)`` here that converts to
MarkdownV2 and would escape/destroy rich syntax like table pipes.
"""
payload: Dict[str, Any] = {"markdown": content}
if skip_entity_detection:
payload["skip_entity_detection"] = True
return payload
def _is_rich_capability_error(self, exc: Exception) -> bool:
"""True ⇒ the rich endpoint itself is unavailable (old PTB/server).
These latch rich off for the rest of the adapter's life — retrying is
pointless and would cost a failed roundtrip on every send. Per-message
rejections (BadRequest from a parser/limit issue) are NOT capability
errors: the next message may be fine.
"""
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
return True
if getattr(exc, "error_code", None) == 404:
return True
s = str(exc).lower()
if ("method" in s and "not found" in s) or "no such method" in s:
return True
if "unsupported" in s or "not implemented" in s:
return True
return False
def _is_rich_fallback_error(self, exc: Exception) -> bool:
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
Conservative on purpose: only clearly-permanent failures (BadRequest,
capability errors, unknown/unsupported endpoint) qualify. Everything
else is treated as transient the rich request may have reached
Telegram, so we must NOT legacy-resend and risk a duplicate.
"""
if self._is_bad_request_error(exc):
return True
return self._is_rich_capability_error(exc)
def _compute_single_send_routing(
self,
chat_id: str,
reply_to: Optional[str],
metadata: Optional[Dict[str, Any]],
thread_id: Optional[str],
) -> Optional[tuple]:
"""Routing for a single (rich) send — mirrors send()'s index-0 block.
Returns ``(reply_to_id, thread_kwargs)``, or ``None`` to signal "skip
rich, let the legacy path handle it" — used for the DM-topic fail-loud
case so the legacy path stays the single source of the refuse result.
"""
metadata_reply_to = self._metadata_reply_to_message_id(metadata)
private_dm_topic_send = self._is_private_dm_topic_send(chat_id, thread_id, metadata)
dm_topic_reply_to_off = (
private_dm_topic_send
and self._reply_to_mode == "off"
and bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
)
reply_to_source = reply_to or (
str(metadata_reply_to)
if private_dm_topic_send and metadata_reply_to is not None
else None
)
if private_dm_topic_send:
should_thread = reply_to_source is not None and self._reply_to_mode != "off"
else:
should_thread = self._should_thread_reply(reply_to_source, 0)
reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None
if private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off:
# Refusing to send outside the requested DM topic — defer to the
# legacy path, which returns the canonical fail-loud SendResult.
return None
thread_kwargs = self._thread_kwargs_for_send(
chat_id,
thread_id,
metadata,
reply_to_message_id=reply_to_id,
reply_to_mode=self._reply_to_mode,
)
return reply_to_id, thread_kwargs
async def _try_send_rich(
self,
chat_id: str,
content: str,
reply_to: Optional[str],
metadata: Optional[Dict[str, Any]],
) -> Optional[SendResult]:
"""Attempt a single ``sendRichMessage`` send.
Returns a :class:`SendResult` (success, or a transient failure that the
caller must NOT legacy-resend), or ``None`` to signal "fall back to the
legacy MarkdownV2 path" (permanent/capability error or DM-topic skip).
"""
thread_id = self._metadata_thread_id(metadata)
routing = self._compute_single_send_routing(chat_id, reply_to, metadata, thread_id)
if routing is None:
return None
reply_to_id, thread_kwargs = routing
payload: Dict[str, Any] = {
"chat_id": int(chat_id),
"rich_message": self._rich_message_payload(content),
}
# Only forward non-None routing keys: when direct_messages_topic_id is
# present _thread_kwargs_for_send pairs it with message_thread_id=None,
# which must not be sent as a stray field on the raw endpoint.
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
payload.update(self._notification_kwargs(metadata))
if reply_to_id is not None:
# Spec: sendRichMessage takes reply_parameters (ReplyParameters
# object), NOT the legacy reply_to_message_id scalar. Unknown
# params are silently ignored by the Bot API, so the scalar would
# quietly drop the reply anchor instead of erroring.
payload["reply_parameters"] = {"message_id": reply_to_id}
try:
msg = await self._bot.do_api_request(
"sendRichMessage", api_kwargs=payload, return_type=Message
)
except Exception as exc:
if self._is_rich_fallback_error(exc):
if self._is_rich_capability_error(exc):
# Endpoint missing (old PTB/server) — latch rich off so
# every later send doesn't pay a doomed extra roundtrip.
self._rich_send_disabled = True
logger.debug(
"[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2",
self.name, exc,
)
return None
# Transient / network / unknown: the request may have reached
# Telegram. Do NOT legacy-resend (duplicate risk); surface a
# failure with retry semantics mirroring the legacy send() except.
err_str = str(exc).lower()
try:
from telegram.error import TimedOut as _TimedOut
except (ImportError, AttributeError):
_TimedOut = None
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
is_connect_timeout = self._looks_like_connect_timeout(exc)
logger.warning(
"[%s] sendRichMessage transient failure (no legacy resend): %s",
self.name, exc,
)
return SendResult(
success=False,
error=str(exc),
retryable=(is_connect_timeout or not is_timeout),
)
message_id = None
if isinstance(msg, dict):
message_id = msg.get("message_id")
if message_id is None:
message_id = msg.get("result", {}).get("message_id")
else:
message_id = getattr(msg, "message_id", None)
return SendResult(
success=True,
message_id=str(message_id) if message_id is not None else None,
)
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
and content.strip()
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
async def _try_send_rich_draft(
self,
chat_id: str,
draft_id: int,
content: str,
metadata: Optional[Dict[str, Any]],
) -> bool:
"""Emit one ``sendRichMessageDraft`` preview frame; True on success.
Draft frames are ephemeral and overwritten by the next frame / the
final ``sendRichMessage``, so a duplicate or lost rich draft is
harmless any failure simply returns False and the caller renders the
legacy plain-text draft. A permanent/capability failure additionally
latches ``_rich_draft_disabled`` so later frames skip the rich attempt.
"""
payload: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"rich_message": self._rich_message_payload(content),
}
thread_id = self._metadata_thread_id(metadata)
if thread_id is not None:
payload["message_thread_id"] = int(thread_id)
try:
ok = await self._bot.do_api_request("sendRichMessageDraft", api_kwargs=payload)
return bool(ok)
except Exception as exc:
if self._is_rich_capability_error(exc):
self._rich_draft_disabled = True
logger.debug(
"[%s] sendRichMessageDraft unsupported (%s) — using legacy drafts",
self.name, exc,
)
else:
logger.debug(
"[%s] sendRichMessageDraft transient failure (%s) — legacy draft this frame",
self.name, exc,
)
return False
async def _drain_polling_connections(self) -> None:
"""Reset the httpx connection pool used for getUpdates polling.
@@ -1869,6 +2141,22 @@ class TelegramAdapter(BasePlatformAdapter):
return SendResult(success=True, message_id=None)
try:
# Bot API 10.1 rich fast-path: send the raw agent markdown via
# sendRichMessage so tables/task lists/etc. render natively. Falls
# through to the legacy MarkdownV2 path on permanent/capability
# errors or DM-topic routing skips; returns directly on success or
# on a transient failure (which must NOT be legacy-resent).
if self._should_attempt_rich(content):
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
if rich_result is not None:
if rich_result.success:
# Re-trigger typing like the legacy success path does.
try:
await self.send_typing(chat_id, metadata=metadata)
except Exception:
pass # Typing failures are non-fatal
return rich_result
# Format and split message if needed
formatted = self.format_message(content)
chunks = self.truncate_message(
@@ -2550,17 +2838,30 @@ class TelegramAdapter(BasePlatformAdapter):
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Stream a partial message via Telegram's native sendMessageDraft.
"""Stream a partial message via Telegram's native draft API.
The Bot API animates the preview when the same ``draft_id`` is reused
across consecutive calls in the same chat. When the response
finishes, the caller sends the final text via the normal ``send``
path; the draft preview clears naturally on the client (Telegram has
no Bot API to "promote" a draft to a real message the final
``sendMessage`` is what the user receives in their history).
Uses ``sendRichMessageDraft`` (Bot API 10.1) with the raw markdown when
rich messages are enabled and supported, otherwise the plain-text
``sendMessageDraft``. The Bot API animates the preview when the same
``draft_id`` is reused across consecutive calls in the same chat. When
the response finishes, the caller sends the final text via the normal
``send`` path; the draft preview clears naturally on the client
(Telegram has no Bot API to "promote" a draft to a real message the
final ``sendMessage``/``sendRichMessage`` is what the user receives in
their history).
"""
if not self._bot:
return SendResult(success=False, error="not_connected")
# Rich draft fast-path (Bot API 10.1 sendRichMessageDraft): render the
# streaming preview with the same raw markdown the final
# sendRichMessage will persist, so the animated draft matches the final
# message. Any failure degrades to the legacy plain-text draft below.
if self._should_attempt_rich_draft(content):
if await self._try_send_rich_draft(chat_id, draft_id, content, metadata):
# Drafts have no message_id; report success without one.
return SendResult(success=True, message_id=None)
if not hasattr(self._bot, "send_message_draft"):
return SendResult(success=False, error="api_unavailable")
+99 -59
View File
@@ -1420,6 +1420,8 @@ def _build_media_placeholder(event) -> str:
parts.append(f"[User sent an image: {url}]")
elif mtype.startswith("audio/"):
parts.append(f"[User sent audio: {url}]")
elif mtype.startswith("video/") or getattr(event, "message_type", None) == MessageType.VIDEO:
parts.append(f"[User sent a video: {url}]")
else:
parts.append(f"[User sent a file: {url}]")
return "\n".join(parts)
@@ -7637,6 +7639,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Declare at outer scope so the audio-file-paths handling block below
# remains safe when ``event.media_urls`` is empty (no inner block runs).
audio_file_paths: list[str] = []
video_paths: list[str] = []
if event.media_urls:
image_paths = []
@@ -7654,6 +7657,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT}
):
audio_paths.append(path)
if mtype.startswith("video/") or event.message_type == MessageType.VIDEO:
video_paths.append(path)
if image_paths:
# Decide routing: native (attach pixels) vs text (vision_analyze
@@ -7752,6 +7757,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
message_text = f"{_note}\n\n{message_text}"
if video_paths:
from tools.credential_files import to_agent_visible_cache_path as _to_agent_path
for _vpath in video_paths:
_basename = os.path.basename(_vpath)
_parts = _basename.split("_", 2)
_display = _parts[2] if len(_parts) >= 3 else _basename
_display = re.sub(r'[^\w.\- ]', '_', _display)
_agent_path = _to_agent_path(_vpath)
_note = (
f"[The user sent a video attachment: '{_display}'. "
f"It is saved at: {_agent_path}. "
f"Its content is not inlined here. If the user's request involves "
f"what the video contains, inspect or process it yourself — for "
f"example by passing the path to a video analysis or media tool — "
f"instead of asking the user to describe it. Only ask what to do "
f"with it if their intent is genuinely unclear.]"
)
message_text = f"{_note}\n\n{message_text}"
if event.media_urls and event.message_type == MessageType.DOCUMENT:
import mimetypes as _mimetypes
from tools.credential_files import to_agent_visible_cache_path
@@ -8791,12 +8815,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Auto-resetting session %s after compression exhaustion.",
session_entry.session_id,
)
self.session_store.reset_session(session_key)
new_entry = self.session_store.reset_session(session_key)
self._evict_cached_agent(session_key)
self._session_model_overrides.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
if new_entry is not None:
# Drop the stale reference to the bloated compressed child and
# re-point the Telegram topic binding at the fresh session.
# Compression rotated session_entry.session_id to the oversized
# compressed child earlier this turn (the agent-result sync
# above), and that _sync also rewrote the (chat_id, thread_id)
# -> bloated-child binding. reset_session swaps in a clean,
# parentless session, but without re-syncing the binding the
# next inbound message in this topic gets switch_session'd back
# onto the bloated child by the binding-heal walk, reloads the
# oversized transcript, and re-triggers compression exhaustion
# forever (#35809 — regression of the #9893/#10063 auto-reset).
# No-op on non-topic lanes.
session_entry = new_entry
self._sync_telegram_topic_binding(
source, session_entry, reason="compression-exhausted-reset",
)
response = (response or "") + (
"\n\n🔄 Session auto-reset — the conversation exceeded the "
"maximum context size and could not be compressed further. "
@@ -14541,6 +14582,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
_resolved_model = getattr(_agent, "model", None) if _agent else None
# Sync session_id immediately after run_conversation(). Compression
# can rotate before a follow-up model call fails; the failure return
# below must still point the gateway at the compressed child.
agent = agent_holder[0]
_session_was_split = False
agent_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
if agent and session_key and agent_session_id != session_id:
_session_was_split = True
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent_session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent_session_id
self.session_store._save()
# If this is a Telegram DM and source.thread_id was lost during
# the session split (synthetic / recovered event), restore it
# from the binding so _thread_metadata_for_source produces the
# correct message_thread_id instead of routing to the General
# thread. Failure here is non-fatal — we log and continue;
# worst case the message lands in General, which is the
# pre-fix behaviour.
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
and getattr(source, "thread_id", None) is None
and self._session_db is not None
):
try:
_binding = self._session_db.get_telegram_topic_binding_by_session(
session_id=agent_session_id,
)
if _binding and _binding.get("thread_id"):
source.thread_id = str(_binding["thread_id"])
logger.debug(
"Restored source.thread_id=%s from binding after session split %s%s",
source.thread_id,
session_id,
agent_session_id,
)
except Exception:
logger.debug(
"Failed to restore thread_id from binding after session split",
exc_info=True,
)
if entry:
self._sync_telegram_topic_binding(
source, entry, reason="agent-run-compression",
)
effective_session_id = agent_session_id
_effective_history_offset = 0 if _session_was_split else len(agent_history)
if not final_response:
error_msg = f"⚠️ {result['error']}" if result.get("error") else ""
return {
@@ -14555,7 +14651,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"error": result.get("error"),
"compression_exhausted": result.get("compression_exhausted", False),
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"history_offset": _effective_history_offset,
"session_id": effective_session_id,
"last_prompt_tokens": _last_prompt_toks,
"input_tokens": _input_toks,
"output_tokens": _output_toks,
@@ -14601,63 +14698,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
unique_tags.insert(0, "[[audio_as_voice]]")
final_response = final_response + "\n" + "\n".join(unique_tags)
# Sync session_id: the agent may have created a new session during
# mid-run context compression (_compress_context splits sessions).
# If so, update the session store entry so the NEXT message loads
# the compressed transcript, not the stale pre-compression one.
agent = agent_holder[0]
_session_was_split = False
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
_session_was_split = True
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent.session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent.session_id
self.session_store._save()
# If this is a Telegram DM and source.thread_id was lost during
# the session split (synthetic / recovered event), restore it
# from the binding so _thread_metadata_for_source produces the
# correct message_thread_id instead of routing to the General
# thread. Failure here is non-fatal — we log and continue;
# worst case the message lands in General, which is the
# pre-fix behaviour.
if (
getattr(source, "platform", None) == Platform.TELEGRAM
and getattr(source, "chat_type", None) == "dm"
and getattr(source, "thread_id", None) is None
and self._session_db is not None
):
try:
_binding = self._session_db.get_telegram_topic_binding_by_session(
session_id=agent.session_id,
)
if _binding and _binding.get("thread_id"):
source.thread_id = str(_binding["thread_id"])
logger.debug(
"Restored source.thread_id=%s from binding after session split %s%s",
source.thread_id,
session_id,
agent.session_id,
)
except Exception:
logger.debug(
"Failed to restore thread_id from binding after session split",
exc_info=True,
)
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
# When compression created a new session, the messages list was
# shortened. Using the original history offset would produce an
# empty new_messages slice, causing the gateway to write only a
# user/assistant pair — losing the compressed summary and tail.
# Reset to 0 so the gateway writes ALL compressed messages.
_effective_history_offset = 0 if _session_was_split else len(agent_history)
# Auto-generate session title after first exchange (non-blocking)
if final_response and self._session_db:
try:
+16
View File
@@ -1311,6 +1311,22 @@ class GatewayStreamConsumer:
self._flood_strikes = 0
return True
else:
if (
finalize
and is_turn_final
and self.cfg.cursor
and self._last_sent_text.endswith(self.cfg.cursor)
and self._visible_prefix() == text
):
# The final clean-up edit failed, but the complete
# answer is already visible from the last streaming
# frame (usually with only the cursor still stuck on
# screen). Mark the content delivered so the
# gateway suppresses its normal full final send;
# otherwise users see the same long answer twice
# when Telegram/Discord rate-limit this cosmetic
# final edit (#36965, #25349).
self._final_content_delivered = True
raw_response = getattr(result, "raw_response", None)
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
# Telegram edited/sent one or more overflow chunks,
+14
View File
@@ -213,6 +213,13 @@ def build_top_level_parser():
default=False,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
)
_inherited_flag(
parser,
"--safe-mode",
action="store_true",
default=False,
help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules)",
)
_inherited_flag(
parser,
"--tui",
@@ -366,6 +373,13 @@ def build_top_level_parser():
default=argparse.SUPPRESS,
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
)
_inherited_flag(
chat_parser,
"--safe-mode",
action="store_true",
default=argparse.SUPPRESS,
help="Troubleshooting mode: disable ALL customizations — user config, AGENTS.md/memory injection, plugins, and MCP servers (implies --ignore-user-config and --ignore-rules). Use to isolate whether a problem comes from your setup or from Hermes itself.",
)
chat_parser.add_argument(
"--source",
default=None,
+69 -7
View File
@@ -3524,6 +3524,22 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
_save_auth_store(auth_store)
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
imported = _import_codex_cli_tokens()
# Require BOTH tokens before adopting: persisting a payload without a
# usable refresh_token would only break the next refresh cycle.
if not (
imported
and str(imported.get("access_token", "") or "").strip()
and str(imported.get("refresh_token", "") or "").strip()
):
return None
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
_save_codex_tokens(imported)
return dict(imported)
def refresh_codex_oauth_pure(
access_token: str,
refresh_token: str,
@@ -3660,11 +3676,34 @@ def _refresh_codex_auth_tokens(
Saves the new tokens to Hermes auth store automatically.
"""
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
try:
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
except AuthError as exc:
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
# Codex OAuth token (per profile + top-level), separate from the Codex
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
# the Codex CLI (or another Hermes process) rotates the shared token,
# this frozen copy's refresh_token goes stale and the refresh fails with
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
# Before surfacing that as a hard 401 to the turn, adopt the canonical
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
# idle profiles / desktop sessions recover automatically instead of
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
# keep relogin_required=False — the stored token is still valid there, so
# we never self-heal those and re-raise unchanged.
if not getattr(exc, "relogin_required", False):
raise
imported = _recover_codex_tokens_from_cli(
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
)
if not imported:
raise
return imported
updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
@@ -3724,9 +3763,25 @@ def resolve_codex_runtime_credentials(
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
credential. See issue #32992.
"""
read_error: Optional[AuthError] = None
try:
data = _read_codex_tokens()
except AuthError:
except AuthError as exc:
read_error = exc
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
"codex_auth_missing_access_token",
"codex_auth_missing_refresh_token",
"codex_auth_invalid_shape",
}:
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
if imported:
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
else:
data = None
else:
data = None
if data is None:
pool_token = _pool_codex_access_token()
if pool_token:
base_url = (
@@ -3741,7 +3796,14 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
raise
if read_error is not None:
raise read_error
raise AuthError(
"No Codex credentials stored. Run `hermes auth` to authenticate.",
provider="openai-codex",
code="codex_auth_missing",
relogin_required=True,
)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
+6 -4
View File
@@ -1309,6 +1309,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"language": "",
},
"tts_audio_tags": {
"provider": "auto",
@@ -1760,10 +1761,11 @@ DEFAULT_CONFIG = {
"inherit_mcp_toolsets": True,
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
# independent of the parent's max_iterations)
"child_timeout_seconds": 600, # wall-clock timeout for each child agent (floor 30s,
# no ceiling). High-reasoning models on large tasks
# (e.g. gpt-5.5 xhigh, opus-4.6) need generous budgets;
# raise if children time out before producing output.
"child_timeout_seconds": 0, # optional wall-clock cap per child agent. 0 (default)
# = no timeout: children fail only from real errors
# (API, tools, iteration budget), never a delegation
# stopwatch. Set a positive number of seconds
# (floor 30s) to enforce a hard cap.
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
# "low", "minimal", "none" (empty = inherit parent's level)
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
+29 -3
View File
@@ -1543,8 +1543,14 @@ def run_doctor(args):
total = critical + high + moderate
# Determine a scoped fix command for the remediation hint.
if audit_extra and audit_extra[0] == "--workspace":
fix_scope = " ".join(audit_extra)
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
# Detection (`npm audit --workspace <name>`) is read-only and
# safe, but `npm audit fix --workspace <name>` crashes on
# current npm with "Cannot read properties of null (reading
# 'edgesOut')" — an arborist bug with workspace-filtered
# audit fix. The root-level `npm audit fix` can crash on the
# same tree with "isDescendantOf", so do not hand the user a
# manual fix command for these build-tool advisories.
fix_cmd = None
elif audit_extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
@@ -1552,10 +1558,30 @@ def run_doctor(args):
if total == 0:
check_ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
if fix_cmd:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
)
else:
vuln_detail = (
f"{critical} critical, {high} high, {moderate} moderate — "
"build-tool advisory; clears via lockfile bump"
)
check_warn(
f"{label} deps",
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
f"({vuln_detail})"
)
if audit_extra and audit_extra[0] == "--workspace":
# The web/ui-tui workspace advisories are in build-time
# tooling (esbuild/vite, etc.), not runtime code that ships
# to users. Manual npm remediation may error with a known
# arborist crash (edgesOut / isDescendantOf) on this monorepo
# tree — in that case it is an npm bug, not a Hermes one.
check_info(
" ^ build-time tooling (not runtime); if manual npm remediation "
"errors with an arborist crash it's a known npm bug — clears "
"via a lockfile bump"
)
issues.append(
f"{label} has {total} npm "
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
+37
View File
@@ -6675,6 +6675,40 @@ def _worker_terminal_timeout_env(
return str(desired)
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
"""Return the assigned profile's effective CLI toolsets for a worker.
Dispatcher-spawned workers are launched from a long-lived gateway process,
then the child re-enters the CLI with ``-p <assignee>``. Resolve the
assignee profile's CLI tool surface at dispatch time and pass it as an
explicit ``--toolsets`` pin so worker startup cannot fall back to a stale
root/active-profile config or a profile whose top-level ``toolsets`` entry
is only the kanban orchestrator surface. ``model_tools`` still appends the
task-scoped kanban lifecycle tools when ``HERMES_KANBAN_TASK`` is set.
"""
if not hermes_home:
return None
try:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
token = set_hermes_home_override(hermes_home)
try:
cfg = load_config()
toolsets = sorted(_get_platform_tools(cfg, "cli"))
finally:
reset_hermes_home_override(token)
return toolsets or None
except Exception as exc:
_log.debug(
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
hermes_home,
exc,
)
return None
def _default_spawn(
task: Task,
workspace: str,
@@ -6808,6 +6842,9 @@ def _default_spawn(
cmd.extend(["--skills", sk])
if task.model_override:
cmd.extend(["-m", task.model_override])
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
if worker_toolsets:
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
cmd.extend([
"chat",
"-q", prompt,
+179 -40
View File
@@ -2199,6 +2199,18 @@ def cmd_chat(args):
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
# --safe-mode: troubleshooting mode that disables ALL customizations.
# Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a
# pristine environment to isolate whether a problem comes from the user's
# setup (config, rules files, plugins, MCP servers) or from Hermes itself.
# Implemented as a superset of --ignore-user-config + --ignore-rules plus
# plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by
# hermes_cli/plugins.py and tools/mcp_tool.py).
if getattr(args, "safe_mode", False):
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
# --ignore-user-config: make load_cli_config() / load_config() skip the
# user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE
# importing cli (which runs `CLI_CONFIG = load_cli_config()` at module
@@ -2256,8 +2268,8 @@ def cmd_chat(args):
"checkpoints": getattr(args, "checkpoints", False),
"pass_session_id": getattr(args, "pass_session_id", False),
"max_turns": getattr(args, "max_turns", None),
"ignore_rules": getattr(args, "ignore_rules", False),
"ignore_user_config": getattr(args, "ignore_user_config", False),
"ignore_rules": getattr(args, "ignore_rules", False) or getattr(args, "safe_mode", False),
"ignore_user_config": getattr(args, "ignore_user_config", False) or getattr(args, "safe_mode", False),
"compact": getattr(args, "compact", False),
}
# Filter out None values
@@ -8750,6 +8762,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception:
pass # profiles module not available or no profiles
# Backfill per-profile .env files for profiles created before the
# .env-seeding fix (#44792). Copies the default install's .env so
# those profiles keep the credentials they were effectively using.
try:
from hermes_cli.profiles import backfill_profile_envs
backfilled = backfill_profile_envs(quiet=True)
if backfilled:
print()
print(
f"→ Seeded .env for {len(backfilled)} profile(s) "
f"(copied from default): {', '.join(backfilled)}"
)
except Exception:
pass # profiles module not available or no profiles
# Sync Honcho host blocks to all profiles
try:
from plugins.memory.honcho.cli import sync_honcho_profiles_quiet
@@ -9065,6 +9093,66 @@ def _cmd_update_impl(args, gateway_mode: bool):
break
return total if matched else default
_manage_cmd_cache: dict = {}
def _resolve_manage_cmd(scope_: str, scope_cmd_: list, svc_name_: str):
"""Resolve the command prefix for manage-units operations.
Read-only systemctl calls (``is-active``, ``show``,
``list-units``) work unprivileged, but manage-units verbs
(``reset-failed``, ``start``, ``restart``) on a *system*
service trigger a polkit ``org.freedesktop.systemd1.manage-units``
authentication prompt when run as a non-root user. That
interactive prompt runs inside our captured subprocess with a
10-15s timeout the user sees the prompt flash and "exit
directly" before they can answer, and the resulting
TimeoutExpired used to be swallowed silently.
Strategy: if root, plain systemctl. If not root, try
non-interactive sudo (``sudo -n``) first a blanket probe,
then a targeted ``systemctl reset-failed`` probe so a
least-privilege sudoers entry scoped to
``systemctl ... hermes-gateway*`` also qualifies
(``reset-failed`` is an idempotent no-op we run before every
privileged restart anyway). If neither works, return None
the caller must SKIP the restart (without draining the
gateway first!) and tell the user how to restart manually.
``--no-ask-password`` guarantees polkit can never hang a
captured subprocess on this path.
"""
if scope_ in _manage_cmd_cache:
return _manage_cmd_cache[scope_]
cmd = scope_cmd_ + ["--no-ask-password"]
if (
scope_ == "system"
and hasattr(os, "geteuid")
and os.geteuid() != 0 # windows-footgun: ok — systemd path, Linux-only
):
sudo_cmd = ["sudo", "-n"] + scope_cmd_ + ["--no-ask-password"]
sudo_ok = False
try:
_probe = subprocess.run(
["sudo", "-n", "true"],
capture_output=True,
timeout=5,
)
sudo_ok = _probe.returncode == 0
if not sudo_ok:
# Blanket sudo refused — a targeted sudoers entry
# (NOPASSWD for systemctl ... hermes-gateway*)
# may still allow the exact commands we need.
_probe = subprocess.run(
sudo_cmd + ["reset-failed", svc_name_],
capture_output=True,
timeout=5,
)
sudo_ok = _probe.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
sudo_ok = False
cmd = sudo_cmd if sudo_ok else None
_manage_cmd_cache[scope_] = cmd
return cmd
# Drain budget for graceful SIGUSR1 restarts. The gateway drains
# for up to ``agent.restart_drain_timeout`` (default 60s) before
# exiting with code 75; we wait slightly longer so the drain
@@ -9147,6 +9235,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
if check.stdout.strip() != "active":
continue
# Resolve how we may run manage-units verbs
# (reset-failed/start/restart) for this scope.
# None ⇒ no non-interactive privilege path; we
# must avoid those verbs entirely or polkit will
# throw an interactive auth prompt inside our
# captured 10-15s subprocess (the user sees it
# flash and "exit directly" — reported June 2026).
_manage_cmd = _resolve_manage_cmd(
scope, scope_cmd, svc_name
)
# Prefer a graceful SIGUSR1 restart so in-flight
# agent runs drain instead of being SIGKILLed.
# The gateway's SIGUSR1 handler calls
@@ -9206,35 +9305,40 @@ def _cmd_update_impl(args, gateway_mode: bool):
# ``start`` is a no-op and we fall through to
# the poll below. Either way we collapse the
# 60s+ delay to a ~5s one.
subprocess.run(
scope_cmd + ["reset-failed", svc_name],
capture_output=True,
text=True,
timeout=10,
)
subprocess.run(
scope_cmd + ["start", svc_name],
capture_output=True,
text=True,
timeout=15,
)
# Short poll: the gateway should be up within
# a few seconds now that we bypassed
# RestartSec. Fall back to the longer
# RestartSec + slack budget ONLY if the
# explicit start failed and we need to rely
# on systemd's auto-restart.
if _wait_for_service_active(
scope_cmd,
svc_name,
timeout=10.0,
):
restarted_services.append(svc_name)
continue
# Explicit start didn't take. Fall back to
# the original passive poll (systemd's
# auto-restart WILL fire after RestartSec
# regardless).
#
# The shortcut needs manage-units privileges.
# Without them (system service, non-root, no
# passwordless sudo) skip it — systemd's own
# auto-restart still relaunches the unit after
# RestartSec, no privileges required.
if _manage_cmd is not None:
subprocess.run(
_manage_cmd + ["reset-failed", svc_name],
capture_output=True,
text=True,
timeout=10,
)
subprocess.run(
_manage_cmd + ["start", svc_name],
capture_output=True,
text=True,
timeout=15,
)
# Short poll: the gateway should be up
# within a few seconds now that we
# bypassed RestartSec.
if _wait_for_service_active(
scope_cmd,
svc_name,
timeout=10.0,
):
restarted_services.append(svc_name)
continue
# Passive poll: systemd's auto-restart fires
# after RestartSec regardless of privileges.
# This is the primary path when _manage_cmd is
# None, and the fallback when the explicit
# start didn't take.
_restart_sec = _service_restart_sec(
scope_cmd,
svc_name,
@@ -9244,6 +9348,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
10.0,
_restart_sec + 10.0,
)
if _manage_cmd is None and _restart_sec > 5.0:
print(
f"{svc_name}: waiting for systemd "
f"auto-restart (~{int(_restart_sec)}s; "
"no root for an immediate restart)..."
)
if _wait_for_service_active(
scope_cmd,
svc_name,
@@ -9259,6 +9369,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
f"{svc_name} drained but didn't relaunch — forcing restart"
)
# Forcing a restart requires manage-units
# privileges. Without a non-interactive path,
# running systemctl here would spawn a polkit
# auth prompt inside a captured 10-15s subprocess
# — it flashes and dies before the user can
# answer. Skip with clear instructions instead.
if _manage_cmd is None:
print(
f"{svc_name} is a system service and restarting it needs root.\n"
f" Restart it manually to load the new version:\n"
f" sudo systemctl restart {svc_name}\n"
f" To let `hermes update` restart it automatically, allow\n"
f" passwordless sudo for systemctl, or run updates with sudo."
)
continue
# Fallback: blunt systemctl restart. This is
# what the old code always did; we get here only
# when the graceful path failed (unit missing
@@ -9276,13 +9402,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
# path in `hermes gateway restart`
# (`systemd_restart()`) as of PR #20949.
subprocess.run(
scope_cmd + ["reset-failed", svc_name],
_manage_cmd + ["reset-failed", svc_name],
capture_output=True,
text=True,
timeout=10,
)
restart = subprocess.run(
scope_cmd + ["restart", svc_name],
_manage_cmd + ["restart", svc_name],
capture_output=True,
text=True,
timeout=15,
@@ -9308,13 +9434,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
f"{svc_name} died after restart, retrying..."
)
subprocess.run(
scope_cmd + ["reset-failed", svc_name],
_manage_cmd + ["reset-failed", svc_name],
capture_output=True,
text=True,
timeout=10,
)
subprocess.run(
scope_cmd + ["restart", svc_name],
_manage_cmd + ["restart", svc_name],
capture_output=True,
text=True,
timeout=15,
@@ -9328,19 +9454,29 @@ def _cmd_update_impl(args, gateway_mode: bool):
print(f"{svc_name} recovered on retry")
else:
_scope_flag = "--user " if scope == "user" else ""
_sudo_hint = "sudo " if scope == "system" else ""
print(
f"{svc_name} failed to stay running after restart.\n"
f" Check logs: journalctl {_scope_flag}-u {svc_name} --since '2 min ago'\n"
f" Check logs: {_sudo_hint}journalctl {_scope_flag}-u {svc_name} --since '2 min ago'\n"
f" Recover manually:\n"
f" systemctl {_scope_flag}reset-failed {svc_name}\n"
f" systemctl {_scope_flag}restart {svc_name}"
f" {_sudo_hint}systemctl {_scope_flag}reset-failed {svc_name}\n"
f" {_sudo_hint}systemctl {_scope_flag}restart {svc_name}"
)
else:
print(
f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}"
)
except (FileNotFoundError, subprocess.TimeoutExpired):
except FileNotFoundError:
pass
except subprocess.TimeoutExpired as exc:
# Don't swallow this silently — a wedged systemctl
# call here used to make the whole restart phase
# vanish with no output (June 2026 report).
print(
f" ⚠ systemctl timed out during the {scope}-scope "
f"gateway restart ({exc.cmd if exc.cmd else 'unknown command'}). "
f"Check the gateway with: hermes gateway status"
)
# --- Launchd services (macOS) ---
if is_macos():
@@ -9741,7 +9877,10 @@ def cmd_profile(args):
getattr(args, "clone_from", None) or get_active_profile_name()
)
if clone_all:
print(f"Full copy from {source_label}.")
print(
f"Full copy from {source_label} "
"(excluding session history, backups, and snapshots)."
)
else:
print(
f"Cloned config, .env, SOUL.md, and skills from {source_label}."

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