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.
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 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
- 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)
* 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
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.
Selecting a large changed file in the review pane froze it: FileDiffPanel with
no fullText + no showLineNumbers rendered SyntaxDiff over EVERY line — a full
Shiki highlight + thousands of mounted DOM nodes — because windowing was tied to
showLineNumbers/fullText and the review call had neither.
Decouple windowing from the gutter:
- `windowed = showLineNumbers || virtualized`; windowed paths always render the
fixed-row chunked body (TokenizedDiffBody chunked / PreviewDiffRows), never
SyntaxDiff, so only visible rows mount.
- New `virtualized` prop → windowed scroller WITHOUT the line-number gutter.
- Review passes `virtualized` + the preview's fill className.
Preview (showLineNumbers + fullText) and tool-card (compact) render byte-for-byte
as before — the gutter body just reads the same chunked window it already used,
and the no-fullText+highlight case (previously SyntaxDiff) now windows too.
tsc + eslint clean. Visual paths preserved by construction; needs an in-app
eyeball on a large review diff.
One shared `stableArray(prev, next)` helper replaces the duplicated
element-equal/keep-prev logic in both stores, and freezes the shared ref so a
future in-place mutation fails loud instead of silently corrupting the cache.
Computed return type is now `readonly string[]` (it always was, immutably).
* feat(desktop): add custom endpoint settings (supersedes #42745)
Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no
longer merge cleanly against main. Re-integrated the work onto current
main and reconciled the conflicts:
- Settings nav: wired the new 'Custom Endpoints' provider sub-view into
main's data-driven navGroups/OverlayNav layout (PR predated that
refactor) and added it to PROVIDER_VIEWS.
- providers-settings: kept BOTH main's LocalEndpointRow affordance and
the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry
onClose + onConfigSaved + onMainModelChanged.
- web_server: kept main's _normalize_main_model_assignment + api_key
propagation AND the PR's provider base_url lookup in
_apply_model_assignment_sync.
- model_switch: dropped the PR's bare direct-custom-config picker block;
main already implements it (source='model-config', with live model
discovery). Updated the salvaged test to assert main's behavior.
- Merged additive import/type blocks in hermes.ts and types/hermes.ts.
Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the
custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint
tests pass.
Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
* chore(contributors): map elashera's commit email
Salvage of #42745 (superseded by #67759) preserves @elashera's
authorship, whose corporate commit email had no contributor mapping.
Adds contributors/emails/ mapping so check-attribution passes.
Verified: GitHub user 'elashera' id=135239963 matches their own
noreply commit email (135239963+elashera@users.noreply.github.com).
---------
Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
Salvaged and rebased from #66354 by @UnathiCodex onto current main.
Fixes a fresh-chat race in Hermes Desktop where a model, reasoning-effort,
or Fast selection made before the first Send could be replaced by an
in-flight profile refresh, or read only after the profile handshake
yielded. Send is now the linearization point: the visible selector state is
snapshotted before awaiting profile readiness, and intent-generation guards
make older config/model responses stand down after a picker/toggle action.
Adds the contract-v4 session-create wire contract for explicit Fast=false.
Conflict resolution vs the original branch (use-model-controls.ts / .test.tsx):
combined main's catalog-aware keepManualPick() sticky-pick logic with the
PR's profileRefreshEpoch + composerSelectionGeneration staleness guards so
both a removed-from-catalog reseed and the in-flight-picker race are handled.
Verified on current main: apps/desktop tsc --noEmit clean; 80 affected
UI/store tests pass (use-model-controls, use-hermes-config,
use-session-actions, model-edit-submenu, model-presets, updates).
Co-authored-by: UnathiCodex <theunathi@gmail.com>
Two real render-cost wins found by inspection (no behavior change):
1. Sidebar re-rendered on every stream token. $sessionStates is republished on
every message delta (tens/sec during a turn), and the derived ID computeds
($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds)
allocated a fresh array each time. nanostores notifies on !==, so the whole
ChatSidebar + every mounted row re-rendered per token even when the working/
attention/background set was unchanged. Return the previous array reference
when the contents match → nanostores skips the notify unless the set actually
changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar.
2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant`
(lowercase + whitespace-collapse over the entire read_file/terminal payload)
ran twice in the ToolEntry render body, so every completed tool re-normalized
its whole output on every stream tick of the running message. Memoize on the
view fields so it recomputes only when the tool's content changes.
Both are correctness-preserving (stable refs + memoization). The CI stream
scenario drives $messages directly, not the publishSessionState path, so it
won't reflect #1 — verified by inspection.
Stop drip-feeding scenarios: extend the harness to cover the latencies that
actually dominate perceived speed, and measure them on a REAL production build.
- --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1,
off in normal builds) and launch it from dist/. Measures minified React, so
numbers are representative shipped figures instead of ~3x-inflated dev ones.
- cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a
fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms.
- first-token scenario (backend tier): Enter → first assistant token painted —
the TTFT latency an agent app is uniquely judged on.
- run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates
ci+cold tiers against the baseline.
Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all
green. Representative numbers:
cold-start spawn→interactive ~1.6s, FCP ~0.5s
stream frame p95 22ms, 1 longtask
keystroke p50 2ms, p95 8.7ms
transcript mount 145ms, 82ms longtask (400-msg open)
The prod build also settled the open question from the dev numbers: the
transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not
actionable. Measurement did its job.
Chased the "stream frame p95 = 60ms with ZERO longtasks" mystery to its actual
cause: the default stream chunk had no paragraph breaks, so it grew into one
giant ~22KB block that re-rendered fully every flush — defeating the block
memoization real streaming relies on. Plain text = 21ms; realistic chunk with
`\n\n` breaks (blocks settle, only the tail re-renders) = 23ms. Fixed the
default chunk to model real LLM output; a break-less `--chunk` remains available
as a single-block worst-case stress.
Also hardened the isolated instance so measurements reflect real cost:
- Wait for the gateway socket to actually connect before measuring (a booting/
absent backend's reconnect backoff churns the main thread). Exposed via a new
__PERF_DRIVE__.connected() probe reading $gateway.connectionState.
- Focus emulation + anti-throttle/occlusion flags so a backgrounded perf window
isn't frame-throttled (no OS focus stealing).
- Generation-guarded the rAF frame recorder so repeated runs don't leave
overlapping recorders polluting frame intervals.
Baseline re-captured as the median of 5 --spawn runs (darwin-arm64); all three
CI scenarios now green and stable. Absolute values are dev-build (noted in
_meta) — regression guards, not shipped numbers.