Pin user bubbles 0.75rem below the scroll top via a single token instead of
flush top-0, so the sticky header doesn't sit hard against the thread edge.
Long user prompts stick to the top of the thread while the response streams
beneath them, so a multi-line prompt could eat most of the viewport. Clamp the
read-only human bubble's text to ~2 lines with a soft bottom fade; the clamp
lifts on hover or keyboard focus, and clicking the bubble still opens the edit
composer (which shows the full text). Short messages are untouched — no clamp,
no fade.
Overflow is measured on an unclamped inner wrapper so the ResizeObserver only
fires on real content/width changes, not every frame while the outer
max-height animates open; the measured height feeds --human-msg-full so
expand/collapse animate to the true height instead of overshooting the cap.
The thread renders virtualized turns in natural document flow with padding
spacers, and @tanstack/react-virtual already adjusts scrollTop itself when an
off-screen turn is measured and its real height differs from the 220px
estimate. With the browser default `overflow-anchor: auto`, native scroll
anchoring corrects that SAME size delta too, so the two double-correct and the
view lurches — most visibly with Windows mouse wheels, whose coarse notches
mount/measure several under-estimated turns per tick (Mac trackpads scroll
~1-3px/frame, keeping it sub-perceptual).
Set `overflow-anchor: none` on the thread viewport so only the virtualizer
compensates. Also adds `diag-scroll-reset.mjs`, a CDP wheel-up repro that A/B
tests the anchor behavior at runtime to confirm the fix.
Two TUI polish fixes.
(1) Right-click copy now clears the highlight.
The right-click handler copied an active selection via onCopySelectionNoClear
(the copy-on-select variant that keeps the highlight during a drag) and never
cleared it, so after right-click-to-copy the selection stayed lit with no
confirmation and a follow-up right-click re-copied the stale range instead of
pasting. A successful right-click copy now clears the selection and notifies;
if the copy fails (no clipboard path) the highlight survives and we fall back
to the right-click paste handler, exactly as before.
(2) Group transcript blocks so boundaries read clearly.
Model replies, reasoning/tool trails, and system/error notes rendered with no
vertical separation, so distinct block types butted together and were hard to
scan. Group adjacent blocks by kind: one blank line opens only where the visual
group changes (model prose <-> reasoning/tool trails <-> notes), while a run of
same-kind blocks renders flush. The rule lives in domain/blockLayout.ts
(messageGroup + hasLeadGap) and is applied intrinsically in MessageLine via a
`prev` prop, which fixes the things ad-hoc per-block margins kept breaking:
- Streaming stability: the gap is derived from the stable predecessor, never
the live block's own changing text, so the actively-streaming reply computes
the same gap while it streams as the settled segment does once it flushes.
No reflow/jump.
- Transparent empty trails: a trail hidden by /details, or one carrying only a
token tally (the finalDetails segment message.complete appends), renders
nothing and is transparent to grouping (prevRenderedMsg skips it), so there
are no floating gaps, no doubled gap after a prompt, and no padded space
above the final reply. In the default/collapsed modes content-bearing trails
always render, so the grouping is a no-op there.
The virtual-height estimator counts the group-boundary line so scroll math
stays accurate before Yoga remeasures.
ui-tui/src/domain/blockLayout.ts (new), components/messageLine.tsx,
components/streamingAssistant.tsx, components/appLayout.tsx,
lib/virtualHeights.ts, app/useMainApp.ts.
Tests: blockLayout.test.ts (grouping + hidden/empty-trail visibility),
virtualHeights leadGap, app-mouse.test.ts copy behavior. Full ui-tui suite
green apart from 3 pre-existing local/env failures (cursorDrift, ink-resize,
virtualHeights user-prompt-width) unchanged from main.
The Browser Automation and Text-to-Speech provider pickers listed the paid
"Nous Subscription" gateway row first, so on a fresh install the menu cursor
defaulted to index 0 (Nous). Pressing Enter selected it and ran the inline
Nous Portal device-code login — walking users into a paid offering they
never chose.
Reorder both provider lists so the free, no-key local backend is index 0
(Local Browser / Microsoft Edge TTS). Users who already configured Nous are
unaffected: _detect_active_provider_index still resolves their active row
first, so the cursor lands on Nous (now index 1) for them.
Reported by Javier via Kujila.
Add `display.interface` config key so users can make the modern TUI the
default for bare `hermes` / `hermes chat` without exporting HERMES_TUI=1 in
every shell. Default stays "cli" to preserve current behavior.
Add a `--cli` flag (mirrors `--tui`) so an explicit invocation can force the
classic prompt_toolkit REPL even when `display.interface: tui` is configured.
Precedence (highest first): `--cli` > `--tui`/`HERMES_TUI=1` > config
`display.interface` > classic REPL. Two resolvers enforce it:
* `_resolve_use_tui(args)` — the args-aware resolver used by `cmd_chat`
and the Termux fast-TUI path (uses full load_config()).
* `_wants_tui_early(argv)` — a dependency-free early resolver used by
mouse-residue suppression and the Termux fast paths, which run before
argparse / hermes_cli.config are importable (minimal cached YAML read).
Both `--cli` and `--tui` are registered via `_inherited_flag`, so they are
carried across self-relaunch automatically.
- config: add display.interface ("cli" default), bump _config_version 25->26.
The generic missing-field migration + load_config() deep-merge seed the key
for existing configs; no bespoke migration block needed.
- docs: document --cli flag and display.interface in cli-commands.md and
the TUI user guide.
- tests: new test_default_interface_resolution.py covering resolver
precedence at every layer, early resolver edge cases (missing/garbage
config), parser flags, and relaunch inheritance.
The model row is a Radix sub-trigger (no onSelect), so switching was
pointer-only. Wire Enter/Space alongside onClick so keyboard users can switch
models too.
Address Copilot review: document the `adopted` flag and nullable `pinnedCommit`
in the marker schema comment, and default `done(note = {})` so the dock-pinned
marker write is unambiguous (object spread of undefined was already a no-op, but
explicit is clearer).
selectModel snapshots the prior model/provider and restores the store +
query cache when the backend switch fails, so the UI never shows a model the
backend didn't actually select.
Pin #37718: the inherit plist must grant audio-input, every device.*
entitlement on the main app must also be inherited by the Helper/Setup
processes, and both entitlement files must stay valid plists.
Add com.apple.security.device.audio-input to entitlements.mac.inherit.plist.
Under hardenedRuntime the Electron Helper/Setup processes inherit this file,
and the missing entitlement made macOS TCC deny the microphone with no prompt,
breaking voice chat.
Fixes#37718
Electron's chrome-sandbox helper must be root:root 4755 on Linux or the
sandboxed renderer aborts before the desktop app starts. The existing
installer only searched for macOS .app bundles, so a successful Linux
build was reported as missing.
Changes:
- Add _desktop_linux_sandbox_fixup() to hermes_cli/main.py, called
before launching a packaged desktop app on Linux.
- Use lstat() + S_ISREG check to reject symlinks — chown/chmod on a
symlink target would set SUID on an arbitrary path.
- Update install.sh to recognize Linux unpacked artifacts and configure
chrome-sandbox with proper error handling (the original PR silently
ignored chown/chmod failures).
- Add regression tests: normal fixup flow, symlink rejection, and
already-configured skip path.
Closes#37529 (rebased, merge conflicts resolved, copilot review
feedback addressed).
The Dock stores persistent-apps as type-15 file:// URLs; the type-0/raw-path
tile we wrote was silently dropped on the next Dock restart (so the pin never
took, yet we'd stamped the marker and never retried). Use pathToFileURL + type
15 and flush prefs through cfprefsd before `killall Dock`. Verified end-to-end
on a packaged build: move -> adopt -> Dock tile lands as
file:///Applications/Hermes.app/.
Production code now uses ensure_uv()/update_managed_uv() from
managed_uv.py instead of shutil.which("uv") directly. Tests that
patched shutil.which to control uv availability no longer controlled
the actual code path, causing CI failures.
Add an autouse _patch_managed_uv fixture to test_update_autostash.py
and test_uv_tool_update.py (matching the existing fixture in
test_cmd_update.py). The fixture makes managed_uv functions delegate
to shutil.which so existing test patches flow through naturally.
Replace the multi-path UV resolution chain (PATH probing, conda guards,
5-location trust ordering, temp-dir fallback installs) with a single
managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs
uv resolves it from that one location; if missing, ensure_uv()
bootstraps it via the official standalone installer.
Key changes:
- New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(),
ensure_uv() (returns (path, freshly_bootstrapped) tuple),
update_managed_uv(), rebuild_venv(), installer internals.
- hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(),
add venv rebuild on first-time managed uv bootstrap, update_managed_uv
before dep install on all 3 update paths.
- scripts/install.sh: install_uv() always installs to
$HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5,
_reinstall_python_with_fts5, _warn_no_fts5 (61 lines).
Managed uv always installs current Python with FTS5.
- scripts/install.ps1: Install-Uv always installs to
$HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first.
- hermes_state.py: simplified FTS5 warning now suggests 'hermes update'
as the fix instead of blaming install method.
- tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv
fixture in test_cmd_update.py.
Closes#37605, Closes#37622
Consolidate per-package package-lock.json files into a single root-level
workspace lockfile. Update all consumers:
- Nix: shared src/npmDeps/npmDepsHash in lib.nix; devshell hook stamps
package.json paths then runs npm ci from root; individual .nix files
use mkNpmPassthru attrs instead of per-package fetchNpmDeps.
- Python CLI: new _workspace_root() helper so _tui_need_npm_install,
_make_tui_argv, _build_web_ui resolve lockfile/node_modules from the
workspace root.
- Desktop: replace --force-build/mtime heuristic with content-hash build
stamp (_compute_desktop_content_hash via pathspec). Remove --force-build
flag.
- Dockerfile: single root npm install; no per-directory lockfile copies.
- CI: nix-lockfile-fix and osv-scanner reference root package-lock.json;
apps/dashboard → apps/desktop.
- Tests: new test_tui_npm_install.py; desktop stamp tests in
test_gui_command.py; updated assertions in test_cmd_update.py,
test_web_ui_build.py, test_dockerfile_pid1_reaping.py.
- Docs: remove --force-build from desktop flag table.
Deleted: apps/desktop/package-lock.json, ui-tui/package-lock.json,
ui-tui/packages/hermes-ink/package-lock.json, web/package-lock.json.
- selectModel reports success; edits bail (and roll back) instead of landing
on the previously active model when a switch fails
- Fast toggle stays available to turn off a carried-over speed param even when
the new model has no native fast mechanism
- active row's "Fast" label derives from the same fastControl as the submenu
toggle, so it's consistent and handles standalone `-fast` model ids
Seven Copilot inline review comments on #37679, four worth landing
in a polish pass before merge:
1. _dispose_unused_adapter signature: 'BasePlatformAdapter' ->
'BasePlatformAdapter | None'. The function explicitly handles
None and the reconnect watcher calls it with None in the
except arm, so the annotation now matches the actual contract.
2. (duplicate of #1 on a different line) — same fix.
3. except Exception in _dispose_unused_adapter — the reviewer
asked about asyncio.CancelledError swallowing. On Python 3.8+
(Hermes requires 3.13, see pyproject.toml), CancelledError
inherits from BaseException, NOT Exception, so the existing
'except Exception' does NOT swallow task cancellation. Added
an explicit comment explaining the contract so future readers
don't repeat the analysis. We don't re-raise because the
watcher loop intentionally treats dispose failures as
best-effort: a failed dispose on an unowned adapter should not
take down the watcher that's keeping the gateway alive.
4. _response_store = None after close in api_server.py — the
reviewer flagged this for idempotency. Decided to keep the
non-None state intentionally: setting it to None cascades
to ~9 callers that access self._response_store without a
None check, and 'close() is idempotent on a closed sqlite3
Connection' means the current code is already safe. The
type stays stable; LSP doesn't flag a cascade of
reportOptionalMemberAccess errors. (This matches the
pre-existing pattern in the codebase — e.g.
_mark_disconnected doesn't reset state to None either.)
5. _build_adapter_with_store: reviewer worried about
disconnect() failing on the self.name property if
__init__ wasn't called. Already handled: we set
'adapter.platform = Platform.API_SERVER' so the
'self.platform.value.title()' property returns
'Api_Server' without raising. The exception-swallowing
branch in disconnect() does call self.name via the
logger.debug format, so this is a real path that needs
the platform attribute, and we have it.
6. test_disconnect_closes_response_store: bare 'pytest.raises(Exception)'
-> 'pytest.raises(sqlite3.ProgrammingError)'. The bare
Exception matcher would silently accept AttributeError,
OperationalError, env-related issues, etc. The specific
exception type ('Cannot operate on a closed database') is
the actual signal we want — proves the SQLite conn is
closed, not just that *something* raised.
7. test_nonretryable_failure_disposes_unowned_adapter:
assertion tightened from '>= 1' to '== 1' on
adapter._disconnect_calls. The docstring said 'exactly once',
the assertion now matches. Catches the hypothetical
'watcher disposes the same adapter twice' regression that
'>=' would have missed.
The check-attribution CI job on #37679 failed because the commit
author email nolan@0xvox.com (a local git config mistake on this
machine) is not in scripts/release.py AUTHOR_MAP. The commit
itself is now re-authored to fearvox1015@gmail.com, and this
follow-up adds the entry to AUTHOR_MAP so any future commits
authored from this email also pass the check.
Three separate code paths in the gateway's platform reconnect loop
leaked file descriptors every retry, exhausting the default 2560-fd
ulimit in ~12 hours of continuous failure and turning the gateway
into a zombie that raises OSError: [Errno 24] on every open() (#37011).
Root cause:
* APIServerAdapter.__init__ opens a ResponseStore SQLite connection
that holds 2 fds (db file + WAL sidecar).
* APIServerAdapter.disconnect() previously only stopped the aiohttp
web server — the ResponseStore connection was never closed.
* The reconnect watcher in _platform_reconnect_watcher constructs a
fresh adapter on every retry attempt. When the connect call fails
(3 paths: non-retryable error, retryable error, exception during
connect) the adapter is dropped without ever being installed on
self.adapters, so nothing else calls its disconnect(). Result: the
2 ResponseStore fds stay open until GC sweeps the unreachable
object, which Python's cyclic GC does not do promptly for
asyncio-bound native handles.
2 fds × 1 retry × (3600s / 300s backoff cap) ≈ 12 fds/hour.
2560 fds / 12 fds/hr ≈ 12h to ulimit exhaustion.
Fix:
* APIServerAdapter.disconnect() now also calls
self._response_store.close() (with a try/except so a SQLite
close failure doesn't abort the aiohttp teardown).
* New module-level helper _dispose_unused_adapter(adapter) in
gateway/run.py that calls adapter.disconnect() and swallows
any exception (so half-constructed adapters whose __init__
crashed don't kill the watcher loop).
* _platform_reconnect_watcher calls _dispose_unused_adapter() in
all three failure paths: non-retryable, retryable, and the
except Exception arm. adapter = None is initialized
before the try so the except arm can see the partial
construction.
Tests:
* New file tests/gateway/test_platform_reconnect_fd_leak.py with
7 regression tests covering all three failure paths, the
_dispose_unused_adapter helper (None + raising-disconnect cases),
and the APIServerAdapter ResponseStore close behavior (success +
close-exception cases). The _CountingAdapter fixture tracks
disconnect() invocations and an _open_fds counter that is
decremented on dispose, so the assertion is the literal
observable behavior of the leak.
Refs:
- Closes#37011 (the original fd-leak report)
- Supersedes #37018, #37110, #37238, #37260, #37394 (7 competing
open PRs all addressing the same root cause from different angles;
none of them rebased cleanly against current main, and none
covered all three failure paths in one fix with regression tests
for both the watcher and the platform-level close behavior)
A long-lived process (gateway, watcher) caches the Nous Portal's
recommended-models payload and can pin a model for its whole lifetime.
When that model is later dropped from the Nous -> OpenRouter catalog,
every auxiliary call 404s with 'model does not exist in our
configuration or OpenRouter catalog' until the process restarts.
Now such a 404 force-refreshes the Portal recommendation and retries
once with the current pick (or the gemini-3-flash-preview default).
Scoped to Nous-routed calls only.
- _is_model_not_found_error(): 404/400 'not found / does not exist /
not a valid model' predicate, excludes billing keywords so it never
overlaps _is_payment_error.
- _refresh_nous_recommended_model(): force-refresh fetch, returns a
model distinct from the one that failed, else the known-good default.
- Wired into both call_llm and async_call_llm error chains.
First-launch "already installed?" hinged solely on a marker that only the
desktop's own bootstrap writes, so a runtime from `install.sh --include-desktop`
(or a DMG launch over a prior CLI install) was runnable yet markerless and got
the WHOLE installer re-run on top of it. Detect a runnable ACTIVE_HERMES_ROOT
(valid source + venv), adopt it (stamp the marker, recording HEAD), and forward
straight to the app. Repair keeps forcing a real re-bootstrap.
Also: on first packaged macOS launch relocate the bundle into /Applications
(Electron relaunches from there) and pin the canonical copy to the Dock once,
so users stop re-opening the installer from Downloads/the DMG.
Replace the status-bar model chip's modal with a Cursor-style dropdown:
- providers grouped by name in a stable order (no recency reshuffle on select)
- per-model hover-Edit submenu for reasoning effort + fast, gated by per-model
capabilities now surfaced in the model.options payload
- unified Fast toggle: flips the speed=fast param where supported, else swaps
to the model's `-fast` variant (base and variant collapse into one row)
- localStorage-backed "Edit Models" dialog to choose which models appear
Adds reusable dropdown primitives (DropdownMenuSearch, shared row/label
tokens, portaled + collision-aware submenus) and reads session state from
nanostores rather than prop-drilling, so editing options doesn't rebuild and
close the menu.
TestDialecticLifecycleSmoke._await_thread did a single join(timeout=3.0) and
then proceeded regardless of whether the background dialectic thread had
finished. On a loaded CI runner (6 parallel test slices) the prewarm thread's
completion can slip past that 3s window, so the join times out silently and the
test reads _prefetch_result before the worker wrote it — the intermittent
'session-start prewarm must land in _prefetch_result' failure.
Join in a loop up to a 30s ceiling and assert the thread is actually dead, so a
genuine hang surfaces as a clear failure instead of a timing race. Reproduced
the old failure deterministically (5/5 fails with a 3.5s prewarm delay) and
confirmed the fix (0/8) before/after.
Background-task (/background, /btw) result media now routes to the
type-specific sender — TTS clip → voice bubble, video → send_video,
image → send_image_file — instead of forcing everything through
send_document. Mirrors the streaming + kanban delivery paths and
reuses base.should_send_media_as_audio for the Telegram OGG nuance.
Co-authored-by: LJ Li <liliangjya@gmail.com>
Co-authored-by: Kolektori <256073454+Kolektori@users.noreply.github.com>
Module-level asyncio.Lock() binds to whatever event loop was active at
import time. When the same web_server module is reused across multiple
TestClient instances (or across uvicorn reloads), the old lock still
references a defunct loop, causing 'attached to a different loop' errors
and flaky subscriber-registration races in CI.
Replace the module-level _event_channels dict + _event_lock with:
- _lifespan() async context manager that creates both on the running
event loop during FastAPI startup (guaranteed correct loop binding)
- _get_event_state() lazy accessor that initialises on app.state when
TestClient is used without a `with` block (preserves backward compat)
All call sites (_broadcast_event, /api/pub, /api/events) now receive the
app reference and read state via _get_event_state(app) instead of the
module globals. The test polling loop is updated to check
app.state.event_channels rather than the removed module attribute.
Shutting down the callback server stopped the serve thread but left the
worker spinning in _xai_wait_for_callback (which polls callback_result)
until the timeout. Flag callback_result as cancelled on DELETE so the
wait returns promptly and the daemon thread exits — avoids thread
buildup on repeated cancel/retry.
source_label is meant to be a human-readable origin (file path / source),
not the internal auth_mode string ("oauth_pkce"). Surface the auth-store
path, then the source slug, then a generic label.
Add build-package and build-devshell as cross-platform check
derivations so nix flake check verifies the default package and
devShell build on every platform (including darwin, which previously
only did eval-only checks).
This lets us drop the separate nix build step from the CI workflow
and removes the macOS-only eval fallback — a single nix flake check
now covers builds + runtime checks on all runners.
- onboarding: openSignInUrl now falls back to window.open when the desktop
bridge's openExternal throws/rejects (OS handler missing, user denied),
not just when the bridge is absent
- web_server: cancelling a loopback session shuts down the 127.0.0.1
callback server + joins its thread immediately, freeing the port instead
of holding it until the wait times out (+ regression test)
- web_server: document the new "loopback" flow in the /api/providers/oauth
enum, the poll-endpoint docstring, and the Phase 2 flow comment block
- web_server: join the callback-server thread in the start error path so a
failed discovery/URL build doesn't leave a daemon thread running
- web_server: loopback worker now bails if the session was cancelled while
waiting for the callback or exchanging the code, instead of persisting
tokens the user no longer wants (+ regression test)
- onboarding: fall back to window.open when the desktop bridge's
openExternal is unavailable, so the flow never silently stalls
xAI Grok was only reachable via the "I have an API key" form. xAI's
OAuth (SuperGrok / Premium+) flow already exists in the backend
(`hermes auth add xai-oauth`) but was never surfaced in the desktop
onboarding launcher.
Add a loopback PKCE flow: the local backend binds the 127.0.0.1
callback listener, the client opens the browser, and the redirect lands
back automatically — no code to copy/paste. Reuses the existing xAI
OAuth helpers (discovery, callback server, token exchange, persist)
rather than duplicating them.
- web_server: catalog entry (flow: loopback) + status dispatch +
_start_xai_loopback_flow + background worker + route branch
- desktop: 'loopback' flow type, awaiting_browser status, xAI Grok card
(PROVIDER_DISPLAY / FLOW_SUBTITLES / FlowPanel waiting render)
- tests: catalog listing, start authorize-url, worker persist, state
mismatch rejection
* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing
A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.
Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
paint a horizontal scrollbar at the bottom of the window.
Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
@icons-pack/react-simple-icons (telegram, discord, matrix, signal,
whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
owner request).
- Drop the duplicate "Create first cron" button in the empty state.
Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
Blob identity; Chromium hands us the same screenshot via both
clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
spellchecker with the system locale on whenReady, and add
replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
backtick code + fenced ``` blocks) while keeping @file:/@image:
directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
submenu.
- Bake cursor-pointer into the <Button> primitive (with
disabled:cursor-default) and into titlebarButtonClass.
Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
every update check, and on throttled window focus so About reflects
the just-installed binary.
Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
the two streams as separate labeled blocks with stderr in a neutral
tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.
Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
preload bridge + global.d.ts typing + a "Default project directory"
row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
base; ShellFileOperations.delete_file rewritten to run a cross-
platform python3 -c snippet so deletes work on Windows shells (which
have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
and theme-color meta.
Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
8-minute silence on the stream auto-clears stuck $workingSessionIds
entries so "Session Busy" never gets permanently wedged. Wired into
useSessionStateCache so every state update refreshes the timer.
i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
(recommends react-intl, audits IME/RTL/CJK in the composer +
chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
non-English locale).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): replace native OS scrollbar in portaled dropdown menus
Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.
Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle
Two regressions from the previous dropdown-scrollbar fix:
- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
cn() call were being mis-resolved so the `rounded-full` leaked onto the
menu container itself. Replaced the whole tower of arbitrary variants
with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
`.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
--radix-dropdown-menu-content-available-height on Content but NOT on
SubContent, so the `max-h` bound to that variable computed to 0 and the
submenu collapsed to zero height. Switched SubContent to a fixed
max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog
The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.
Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
another round of Radix positioning bugs.
Also extract types/interfaces to the bottom of the file per workspace
convention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): move cron 'New cron' button off the top bar into the body
Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.
- Empty (zero jobs): EmptyState renders the "Create first cron" button
again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
a single "New cron" button (right-aligned). The rows themselves
already cover edit/pause/trigger/delete, so this is the only "create"
affordance.
Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address Copilot review on PR 37536
- sessions-settings: guard the WHOLE bridge call rather than chaining
`?.settings.foo().then(...)` — the latter throws when
`window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
generated delete snippet still works on remote backends running
Python 3.7. The existing FileNotFoundError handler covers the same
case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
(basic/bright colors, bold toggles, default-fg reset, coalescing,
256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
full-reset) so future refactors can't silently regress terminal
rendering.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop/updates): swallow refreshDesktopVersion bridge errors
`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(desktop): drop work duplicated by other in-flight PRs
- composer/text-utils.ts: revert paste-image dedupe — PR #37596
ships the same fix with a cleaner content-key approach and a
Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
has already shipped a working i18n surface (homegrown nanostores
`t()` helper over en/zh dictionaries), so the RFC's framework
recommendation (`react-intl`) is now obsolete and would just
contradict the implementation that's actually landing.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>