Skip to content

perf(message-scroller): transcript perf fixes + keep identity continuous across session bind - #1210

Open
roxi3906 wants to merge 7 commits into
aipoch:mainfrom
roxi3906:perf/message-scroller
Open

perf(message-scroller): transcript perf fixes + keep identity continuous across session bind#1210
roxi3906 wants to merge 7 commits into
aipoch:mainfrom
roxi3906:perf/message-scroller

Conversation

@roxi3906

@roxi3906 roxi3906 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Background

Two transcript issues, diagnosed and fixed together (both reproduced with the benchmark/regression specs included here):

  1. Streaming perf audit per the shadcn Message Scroller Performance guidance — exposed an O(n²) per-tick row mapping as the dominant streaming cost.
  2. User-reported bug: mid-stream the reply suddenly freezes (>5s in dev), and when it resumes the viewport jumps back to the turn's user message. Diagnosed to session-identity flips during pending-Session bind.

Changes

Performance

  • New e2e/message-scroller-performance.spec.ts: seeds sessions with 500/2000 messages and measures open TTI, DOM size, scroll frame health (rAF deltas + longtasks), and main-thread cost while the fake agent streams. Emits one PERF_RESULT JSON line per scenario, cross-checked against the app's own streaming pipeline counters (window.__streamingMetrics, exposed in the e2e build via vite mode e2e).
  • Fix O(n²) row mapping: every 33ms presentation tick linearly scanned conversationGraph.messages per row — hoisted into per-render indexes memoized on conversationGraph identity (O(1) per row).
  • Per-row-type containment estimates: MessageScrollerItem gains containmentEstimate; user rows 3rem / agent rows 21rem (measured heights, named constants), cutting scrollHeight drift from ~8.5% to ~1%.

Session bind identity continuity (the freeze + scroll reset)

Sending a message opens a pending Session; bindPendingSession later rewrites the session id and selectedSessionId. Both flips invalidated the MessageScrollerProvider key and every row key ([scopeId, item.id]), causing two full-transcript remounts (multi-second main-thread block in dev) and a re-anchor to the turn's user row via defaultScrollPosition="last-anchor".

Fix (stable logical identity — bind-in-place was rejected because ACP assigns the session id and event routing keys on it):

  • bindPendingSession records boundFromPendingSessionId (+ branch id pair) as transient, non-persisted fields, and rebases the graph with the new rebaseConversationGraphSessionId so no graph id embeds the dead pending id.
  • The scroller keys Provider/scope on the bind-stable identity: the id swap and graph rebase no longer remount the transcript, while intentional branch switches (fix(message-stream): preserve presentation progress across remounts #1124) still do.
  • New regression spec e2e/message-scroll-session-bind.spec.ts (fake agent with opt-in FAKE_OPENCODE_NEW_SESSION_DELAY_MS): RED without the fix (row nodes replaced, scrollTop drops 336/496px), GREEN with it (zero scope flips, row nodes survive, stays at live edge).

Benchmark (2000 rows, back-to-back same-machine runs)

Metric main this PR
Streaming frame p95 / max 200 / 1026ms 133 / 908ms
Streaming longtasks (total / max) 2316ms / 946ms 2106ms / 836ms
Scroll frame p95 67.7ms 53.3ms
Scroll longtasks 3 / 245ms 0

Note: absolute numbers at this base are ~2x worse than at 6e2188d across all configurations including unmodified main (streaming longtask max 320-420ms→840-960ms; DOM 50,892→63,898 elements). A git bisect with the benchmark attributes that regression to d5789a7 feat(workspace): branch from completed Agent messages (#1276) — ~5 extra DOM elements per agent row (+18.6%). On main itself, not caused by this PR; to be investigated separately.

Audit conclusions (plan items that needed no change)

  • Streaming-row Streamdown cost: negligible per CDP CPU profile (incremental normalizer + block streaming cover it).
  • Session-level memo comparator: field-complete; no over-render observed.

Verification

  • 6 e2e pass: message-scroll anchor/reanchor/release, session-bind regression, benchmark (2 scenarios)
  • 278 related unit tests pass (4 added); tsc (web+node) + eslint clean
  • E2E note: specs use English locators and require an English locale since the i18n rollout; pre-existing suite convention, unchanged by this PR

Virtualization (@tanstack/react-virtual) was evaluated on a companion branch and deferred; archived as draft PR #1318 with the full A/B/A+B comparison.

Measure long-transcript behavior at 500 and 2000 seeded messages:
open TTI, DOM size, scroll frame health (rAF deltas + longtasks),
and main-thread cost while the fake agent streams a reply.

Logs one PERF_RESULT JSON line per scenario for run-to-run comparison.
…ent estimates

Streaming into a long transcript rebuilt per-row data with O(n) scans
of conversationGraph.messages (graph node lookup, revisions collection)
for every row on every 33ms presentation tick — O(rows^2) per tick.
At 2000 rows this produced multi-hundred-ms long tasks per streamed
chunk (p95 frame 134-172ms, 16 longtasks/~1.9s per stream).

Hoist the scans into per-render indexes memoized on conversationGraph
identity (graphMessageNodeById, graphRuntimeSegmentById,
revisionMessagesByRootId, messageCreatedAtById,
subsequentTurnCountByMessageId), making the row map O(1) per row.

Also let MessageScrollerItem take a containmentEstimate and pass
per-row-type contain-intrinsic-size values (user 3rem, agent 21rem,
from measured row heights), cutting scrollHeight drift while
offscreen rows resolve from ~8.5% to ~1%.

Benchmark (2000 rows, streaming): frame p95 134-172 -> 66-68ms,
longtasks 16 -> 5, longtask total ~1.9s -> ~0.5s; scroll and TTI
unchanged.
Lift the 3rem/21rem contain-intrinsic-size guesses into named constants
with their measured provenance (user rows ~46px, agent rows ~336px per
the performance benchmark), so future retunes know where the numbers
came from.
…ters

Expose window.__streamingMetrics in the e2e build (vite mode "e2e"
keeps it out of production) and snapshot the counters around the
benchmark's streaming leg. The counters validate the frame sampling
against what the pipeline actually did — e.g. at 2000 rows 36 chunk
events now land in 10 store commits (tick batching), and future
regressions in commit cadence become visible in PERF_RESULT.
Lifecycle races such as pending Session binding only become observable
when session creation is slow like a real agent spawn. Honor
FAKE_OPENCODE_NEW_SESSION_DELAY_MS in the session/new handler.
…ssion bind

Sending the first message (or branch-submitting) renders the transcript
in a pending Session; bindPendingSession later rewrote the Session id
and selectedSessionId to the backend id. Both the MessageScrollerProvider
key (activeSession.id) and every row key ([scopeId, item.id], scope =
[sessionId, activeBranchId]) flipped twice (real -> pending -> real),
remounting the whole transcript (multi-second freeze in dev) and
re-anchoring the viewport to the turn's scrollAnchor row via
defaultScrollPosition=last-anchor. The graph's synthesized root ids
(root-frame/message-branch/runtime-segment) also kept embedding the dead
pending id after the bind.

- bindPendingSession now records the pending-side presentation identities
  (boundFromPendingSessionId, boundFromPendingMessageBranchId,
  boundToMessageBranchId) and rebases the conversation graph's
  session-derived root ids onto the backend id via
  rebaseConversationGraphSessionId (shared/conversation-graph), so no
  graph id references the pending id anymore.
- WorkspaceMessageScroller keys the provider and the presentation scope
  on the recorded pending identities, translating the rebased branch id
  back for the scope, so neither the id swap nor the rebase remounts
  rows; intentional branch switches still change the scope (aipoch#1124).
- The identities stay in memory only (excluded from toPersistedSession).

Regression: e2e/message-scroll-session-bind.spec.ts branch-submits into
a seeded 500-message session with session creation slowed by
FAKE_OPENCODE_NEW_SESSION_DELAY_MS and asserts the transcript rows are
not remounted (first row DOM node survives) and the viewport never
resets, staying at the live edge. Fails without the fix
(firstRowConnected=false), passes with it.
@roxi3906 roxi3906 changed the title perf(message-scroller): eliminate O(n²) per-tick row scans, add perf benchmark perf(message-scroller): transcript perf fixes + keep identity continuous across session bind Aug 18, 2026
@roxi3906
roxi3906 force-pushed the perf/message-scroller branch from ac2cdfe to ee2bf72 Compare August 18, 2026 05:57
…graph

Review follow-ups for the pending-Session bind fix:

- bindPendingSession computed boundToMessageBranchId with string surgery
  (endsWith + first-match replace) alongside the rebase's exact-id rename
  map — two mechanisms that could silently disagree, in which case the
  scope translation never fires and the original flip returns. Run the
  rebase first and read the before/after active branch ids off the
  graphs; a pair is recorded only when the rebase actually remapped it.
- The session-bind e2e spec set FAKE_OPENCODE_NEW_SESSION_DELAY_MS on
  the worker process permanently, leaking a 4s session-creation delay
  into every later spec in the same worker. Save/restore around the
  file's tests instead.
@roxi3906
roxi3906 marked this pull request as ready for review August 18, 2026 10:06
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.

1 participant