Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to
openNewWindow(), which opens a full peer instance via the new openWindow
bridge, and add a "New Window" entry to the ⌘K palette (shown with its
hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window".
Drops the retired openNewSessionWindow bridge and the vestigial
isNewSessionWindow()/new=1 flag; renames the shared opener helper.
Add createInstanceWindow() — a full-chrome peer of the primary that
renders the complete app (sidebar, routing, its own draft) against the
shared backend, so several GUI windows can run at once. Mirrors the
primary's window options + chatWindowWebPreferences (backgroundThrottling
stays off so a streamed answer never stalls when blurred) but never
overwrites the mainWindow global and doesn't respawn the backend — the
renderer's getConnection() joins the running one. New windows cascade off
their source via the pure, tested instanceWindowBounds().
Exposed via the hermes🪟openInstance IPC and a "New Window" File
menu item. Per-window fullscreen state now targets the window itself, and
titlebar/native-theme repaints reach every open chat window instead of
only the primary.
Retires the now-orphaned compact new-session pop-out (its only caller was
⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow,
the hermes🪟openNewSession handler, and the newSession/new=1 URL
flag.
Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env
init + module transform + the @/hermes/@/store/profile import graph),
which pushed past vitest's 5000ms default under load — caught at 8871ms
on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms
each because all that setup is already cached, so only test 1 was at
risk of timing out.
Bump the describe-level timeout to 15s. Verified with 10 consecutive
runs, 4 of which took 5.5-6.6s of test time and would have hard-failed
under the old 5s default.
Keep-awake lives only in Settings → Advanced now. Remove the statusbar
quick-toggle (+ its Sun icon, store toggle helper, and keepAwakeOn/Off
strings across locales). Since the statusbar was what eagerly loaded the
store at boot, move persistence to the main process (keep-awake.json,
re-applied on app ready — same pattern as translucency), so a cold launch
restores the blocker without the renderer opening Settings.
The settings OverlayMain has a titlebar-height top pad (no bottom pad), so
the full-panel LoadingState centered in the band beneath it and read low.
Cancel the top pad on the loader so it centers in the whole card; the one
inline (mid-panel) memory loader switches to a plain min-height PageLoader
so it's unaffected.
Revert the dedicated System section: Window Translucency + UI Scale move
back to Appearance, and Haptics returns to its titlebar-only home. Keep
computer awake now lives as a device-local toggle at the top of Advanced
(a ConfigSettings section-specific extra, like the Model block), keeping
the statusbar quick-toggle. Relocated i18n back to settings.appearance /
settings.config across all four locales.
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression:
the top `if (reduce) setPhase('gone')` fired unconditionally on mount
whenever reduce-motion was on, so every OS reduced-motion user lost the
CONNECTING overlay during cold boot entirely (jumped to 'gone' before the
gateway was even open). The intent was to skip the exit *choreography*,
not to skip showing the overlay. Removed the unconditional top block and
the redundant nested preview block; kept only the third branch
(`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' :
'text-out'`) which correctly gates the short-circuit on connect. Also
fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line
comment pasted three times.
Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI.
Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts,
adds @playwright/test types) and wired it into the typecheck script. This
surfaced three latent type errors that are fixed in the same commit:
- fix-electron-tracing.ts: `app._context` and `electron._playwright` are
private APIs — added `as any` on the access before the existing cast.
- playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:`
is not a valid UseOptions property in playwright 1.58; it's a
BrowserContextOption accessed via `contextOptions: { reducedMotion:
'reduce' }`. The old form was silently ignored at runtime, so
reduced-motion emulation wasn't actually active — screenshots could
catch overlays mid-fade (exactly what the comment warned about).
Nit #2 — fix-electron-tracing.ts reaches into Playwright internals
(_playwright, _allContexts, _context) with no public contract. Added a
header comment calling out the `@playwright/test` exact pin (=1.58.2) so a
future bump knows to re-verify the private symbols still exist.
Nit #3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation.
Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors;
vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass;
npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
Reverts the Preparing component changes from b2857110b so the progress bar
turns red (bg-destructive) and the error text shows below it when boot.error
is set, instead of bailing out with an early return null.
The corresponding e2e guard in waitForBootFailure (e2e/fixtures.ts) that
rejected any progress bar in the DOM is dropped — it now waits for the
failure dialog (Retry/Repair/Use local gateway/Connection settings) or the
"Desktop boot failed" toast. The boot-failure.spec.ts header comment is
updated to match.
Verified: tsc clean, vitest boot-failure-reauth (21/21) + boot-failure-overlay
(3/3) pass, npm run build clean, playwright e2e/boot-failure.spec.ts 2/2 pass.
Add a "keep computer awake" toggle (Claude-style) for long/overnight runs:
the renderer owns the device-local pref and mirrors it to the Electron main
process, which holds a single `powerSaveBlocker('prevent-app-suspension')` —
the same authority split as translucency. Surfaced as a statusbar quick-toggle
and a Settings row.
Introduce a dedicated System settings section (device-local machine prefs) and
de-crowd Appearance by moving Window Translucency + UI Scale into it (both are
main-process/window-owned, not visual theme). Give Haptic Feedback its first
Settings home there too (the titlebar quick-toggle stays). Relocated i18n copy
into `settings.system` across en/zh/zh-hant/ja; wired the `system` route into
the SettingsView union + allowlist + nav.
LiveDuration returned a bare string with proportional digits, so the
statusbar reflowed every second as the timer ticked. Extract a shared
StableText component that renders each character in its own 1ch-wide
cell, preventing any digit from shifting the layout — works with the
proportional sans font, no need for font-mono.
Both LiveDuration (statusbar session/running timers) and
ActivityTimerText (tool activity timers) now use StableText, dropping
the font-mono + tabular-nums workaround from the latter.
Also renames statusbar.ts → statusbar.tsx since LiveDuration now
returns JSX.
The boot-failure screenshot showed a progress bar because of two bugs:
1. waitForBootFailure matched on "Let's get you setup" (the onboarding
header that mounts from frame 1 during normal boot), so the screenshot
fired at ~86% progress while the Preparing component's progress bar was
still painted.
2. The Preparing component kept rendering the progress bar even after
boot.error was set — it just turned the bar red and appended the error
text below it.
Fixes:
- Preparing bails out (returns null) when boot.error is set, so
BootFailureOverlay (z-1400) owns the screen exclusively.
- applyDesktopBootProgress no longer clobbers a previously-set boot.error
when a late progress event arrives with error: null — failDesktopBoot is
terminal for the boot cycle.
- waitForBootFailure guards against progress bars being visible and matches
on actual failure signals (error toast, Retry/Repair buttons), not the
onboarding header.
- setupDeadBackend now accepts { fakeError: true } which injects
HERMES_DESKTOP_BOOT_FAKE_ERROR to trigger a real boot failure — the
previous dead-provider fixture never actually caused a boot failure
(hermes serve starts fine; the dead endpoint only matters at chat time).
- boot-failure.spec.ts updated to use { fakeError: true }.
Verified: e2e test passes with 0 progress bars in the DOM at screenshot
time (confirmed via DOM inspection), 16/16 vitest tests pass, typecheck
clean.
Screenshots were catching the app mid-boot at ~92% with the onboarding
Preparing progress bar still visible. waitForAppReady checked for the
composer (textarea/contenteditable) with state:'visible', but Playwright
considers an element visible even when a z-1300+ fixed overlay covers it
(non-zero bounding box, not display:none).
Now waits for the composer to be attached, then polls
document.elementFromPoint at viewport center — if the topmost element is
inside a position:fixed inset:0 overlay, the app isn't ready yet.
Screenshots were catching the CONNECTING overlay and onboarding Preparing
loading bar mid-transition because the wait helpers fire on text content
while visual state lags behind. Fix at the source via reduced motion:
- playwright.config.ts: emulate prefers-reduced-motion: reduce
- styles.css: blanket reduced-motion rule kills all CSS animations/transitions
- gateway-connecting-overlay.tsx: skip JS setTimeout exit choreography
(text-out 360ms + hold 300ms + overlay fade 520ms) — jump straight to gone
- decode-text.tsx: skip scramble interval, render resolved text immediately
The check script ran: typecheck && test && test:desktop:all && build
test:desktop:all calls ensurePackagedApp() → npm run pack, which itself
runs npm run build (vite + electron-main + preload + stage-native-deps)
before electron-builder --dir. The trailing npm run build was rebuilding
the exact same dist/ output a second time. electron-builder --dir reads
dist/ as input and doesn't mutate it, so nothing between the two builds
invalidates the first one.
On the CI linux runner this saves the vite+bundle build (~5s) on every
js-tests run. The postbuild assert-dist-built.mjs still runs as part of
pack's build step.
The visual diff summary told reviewers to 'download and open to compare'
instead of linking to the actual run's artifacts page. Now links directly
to the run's /artifacts page and lists both artifacts with descriptions.
Also, screenshots that matched their baselines were never written to
test-results/, so the artifact only contained screenshots that diffed.
Now the actual screenshot is always written to the output dir regardless
of match/diff, so CI artifacts include every screenshot.
Adds a full desktop Playwright E2E suite that launches the Electron app
against a mock inference server, exercising the full boot chain:
electron -> hermes serve -> mock provider -> renderer
Includes:
- Mock OpenAI-compatible inference server (mock-server.ts)
- Shared fixtures with sandbox isolation (credentials, HERMES_HOME,
userData, fixed window-state.json for reproducible screenshots)
- Test specs: boot, boot-failure, onboarding, mock-backend-setup, chat,
and packaged-app launch
- Visual regression: expectVisualSnapshot() wraps toHaveScreenshot in
try/catch so diffs are reported without failing the test suite
- CI workflow: xvfb at 1280x1024, baseline cache from main
(--update-snapshots on main, compare on PRs), step summary table with
diff/actual/expected image links, dedicated visual-diffs artifact
- dev:mock script for local fake-provider development
- test:e2e:visual + test:e2e:update-snapshots scripts using cage
- .gitignore: *-snapshots/ (baselines cached in CI, not committed)
- Type gatewayState in session store
- Electron main.ts: force-show window for e2e test workers
- tsconfig: include e2e test types
- nix/devShell.nix: add cage for headless visual testing on tiling WMs
* fix(desktop): preserve interim assistant text wiped at message.complete
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
* fix: prefix-match interim streamed content to avoid benign duplicate bubbles
_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.
Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().
* test(desktop): add partial-stream-then-nudge dedup edge case
Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.
Acceptance protocol #2 — covers all three dedup edges:
1. interim == final (existing)
2. interim = strict prefix of final (existing)
3. partial-stream-then-nudge (this commit)
---------
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
* fix(desktop): stabilize runtime readiness polling
* fix(tui_gateway): pool live-session status polling
* fix(desktop): clear readiness when gateway disconnects
* fix(desktop): extend read aloud timeout
Hermes Desktop Read Aloud can show a false failure after 15s even when
backend TTS synthesis succeeds. The Desktop renderer bridge request to
/api/audio/speak blocks until provider synthesis, audio file read, and
base64 response encoding finish, so larger messages or slower remote TTS
providers can legitimately exceed the default 15s Electron backend
timeout (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts).
Give only the blocking Read Aloud request a bounded TTS-specific timeout
(180s floor, 600s cap, text-length scaling). Normal Desktop API requests
keep the short default so real backend hangs still fail promptly.
Salvage of #39286 rebased onto current main (original branch conflicted
in apps/desktop/src/hermes.ts and hermes.test.ts against the newly-added
STARTUP_REQUEST_TIMEOUT_MS / PROMPT_SUBMIT_REQUEST_TIMEOUT_MS constants
and model-options tests). Both additions coexist; no behavior changed.
Co-authored-by: Ruslan Vasylev <ruslan.vasylev.vfx@gmail.com>
* fix(desktop): extend read aloud + transcription timeouts
Hermes Desktop Read Aloud can show a false failure after 15s even when
backend TTS synthesis succeeds. The Desktop renderer bridge request to
/api/audio/speak blocks until provider synthesis, audio file read, and
base64 response encoding finish, so larger messages or slower remote TTS
providers can legitimately exceed the default 15s Electron backend
timeout (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts).
/api/audio/transcribe is the sibling blocking endpoint with the same bug
class: it blocks on provider STT + file handling + encoding behind the
same 15s default, so long clips / remote providers hit the same spurious
timeout. Give both requests a bounded, endpoint-specific timeout (180s
floor, 600s cap) — speak scales on text length, transcribe on the base64
payload length. Every other Desktop API request keeps the short default
so real backend hangs still fail promptly.
Salvage of #39286 rebased onto current main. The original PR was scoped
to /api/audio/speak only; this extends it to also close the transcribe
twin per OutThisLife's review suggestion on #39286, closing the whole
'audio endpoints time out at 15s' class in one shot.
Co-authored-by: Ruslan Vasylev <ruslan.vasylev.vfx@gmail.com>
---------
Co-authored-by: Ruslan Vasylev <ruslan.vasylev.vfx@gmail.com>
* feat(desktop): inline TTS voice/model settings in the Capabilities tab
The Capabilities > Tools > Text-to-Speech panel only surfaced API keys per
provider — voice and model settings (e.g. tts.openai.voice) lived exclusively
in Settings > Voice, so users couldn't select or type a voice/model name where
they configure the backend.
- web_server: TTS provider rows now carry their tts_provider config key
(the section holding that backend's voice/model settings)
- desktop: new VoiceProviderFields renders the provider's config fields
inline in the toolset panel, deriving the key list from the curated
Settings > Voice section so the two surfaces can't drift; shared
ConfigField extracted to config-field.tsx
- voice/model name fields are now free-input comboboxes (Input + datalist)
instead of closed Selects — custom voice IDs (ElevenLabs cloned voices,
xAI custom voices, Edge's 400+ catalog) are typeable, known values remain
suggestions
- refreshed the stale OpenAI voice list (adds ash/ballad/cedar/coral/marin/
sage/verse) and added suggestion lists for edge/gemini/minimax/mistral/
kittentts/piper/neutts models and voices
- config.py: added missing tts.minimax and tts.kittentts default blocks and
deepinfra model/voice fields to the Voice section so those providers are
configurable from the GUI at all
* test(desktop): await effect-driven panel content in post-setup CTA tests
The auto-expand effect renders the provider's inner panel one re-render
after the row; with the QueryClientProvider wrapper the extra provider
tick made the synchronous getByRole race it (~10% local flake, failed on
CI). Await the panel content with findBy* instead.
Two stacked failures turned desktop/runtime version skew into a full
'Hermes couldn't start' boot brick:
1. listSidebarSessions() had no fallback when the backend predates the
batched /api/profiles/sessions/sidebar route (added Jul 18) — the
backend catch-all 404 ('No such API endpoint') rejected the refresh.
2. boot() awaited refreshSessions() unguarded inside Promise.all —
unlike the reconnect and softSwitch call sites — so a session-LIST
failure rejected the whole boot and raised failDesktopBoot() even
though the gateway WS was already open and the app was usable.
Fixes:
- listSidebarSessions() now detects endpoint-missing errors (backend
catch-all, IPC-wrapped 404, Electron HTML-guard), falls back to the
three proven per-slice /api/profiles/sessions calls with identical
scoping (recents on the caller's profile, cron + messaging
cross-profile), and remembers the verdict so later refreshes skip the
dead probe. Transient failures (timeout, 5xx, ECONNREFUSED) still
throw — no silent fast-path degradation from one blip.
- wipeSessionListsForGatewaySwitch() resets the capability flag so a
soft gateway switch re-probes the next backend instead of leaking the
old one's verdict (hard re-homes reload the window and reset anyway).
- boot() treats refreshSessions() as non-fatal, matching its sibling
call sites: worst case is an empty sidebar that the next
reconnect/turn refresh repopulates, never a bricked boot.
Tests: endpoint-missing fallback slices + scoping, sticky verdict (no
re-probe), re-probe after gateway switch, transient errors NOT
triggering fallback, and a real-hook harness test proving a rejecting
refreshSessions still completes boot with no overlay.
The collapsed-providers atom (`hermes.desktop.collapsed-providers`) is a
global presentation-layer preference, but the catalog the picker renders is
profile-scoped (`getGlobalModelOptions` routes through `profileScoped()`,
model-menu-panel.tsx:87-93). The previous code called `pruneStaleCollapsed`
on every render against `pickerProviders`, which silently deleted any
slug not present in the active catalog.
Bug class (review from @teknium1):
- Profile switch to a catalog that lacks a previously-collapsed provider
→ that collapse preference is permanently lost.
- Refresh Models that drops a provider (revoked key, plugin disabled,
backend policy change) → same loss.
- Any transient empty catalog (loading → []) → guarded by `length > 0`,
but still loses state once the new (smaller) catalog resolves.
Fix:
- Remove the prune `useEffect` from model-menu-panel.tsx.
- Delete `pruneStaleCollapsed` (no other caller; the AGENTS.md "no
speculative infrastructure" rule applies — keeping it exported with a
plausible-sounding docstring is a foot-gun for future contributors who
would re-call it against a single active catalog and reintroduce the bug).
- Document on the atom why we deliberately don't prune: provider slugs
come from a bounded configured set (not user input); the render loop
only visits providers in the active `groups`; dead slugs have no
observable effect (`collapsedProviders.includes(slug)` against an
absent slug is a no-op).
Tests:
- `preserves the collapsed set across a profile switch whose catalog
lacks the slug` — regression pin for the profile-switch case.
- `preserves the collapsed set when Refresh Models drops a provider` —
regression pin for the refresh-models case.
Verified: `tsc -p . --noEmit` is clean on the changed files. CI is the
source of truth for the vitest run (the local React-detection env
failure pre-dates this PR; CI passes).
Closes the review thread on #64690.
Click a provider header (DEEPSEEK, GOOGLE, etc.) in the model picker dropdown
to collapse/expand its model list. State persists across sessions via localStorage.
- New provider-collapse store with persistentAtom<string[]> + stale-key pruning
- Provider headers become clickable DropdownMenuItems with chevron indicators
- textValue="" excludes headers from Radix typeahead (both reviewers flagged)
- Auto-expands the active provider so the checkmark is always visible
- Search bypasses collapse (typing shows all matching models)
- Keyboard accessible via Enter/Space on the header row
- Store double-read eliminated per cross-vendor review feedback
Reviewed-by: Flash + GPT-OSS cross-vendor review (all SHOULD-FIXs addressed)
Related: #60966 (different interaction model — hover-to-expand)
A `persist:` partition's cookie store hydrates lazily, so the first
cookies.get() on a fresh launch can return empty for a signed-in user.
That false-negative made hasLiveOauthSession() throw "not signed in",
which on the no-retry initial boot path surfaced as the transient
"Hermes couldn't start" OAuth overlay that always cleared on Retry.
hasLiveOauthSession now reads once (no added latency on the happy path);
only on an empty read does it warm the store (flushStorageData + a
throwaway get, memoized) and re-read with a bounded ~180ms backoff
before trusting the negative. Genuinely signed-out users still resolve
false quickly and get the overlay. Fixes the whole class: the same
function backs the reconnect path and the Settings connected indicator.
* fix(desktop): scope multi-pane model UI and stabilize tile chrome
Composer model controls were still keyed off the primary session globals, so every tile showed the same model and a busy primary blocked switches in idle panes. Bind the pill/menu/select path to SessionView, force lone session-tile headers (incl. after tab cycle), and persist strip order so add/remove/switch stops scrambling adjacent panes.
* fix(desktop): scope preset effort/fast writes per surface, simplify tile order sync
A tile's model pick still pushed effort/fast onto the primary composer globals via applyModelPreset — scope it to the surface (primary → globals, tile → its session slice). Tile order persistence drops the before-stamping walk for a plain sort by tree encounter order; restore replays the array sequentially so array order is strip order.
* test(desktop): cover tile strip-order + selection-home; fix stale docs
Extract syncTileStripOrder's sort into a pure `orderTilesByTree` and the
selection listener's guard into `selectionHomesToWorkspace` (same shape as
the PR's lone-header extraction), then unit-test both — the two store
behaviors that shipped without coverage. Correct the `anchor`/`before` docs
(now persisted, not in-memory) and note that a tile's effort/fast edit still
writes the shared per-model preset even though the session write is scoped.
* fix(desktop): drop forbidden import() type annotations in model tests
`importOriginal<typeof import('…')>()` trips consistent-type-imports (error)
and reddens the desktop lint job. Switch to the repo's accepted top-level
`import type * as X` + `typeof X` form, matching skills/index.test.tsx.
* perf(desktop): idle-mount boot-hidden panes off the cold-start critical path
The layout tree keeps a chrome-hidden pane's content MOUNTED behind
display:none (so toggling back is instant) — but that means files, preview,
review (Shiki diff) and logs all mount their real content during first paint
even though none are visible at launch (fresh profile: no cwd, review off,
no preview target, logs not in the default tree). First paint only needs
sessions + workspace + statusbar; the rest is pure app-mount tax, the one
cold-start lever that's actually in our code (Electron startup and the
un-splittable bundle eval are not).
Wrap those four pane renders in <IdleMount>: mount on requestIdleCallback
(2s timeout fallback), then stay mounted. Idle fires within a frame of first
paint, so a hidden pane is warm before it can be revealed — zero UX change,
the instant-toggle contract intact. Degrades to eager mount where rIC is
absent (jsdom/tests), so no behavioral fork.
* refactor(desktop): collapse the four idle-mount wrappers into one idle() helper
findBy*/waitFor default to a 1000ms deadline, which is too tight for
async-heavy settings panels (radix menus + refetch chains) when the full
suite runs under xdist CPU contention in CI. toolset-config-panel.test.tsx
has reddened unrelated PRs multiple times with `Unable to find ...` timeouts
that pass on re-run — the textbook contention flake.
Bump asyncUtilTimeout to 5000ms in the shared ui setup. Success still
resolves the instant the node appears; the wider deadline only absorbs a
starved runner, so happy-path speed is unchanged and only genuine failures
wait longer.
Two tool-render wins during streaming / on session switch:
1. Every ToolEntry did useStore($activeSessionId)+useStore($currentCwd), so any
session or cwd change re-rendered *every* mounted tool row — but they're only
read inside the preview-artifact effect. Read .get() at fire time instead
(the effect only runs when a previewable target appears); no subscription.
2. memo() AnsiText + CompactMarkdown. Their text props are string values
(value-equal across renders), so memo skips the re-render — and the per-tick
ANSI parse / Streamdown re-run — when a parent ToolEntry re-renders on an
unrelated stream delta.
No behavior change. typecheck + eslint clean; tool fallback tests green (30).
buildToolView ran prettyJson (JSON.stringify + clamp) on part.args AND part.result
for EVERY tool row, on every rebuild:
- rawArgs was dead — assigned + typed, never read anywhere. Removed.
- rawResult is only rendered by the web_search raw-JSON drilldown, yet was
serialized for read_file/terminal/every tool. Moved to a memoized, web_search-
only computation in the consumer (fallback.tsx), so a 100KB read_file result
is no longer stringified just to be discarded.
No behavior change (web_search drilldown identical; clamp still applies via
prettyJson). The oversized-result guard test retargets from view.rawResult to
prettyJson (its real layer now).
typecheck + eslint clean; fallback-model tests green (26).
Both drag handlers wrote to nanostores on every pointermove — the pane sash via
setPaneWidth/HeightOverride / setTreeSplitWeights (relayouts the whole pane
tree), the preview console sash via consoleState.setHeight (reflows webview +
split). pointermove outpaces 60fps, so that's several store-driven relayouts per
frame during a drag.
Stash the latest clamped value and apply it once per frame in a requestAnimation-
Frame (the same pattern drag-session.ts / use-popout-drag.ts already use);
cleanup cancels the pending frame and commits the final position. Behavior
identical, just one relayout per frame instead of per event.
typecheck + eslint clean; preview-pane tests green.
Rewrite of the paradigm, not just a cheaper version of it. Before, any file
mutation bumped a contentless $workspaceChangeTick and the tree re-read EVERY
loaded directory to diff — the parent state was never told what actually changed.
Now the mutation carries its path:
- workspace-events accumulates the changed dir(s) (dirname of an absolute tool
path) and exposes consumeWorkspaceChange(); an opaque mutation (terminal, or a
relative/unresolvable path) sets `full` instead.
- gateway-event passes toolChangedPath(payload) through on tool.complete.
- revalidateTree(cwd, change) re-reads ONLY the changed dirs that are loaded and
patches just those subtrees — root + untouched folders never hit the FS or
re-render. Full recursive reconcile is kept as the fallback for `full`.
So a write in one folder no longer crawls the whole tree; the opaque terminal
case still self-heals via the full path. Safe fallback everywhere a path can't be
resolved, so no change is ever missed.
typecheck + eslint clean; use-project-tree / right-sidebar / gateway-events tests green.
revalidateTree runs on every $workspaceChangeTick (mutating-tool completion,
coalesced ~500ms). Two costs per tick, gone:
1. clearProjectDirCache() wiped the gitroot + gitignore caches. But listings are
read fresh every time (readProjectDir never caches them), so the wipe bought
nothing except forcing a full re-read of every ancestor .gitignore — each a
full readdir — for every loaded dir, every tick. Dropped; a .gitignore edit is
still picked up on the next full refresh (cwd/connection change / manual).
2. reconcile awaited each child dir serially, crawling a wide/deep tree one dir
at a time. Now Promise.all over siblings (order preserved), recursing per
loaded subfolder.
use-project-tree.test.ts + right-sidebar/index.test.tsx green (15). tsc + eslint clean.