Fold ctx.notifyNative into a ctx.os namespace so every way a plugin
reaches outside the app window lives behind one attributed door instead
of accreting one top-level ctx method per capability:
- ctx.os.notify — the native-notification door from the previous commit,
unchanged semantics (plugin kind pref, away-gating, per-plugin throttle).
- ctx.os.openExternal / ctx.os.revealPath / ctx.os.writeClipboard — the
existing window.hermesDesktop bridge capabilities, now sanctioned and
result-shaped: each resolves false (never throws) when the bridge or
member is missing, so a plugin branches on the result instead of
sniffing the preload surface or crashing on an older shell.
No new Electron surface: everything routes through bridge members the
app already ships; the notification path keeps every existing gate.
Show earlier spends the already-materialized DOM budget first and only asks the
session store for another page once that is exhausted, so the click stays cheap
and the store window stays as small as it can be.
Paging has no ceiling: each expand grows the window by one budget page until the
whole transcript is loaded. Branch persistence stays wired throughout —
setMessages is never dropped, so switchToBranch and applyBranchVisibility keep
working on a windowed session.
Co-authored-by: HexLab <8422520+HexLab98@users.noreply.github.com>
An oversized session rebuilt an unbounded runtime repository on every store
update and exhausted the renderer's V8 heap, crash-looping the window. The DOM
budget in thread/list.tsx bounds what PAINTS, but every message was still
normalized into the repository first, so a session only had to be heavy — not
visible — to kill the renderer.
selectTranscriptWindow keeps the tail that fits one render-weight page. Weight,
not message count: measured against a real 1,175-session store, a 400-message
cap disengages on 37 sessions that are heavy but short (one is 133 messages /
1.05MB) while firing on 92 long-but-light sessions that were never at risk.
The cut aligns off branch-group boundaries. useRuntimeMessageRepository records
a group's fork point the first time it sees the group, so a window starting
mid-group would re-parent the surviving branches to whatever happened to
precede them.
Co-authored-by: HexLab <8422520+HexLab98@users.noreply.github.com>
messageRenderWeight moves out of thread/list.tsx into lib/render-weight.ts.
The DOM page budget already spends render cost rather than message count —
the store window added next needs the same currency, and one weight function
keeps the two layers from drifting apart.
No behavior change.
Desktop plugins can toast in-app (host.notify) but have no sanctioned way to
reach the OS notification pipeline the app's own approval/turn alerts use, so
a plugin surfacing a genuinely notable background event (e.g. a discovery
plugin finding a match) stays invisible once the user steps away from Hermes.
Add a curated per-plugin door instead of exporting the raw dispatcher:
- ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed
to the plugin id, routed through dispatchNativeNotification so every
existing gate applies (master + per-kind prefs, post-connect baseline,
away-from-app gating, throttle).
- New 'plugin' native-notification kind with its own Settings ▸ Notifications
toggle (default on), so users silence plugins without losing app alerts.
- New optional `tag` discriminator on the notify payload keys the renderer
throttle and main-process cross-window dedupe per plugin, so two plugins
can't collapse each other's session-less notifications.
Consumer: the Index Network desktop plugin wants background opportunity
alerts; anything in ~/.hermes/desktop-plugins gets the same door.
Hitting ⌘1 (or cycling ⌃Tab onto the main tab) while Capabilities /
Messaging / Artifacts covered the workspace looked dead: the workspace
pane was already the zone's active tab behind the page, so fronting it
changed nothing on screen.
activateTreeTabSlot / cycleTreeTabInFocusedZone now return the activated
pane id, and the keybind handlers route back to the loaded session (or
the new-chat draft) when the landing pane is the workspace under a full
page — the same rule openSession already applies.
Loopback dashboard tabs now share one one-shot stale-token recovery path across REST 401s, the PTY socket, the structured event socket, and the shared JSON-RPC gateway wrapper. The shared client exposes only an optional close-event interception hook; the dashboard remains responsible for deciding that loopback 4401 means reload.
Constraint: Current main delegates the web gateway to apps/shared JsonRpcGatewayClient, and #54022 review requires a shared-client-compatible close-code hook plus direct ChatSidebar event-socket coverage.
Rejected: Restore the dashboard's old direct WebSocket implementation | stale against the shared JSON-RPC client and would duplicate transport behavior.
Confidence: high
Scope-risk: moderate
Directive: Keep stale-token policy dashboard-specific; the shared JSON-RPC client should expose close events without learning dashboard auth semantics.
Tested: npm --workspace web test (21 files, 106 tests); focused stale-token tests (5 files, 14 tests); npm --workspace web run typecheck; npm --workspace @hermes/shared run lint; npm --workspace @hermes/shared run typecheck; focused web eslint; git diff --check.
Not-tested: Manual browser smoke test across a real dashboard restart.
Regression coverage requested in review of #74357: mock
window.hermesDesktop.onWindowStateChanged (pattern from
persistent.test.tsx) and assert minimized/hidden clears the spinner
interval while restore resumes it; also cover document.visibilityState
hidden/visible via visibilitychange.
Advance the existing animated status glyph through its DOM text node instead of React state, and pause its timer for hidden panes or inactive windows. Cover frame advancement, zero update-phase commits, and timer suspension with behavior tests.
CI-caught: cron-jobs-section had an extra blank line between sorted imports; use-message-stream's visibility-flush effect assigns flushHandleRef.current=null inside a useEffect (legitimate timer-clear, not an atom mirror) — eslint-disable-next-line per the rule's documented convention.
Two findings from the simplify pass on the final trio diff:
- status-pulse: one pause controller + one aligned period timer shared by all StatusPulse instances (ref-counted), instead of N x (document/window/bridge listeners + unsynchronized 5s wakes) — a sidebar can show dozens of pulsing dots. Pause still cancels in-flight animations so the compositor sleeps immediately.
- cron-jobs-section: the runs-peek effect created its interval even while the pane was hidden (callback no-oped but the timer still woke the renderer every 8s/60s per expanded job). Early-return when hidden — visibility is already in the dep array, so becoming visible restarts load + timer.
Partial pick of the surviving renderer hunks from #75395 (perf commit
6502e441d plus fixup 3fbbc9c1d): gate the 500ms subagent now-ticker and
the cron sidebar 1s ticker/run-poll on usePaneVisible, and skip the
legacy floating-pet poll while the document is hidden. Dropped hunks
(electron/main.ts, vitest.setup.ts/config) intentionally excluded.
Follow-up to #77652: each runFlush registered a fresh requestAnimationFrame and never cancelled it. Chromium parks rAF callbacks for hidden renderers, so a long hidden stream at the 33ms floor accumulates thousands of parked closures that all fire in the first frame on refocus (all but one no-oping through the stale-frame guard). Track the pending handle, cancel it before requesting a new one (only the newest flush's measurement matters), and cancel on unmount.
Two fixes landed overlapping helpers on the same statement: the mid-turn
reply guard grew `isLiveProjectionRow` / `hasStreamedContent`, while the
inflight-dump guard grew `isLiveTailRow` / `hasStructuralParts`. Two
definitions of "is this row live" and "does it carry content" in one
function is how the next change silently reshapes one of them.
Collapse to a single module-level pair. `isLiveTailRow` now covers pending,
stream ids, inflight projections and sealed interim rows, so the reply guard
also stops treating an interim row as committed history; `hasStreamedContent`
is defined in terms of `hasStructuralParts`. Both text-extension checks route
through `isStrictAnswerTextExtension` rather than a bare `startsWith`.
Also hoists the live-tail lookup out of an inline IIFE and fixes the lint
warnings it carried.
Co-authored-by: 686f6c61 <github@00b.tech>
Structure-only same-turn carry used (live(previous) || live(message)), so a
new live text-only assistant at a compression-rewritten ordinal could inherit
reasoning/tool parts from an unrelated historical structured row.
Require the structure-bearing cached row itself to be live-tail (pending /
assistant-stream-* / interim). Add regressions for non-extending live dump
carry and the compression graft rejection.
Addresses salvage path on #76744 / #76444.
Only skip/graft structure for the current live assistant (stream id,
pending, or after the latest user), not completed historical tool rows.
Require live-tail identity for same-turn structure carry. Align journal
overlay with strict answer-text extension.
Addresses review + CI on #76744.
Skip pure-text inflight.assistant projections when the transcript already
has reasoning/tool-call structure, and only overlay journal answer text
on strict extension.
Fixes#76444
Follow-up to #77653: textWithoutReferenceLines built a fresh /g RegExp per call and hand-managed lastIndex — but it runs on both sides of every message comparison in the reconcile loops. An anchored non-global regex has no shared-lastIndex hazard and can be hoisted to module scope. Also removes textWithoutImageRefs, whose last production consumer #77653 replaced (kept IMAGE_REF_LINE_RE for extractImageRefs), and retargets its now-stale comment.
When a turn's reply commits under its own id, the settled local
`assistant-stream-*` row shifts one assistant ordinal earlier, so ordinal
pairing finds nothing at its slot and re-appends it — the same answer twice.
Drop a settled stream row only when the authoritative transcript already
carries that exact text. Keying `isPendingAssistant` on the explicit pending
flag alone would also have fixed this, but it discards the sibling case in
the same report: a reply that finished locally before the gateway committed
it, where the local row is the only copy that exists.
Co-authored-by: Dolverin <59100064+Dolverin@users.noreply.github.com>
Switching sessions while a turn streams (or right as it completes) could
leave the assistant reply missing until restart. Resume merges stored
history with the gateway's `inflight` projection, whose assistant row is
text-only and often an empty `assistant-stream-${sessionId}` shell; both
reconcile paths then dropped the local pending row that held the only copy
of the streamed text, reasoning and tool calls.
A shared pair of guards replaces the ad-hoc comparisons at all three sites.
`localPendingSupersedes` accepts the cached row only when it is the same
reply further along — an empty shell it has content for, or text it strictly
extends — so a longer unrelated row can no longer hijack an ordinal or reuse
a stream id, and a retained `inflight.error` snapshot is never mistaken for
an empty shell. `withAuthoritativeTurnState` then takes content from the
renderer while liveness, row id and reactions stay the backend's call, so a
settled shell cannot leave a finished reply spinning.
Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com>
Re-derive of PR #38470 on today's main (its target file desktop-controller.tsx no longer exists after the contrib/ refactor; the three surviving ideas are applied at their new homes):
- incremental-external-store-runtime: the dep-less setAdapter effect ran every render; gate on [runtime, store] — behavior-preserving because __internal_setAdapter early-exits on identical store.
- ChatView is now memo()d, and session-tile hoists its inline arrow props to useCallbacks/module constants so the memo actually holds.
- Render-count regression test (mocked Thread) proves an unrelated parent re-render no longer re-renders the chat shell.
Credit: idea and original implementation by @hdd69 in #38470.
The comment above .btn-arc contained 'bg-*/', whose */ terminated the comment mid-sentence, leaving 'text-* variant utilities. */ .btn-arc {' as an invalid prelude — CSS error recovery can drop the whole .btn-arc rule. Reword so no */ appears inside the comment.
Extracted from #59352 by @rerdi92 (the rest of that PR — a month-stale icons.ts rewrite and a chunk-size warning-ceiling bump — is superseded/masking and was not salvaged).
With the full-jitter backoff (300ms base) six attempts can elapse in ~9s,
so the old RECONNECT_ESCALATE_AFTER=6 attempt threshold raised the
recoverable boot error during a brief post-boot blip — breaking the
'a remote that drops post-boot keeps looping with NO boot.error' contract.
Escalate after RECONNECT_ESCALATE_AFTER_MS (45s, matching the old
deterministic 1->15s ladder's calibration) elapsed since the first failed
reconnect of the episode. Reset on clean open, manual/wake reconnect, and
soft switch, preserving the reset-on-success path.
All three desktop reconnect loops (primary gateway boot, secondary
multi-profile gateway pool, plugin event socket) used bare exponential
backoff with no jitter. After a gateway restart every disconnected
client redials on the exact same schedule, so the reconnect attempts
land in lockstep instead of spreading out -- a burst that can starve
the gateway's file descriptors while it's still coming back up.
Add reconnect-backoff.ts implementing AWS-style full-jitter backoff
(random delay in [0, min(cap, base * 2^attempt))) and wire it into all
three call sites in place of their local Math.min/2**attempt math.
Manual reconnect paths already reset the attempt counter and bypass
the timer entirely -- unchanged.
scheduleDeltaFlush's adaptive floor is driven by lastFlushCostRef, but
runFlush only timed flushQueuedDeltas(), the synchronous store write.
While a session streams, syncSessionStateToView defers the $messages
publish (React commit + Streamdown re-parse) to its own rAF, so the
measured cost stayed near zero and the floor collapsed to the fixed
33ms path no matter how expensive the real commit was.
runFlush now records the write cost as a fallback, then extends the
measurement through a rAF registered after the view-sync one: it runs
in the same frame right after the deferred commit, and the rAF
timestamp marks frame start so only in-frame work is counted, not the
vsync wait. A stale callback from before a newer flush is ignored, and
a hidden renderer that never fires rAF keeps the write-cost fallback.
Two more call-shape-pinning tests (cold tile resume, default-profile
resume) assert session.resume's exact params; the delegate passes
omit_messages: true like every other Desktop resume call site.
Swept all 5 desktop test files that reference session.resume/activate:
380 of 381 files green (the one failure is a pre-existing locale-
dependent number-grouping test that fails identically on clean main).
Salvage of #69926: omit_messages support ported from the PR's
tui_gateway/server.py base onto the post-split methods_session.py
layout. When a Desktop client passes omit_messages=true on
session.resume / session.activate, the RPC returns messages: [] with
messages_omitted: true and an accurate message_count, skipping the
potentially multi-megabyte compression-lineage serialization over the
WebSocket; Desktop hydrates the transcript via the authenticated REST
route in parallel.
The PR's bundled cron-outputs endpoint and codex quiet-timeout bump
were dropped from this salvage as unrelated (invited back separately).
mergePetInfoMeta now returns the same object reference when all fields
match, and callers skip setPetInfo on reference equality. Without this,
every 15s poll and window-focus refetch fired a nanostores set with a
new-allocated object, triggering a React re-render of FloatingPet even
when nothing changed — a regression from the old samePetRevision guard
which returned without calling setPetInfo.
Copying a Discord thread (or any rich-text selection with images) attached
one or more blank thumbnails and dropped the message text entirely.
Two causes. The clipboard's `text/html` was scraped for inline
`<img src="data:…">` regardless of whether the copy carried its own text —
and what Discord ships beside each image embed is a 32x5 blurhash
placeholder, which is exactly the blank attachment. Then, because any image
blob short-circuited the paste handler, the prose that came with it never
reached the composer.
Inline HTML images now only count for an image-only copy, and are ignored
below a thumbnail-sized floor so spacers and trackers don't attach either. A
mixed paste attaches its real images and still inserts its text.
Also registers pasteAndMatchStyle in the Edit menu — Cmd+Shift+V had no menu
entry, so the chord was never translated into an editor command anywhere in
the app.
Right-click any tab and pick Reload: the pane's content remounts in
place — effects re-run, state resets, measurements are retaken — while
the tab keeps its slot and every other tab is untouched.
A per-pane epoch atom keys the contribution inside the zone body, so
reload never rewrites the layout tree. Both tab menus offer it: the
zone strip menu (tool panels, the file tree, a fresh draft's main tab)
and the session tab menu (tiles + the loaded main tab).
`hermes desktop` still failed with EBADENGINE demanding Node >=26 after
#76562, on a machine whose `apps/desktop/package.json` already said
`^20.19.0 || >=22.12.0`. #76562 fixed the manifest but not its mirror in
`package-lock.json`, and `npm ci` reads engines from the lockfile:
package.json apps/desktop -> {'node': '^20.19.0 || >=22.12.0'}
package-lock apps/desktop -> {'node': '>=26.0.0'} <- what gated
Chasing that exposed a second, pre-existing problem: the floor #76562
declared was too generous. Running the real `npm ci` against the whole
workspace on Node 22.21.1 fails on a transitive dependency —
npm error notsup Not compatible with your version of node/npm:
react-router@8.3.0
npm error notsup Required: {"node":">=22.22.0"}
react-router 8.3.0 (a direct dependency of both `apps/desktop` and `web`)
declares `>=22.22.0`, which is tighter than Vite's `^20.19 || >=22.12` and
excludes all of Node 20. So `>=20.0.0` promised support the tree cannot
deliver: an install on Node 20 or early 22 passed the installer's gate and
then died inside `npm ci` on someone else's package.
All four engine declarations now state the floor the dependency tree
actually has, `>=22.22.0`: root `package.json`, `apps/desktop/package.json`,
and both of their `package-lock.json` mirrors. The installer gates move with
them (`node_satisfies_build` in install.sh, `Test-NodeVersionOk` in
install.ps1) so a too-old system Node is replaced with the managed one
*before* npm runs, and the failure a user does see names hermes-agent rather
than a transitive package. NODE_VERSION stays 22 — latest-v22.x is 22.23.2,
comfortably above the floor.
The invariant test gains the case that would have caught the mirror drift on
its own: the desktop assertion now pins the tightest floor a dependency
actually declares, and the managed-runtime check compares majors, since
install.sh fetches latest-v{major}.x rather than {major}.0.0.
Verified with real `npm ci --dry-run` over the full workspace:
- node 22.23.2 (what install.sh provisions) -> 1258 packages
- node 26.5.1 -> 1189 packages
- node 22.21.1 (below the floor) -> EBADENGINE naming
hermes-agent, i.e. our own manifest, not react-router
Fresh installs and `hermes update` both fail at the first `npm ci`:
npm error code EBADENGINE
npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
npm error notsup Actual: {"node":"v24.15.0","npm":"11.12.1"}
`.npmrc` sets engine-strict=true, so `engines` is a hard gate on every
install. The floor was raised to npm >=12 — but no Node release bundles
npm 12: Node 26 ships 11.17.0, 24 ships 11.16.0, 22 ships 10.9.8. The
requirement is unsatisfiable by any stock toolchain, so the installer
provisions a Node and is immediately unable to install with it.
engines.npm becomes `<11.10.0 || >=11.17.0`. That still excludes the band
the strictness was actually for: npm 11.10-11.16 honor `min-release-age`
but ignore `min-release-age-exclude`, both set in .npmrc, so they apply the
14-day gate to packages we exempted. Verified rather than assumed — npm
11.12.1 fails `ETARGET ... vite@8.2.0 with a date before 7/18/2026` while
11.17.0 installs it.
engines.node returns to >=20.0.0 and the toolchain floor to Node 22.
Nothing in the tree needs 26: Vite 8.2.0 declares `^20.19.0 || >=22.12.0`
and Electron 40 declares `>=12.20.55`. Requiring 26 force-migrated every
working install for no dependency reason. apps/desktop drops to Vite's own
floor for the same reason; the desktop bundle builds clean on Node 22.
install.sh gained a second gate: a system Node was accepted on version
alone, so a machine with Node 24 + its bundled npm 11.16.0 (the bad band)
passed the check and then failed `npm ci`. npm_supports_npmrc() now rejects
that band and installs the managed Node instead.
CI, Docker and nix are hermetic and keep pinning Node 26 / npm 12 — they
provision their own toolchain, and both satisfy the relaxed range.
tests/test_engines_satisfiable.py encodes the invariants that would have
caught this: the npm floor must be met by an npm some shipping Node
bundles, the node floor by the runtime install.sh provisions, the desktop
floor by its own build toolchain, and the lockfile mirror must match.
Restoring the broken values fails 5 of them with the reason stated.
Verified end-to-end (real downloads, temp HERMES_HOME):
- fresh install: managed node v22.23.2 / npm 10.9.8 -> npm ci, 209 packages
- existing managed tree (v22.22.3 / npm 10.9.8) -> npm ci, 209 packages
- node 26.5.1 + bundled npm 11.17.0 -> npm ci, 208 packages
- system npm 11.12.1 (the reported case) -> EBADENGINE, recovery provisions
a managed tree and retries green
- apps/desktop `npm run build` on Node 22 -> dist built, assert passes
The glyph sat at the far end of the row instead of beside the path it
copies, and it fired a toast for a one-word confirmation. It's the shared
CopyButton now — same inline checkmark as every other copy in the app —
and the path label sizes to its content so the pair reads as one unit.
#76499 landed the npm floor as >=11.17.0 on a Node >=20 baseline. This
branch takes the other half of the same constraint: the vendored Node 26
tree now installs npm 12 into itself, so the toolchain satisfies the
stricter floor rather than the manifest relaxing to meet the tarball.
Resolved package.json + package-lock.json to node >=26.0.0 / npm >=12.0.0
and refreshed npm_engine.py's illustrative range to match. website/'s
mirror keeps #76499's >=11.17.0 — it is not a root workspace and builds
on its own Node.
Node 26 defines its own `localStorage` accessor on the global object,
which returns `undefined` unless the process was started with
`--localstorage-file` (hence the "localStorage is not available because
--localstorage-file was not provided" warning now printed by every
worker). In the jsdom environment `globalThis` IS the window, so that
accessor shadows jsdom's Storage and every `localStorage.getItem(...)` in
a test throws "Cannot read properties of undefined".
CI caught this on the Node 26 bump: `check:test:ui` failed with 22
errors across session.test.ts, terminals.test.ts, model-settings and
onboarding stores — all storage-backed. Reproduced locally against
nodejs_26 (12 failures in src/store/session.test.ts alone) before fixing.
vitest.setup.ts now installs a real in-memory Storage on both globalThis
and window when the global resolves to undefined, before any test module
reads it. Guarded on `typeof === 'undefined'` so Node < 26 and any future
runtime that provides a working Storage keep jsdom's own implementation.
Verified under nodejs_26: the full `--project ui` lane is 378 files /
3268 tests green (was 22 failures).
Both hand-off sites pre-write the update marker: the in-app Update button
(applyUpdates) and the Windows bootstrap-recovery path
(handOffWindowsBootstrapRecovery). Either one can strand a user on a
pre-#74782 staged installer, and the recovery path is worse — it fires
when the install is already unhealthy, so a refused claim there wedges
the very repair meant to heal it.
Route both through stagedUpdaterSupportsPrewrittenMarker and log the skip
so the reason is visible in desktop.log instead of looking like a missing
write.
Also document on copy_self_to_hermes_home that its --update no-op is what
lets an installer-protocol change strand the entire installed base on a
binary that predates it — the root enabler of this class of bug.
copy_self_to_hermes_home no-ops during --update, so the hermes-setup.exe
staged by a user's ORIGINAL install orchestrates every later update
forever. Installers predating #74782 have no self-PID exclusion in
UpdateMarkerGuard::acquire, so when the desktop pre-writes the marker
naming that very updater (#59313), the updater reads its own claim as a
foreign live owner and aborts:
Another Hermes update is already running (PID <itself>, started 1s ago)
mapped to the "Hermes is still running. Close all Hermes windows" screen.
Retry relaunches the desktop, which pre-writes a fresh marker naming the
next updater, which refuses itself again — an unbreakable loop. The
always-live PID also defeats the staleness self-heal in
readLiveUpdateMarker, and the update that would replace the stale binary
is precisely the one being refused, so there is no route out.
Gate the pre-write on the staged installer's mtime, which faithfully
stamps the installer generation (the binary is written at install/repair
time). Anything staged before the self-adopt fix skips the pre-write and
lets the updater write its own claim; the hand-off itself is untouched,
because that stale binary is the only updater those users have and it
works fine once allowed to acquire.
Unreadable mtime counts as unsupported: skipping the pre-write only loses
anti-respawn hardening, while a wedged updater can never update again.
A directive chip (`@url:`, `@session:`) reads as the thing it points at and is
coloured like one, but a composer is an editor — a click inside the
contenteditable only places the caret, so there was no way to actually act on
the reference.
Hovering a chip whose kind has an action now floats a pill above it that runs
it: `@url:` opens in the browser, `@session:` opens the session as a tab. It's
a small registry (`DIRECTIVE_ACTIONS`), so a new actionable kind is one entry,
not another watcher.
The pill portals to `<body>` and anchors to the chip's rect, so it can't end
up inside the submitted draft, and it re-anchors on scroll and resize rather
than stranding itself over a reference that moved or was deleted. The press is
swallowed before it reaches the editor — mousedown in a contenteditable moves
the caret, and the edit composer reads a blur as "cancel".
Listeners bind to `document`, not the editor: the edit composer's
contenteditable isn't reliably attached when the effect first runs, so an
editor-bound listener never fired there. A document listener that reads the
editor lazily works in both composers, and each instance filters to its own
editor so one chip never shows two pills.
Hermes now pins its toolchain to Node 26 everywhere. Every path that
installs, accepts, heals, or upgrades a Node runtime moves from the old
22-default / `^20.19 || >=22.12` floor to a single rule: Node >=26.
Installers:
- scripts/install.sh — NODE_VERSION=26; node_satisfies_build() collapses
the two-branch Vite floor to `major >= 26`; user-facing messages updated.
- scripts/install.ps1 — $NodeVersion=26; Test-NodeVersionOk likewise;
winget fallback switches OpenJS.NodeJS.LTS -> OpenJS.NodeJS (26 is
Current, not LTS — the LTS manifest would reinstall a too-old Node).
- Dockerfile — node_source stage node:22-bookworm-slim -> node:26 (digest
pinned, amd64 sha256:9e6f...bf73).
- nix/ was already on nodejs_26 (lib.nix, npm-12-0-2.nix); the checks.nix
wrapper check ratchets from `>= 20` to `>= 26`.
Heal/upgrade paths:
- scripts/lib/node-bootstrap.sh — HERMES_NODE_TARGET_MAJOR default 22->26
and HERMES_NODE_MIN_VERSION default 20->26, so heal_managed_node,
_nb_install_bundled_node, and the fnm/proto/nvm/brew rungs all target 26
and stop accepting an on-PATH Node below it. Both remain env-overridable.
- hermes_constants.py — _HERMES_NODE_TARGET_MAJOR fallback 22->26, which
drives the Windows heal path's latest-v26.x download.
Version gates:
- package.json engines.node >=20 -> >=26; apps/desktop engines
`^20.19.0 || >=22.12.0` -> `>=26.0.0`.
- CI setup-node: all five workflows 22 -> 26.
- Docs describing Hermes's own toolchain updated (windows-native, docker,
acp, nix-setup, contributing). Skill docs describing third-party tools'
own requirements are untouched.
Termux still installs via `pkg install nodejs` best-effort (nodejs.org
ships no Android tarballs); that path was never version-gated.
Verified: bash -n on both shell scripts, PowerShell AST parse of
install.ps1, latest-v26.x index resolves (node-v26.5.1), and the install
test suite — 18 tests across the 5 install/runtime test files — passes.
Two paths let a pre-existing system Node win over the Hermes-managed one.
The desktop backend spawn built its managed-Node PATH entry as
`<home>/node/bin` only. That is the POSIX layout install.sh produces;
install.ps1 unpacks portable Node straight into `%LOCALAPPDATA%\hermes\node`
with node.exe at the root and no `bin\`. On Windows the entry therefore
pointed at a directory that does not exist, and the backend fell through to
whatever Node was already on PATH.
main.ts already had the correct platform-ordered list, behind a "keep this
in sync with iter_hermes_node_dirs()" comment on a second copy of the rule.
The two copies had drifted. Export the ordering from backend-env.ts and have
main.ts consume it so there is one source of truth on the Node side (the
Electron main process cannot import hermes_constants.py, so a mirror is
unavoidable — but one mirror, not two).
install.ps1 appended the node dir to the persisted User PATH instead of
prepending it. The session PATH was already prepended correctly, so this only
bit later processes: any shell opened after install, and a standalone
hermes-setup.exe run that inherits User PATH rather than a curated env, both
resolved a system Node ahead of the bundled one.
Not a bug, for the record: update.rs's prepend list omits the same Windows
root, but it inherits PATH from the desktop, which supplies the correct
entries — so it is redundant rather than broken, and no installer rebuild is
needed for this fix.
Tests: managed dirs lead with the platform-native layout while always
offering both shapes, empty without a home, and every managed dir outranks
the inherited PATH on darwin and win32. The three existing tests that pinned
`entries[1]` by index asserted the old single-dir shape and now assert the
relationship instead.
install.ps1 has no behavioral test here: CI has no PowerShell host, and
AGENTS.md bans source-reading tests (the neighbouring
test_install_ps1_node_path_for_npm.py predates that rule).
Input events are deliberately skipped for the duration of an IME composition
(they carry uncommitted preedit text), so nothing clears the empty marker
until compositionend — the hint kept painting behind the hiragana the user was
composing. Drop the marker as composition starts; the normalizer restores it
if composition ends with nothing committed.
Taking the hint out of the text flow fixed the displacement half of #75960 on
its own — preedit now starts at the field's left edge either way — but the
overlap needed this too.
Co-authored-by: Ryuichi Natori <to-na@users.noreply.github.com>
The empty-composer prompt was painted with an inline `::before`, which puts a
real box in the contenteditable's text flow. Click an empty composer and the
caret lands past the hint instead of at the field's left edge, and a hint that
wraps — a narrow composer, a long locale string — makes the empty composer two
lines tall. It is a hint, so it now sits out of flow: absolutely positioned,
clipped to one line, unselectable and untouchable by the pointer. Measured in
Chromium against the built stylesheet, the caret lands at the left edge for a
wide composer, a narrow one, and a Japanese hint alike.
Backspace on a fresh `@folder:` chip had a second, related problem. Committing
a completion empties the typed token's text node rather than removing it, and
`Range.insertNode` splits the line around the caret, so the chip ends up
between zero-length text nodes. Those read as content: the atomic chip-delete
declined, Chromium's own backspace bounced between the leftovers, and the chip
took extra presses to remove — leaving a `"\n"` draft with the hint still
hidden behind it. Emptiness, the chip-delete, and the DOM normalizer now all
step over that litter.
The stylesheet owns the rule now that it needs `position: relative` on the
editor, so the utility-class constant both composers imported is gone.
The desktop lint rule added after the original PR bans mirroring reactive
values into refs via useEffect. Rework the setting to seed from draft-null
state, guard profile switches by stale-config identity, and derive the
save base + rollback value from the shared config record instead of
latestConfigRef/lastSavedRef/seededRef.
Adds a new config option terminal.font_family that lets users customize the
CSS font-family for the desktop app's embedded xterm.js terminal.
Previously the font was hardcoded in use-terminal-session.ts:
'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace
Now the value from config.yaml (terminal.font_family) is threaded through:
useHermesConfig → PersistentTerminal → TerminalTab → useTerminalSession
When font_family is empty or unset (default), the built-in fallback is used,
preserving backward compatibility. Users with Nerd Fonts installed (e.g.
CaskaydiaCoveNerdFont) can now set:
terminal:
font_family: 'CaskaydiaCoveNerdFont', 'JetBrains Mono', monospace
Closes: #terminal-font-config
Two regressions from moving Edit Models into the shared catalog:
The row opened its own separator group, so the composer showed two rules in
the trailing block where it had always shown one. Edit Models now renders
inside the host footer's group — same single separator, same order.
The panel also read the catalog with a non-reactive getQueryData. With no
model in the session store yet (a fresh draft), currentPickerSelection falls
back to the catalog's reported current, and a cache peek never repaints once
that resolves — the menu could sit on a stale or empty selection. Back to a
live useQuery on the same key, which React Query dedupes against the menu's
own subscription rather than double-fetching.
The board listed every model while the composer honoured the user's Edit
Models shortlist, because visibility arrived as a prop each caller opted into.
Curation is one stored preference, so a per-caller opt-in guarantees the two
surfaces eventually disagree about what "my models" means — the exact drift
extracting the menu was meant to end.
ModelCatalogMenu now reads $visibleModels itself and renders the Edit Models
row, so every picker shows the same shortlist and offers the same way to
change it. The composer's footer keeps only Refresh Models. No plugin surface
is involved: the menu is core and the plugin just mounts it.
Also restores the session-scoped catalog fetch the extraction dropped. The
composer's query key must carry its session id — the app invalidates the
session-scoped key on model changes, so a global key would have gone stale
mid-conversation.
The desktop board had no model control at all: a task ran whatever the profile
you assigned it to happened to be configured with, and the only way to point
one task elsewhere was `hermes kanban set-model` or the browser dashboard's
flat provider:model select.
Adds a Model row to New Task and to the task drawer, rendering the composer's
own picker via the SDK — same search, same provider groups, same submenu — so
the board and the chat bar cannot drift. Unset reads "Profile default" and
changes nothing; a pin reads "provider: model · High" with an inline clear.
Presets are read-only here: picking a model seeds the depth from what you last
used for it, but a per-task choice never rewrites what the composer opens at.
Fast mode is omitted rather than shown-and-ignored — it's a live-session
request parameter with no worker-spawn equivalent.
The New Task dialog opts out of DialogContent's clip: the dialog publishes
itself as the portal container for popovers opened inside it, so its
overflow-y-auto cropped the menu at the dialog's edge. The general fix is in
flight as #75600; this override is scoped to one dialog to avoid conflicting
with it and disappears when that lands.
The model menu — search, provider grouping, -fast family collapse, keyboard
selection, the per-row thinking/effort submenu — was welded to the chat
composer's session writes, so any other surface wanting a model picker had to
fork it and drift.
Splits rendering from meaning. ModelCatalogMenu owns the catalog and the
navigation; a ModelMenuController decides what a selection DOES. The composer
is now one controller over it, keeping its session scoping, sticky manual
pick, preset restore, MoA presets, and rollback-on-failed-write intact.
ModelEditSubmenu becomes pure: it reports edits instead of performing them.
It previously called setCurrentReasoningEffort and config.set inline, so any
non-composer host would have silently retargeted the user's live chat when
they picked an effort. Its default effort is passed in rather than read from a
store, which is what lets it render outside a session at all.
Exported through the SDK so plugins consume the real component instead of a
copy. The composer's existing behaviour suite passes unchanged against it.
The micro-action strip owned this skin inline: a full-radius hairline pill
on the composer's own fill behind a blur, sized to `--composer-control-size`.
It's the right look for anything that floats over the composer, so lift it
into `composer-dock` next to the other shared composer surfaces and have the
strip compose it with its own width cap and disabled state.
A local pin/unpin fires reconcile synchronously via the
$pinnedSessionIds listener — before any PATCH exists — so
pullRemotePins() read the still-stale server row and immediately
undid the user's action: !row.pinned && heldLocally reverted a fresh
pin, and row.pinned && !heldLocally re-pinned a fresh unpin, after
which the push pass saw nothing to write and no PATCH ever fired.
Fence local intent ahead of the pull:
- Run the push pass first, so pending/unconfirmed record the local
intent before the page is read, then pull.
- Skip the pull for ids still in `pending` (row not yet resolved) —
local intent awaiting its PATCH is newer than any loaded page.
- Update mirrored bookkeeping before mutating the pin store inside
the pull, so the re-entrant reconcile doesn't echo adopted state
back as redundant PATCHes.
Regression tests cover both directions (fresh pin over a stale
pinned=false row, fresh unpin over a stale pinned=true row) plus the
deferred-pin case where the row loads stale after the toggle. All
three fail against the previous reconcile order.
Fixes#74570
handOffWindowsBootstrapRecovery() writes the update marker
unconditionally, same as applyUpdates() before the previous commit.
It's reachable during boot whenever resolveHermesBackend reports
bootstrap-needed, which a relaunch mid-update can plausibly trigger
on Windows -- clobbering a live updater's marker through this second
path. Apply the same updateHandoffConflict() guard here: refuse to
spawn a second updater when one is already alive, and quit instead
so the live updater can finish and restart us.
writeUpdateMarker unconditionally overwrites HERMES_HOME/.hermes-update-in-progress
before every hand-off. If the user retries "Update" while a prior updater is
still alive and parked (e.g. waiting for the desktop to exit), the retry's
pre-write clobbers the still-running updater's claim, so the older updater is
no longer recorded as the owner even though it's actively mutating the
checkout. A second updater can then run concurrently over the same tree.
Add updateHandoffConflict() to check for a live foreign marker owner before
spawning a new updater, and refuse the hand-off (surfacing an "update already
running" message) instead of overwriting the marker.
Ref: #75778
All-fixed splits used to promote the last track to flex-grow and drop its
max clamp. Review and files both declare maxWidth, so ⌘G/⌘J ballooned them
and sash overrides only set a basis that grow still expanded past.
resolveNewSessionCwd now inherits the focused chat's workspace when you
aren't drilled into a sidebar project, so a new tab or draft stays in
the same repo as the chat you were looking at.
Workspace indicator was stuck on the primary $currentCwd while timers
and context already tracked focus. Resolve from the focused runtime
slice, then the stored session row, with a mid-switch ownership gate.
Path tips across chrome use displayPath (~/).
A $currentCwd listener in the terminal store picks the user tab whose
live shell cwd (restoreCwd, falling back to launch dir) matches the
session's workspace. Selection only: no tab is created, closed, or
revealed; detached sessions and unmatched cwds leave the rail alone,
and an already-matching active tab keeps focus.
Same gesture the pane tabs already carry — isMetaClose hoists from
pane-tab.tsx into lib/middle-click.ts beside its sibling middle-click
gesture, so the two surfaces share one predicate instead of drifting.
A turn that ended without its message.complete (turn crash, reconnect gap,
steer race) left its streaming bubble pending:true forever. The next user
message then landed after it, stranding a live dither indicator
mid-transcript.
Three layers:
- session.info running=false (the agent loop's finally-block signal, the
only settle edge those paths still emit) now finalizes the streaming
bubble via the same math as Stop.
- A fresh submit settles any leftover pending bubble before appending the
new user message, and drops a stale streamId so the new turn seeds fresh.
- AssistantMessage renders the loading/stall indicator only on the thread's
last message, so no upstream state bug can ever paint one mid-transcript.
mirrorSelection called textarea.select() whenever xterm had a scrap,
which replaced any chat highlight so ⌘C copied the wrong thing. Only
claim the document selection while the terminal is focused and nothing
outside it is highlighted.
User bubbles are buttons, so the global user-select:none rule killed
text selection. Right-click-to-react and click-to-edit also ate a live
highlight. Prefer selection when one exists.
The first regeneration kept a ~5% transparent margin around the icon
plate (94.9% coverage). Windows expects the plate itself to be the icon
edge — scale the artwork's rounded plate to span the canvas exactly.
The shipped artwork bakes in the macOS-style ~10% transparent margin
(content covered only ~80% of the canvas), so the taskbar/titlebar icon
rendered visibly smaller than neighboring Windows apps, which draw
full-bleed.
- Regenerate assets/icon.ico full-bleed (~95% coverage) from the same
art, with the standard 16-256px frames. This feeds both the exe stamp
(set-exe-identity via rcedit) and the installer.
- On Windows, resolve the BrowserWindow icon from the full-bleed ico
(resources/icon.ico, shipped via extraResources) before falling back
to the padded apple-touch PNG.
macOS is untouched: the dock icon and icon.icns keep the padded art,
which is correct there.
main reworked the same surface while this was open, so three hunks needed
deciding rather than accepting.
Logs became summon-only (#75862): the contribution only exists while $logsOpen
is on, docked as its OWN zone beside the terminal instead of a tab in its
strip. That supersedes the static logs pane and the bindToolPaneCollapse call
here — main already registers logs' closer/opener directly, so both were
dropped in favour of its version.
main also added a ⌘K "Toggle terminal" row reading $terminalTakeover, and
gave logs back a 7.5rem minHeight under a comment claiming the terminal's
sizing rule. Both are the bugs this branch fixes, so they move onto the
shared behaviour: the palette row reads isPaneVisible/togglePaneVisible like
every other pane toggle, and logs loses the floor so the comment is true —
the sash folds its zone to the rail instead of stranding a sliver.
A turn that rewrote twenty files grew a twenty-row card, so the summary
that is supposed to close the turn became the thing you scroll past to
reach the composer. Cap the rows at ~5 and let the clipped edge fade,
the way every other overflow in the app reads.
The horizontal padding moves onto the scroller so a row's hover fill
still bleeds to the card's edge instead of stopping at a scroll gutter.
The kanban drawer had grown the only edge-aware masked scroller in the
tree, and the next surface that wants one would have copied it. Lift it
to components/ui as FadeScroll, export it on the plugin SDK, and leave
kanban's ScrollFade as a name its call sites already pass `max` to.
The mask math comes out as two pure functions. jsdom's CSS parser drops
any gradient containing calc(), so a rendered mask-image can't be read
back off the style attribute -- edgeMask/scrollEdges are testable for
real where the component's inline style is not.
The terminal fix was only one instance. An audit of the other pane toggles
found ⌘G and ⌘J diverging the same way, proven with a probe: with review
stacked behind files in the right column, or either pane inside a minimized
zone, the store reads open while nothing is on screen, so the press
re-asserts a value it already held and the key does nothing.
isPaneVisible / togglePaneVisible replace the tool-panel-only pair and now
back every toggle. Close still routes through closeTreePane, so each pane
keeps its own semantics: a tool panel collapses to its rail, files and
review close through their store, anything else is dismissed.
files and review were bound with a closer and no opener, so the boolean went
stale as soon as anything but the toggle revealed them. bindPaneVisibility
moves into the tree store beside bindToolPaneCollapse, documents the two as a
pair, and both panes now pass both halves. Keeping the binding in the store
also means the tests drive the real function — the earlier copy in the test
file passed with the fix reverted, which is how the missing opener survived
the first pass.
setTreePaneHidden keeps its quiet path: a reactive unhide (a cwd arriving)
must not front or un-minimize over what the user is looking at. Only user
intent goes through the reveal path.
Twenty-odd desktop files import `@assistant-ui/core` directly, but it was
never in `dependencies` — it resolved only because react 0.15 pulled core
0.3.2 and npm hoisted it to the workspace root. react 0.14.24 wants core
^0.2.19, which nests under `react/node_modules`, so the undeclared imports
stop resolving and the build fails on `@assistant-ui/core/internal`.
Declare it so resolution doesn't depend on hoisting luck either way.
The 0.15 migration swapped the `useMessageRuntime()` hook for a plain
`useAui().message` accessor read. The hook subscribes and re-renders on
change; the accessor does not, so message components paint once and then
go stale until the window is reloaded. `useComposerRuntime().subscribe`
was widened to `aui.subscribe` the same way.
Restores the 0.14 call form, including the test mock whose added
`getState` stub let the API break through CI unnoticed.
The 0.15 bump rode along with the npm audit pass in #75037, but it was
not required by any advisory — `npm audit` reports 0 vulnerabilities with
0.14.24 pinned. It is a breaking major that costs us a working transcript,
so take the API stability instead.
Issue #74874. The renderer's 'Repair' button treated every transient
backend GIL stall (event loop stalled ... ws ready frame send failed)
as a fatal backend fault, asking the bootstrap to force-reinstall +
restart, which then stalled again for the same reason — looping the
user through 30+ minutes of reinstall cycles.
Distinguish 'venv is genuinely broken' from 'backend is just transiently
stalled' before honouring a repair request. Probe the live backend
process (exitCode === null && signalCode === null) and an in-flight
repair-attempt counter:
attempt <= 3 AND primary alive → soft restart (skip installer)
attempt <= 3 AND primary dead → soft restart (verify before reinstall)
attempt > 3 → hard reinstall (escalate)
Counter resets on a clean backend.ready so a later, unrelated failure
episode starts at attempt 1. The guard is a pure helper (decideBootstrap
Repair in electron/bootstrap-repair-guard.ts) so the decision logic is
unit-tested in isolation; main.ts only wires the existing flag and
counters to it.
Refs #74874
resolveUpdaterBinary() picked up a staged hermes-setup on every platform, so a
macOS binary predating the update hand-off protocol took over the update, held
the marker, and had its `hermes update` child refuse its own parent. The in-app
Update button then failed for good, with no route -- update, re-download or
reinstall -- back to a capable binary (#74836).
Move the decision into a pure resolveStagedUpdaterBinary() helper in
updater-process.ts and return null off Windows. The installer self-copies into
HERMES_HOME on every platform (paths::installer_dest,
bootstrap::copy_self_to_hermes_home), so finding that binary on macOS or Linux
is expected rather than leftover junk: declining to hand it an update is a
policy decision, and the comments now say so instead of describing the binary
as Windows-specific.
Cover the resolver in updater-process.test.ts: Windows accepts a staged
hermes-setup.exe, macOS/Linux return null even when hermes-setup exists, and
Windows returns null when nothing is staged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolveUpdaterBinary() returned a path on macOS if hermes-setup existed
in HERMES_HOME, routing macOS into the Windows-style quit→hand-off→rebuild
update dance. A stale hermes-setup (e.g. from 2026-06-08, predating the
applyUpdatesPosixInApp path) permanently breaks the in-app Update button.
The Tauri hermes-setup binary is a Windows-specific mechanism; macOS and
Linux use applyUpdatesPosixInApp instead. Always return null on non-Windows
so the native drag-and-drop updater is used on those platforms.
Fixes#74836
Users pasting a Tailscale IP or LAN host as 'host:port' (no http://) hit
either a hard 'URL is not valid' error in the main process or, worse, a
silent dead probe in the renderer: the ^https?:// gates in the settings
and first-run forms never fired, so the field sat idle with no feedback.
- normalizeRemoteBaseUrl() (electron/connection-config.ts) now prepends
http:// when the input has no scheme:// prefix; explicit non-http
schemes (ws://, ftp://) still reach the protocol check and get a clear
rejection.
- New renderer twin coerceRemoteUrlScheme() (src/lib/remote-url.ts),
wired into both probe gates (gateway-settings.tsx and
first-run-remote-form.tsx) so the debounced /api/status probe, sign-in,
test, and save all see the coerced URL.
- Tests for both sides (electron/connection-config.test.ts,
src/lib/remote-url.test.ts).
Map ENOSPC / SQLITE_FULL / "disk full" error strings through notifyError to a
clear free-space toast, and fire it from rejected prompt.submit, gateway error
events, and terminal failure frames so a full disk never looks like silence.
The logs pane no longer exists as standing chrome: it isn't registered in
any default layout or preset, never rides the terminal strip as a secondary
tab, and never opens automatically. The contribution is registered only
while the palette's "Toggle logs" command has it summoned, and closing it
(toggle, tab ✕, ⌘W) removes it from the registry and the tree entirely —
including sweeping it out of persisted layouts from before it was
summon-only. The open state is session-only, so a fresh boot always starts
without it.
useAgentTerminal hardcoded resolveSurfaceColor('#ffffff'), so any read that
misses the surface token — a pre-paint mount — flashed a white slab in dark
mode. The user terminal already passes the palette's own background; the
agent mirror is a copy of it that lost that detail.
Pass the same fallback, so both siblings degrade to their mode's surface.
The terminal borrowed --ui-editor-surface-background, the same token the
preview pane, diff gutters, and pane tabs wear. Nothing could retint the
terminal without moving all of them, and it was pinned to the editor surface
rather than the conversation it sits beside.
Give it --ui-terminal-surface-background, aliased to the chat surface, and
point every layer that paints the terminal at it: the pane wrapper, the
persistent overlay, the rail, the instance chrome, the xterm host, and the
probe that feeds xterm's canvas. One knob, and a skin can now move the
terminal alone.
No visual change today — both tokens resolve to --ui-bg-chrome.
The two xterm host divs carried an identical class string; hoist it to
HOST_CLASS so the user terminal and the agent mirror can't drift again.
The terminal row still lived under Go to as a one-way open. It never showed
live state and couldn't hide. Move it through paletteToggle next to logs,
yolo, status bar, and layout edit so every binary ⌘K toggle shares the same
underlined on/off note.
The terminal and logs carried a 7.5rem minHeight, so dragging the seam down
jammed against a floor with a sliver of unusable terminal still showing.
Tool-panel zones now floor at COLLAPSED_ZONE_PX (the h-7 header strip)
instead of the generic 80px, and releasing at that floor minimizes the zone
rather than persisting the sliver — it folds to its collapsed header,
vertical rail or horizontal strip depending on the parent axis. The sliver
size is never written, so restoring returns the height it had before.
Two independent clobbers threw the choice away, so "hide header" never
survived a close/reopen cycle.
normalize() deleted headerHidden whenever a zone dropped to one pane, on
the grounds that a lone zone is headerless by default. It is — but the flag
is the user's standing preference for that zone, not a redundant value, and
dropping it meant the bar returned the moment a pane rejoined (close the
stacked logs pane, toggle it back).
Re-adoption then pinned headerHidden false unconditionally. That is right
for a pane arriving somewhere new, where zero chrome leaves no handle to
drag or close, and wrong for a zone whose bar the user deliberately hid.
Adoption now carries the destination zone's own setting, read before the
insert since insertAtGroup pins the flag itself on a center drop.
Dragging the terminal to the bottom stacks logs into its zone, and every
tool-panel toggle broke in that stack.
Boot revealed instead of leaving the tree alone: bindPaneCollapse ran
setPaneCollapsed(id, !open), and `false` there fronts the pane. Logs binds
last, so it stole the active tab from the persisted tree. Ctrl-` then asked
to collapse a terminal that was no longer the active tab, the shared-zone
branch declined by design, and the key did nothing until the stack was
broken up. Boot now only ever collapses.
The toggles also asked the wrong question. !$terminalTakeover.get() flips a
boolean that has no idea what is on screen, so once anything else moved the
pane the press spent itself re-asserting a value the store already held.
toggleToolPane derives from the tree instead, and ctrl-`, Cmd+J's terminal
fallback, the statusbar button and the logs palette row all route through
it. The terminal cycle/close keys drop the same stale boolean.
bindPaneCollapse moves into the tree store as bindToolPaneCollapse so the
boot rule is testable against the real function rather than a copy.
The hit targets are display:contents buttons now, so the branch label and the
counts stay the same flex children of the row with the same classes; the glyph
button fills the existing 3.5 leading slot. Only the hover background is gone.
Evict the runtime the backend just reclaimed instead of waiting for a
resume to 404, and refresh the lists whose ended_at moved. The stored
row is untouched, so reopening resumes from the DB.
The composer's coding strip made the whole bar a button, so a click anywhere
along it — including the dead space between the branch and the counts — opened
the review pane. Only the two things that name the diff are clickable now: the
branch glyph + label, and the ahead/behind + ±lines cluster. The strip itself
is inert and no longer paints a hover state.
Two things made a tool panel tab feel unclosable.
Cmd-W was a dead key over the terminal and the logs pane. The keyboard
close ladder resolved its target with focusedSessionGroup, which only
matches zones hosting a CHAT strip, so a focused tool panel fell through
every rung and Cmd-W emptied the main tab instead. Add a tool rung that
resolves through the same hover/focus ladder the number keys use.
Right-click Close was missing or inert. The zone menu's target was only
resolved by the tab strip's own onContextMenu, so a right-click anywhere
else in the zone (pane body, collapsed rail, edit veil) reused the
PREVIOUS target -- landing on the uncloseable workspace dropped Close
from the menu entirely. Resolve the target on the zone instead, so every
surface that opens the menu names the chip under the pointer.
Close on a tool panel now takes the tab out of the strip and syncs its
owning store, so the ctrl-backtick toggle and the Cmd-K row stay
truthful and bring the pane back; the toggle's open path reveals
(un-dismiss + re-adopt) rather than un-collapsing a pane that has left
the tree.
The logs (and terminal) tab ✕ dismissed the pane from the layout but
never synced the owning store — so the ⌘K toggle was stale and its open
listener called setPaneCollapsed, a no-op when the pane isn't in the
tree. The tab was gone with no way back short of a layout reset.
Route the tab ✕ through closeCollapsePane (dismiss + store sync) so the
toggle stays truthful, and make bindPaneCollapse's open listener call
revealTreePane (un-dismiss + re-adopt) instead of setPaneCollapsed.