Skip to content

perf(desktop): cut GUI streaming & interaction lag#45343

Merged
OutThisLife merged 4 commits into
mainfrom
bb/guiperf
Jun 13, 2026
Merged

perf(desktop): cut GUI streaming & interaction lag#45343
OutThisLife merged 4 commits into
mainfrom
bb/guiperf

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Six focused fixes to desktop GUI streaming/interaction latency, profiled end-to-end. Measured deltas (879-msg session, synthetic gateway events through the real pipeline):

Scenario Before After
Markdown stream 300 tok @60/s 28–38 fps, 2–5.2 s blocked 56 fps, 0.34 s blocked
600 tok / 57 KB message (worst case) 20 fps, 17–22 s blocked 56 fps, 0.87 s blocked
Typing while streaming 40/120 frames missed 6/120
Cold resume, 879-msg session 16.5 s ~2.6 s
First-stream haptic hitch 852 ms frame 135 ms

Fixes

  1. Per-frame markdown re-parse (markdown-text.tsx) — throttle smooth-reveal commits to ~33 ms; LRU-cache parseMarkdownIntoBlocks so remounts/republishes don't re-lex.
  2. Re-render isolation (chat/index.tsx, thread.tsx, composer/, store/session.ts) — a new ChatRuntimeBoundary owns the $messages subscription so per-token flushes stop re-rendering the chat shell; AssistantMessage reads only streaming-stable selectors; footer/copy/read-aloud read text lazily; composer reads $messages imperatively; $messagesEmpty / $lastVisibleMessageIsUser computeds for coarse consumers.
  3. Layout thrash (use-resize-observer.ts, thread.tsx, fade-text.tsx) — pass RO entries through; use entry sizes, cache line-height, skip redundant writes.
  4. Resume (use-session-actions.ts) — run the REST prefetch and session.resume RPC concurrently; skip re-converting the resume payload when the prefetch already hydrated.
  5. First-stream hitch (haptics-provider.tsx) — warm a throwaway AudioContext at idle so web-haptics' lazy ensureAudio doesn't pay the first-context spin-up mid-stream.
  6. Tail-bounded incomplete-markdown repair (lib/remend-tail.ts) — replace Streamdown's whole-message parseIncompleteMarkdown (~18% of script on 50 KB+ messages) with a single char-scan that bounds remend to the trailing open block (widening over open fences / $$ math). Block-exact render equivalence vs full remend is tested at every streaming prefix.

Test plan

  • remend-tail.test.ts — block-exact equivalence at every prefix of a mixed corpus; fence/math window cases
  • tsc --noEmit + eslint clean on touched files
  • Desktop vitest — no new failures vs main

@OutThisLife OutThisLife requested a review from a team June 13, 2026 01:50
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/guiperf vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10882 on HEAD, 10882 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5706 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@OutThisLife OutThisLife changed the title perf(desktop): cut GUI streaming/interaction lag 70–95% (profiled, 6 fixes) perf(desktop): cut GUI streaming & interaction lag Jun 13, 2026
During a token stream $messages is replaced ~30x/s. Subscribing the whole
chat view to it re-rendered the composer, runtime boundary, and every
message on every delta.

- Derive coarse facts (empty thread? tail is user?) via nanostores
  `computed` atoms so per-token flushes don't re-render their consumers.
- Move the $messages subscription + runtime wiring into a dedicated
  ChatRuntimeBoundary; the composer reads $messages imperatively.
- Drive message rows off stable useAuiState selectors and a lazy
  getMessageText getter instead of eagerly materialized text.
- Feed ResizeObserver entry sizes into measureClamp / FadeText and dedupe
  the style writes, killing the read-write-read reflow cascade.
Re-parsing the full message markdown every reveal frame is O(N^2) over a
long answer and dominated stream CPU.

- Throttle useSmoothReveal commits to ~1 frame (REVEAL_MIN_COMMIT_MS).
- Memoize block parsing with an LRU keyed on source text so only changed
  blocks re-parse.
- Replace Streamdown's full-text parseIncompleteMarkdown with a
  tail-bounded remend: scan to the last top-level boundary outside
  fences/math and repair only the trailing open block. New remend-tail.ts
  is proven render-equivalent to full remend at every streaming prefix
  (remend-tail.test.ts), minus an intentional, documented divergence on
  cross-block dangling openers.
- Resume: fire the REST transcript prefetch and the session.resume RPC in
  parallel, and skip the redundant message conversion + reconciliation
  when the prefetch already hydrated the transcript.
- Haptics: web-haptics builds its AudioContext lazily on first trigger,
  paying the ~850ms CoreAudio spin-up on the first streamStart haptic as
  the first token paints. Open/close a throwaway context at idle so the
  real one connects to an already-warm audio service.
Adding remend changed package-lock.json, so the flake's pinned npm deps
hash went stale and `nix flake check` failed. Bump it to match.
@OutThisLife OutThisLife merged commit 492c402 into main Jun 13, 2026
27 checks passed
@OutThisLife OutThisLife deleted the bb/guiperf branch June 13, 2026 02:22
Liao-Jun-Tao added a commit to Liao-Jun-Tao/hermes-agent that referenced this pull request Jun 13, 2026
… fixes

Squash-merge of origin/main into fix/desktop-gateway-reconnect.
Keeps local WIP commit (7c49f0d1d) at the top of the squash.

Local fixes preserved (vs origin/main):
- apps/desktop/electron/main.cjs:
  * Fix IPC 'An object could not be cloned' on hermes:sys:env by
    returning { ...process.env } (process.env has prototype getters
    that break Electron's structured-clone algorithm).
  * Fix IPC 'callback must be of type function' in _execAsync by
    merging args into the command string (exec(file, args, callback)
    has no (file, args, options, callback) overload).
- apps/desktop/src/app/settings/constants.ts: drop 'sysmgr' from
  SECTIONS (dedicated view, not a ConfigSettings view), with comment
  explaining why; remove now-unused Cpu import.
- apps/desktop/src/app/settings/index.tsx: register SysmgrSettings
  in the platform/extension sidebar group (providers/gateway/keys/
  mcp/sessions), not the config group.
- apps/desktop/src/app/settings/types.ts: add 'sysmgr' to SettingsView.
- apps/desktop/src/app/settings/sysmgr/*: new System Manager panel
  with 4 sub-views (Processes / Environment / Services / Doctor).
- apps/desktop/src/lib/sys-manager.ts: renderer-side IPC bridge.
- pnpm-lock.yaml: dependency sync.

No conflicts (X theirs on 3-way merge handled everything cleanly).

Upstream commits included (highlights, last 7 days):
- 7d183f6 fix(desktop): theme the image-gen placeholder (NousResearch#45354)
- 492c402 perf(desktop): cut GUI streaming & interaction lag (NousResearch#45343)
- 3cf7d43 perf(desktop): faster session resume & warm AudioContext at idle
- edc36f3 perf(desktop): incremental markdown rendering during streams
- 7c226cc perf(desktop): isolate streaming re-renders & cut layout thrash
- d14f6c9 fix(desktop): stop streaming autoscroll bounce (NousResearch#45251)
- 78ce917 fix(desktop): crisp terminal text via opaque xterm canvas
- 956af7f fix(agent):  add metadata flag to context compression (NousResearch#38389)
- 691ff7c fix(compressor): keep last visible assistant reply in summary (NousResearch#29824)
- bba9b51 fix(delegation): remove default subagent wall-clock timeout (NousResearch#45149)
- 9b01c4d fix(update): never spawn interactive polkit prompt (NousResearch#45145)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants