Three small dedupes in the surfaces this feature touches:
- GLASS_SUPPORTED was a fourth inline copy of "am I on a Mac" in the
renderer. src/lib/platform.ts owns it now and the terminal's
isMacPlatform re-exports it, so the two call sites can't drift.
- The store held intensity and mode as two atoms behind two localStorage
keys with two subscriptions calling one sync. They are one setting: one
atom, one key, one subscription. A pre-mode value under that key is a bare
intensity, which has only ever meant clear -- read() keeps it there.
- global.d.ts re-declared the IPC payload as an inline union. It takes
TranslucencyState, so widening the state can't leave the bridge behind.
isChatWindow is exported and takes its search string as a parameter, because
"which windows may thin their surfaces" is the contract worth pinning.
Some providers re-send the previous assistant text verbatim when a turn
continues past a tool call (a tool_calls row, then a stop row with identical
prose — both persisted). The turn merge folds both rows into one bubble, so
every paragraph in the reply rendered twice; inline ::preview frames made it
obvious. Repeated text parts now dedupe in the same pass as generated-image
echoes — the last occurrence wins.
The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.
Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
Desktop shipped the same bug class four times in one week: a decorative
animation loop that never sleeps (bots face clock #88543, pixel egg #88406,
diffusion placeholder #88564, plus the #77651 hidden-renderer wave). Each fix
hand-rolled the same four behaviors. This extracts them into one helper in
src/lib/budgeted-loop.ts:
- fps budget (default 15) on top of rAF
- observability pause via the existing createRendererLoopPauseController
- idle dormancy: idleWhen() true after a draw parks the loop with zero
pending work until wake() — the piece every hand-rolled loop forgot
- teardown: dispose() cancels the frame, disposes the controller, and makes
wake() a no-op
DiffusionCanvas migrates onto it as the first consumer (net -40 lines at the
call site); its existing scheduling/budget/instance-cap tests pass unchanged
against the migrated implementation. Helper suite covers budget, pause,
park/wake, dormancy-survives-focus-churn, and dispose idempotency; sabotage
run (budget+dormancy stripped) fails 4/5.
Cmd/Ctrl+Shift+B worktree flows on a remote gateway route through the
backend's /api/git mirror (hermes_cli/web_git.py), but that mirror had
drifted behind the Electron-local git ops the same UI drives locally, so
the flows broke exactly and only on remote connections:
- Convert-a-branch: the picker offers remote-tracking refs, and the
Electron op turns "origin/feature" into a local tracking branch. The
mirror ran `git worktree add <dir> origin/feature` verbatim, which
either fails or detaches HEAD. It now resolves the ref's remote via
git (never assuming "origin"), fetches best-effort, and creates the
worktree with `--track -b <short-name>`.
- branch_list omitted remote-tracking refs entirely and never set the
`isRemote` flag the renderer's HermesGitBranch contract requires —
the convert picker on a remote gateway couldn't reach a teammate's
branch and mislabeled every row's action.
- Branching off an `origin/…` base silently wired the new branch to the
remote upstream; the mirror now passes `--no-track` like the Electron
op does.
Renderer side, replace the silent degradation with a capability gate:
when a remote backend predates the /api/git worktree routes, worktree
creation failed with an opaque "Expected JSON … got HTML" toast. The
route-missing shapes now surface a clear "update the Hermes backend"
message (isGitEndpointMissingError, mirroring the sidebar batch-endpoint
detector); real git errors still pass through untouched.
Sibling audit (documented, no code change needed): repo status / review /
file-diff / git-root / default-cwd already route through desktopGit()'s
REST bridge or /api/fs on remote; repo scan is deliberately a no-op there.
Stale comments claiming "empty/false on a remote backend" in projects.ts
and coding-status.ts updated to describe the backend-routed reality.
Fixes#81724
The live suite previously called appendMidTurnUserMessage directly, leaving
redirectPrompt's appendAfterActiveReply guard — the production decision of
WHERE a correction lands — outside the harness. Both hooks now mount together
sharing one state map, exactly as the desktop wires them, so a regression in
the caller (not just the insert) goes red. Verified by mutation: disabling the
guard fails 2/4.
Also covers the rejected-redirect path: a not_running response discards the
optimistic bubble instead of stranding a correction the model never saw.
The hydration fixture now carries the durable row shape the client actually
receives (row_id, reasoning, provider call_id/response_item_id on tool_calls)
instead of a hand-simplified echo, so the 'mirrors real state.db rows' claim
is honest. The fake-timer steer id counter is gone with the local insert —
ids come from redirectPrompt itself.
A steered turn's contract — pre-steer output above the correction bubble,
post-steer output and the settled reply below it — was fixed across
several PRs (#73793/#83151 class, settle fixes) but only covered piecewise:
the mid-turn insert as a unit, the settle math as a unit. Nothing drove the
real stream reducer through a whole steered turn, and nothing asserted the
durable-row hydration renders the same order after reload.
Two suites close that:
- steer-arrival-order: full event sequences through useMessageStream's real
handler + the real optimistic insert — single steer with tool activity,
steer racing message.complete, double steer in one turn.
- steered-turn-hydration-order: toChatMessages over persisted row shapes
copied from a real state.db steered turn, including a tool result that
lands after the correction row.
Multiple Desktop windows share one renderer origin (one localStorage
area) while each window can be connected to a DIFFERENT gateway. The
sidebar pin set (hermes.desktop.pinnedSessions), the manual session
order, and the remembered last-session/route navigation keys were all
persisted under single global (or profile-only) keys, so two windows on
different gateways read and reconciled the same lists: pin-sync's
pullRemotePins() in one window adopted/dropped pins belonging to the
other window's backend, producing the overlapping mixed PINNED/SESSIONS
lists reported after the v0.19.1 update relaunch.
Introduce a connection-scope persistence layer (connectionScopedAtom in
src/lib/connection-scoped.ts): the local connection keeps the bare
legacy key (byte-identical for single-backend users, same contract as
backendScopeKey), while remote connections persist under
`<key>.remote.<encoded baseUrl>.<encoded profile>` — the shape
workspaceCwdKey already established. setConnection() rescopes every
scoped atom when the window's connection changes (null descriptors keep
the current scope, as with syncCronModelImpactConnection), and pin-sync
resets its mirrored/pending/unconfirmed bookkeeping on rescope so a
reconcile never PATCHes one gateway's pins to another.
Legacy globally-keyed values are deliberately not migrated into remote
scopes: ownership of rows accumulated by every window is unknowable
(the #67709 precedent), and backend-mirrored pins self-heal from the
gateway's own `pinned` rows.
Fixes#77318
Add a compact Import popover to the MCP Capabilities page that accepts
anything a user might copy from an MCP server README and infers the
server config:
- mcp.json snippets (mcpServers-wrapped, bare name->config maps, single
unnamed server objects, Cursor/Claude `type` normalized to `transport`)
- bare npx/bunx/uvx/node/docker command lines (name inferred from the
package basename, e.g. server-filesystem -> filesystem)
- `claude mcp add NAME [--transport http|sse] [-e K=V] [-H ...] [--] CMD
ARGS...` and `claude mcp add NAME URL`
- bare http(s) URLs (name inferred from the hostname)
- Cursor deeplinks (cursor://anysphere.cursor-deeplink/mcp/install with
a base64-encoded JSON config payload)
The parser is a pure module (src/lib/mcp-import.ts) with unit tests for
every format plus garbage input. The popover previews the inferred
name + config and, on confirm, merges the entries into the editor draft
exactly like addServer's starter entry: unique keys, dirty (unsaved)
draft, first new block focused. Placeholder env values (YOUR_KEY,
TOKEN_HERE, ...) are kept verbatim for the user to edit in the editor
before saving.
i18n keys added under settings.mcp for en, zh, zh-hant, ja (ar falls
back through defineLocale).
MCP server problems (expired OAuth tokens especially) were only
discovered when the user visited the MCP page and a probe ran. Now a
renderer-side background checker (store/mcp-health.ts) sweeps the
active profile's enabled HTTP/SSE MCP servers on gateway connect and
every 30 minutes, and fires an in-app notification with a "Sign in"
action ("<name> MCP needs re-authentication") that navigates to the
MCP page with ?server=<name> so useDeepLinkHighlight focuses the
server and its Authenticate button. Navigation only — OAuth flows are
never auto-launched.
stdio servers are deliberately excluded: probing a stdio server SPAWNS
a local process, so a background timer must never touch them. Only
url-shaped servers (where OAuth expiry lives) are swept, sequentially.
The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to
a shared lib/mcp-probe-cache.ts (behavior identical) so the page and
the checker share one probe cache and its 5-minute TTL — neither
surface re-probes what the other just learned.
Notifications fire only on a TRANSITION into needs-auth/error (pure
state machine, unit-tested), hard-capped at one per server per app
session, keyed per profile. Profile switches drop pending timers and
re-arm for the new profile; sweeps never run while the gateway is
disconnected. No new config knobs. i18n keys added across
en/zh/zh-hant/ja/ar + types.
Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard
base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and
docs can offer an "Add to Hermes" button.
- Electron: the existing generic hermes:// handler already forwards
{kind, name, params}; only its comment is updated (no new handler).
- Renderer: use-desktop-integrations routes kind=mcp/name=install into
a pending-install store; a new confirmation dialog shows the server
name and the FULL pretty-printed config (attacker-controllable input),
with a prominent caution for stdio command entries. Nothing is written
until the user confirms; existing names require a rename or cancel.
On confirm the server is merged over a fresh fetch of the current map
via saveMcpServers, then navigation lands on /skills?tab=mcp&server=…
so useDeepLinkHighlight focuses the new row.
- Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an
object with a string http(s) `url` or a string `command` (never both);
payloads over 32KB rejected; failures surface as a toast.
- Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape,
command shape, bad base64, non-object, javascript: URL, oversized).
- i18n keys in types + en/zh/zh-hant/ja/ar.
- Docs: "Add to Hermes link" section in the MCP config reference.
Desktop/TUI count full displayed lineage after compression, but
prompt.submit validated truncate ordinals against tip-only history.
Translate via display_history_prefix and recover stale 4018s on Desktop.
Co-authored-by: Cursor <cursoragent@cursor.com>
Each configured server row on the MCP Capabilities page now shows what it
costs and whether it earns its keep:
- ~per-call token estimate of the server's tool schemas, summed over ENABLED
tools only (ceil(schema_chars/4) via the existing include/exclude filter)
- 30-day usage count from getUsageAnalytics(30), cached per scope profile
like the Toolsets tab's toolCallsCache, mapped to servers via the
mcp__<server>__<tool> registry-name convention (tools/mcp_tool.py)
- a subtle muted "unused" pill on enabled, probed-ok servers with nonzero
schema cost and zero 30-day uses — never a dialog
Backend: the /api/mcp/servers/{name}/test probe now fills an additive
per-tool `schema_chars` (length of the SAME converted registry schema the
agent registers). Older backends omit it → renderer shows counts only;
older renderers ignore the extra key. Display-only: nothing changes what
schemas are sent to models, no config knobs.
i18n keys (costTokens/usage30d/unusedPill) added to types/en/zh/zh-hant/ja
(ar inherits en via defineLocale overrides). Pure math lives in
lib/mcp-cost.ts with unit tests; Python wire shape pinned in
tests/hermes_cli/test_web_server_profile_unification.py.
Review NIT: a malformed stored binding of just 'mod'/'ctrl' (never
produced by comboFromEvent) could pass the shape-only mod/ctrl check.
Reject bare-modifier bases in actionAllowedInInput and pin it in the
suite.
#86586 replaced the combo-based input gate (any Cmd/Ctrl chord fires
while typing) with an action allowlist that dropped session.new and
every other mod-chord not explicitly listed. ⌘N/⌘T/⌘⇧N and friends
became dead keys whenever focus was in the composer.
Restore the pre-regression rule: primary-modifier chords stay global
even in text fields; the allowlist now gates only bare/Shift/Alt combos,
so rebound letter keys can never hijack typing. Text-navigation chords
(Ctrl+Arrow/PgUp/PgDn) still stay with the input.
Each assistant reply now carries a small time badge below the message text
showing how long its turn took (message.start -> message.complete), so
users can gauge task latency at a glance without hovering.
The duration is computed renderer-side from the per-session turnStartedAt
timestamp the app already tracks and stamped onto the ChatMessage at
completion (successful and failed turns alike). It is not persisted
backend-side, so messages hydrated from history have no badge — matching
how reasoning-block durations already behave.
Also adds the assistant.thread.turnDuration i18n key across all five
locale files.
Follow-up hardening for the #74163 salvage: the no-payload settle gate used
turnStartedAt as "backend reported the turn live", but since the turn clock is
now optimistically seeded at submit (#86923), that signal is ambiguous.
Introduce ClientSessionState.turnLive, set on message.start, the running=true
session.info edge, and resume-onto-running paths; cleared by every settle.
The pre-start bail now gates on turnLive so a running=false heartbeat in the
submit gap still keeps the spinner up, while a genuinely started turn that
dies without a payload settles and unbricks the session.
The scoped find captures the foreground chat surface at bar-open time and
marks it `data-find-root`. A keep-alive tab flip — clicking another session
tab in the same stack — hides that surface with `data-pane-hidden` and
activates an unmarked one, without a pathname change, so the FindBar (which
only closes on a route change) stays open. Every subsequent query then
resolved no visible `data-find-root`, reporting 0/0 while the surface on
screen contained the text; Cmd+G / Cmd+Shift+G became no-ops.
currentFindScope now re-targets when every marked root is hidden: it
re-resolves to the foreground chat surface, clears the old surface's
highlights and marker (so they do not resurface when its tab is revisited),
stamps `data-find-root` on the new surface, and re-arms the re-render
watcher there. The normal case is unchanged — the scope stays on the
captured surface while it remains visible. Also restores the missing
trailing newline in store/find-in-page.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scoped find walker wraps transcript text nodes in <mark> elements that
React does not own. Assistant responses stream through markdown-text.tsx,
which rebuilds the markdown DOM on every delta, and a new message is
appended whenever the assistant answers — so a re-render of a changed
region detaches the marks we inserted, dropping the user's highlights while
the bar stays open.
Watch the captured scope with a MutationObserver and re-wrap only when an
unmarked occurrence of the active query actually reappears. The observer is
gated behind a re-entrancy flag while the walker is mutating, coalesced to
one re-apply per microtask, torn down when the bar closes or the query
clears, and restores the active ordinal so a mid-stream re-render doesn't
reset the user's place to match #1. An append that adds no matching text is
a no-op; re-wrapping only fires when highlights genuinely went stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fast path trusted that marks matching the query case-insensitively
implied the set still covered every match. A stale mark from an earlier
query (raced re-wrap, external DOM writes) can pass that per-mark
comparison while live occurrences stay unwrapped — stepping then walks
old highlights and the new matches never light up. Gate the fast path on
a read-only coverage check (same skip rules as the walker) so any
unmarked occurrence forces a re-wrap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Triage found the findNext fast path compared each existing `<mark>`'s
textContent byte-for-byte against the typed query. Since highlightMatches
stores the ORIGINAL-CASE source slice, the first differently-cased match
('Hermes' for 'hermes', sentence-initial capitals, ALL-CAPS) failed the
check, forcing a full re-wrap on every Enter/⌘G: fresh `<mark>` elements
lose `data-find-active`, `previousActive` resolves null, and the active
ordinal stays 1 forever (backward steps land on the last match every
time).
Compare case-insensitively. Regression test pins the differently-cased
stepping behavior: same marks survive, count stays 2, ordinal advances
to 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Team review (#81778) found the walker terminated sibling traversal when a
text node was entirely consumed by a match: `current = textNode ? ... :
null` — after replaceChild detaches the original node, its `.nextSibling`
reads null, so `<div>needle<span>needle</span></div>` searching "needle"
matched only the first occurrence. Any JSX bare-text + element sibling
pattern (tool rows, attachment rows) could silently drop later matches.
Capture the text node's own `nextSibling` BEFORE the first replaceChild
and resume the outer walker from it on full consumption. The `after`
split path is unaffected (the trailing text node is re-scanned).
Regression test pins `<div>needle<span>needle</span></div><p>needle</p>`
→ 3 matches. 17/17 scope tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pressing Cmd/Ctrl+F in Hermes Desktop searched the entire webContents,
so every keep-alive chat surface matched — a user in chat A got hits from
chat B, every background tile, and every other pane rendering a transcript.
That's not what users expect from in-page find; it's a global search, and
it deserves its own shortcut.
Replace `webContents.findInPage` (whole-document, unscoped) with a
renderer-side DOM walker that captures the active chat surface at
bar-open time and only walks text nodes inside that subtree. The Electron
bridge is retained for secondary session windows (each window still
searches its own webContents); the primary window no longer drives the
bridge.
Scope decision. "Current view" is the foreground `[data-chat-surface]`
filtered through the existing pane-visibility helper, so an inactive
keep-alive tab can't accidentally answer the lookup. The scope is
captured once when the bar opens, not re-resolved on every keystroke,
so a mid-search route change can't silently re-home the highlights — the
FindBar's route-change cleanup closes the bar first.
Walker. Walks text nodes in document order, splits nodes that span a
match boundary, wraps each match in `<mark class="find-hit">`, and tracks
the active match by a single `data-find-active` attribute. Step uses the
existing marks when the query has not changed (no DOM churn) and re-
wraps when it has. Closing the bar unwraps every mark and normalizes the
parent text, restoring the original DOM byte-for-byte.
Tests. New `find-in-page-scope.test.ts` covers the walker in isolation:
multi-match wrapping, case-insensitivity, cross-node splits, script/style
filtering, no-self-match against the search overlay, scope retarget on
surface swap, and unwrap-on-release. Rewrote the FindBar store/component
tests to assert against the real DOM (marks + counts) instead of the
bridge mock — keeping the bridge mock only for the listener-refcount
cases that still cover the secondary-window path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desktop app carried its own hardcoded list of 17 vendor MCP endpoints
(apps/desktop/src/lib/mcp-directory.ts) powering the composer suggestion
pills — a second PR-reviewed vendor list, overlapping and drifting from the
Nous-approved MCP catalog (optional-mcps/).
This makes the catalog the single source of truth:
- manifest schema: optional `suggest:` block (keywords + hosts), parsed,
validated, and normalized in mcp_catalog.py
- 15 new URL-only hosted-remote catalog entries (atlassian, sentry, datadog,
notion, stripe, vercel, supabase, netlify, hugging_face, asana, intercom,
airtable, webflow, paypal, square); figma + linear manifests gain suggest
blocks
- GET /api/mcp/catalog now serves the suggest metadata
- desktop suggestion provider builds its match index from the catalog;
the static directory remains only as a compatibility rung for older
backends without suggest metadata
- setup card source line prefers the catalog entry's transport URL
GitHub stays out of the catalog on purpose: its hosted MCP rejects generic
DCR and the bundled github/* skills (gh CLI) are the stronger integration.
New desktop `github` suggestion provider offers the github-auth skill
instead — gated on a new cached GET /api/git/gh-auth probe so already-
authenticated users never see the pill.
Address review on the persisted unread dots, plus a latent data-loss bug
in the shared persistence helper that the restart e2e exposed.
Review findings:
- Session ids are caller-supplied and each profile backend is its own
namespace, while the desktop's lists routinely mix profiles (cron and
messaging slices are always cross-profile; recents are too in
all-profiles mode). Both persisted records are now bucketed per
profile - nested records keyed by the ROW's own profile
(normalizeProfileKey, absent -> default), never the live gateway's,
except the live busy->idle edge with no loaded row, which can only
come from the active gateway. Same-id sessions in different profiles
no longer share watermarks or markers.
- Markers are now bounded (200 per profile, oldest evicted) and cleaned
up when a session leaves the user's world: forgetSessionUnread() is
wired into removeSession, archiveSession, and the settings
permanent-delete path (which bypasses the other two).
Cold-boot clobber (found by the restart e2e after the refactor):
- persistentAtom wrote its value back to storage immediately at
creation. On a cold boot the bundle can evaluate against a storage
snapshot that has not caught up yet, so that echo overwrote real
records with the fallback. Creation is now read-only; only actual
changes persist. Regression-tested in persisted.test.ts.
- The read side of the same race is handled in session-unread.ts: the
first list arrival re-reads both records from storage (readable by
then) and merges them under the in-memory state, so a boot that
seeded empty atoms adopts the disk state instead of re-seeding every
row and burying the unread gap. Unread listeners are also disabled in
secondary windows - their partial list view must not write the
primary's whole-record state (same isolation rule as session tiles).
Tests: cross-profile same-id regression, live-edge profile fallback,
forgetSessionUnread cleanup, marker cap, persistentAtom creation
read-only; the restart e2e passes again end to end.
#76185 removed both defaults; only the bare shift+n chord hijacks normal
typing (uppercase N / IME input outside an input field). Cmd/Ctrl+N is a
deliberate two-key chord that matches every browser and chat app, so it
stays. Follow-up narrowing of the salvaged fix.
Adds a rebindable 'session.archive' keybind action (shipped unbound, like
session.togglePin) plus an ⌥+⇧-click gesture on sidebar session rows,
extracted into a pure, unit-tested click resolver so modifier precedence
(⌥⇧ archive vs ⇧ pin vs ⌘/⌃⇧ new window) stays correct.
Salvaged from #59759. Closes#59308.
The session.new default keybindings included 'mod+n' and 'shift+n',
which fired when users typed uppercase N (Shift+N) or accidentally
pressed Ctrl+N while using an IME to type Chinese — silently creating
new sessions mid-conversation.
Remove both combos so New Session is only reachable via the sidebar
button. Ctrl+Shift+N (session.newWindow) is unaffected.
Addresses review feedback on #68744: downscaling inside
attachmentPreviewDataUrl made previewUrl the 2048px PNG, but current main
(d8cb73b4ab) feeds that field to ImageLightbox and useImageDownload, so
large attachments would open and download at reduced resolution.
- attachmentPreviewDataUrl returns the full-resolution data URL again
- ComposerAttachment gains thumbnailUrl?: string (store/composer.ts)
- attachImagePath stores previewUrl (full-res) + thumbnailUrl (downscaled)
- Attachment pill renders thumbnailUrl ?? previewUrl; lightbox/download
keep the full-res previewUrl
- optimisticAttachmentRef prefers thumbnailUrl for the in-flight bubble
display ref (same main-thread decode freeze at send time)
- Attachment-level regression test in use-composer-actions.test.ts:
full-res previewUrl preserved while thumbnailUrl is a separate
downscaled value (4000x3000 -> 2048x1536)