perf(desktop): stop the thread timeline working when nothing can see it - #71789
Merged
Conversation
Contributor
૮ >ﻌ< ა ci reviewran on 91a8fe4 ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 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) |
Collaborator
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
force-pushed
the
bb/timeline-idle
branch
from
July 26, 2026 05:45
5280949 to
91a8fe4
Compare
This was referenced Jul 26, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.null, but the effect still attached a listener and walked rects for it.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):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
multitabscenario (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:That's expected once you look at why: #71780 already freezes a hidden tab's
$messagessubscription, 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.tsxrenders 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
auiaccessor'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 rebasetsc -p . --noEmitclean,eslintclean