Skip to content

perf(webui): bound the transcript — containment, block flow, windowing#755

Open
eous wants to merge 1 commit into
mainfrom
perf/webui-transcript-windowing
Open

perf(webui): bound the transcript — containment, block flow, windowing#755
eous wants to merge 1 commit into
mainfrom
perf/webui-transcript-windowing

Conversation

@eous

@eous eous commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #754 (the P2 tranche of the long-session perf plan). #754 removed the per-event O(N) work and the wedge failure modes; this bounds the remaining structural cost — every row of an unbounded transcript participating in every layout, and full re-renders rebuilding all of it.

CSS pair (shared scroller + the ui/static duplicate)

  • Block flow instead of a column flexbox — flex relaid out every row whenever the streaming row's height changed (O(rows) per token); block flow dirties only the tail. Retires the min-height:auto squish hazard the old per-child flex-shrink: 0 pin suppressed; row rhythm moves to a sibling margin at identical spacing.
  • overflow-anchor: none — the pane owns bottom pinning, and native anchoring re-selected an anchor inside the innerHTML-replaced live bubble every frame.
  • content-visibility: auto + contain-intrinsic-size: auto 80px/200px on .msg/.conv-batch rows; the last two children are exempt so the live tail never toggles skip-state mid-stream. The auto keyword memoizes rendered sizes, keeping scrollHeight and the pin stable.

Transcript windowing

  • Full re-renders paint the most recent 300 messages, cut forward to a user-turn boundary (an assistant tool_calls message is never split from the tool results that anchor to it). A "Load earlier messages (N hidden)" pager grows the window a step and refetches, restoring scroll position by scrollHeight delta — no suppression logic needed because fix(webui): wedge-proof the live-session pipeline and de-O(N) hot paths #754's rAF pin re-checks the near-bottom flag at fire time.
  • Live appends are bounded at the idle edge: past 900 rendered rows the oldest rows are trimmed (turn-boundary aligned), only while pinned to the bottom — a scrolled-up user is reading exactly what a trim would remove. Trimmed content stays in /history and returns via the pager; detached agent-card entries are swept.
  • Rewind/edit turn math is tail-relative (counts user rows at-or-after the clicked one) and windowing only hides earlier rows — verified and pinned, so no ordinal bookkeeping was needed. Chunked replay from the original plan was dropped: a ≤300-message replay is ~30ms.

The perf page gains ?window= (and the runner --perf-extra) so windowing and containment effects stay separately measurable.

Measurements (n=3000 history + 20-turn storm)

main pre-#754 #754 this PR (windowed) this PR, window grown to full transcript
full replay 1,060ms 238ms 28ms 107ms
rendered nodes 25,185 25,185 2,514 25,185
storm ms/turn 295 256 219 (= n=300 floor) 220 (= floor)
longtasks 6 / 1,080ms 4 / 495ms 0 4 / 205ms

The window-grown column isolates the containment pair: even with all 25k nodes live, the per-turn transcript tax is gone. One noted degraded-mode cost: the chunk path at a fully-grown window measures ~1,017ms vs the 833ms harness floor (suspected content-visibility bookkeeping at 25k contained roots); the shipped windowed config sits at the floor.

Verification

422 frontend tests green, including new pins for block flow + anchor-off + containment with the live-tail exemption, the turn-boundary window cut, the pinned-only trim, and the tail-relative rewind invariant. A lightweight correctness pass over the diff found nothing.

Browser floor for content-visibility: auto: Chrome 85+, Firefox 125+, Safari 18+ — older engines simply ignore the declarations and get the block-flow/windowing wins alone.

The remaining steady-state cost after the wedge-proofing pass was
structural: every row of an unbounded transcript participated in every
layout, and full re-renders rebuilt all of it.

CSS pair (shared scroller + the ui/static duplicate):
- The messages scroller is BLOCK flow, not a column flexbox — flex
  relayouts all items when the streaming row's height changes, O(rows)
  per token; block flow dirties only the tail. The old per-child
  flex-shrink pin (and the min-height:auto squish hazard it suppressed)
  goes with it; inter-row rhythm moves to a sibling margin.
- overflow-anchor: none — the pane owns bottom pinning, and native
  anchoring kept re-selecting an anchor inside the innerHTML-replaced
  live bubble every frame.
- content-visibility: auto with contain-intrinsic-size: auto estimates
  on .msg (80px) and .conv-batch (200px) rows, exempting the last two
  children so the live tail never toggles skip-state mid-stream. The
  `auto` keyword memoizes each row's rendered size, keeping scrollHeight
  and the bottom pin stable once painted.

Transcript windowing (interactive pane):
- Full re-renders paint the most recent 300 messages, cut FORWARD to a
  user-turn boundary so an assistant tool_calls message is never split
  from the tool results that anchor to it. Hidden content sits behind a
  "Load earlier messages" pager; each click grows the window a step and
  refetches, restoring the scroll anchor by scrollHeight delta (the
  rAF pin re-checks the near-bottom flag at fire time, so no
  suppression is needed). Rewind/edit turn math is tail-relative and
  unaffected — pinned as such.
- Live appends are bounded at the idle edge: past 900 rendered rows the
  oldest rows are trimmed (again to a turn boundary), only while pinned
  to the bottom — a scrolled-up user is reading the rows a trim would
  remove. Trimmed content stays in /history and returns through the
  pager; detached agent-card entries are swept.

The perf page gains ?window= (and the runner --perf-extra) so windowing
and containment effects can be isolated.

Measured (n=3000 history + 20-turn storm; baseline -> previous branch
-> this change): full replay 1060ms -> 238ms -> 28ms windowed / 107ms
with the window grown to the full transcript; longtasks during the run
6/1080ms -> 4/495ms -> none windowed / 4/205ms unwindowed; per-turn
live-storm cost at n=3000 now equals n=300 (~220ms harness floor) even
with all 25k nodes live — the transcript-size tax is gone. Known
degraded-mode cost: the chunk path at a fully-grown window measures
~1017ms vs the 833ms floor; the shipped windowed config sits at the
floor.
Copilot AI review requested due to automatic review settings July 2, 2026 07:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the “P2” tranche of the long-session Web UI performance plan by reducing layout/re-render cost in very long transcripts. It does so via scroller CSS containment/anchoring changes and by windowing the replayed transcript (with a pager to load older history), while extending the perf harness to measure the effects independently.

Changes:

  • Switch the transcript scroller to block flow, disable native scroll anchoring, and add content-visibility containment plus pager styling (interactive + UI static duplicate).
  • Add transcript windowing (replay renders most recent window, pager grows it) and pinned-only live trimming to cap rendered DOM size.
  • Extend scripts/livepass.py perf harness with ?window= and --perf-extra, and add tests pinning these invariants.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
turnstone/ui/static/style.css Align static UI transcript scroller behavior with interactive scroller (anchoring off; no flex column).
turnstone/shared_static/interactive.js Implement transcript windowing, pager-driven history growth, and pinned-only live trimming + agent-card sweep.
turnstone/shared_static/interactive.css Apply block-flow scroller, anchoring-off, content-visibility containment, and pager styling.
tests/test_interactive_pane_js.py Add regression tests for block-flow/anchoring/containment and transcript windowing + trim invariants.
scripts/livepass.py Add perf query override plumbing (?window= + --perf-extra) to measure containment vs windowing separately.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +55 to +57
.pane--embedded .pane-messages > * + * {
margin-top: 2px;
}
Comment on lines +1207 to +1217
if (this.busy) return;
this._historyWindow += _HISTORY_WINDOW_STEP;
const token = this._historyLoadToken;
const prevScrollHeight = this.messagesEl.scrollHeight;
const prevScrollTop = this.messagesEl.scrollTop;
this._beginReplayQuiesce(token);
this._refetchHistory(this.wsId, token).finally(() => {
if (token !== this._historyLoadToken) return;
this.messagesEl.scrollTop =
this.messagesEl.scrollHeight - prevScrollHeight + prevScrollTop;
});
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