Skip to content

perf(desktop): stop the thread timeline working when nothing can see it - #71789

Merged
OutThisLife merged 1 commit into
mainfrom
bb/timeline-idle
Jul 26, 2026
Merged

perf(desktop): stop the thread timeline working when nothing can see it#71789
OutThisLife merged 1 commit into
mainfrom
bb/timeline-idle

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

The thread timeline (the right-edge prompt rail) did its work unconditionally, whether or not anyone could see the result. Every chat surface mounts one, and a tab group keeps inactive tabs mounted rather than unmounting them, so a background tab's rail kept deriving previews and measuring layout for a surface behind another tab. This defers each piece of that work until the moment it's actually needed.

Three things were running when they shouldn't have been:

  • The transcript selector ran in full on every store update. It built a row per user prompt — extracting each prompt's full text — then JSON.stringify'd the lot as the memo's change signal. Because it walked all messages, an assistant reply streaming into that thread re-ran it once per token.
  • The scroll pass measured below the render threshold. With fewer than 4 prompts the rail renders null, but the effect still attached a listener and walked rects for it.
  • The hover popover was always built. All N rows rendered on mount even though the popover only appears on hover.

The gates now run cheapest-first. An inactive pane returns before a single hook is declared, using usePaneVisible — the same pane-shell context #71780 added for the hidden-tab transcript freeze, so there's one visibility mechanism rather than two.

An active rail subscribes to prompt ids instead of prompt text, reading the transcript imperatively only when that signal changes. Prompt text is immutable once sent, and an edit rewinds the transcript and re-appends a fresh id, so a preview can't go stale behind a stable id. An identical derivation hands back the previous array, so a blank or background-process prompt joining the transcript doesn't restart the measure effect for a rail that looks the same.

Measurements

The selector is the hot path — it runs on every store update, per mounted timeline. Measured directly against the synthetic transcript shape the perf harness uses (node /tmp/selector-bench.mjs, 20k iterations, warmed):

turns before after speedup change signal before → after
20 3.48 µs 0.27 µs 12.9× 3.0 KB → 70 B
50 8.33 µs 0.55 µs 15.2× 7.5 KB → 190 B
100 16.76 µs 1.14 µs 14.7× 15.1 KB → 390 B
200 33.28 µs 2.13 µs 15.7× 30.4 KB → 890 B
400 66.97 µs 4.33 µs 15.4× 61.0 KB → 1.9 KB

Sustained, on a 200-turn thread at the ~30 flushes/s streaming cadence: 1.0 ms/s → 0.1 ms/s of main thread per mounted timeline. The allocation delta matters as much as the time — the old signal allocated a fresh 30 KB string per flush purely to detect a change that usually hadn't happened.

Where it does not show up, honestly

The multitab scenario (5 mounted tabs, all streaming, --spawn --prod --runs 3) shows no improvement — 49 vs 50 longtasks, p95 73.7 vs 70.7 ms, which is inside run-to-run noise on this machine:

before after
longtasks_n 50 49
longtask_max_ms 97 111
frame_p95_ms 70.7 73.7
slow_frames_33 77 79

That's expected once you look at why: #71780 already freezes a hidden tab's $messages subscription, so in the multitab workload the runtime above the timeline is already quiet and there are no updates for my hidden-tab gate to skip. The two changes overlap there.

The gain here is on the visible, streaming tab — the one surface #71780 deliberately leaves live — and on any long thread, where the per-flush cost scales linearly with prompt count. transcript (200 turns) moved 682.8 ms → 659.9 ms mount, which I'm calling noise rather than a result; the harness's mount metric is dominated by markdown rendering, not this selector.

Verification

timeline-idle.test.tsx renders the real component and counts the work rather than asserting the shape of the source: a background tab calls neither the selector nor the imperative read; an unhovered popover has zero rows; hover builds them and keeps them for the close fade; a streamed token performs no re-derivation while a new prompt does.

That last test earned its place — it caught a real bug in the first version of this fix, where the memo keyed on the aui accessor's identity and re-derived every render, quietly defeating the whole change. I confirmed it still fails when the dependency is put back.

  • vitest run --project ui — 271 files, 2298 passed, 1 skipped; thread + pane-shell suites re-run green after the rebase
  • tsc -p . --noEmit clean, eslint clean

Desktop UI tests need NODE_ENV=development locally — React's production build doesn't export act, so a bare npm run test:ui fails on main regardless of this change.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 91a8fe4

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

1 visual diff.

inline evidence upload failed.

Failed to upload diff-665a0833239e-onboarding-overlay-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-665a0833239e-onboarding-overlay-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso)

@alt-glitch alt-glitch added type/perf Performance improvement or optimization P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) labels Jul 26, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: #69120 and #71780. This targets the timeline rail's hidden-pane subscriptions and preview/geometry work; #71780 freezes the broader hidden transcript path, so the repairs are complementary.

The prompt rail mounts in every chat surface, and a tab group keeps
inactive tabs mounted, so a background timeline was stringifying every
user prompt's full text on each store update — including on every
streamed assistant token, since the selector walked all messages — plus
running a scroll listener and a getBoundingClientRect per prompt against
a viewport nobody was looking at.

It now defers each piece until it can be seen. An inactive pane returns
before a single hook is declared (usePaneVisible, the same context the
hidden-tab transcript freeze uses), so no subscription is opened at all.
An active rail subscribes to prompt IDS rather than prompt text and
reads the transcript imperatively only when that signal changes, which
takes streaming off the derivation path entirely; an identical
derivation hands back the previous array so a filtered-out prompt
doesn't restart the measure effect. The offset pass bails below the
render threshold instead of measuring a rail that renders null, ahead of
the existing following-the-bottom fast path, and the hover popover keeps
its shell for the fade but builds its rows on first open.
@OutThisLife
OutThisLife merged commit 6ffd730 into main Jul 26, 2026
33 checks passed
@OutThisLife
OutThisLife deleted the bb/timeline-idle branch July 26, 2026 06:58
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
perf(desktop): stop the thread timeline working when nothing can see it
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
perf(desktop): stop the thread timeline working when nothing can see it
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants