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

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.
This commit is contained in:
Patrick Buckley
2026-07-02 00:28:33 -07:00
parent df573b7314
commit 2d174cd71c
5 changed files with 285 additions and 25 deletions
+30 -4
View File
@@ -1267,6 +1267,12 @@ PERF_TEMPLATE = """<!doctype html>
let phase = "mount";
try {
const pane = new InteractivePane("perf-ws");
// ?window= overrides the pane's transcript window (message count),
// e.g. ?window=100000 disables windowing to isolate the
// content-visibility/block-flow effect from the windowing effect.
// Default (0) measures shipped behavior.
const WINDOW = parseInt(q.get("window") || "0", 10);
if (WINDOW > 0) pane._historyWindow = WINDOW;
document.getElementById("mount").appendChild(pane.el);
const msgs = buildHistory(N);
report.heap_start = heapBytes();
@@ -1576,7 +1582,14 @@ def _await_report(
def _perf_run_one(
chrome: str, out: Path, port: int, store: _PerfStore, n: int, turns: int, timeout: float
chrome: str,
out: Path,
port: int,
store: _PerfStore,
n: int,
turns: int,
timeout: float,
extra_query: str = "",
) -> dict[str, object] | None:
"""One headless-Chrome perf pass; returns the page's report or None."""
base_flags = [
@@ -1602,6 +1615,8 @@ def _perf_run_one(
url = (
f"http://127.0.0.1:{port}/perf/livepass.html?n={n}&turns={turns}&post=1&run={run_token}"
)
if extra_query:
url += "&" + extra_query.lstrip("&")
store.event.clear()
store.data = None
profile = out / f".chrome-perf-{n}"
@@ -1624,7 +1639,9 @@ def _perf_run_one(
return None
def run_perf(out: Path, sizes: list[int], turns: int, timeout: float) -> bool:
def run_perf(
out: Path, sizes: list[int], turns: int, timeout: float, extra_query: str = ""
) -> bool:
"""Build, serve, and run the perf page once per history size; print a table."""
import functools
import threading
@@ -1644,7 +1661,7 @@ def run_perf(out: Path, sizes: list[int], turns: int, timeout: float) -> bool:
try:
for n in sizes:
print(f"perf: n={n} turns={turns}", end="", flush=True)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout)
report = _perf_run_one(chrome, out, port, store, n, turns, timeout, extra_query)
if report is None:
print("FAILED (no report — timeout or chrome startup failure)")
continue
@@ -1721,11 +1738,20 @@ def main() -> None:
)
ap.add_argument("--perf-turns", type=int, default=20)
ap.add_argument("--perf-timeout", type=float, default=420.0)
ap.add_argument(
"--perf-extra",
default="",
help="extra query params for the perf page (e.g. 'window=100000' to disable windowing)",
)
args = ap.parse_args()
build(args.out)
if args.perf:
sizes = [int(s) for s in str(args.perf_n).split(",") if s.strip()]
raise SystemExit(0 if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout) else 1)
raise SystemExit(
0
if run_perf(args.out, sizes, args.perf_turns, args.perf_timeout, args.perf_extra)
else 1
)
if args.serve:
import functools
+65
View File
@@ -345,3 +345,68 @@ def test_per_token_hot_path_avoids_container_scans() -> None:
assert "ResizeObserver" in body
for helper in ("_toolRow(callId) {", "_streamEl(callId) {"):
assert helper in body, f"missing lookup-cache helper: {helper!r}"
_INTERACTIVE_CSS = _ROOT / "turnstone/shared_static/interactive.css"
_UI_STYLE_CSS = _ROOT / "turnstone/ui/static/style.css"
def test_transcript_scroller_is_block_flow_with_containment() -> None:
"""P2 (perf audit): the messages scroller is BLOCK flow — a column
flexbox relayouts every row when the streaming row's height changes,
O(rows) per token — with native scroll anchoring disabled (the pane owns
bottom pinning, and the browser's anchor node lives inside the
innerHTML-replaced live bubble). Off-screen rows carry
content-visibility:auto with `auto`-keyword intrinsic sizing; the live
tail (last two children) is exempt so the streaming bubble never toggles
skip-state mid-stream."""
css = _INTERACTIVE_CSS.read_text(encoding="utf-8")
rule = css.index(".pane--embedded .pane-messages {")
body = css[rule : css.index("}", rule)]
assert "display: flex" not in body, "scroller must be block flow"
assert "overflow-anchor: none" in body
assert ".pane--embedded .pane-messages > * + *" in css, (
"inter-row rhythm must come from sibling margins, not flex gap"
)
assert "content-visibility: auto" in css
assert "contain-intrinsic-size: auto" in css
assert ":nth-last-child(-n + 2)" in css, "live tail must be exempt"
ui = _UI_STYLE_CSS.read_text(encoding="utf-8")
ui_rule = ui.index(".pane-messages {")
ui_body = ui[ui_rule : ui.index("}", ui_rule)]
assert "display: flex" not in ui_body, "ui/static duplicate must match"
assert "overflow-anchor: none" in ui_body
def test_transcript_is_windowed_with_pager() -> None:
"""P2 (perf audit): full re-renders paint only the most recent
_HISTORY_WINDOW_STEP 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 the .msg-history-pager button
(click grows the window and refetches with a scroll-anchor restore).
Live appends are bounded at the idle edge by _LIVE_ROW_CAP, trimming
only while pinned (a scrolled-up user is reading the rows a trim would
remove) and sweeping detached agent-card entries."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "const _HISTORY_WINDOW_STEP = 300;" in body
assert "const _LIVE_ROW_CAP = 900;" in body
replay = body.index("replayHistory(messages) {")
seg = body[replay : replay + 4200]
assert 'messages[start].role !== "user"' in seg, (
"the window cut must land on a user-turn boundary"
)
assert "_addHistoryPager" in seg
assert "for (let i = start; i < messages.length; i++)" in seg
assert 'pager.className = "msg-history-pager";' in body
assert "this._historyWindow += _HISTORY_WINDOW_STEP;" in body
trim = body.index("_trimLiveTranscript() {")
trim_seg = body[trim : trim + 2600]
assert "if (!this._nearBottom) return;" in trim_seg, (
"live trim must only run while pinned to the bottom"
)
assert "card.wrap.isConnected" in trim_seg, "live trim must sweep detached agent-card entries"
# Rewind/edit turn math is tail-relative (counts user rows at-or-AFTER
# the clicked one), which is what makes hiding EARLIER rows safe — pin
# the tail-relative form so a refactor to absolute indexing fails here
# and gets re-checked against windowing.
assert body.count("userMsgs.length - idx") >= 2
+49 -15
View File
@@ -38,22 +38,56 @@
min-height: 0;
overflow-y: auto;
padding: 13px;
display: flex;
flex-direction: column;
/* Trimmed from 5px: the .msg turn boxes already carry a 4px margin-bottom, so
a 5px flex gap stacked ~9px of dead space between segments ("too thick").
2px gap + the 4px element margin lands at a compact ~6px between turns. */
gap: 2px;
/* BLOCK flow, deliberately not a flex column: a column flexbox relayouts
ALL items when the streaming row's height changes — O(rows) per token at
long-session scale — where block flow dirties only the appended tail.
Block flow also retires the flex min-height:auto squish hazard the old
per-child flex-shrink pin existed to suppress. Inter-row rhythm moves
to the sibling margin below (2px + the .msg 4px margin-bottom lands at
the same compact ~6px between turns as the old 2px gap). */
/* Native scroll anchoring is pure overhead here: the pane owns bottom
pinning (isNearBottom + rAF pin), and during streaming the anchor node
the browser picks sits inside the innerHTML-replaced live bubble —
forcing anchor re-selection every frame and double-adjusting against
our pin. */
overflow-anchor: none;
}
/* The message list is a SCROLLING flex column (overflow-y:auto), so its children
must size to content and never shrink. Without this, an `overflow:hidden`
card — the .conv-batch tool block — has its flex `min-height:auto` resolve to
0 and gets squished to a ~2px stripe (just its border) once the column fills,
while plain .msg blocks (overflow visible) keep their height. That asymmetry
is the "tool calls collapse to an empty stripe" regression; pinning every row
makes the column scroll instead. */
.pane--embedded .pane-messages > * {
flex-shrink: 0;
.pane--embedded .pane-messages > * + * {
margin-top: 2px;
}
/* Off-screen rows skip style/layout/paint entirely; contain-intrinsic-size's
`auto` keyword remembers each row's last-rendered size, so scrollHeight
(and the bottom pin) stays stable once a row has painted — the estimate
only covers never-rendered rows during upward scrubbing. The last two
children are exempt: the live tail (streaming bubble / filling tool batch)
mutates constantly and must never toggle skip-state mid-stream. */
.pane--embedded .pane-messages > .msg:not(:nth-last-child(-n + 2)) {
content-visibility: auto;
contain-intrinsic-size: auto 80px;
}
.pane--embedded .pane-messages > .conv-batch:not(:nth-last-child(-n + 2)) {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
/* "Load earlier" pager — the windowed transcript's top affordance. A real
button (keyboard/AT reachable); quiet dashed chrome so it reads as an
affordance, not a message row. */
.msg-history-pager {
display: block;
width: 100%;
padding: 6px 12px;
background: var(--panel-2);
color: var(--fg-dim);
border: 1px dashed var(--hair);
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: 12px;
cursor: pointer;
}
.msg-history-pager:hover,
.msg-history-pager:focus-visible {
color: var(--fg);
border-color: var(--accent);
}
.pane--embedded .ws-status-bar {
flex-shrink: 0;
+134 -1
View File
@@ -69,6 +69,17 @@ const _AGENT_ORPHAN_CAP = 256;
// The ordering race the buffer targets resolves within a frame, far inside it.
const _AGENT_ORPHAN_GRACE_MS = 500;
// Transcript window: a full re-render paints only the most recent
// _HISTORY_WINDOW_STEP messages (cut forward to a turn boundary) behind a
// "load earlier" pager; each pager click grows the window by another step
// and re-fetches. Live appends are bounded separately: once the rendered
// row count passes _LIVE_ROW_CAP, the idle-edge trim removes the oldest
// rows (again to a turn boundary) — trimmed content stays in /history and
// comes back through the pager. ~3 rows per message keeps the two caps in
// the same ballpark.
const _HISTORY_WINDOW_STEP = 300;
const _LIVE_ROW_CAP = 900;
function getVoiceRoles(base) {
base = base || "";
if (!_voiceRolesPromises[base]) {
@@ -223,6 +234,14 @@ class Pane {
// Set when replay_truncated arrives mid-stream (refetching then would
// detach the live bubble); consumed on the next idle edge.
this._pendingTruncatedResync = false;
// Transcript window (messages rendered per replay) — grows by
// _HISTORY_WINDOW_STEP per pager click, resets on ws (re)assignment.
// _hiddenEarlier counts the messages above the window after a replay;
// the approx flag marks live-trim hides, whose message count is unknown
// (rows ≠ messages), so the pager label drops the number.
this._historyWindow = _HISTORY_WINDOW_STEP;
this._hiddenEarlier = 0;
this._hiddenEarlierApprox = false;
this._cancelTimeout = null;
this._forceTimeout = null;
this._pendingEditSend = null;
@@ -1048,6 +1067,9 @@ class Pane {
this._replayQueue = null;
this._clearAgentTracking();
this._pendingTruncatedResync = false;
this._historyWindow = _HISTORY_WINDOW_STEP;
this._hiddenEarlier = 0;
this._hiddenEarlierApprox = false;
// Generation token — a slow refetch (e.g. a large resumed session) must
// not render its history, reconnect its stream, or fire its resend after
// the pane has switched to another ws. Newest load wins; older ones drop.
@@ -1156,6 +1178,96 @@ class Pane {
}
}
_historyPagerLabel() {
return this._hiddenEarlierApprox || !this._hiddenEarlier
? "Load earlier messages"
: "Load earlier messages (" + this._hiddenEarlier + " hidden)";
}
_addHistoryPager(beforeEl) {
const pager = document.createElement("button");
pager.type = "button";
pager.className = "msg-history-pager";
pager.textContent = this._historyPagerLabel();
pager.addEventListener("click", () => this._loadEarlierHistory());
if (beforeEl) this.messagesEl.insertBefore(pager, beforeEl);
else this.messagesEl.appendChild(pager);
}
_loadEarlierHistory() {
// Grow the window one step and re-render from REST, restoring the scroll
// anchor so the rows the user was looking at stay put under the newly
// prepended content (scrollHeight-delta restore; content-visibility's
// `auto` intrinsic sizing keeps the delta close enough for a pager
// click). The pin scheduled by replayHistory's trailing scrollToBottom
// is non-forced and re-checks _nearBottom at fire time, so it skips
// while we're mid-transcript — no suppression needed. Disabled while
// busy: an in-flight turn's refetch takes the cursor/omit path, and the
// pager's job (older content) can wait for the idle edge.
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;
});
}
_trimLiveTranscript() {
// Idle-edge live-append bound: past _LIVE_ROW_CAP rendered rows, drop the
// oldest (extending to the next user turn so a turn is never split) and
// surface the pager — the content stays in /history. Only while pinned:
// trimming shifts content, and a user scrolled up is READING the rows
// this would remove. Runs at the idle edge, where no streaming refs or
// pending approval can point at the trimmed range.
if (!this._nearBottom) return;
let excess = this.messagesEl.childElementCount - _LIVE_ROW_CAP;
if (excess <= 0) return;
let node = this.messagesEl.firstElementChild;
if (node && node.classList.contains("msg-history-pager")) {
node = node.nextElementSibling;
}
let removed = 0;
const hardStop = excess + 200; // bound the boundary walk
while (node && removed < excess) {
const next = node.nextElementSibling;
node.remove();
removed++;
node = next;
}
while (
node &&
removed < hardStop &&
!(node.classList.contains("msg") && node.classList.contains("user"))
) {
const next = node.nextElementSibling;
node.remove();
removed++;
node = next;
}
if (!removed) return;
// Message-count for the trimmed rows is unknown (rows ≠ messages) — the
// pager label drops its number until the next windowed replay.
this._hiddenEarlierApprox = true;
const first = this.messagesEl.firstElementChild;
if (first && first.classList.contains("msg-history-pager")) {
first.textContent = this._historyPagerLabel();
} else {
this._addHistoryPager(first);
}
// Agent cards inside the trimmed range are now detached; the Map isn't
// self-healing (unlike _toolRowIndex/_streamElIndex), so sweep it.
if (this._agentCards) {
for (const [key, card] of this._agentCards) {
if (!card.wrap.isConnected) this._agentCards.delete(key);
}
}
}
_clearAgentTracking() {
// Release task-agent bookkeeping ahead of (or after) a full rebuild.
// Entries left in _agentCards would pin every replaced card subtree as
@@ -1355,6 +1467,9 @@ class Pane {
this._beginReplayQuiesce(rsToken);
this._refetchHistory(this.wsId, rsToken);
}
// Live-append bound — see _trimLiveTranscript. The idle edge is
// the safe trim point: no streaming refs, no pending approval.
this._trimLiveTranscript();
// Only steal focus if this is the active pane and no approval pending.
if (this._host.isFocused(this) && !this.pendingApproval) {
this.inputEl.focus();
@@ -2276,8 +2391,26 @@ class Pane {
// branch can flip the card's done/error state from the task's own result
// (mirroring the live appendToolOutput), not from sub-step errors.
const agentCardWraps = {};
// Transcript window: render only the most recent _historyWindow messages.
// The cut walks FORWARD to the next user turn so it can never split an
// assistant tool_calls message from the tool results that anchor to it
// via lastToolBlock (both anchors reset at user messages). Rewind/edit
// stay correct under the window: their turn math counts user rows at-or-
// AFTER the clicked one, and the window only hides earlier rows. No
// boundary in the tail (pathological) ⇒ render everything.
let start = 0;
if (messages.length > this._historyWindow) {
start = messages.length - this._historyWindow;
while (start < messages.length && messages[start].role !== "user") {
start++;
}
if (start >= messages.length) start = 0;
}
this._hiddenEarlier = start;
this._hiddenEarlierApprox = false;
if (start > 0) this._addHistoryPager();
let lastToolBlock = null;
for (let i = 0; i < messages.length; i++) {
for (let i = start; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "user") {
if (msg.source === "system_nudge") {
+7 -5
View File
@@ -78,11 +78,13 @@
min-height: 0;
overflow-y: auto;
padding: 16px 12px;
display: flex;
flex-direction: column;
/* No flexbox gap — .msg supplies its own margin-bottom for inter-card
spacing. A 14px gap here was stacking with the 4px card
margin-bottom and pushing the per-card spacing to ~18px. */
/* Block flow + no native scroll anchoring — mirrors the shared
interactive.css scroller (which also carries the per-row
content-visibility rules): a flex column relayouts every row on each
streaming height change, and native anchoring fights the pane's own
bottom pin. .msg supplies its own margin-bottom for inter-card
spacing. */
overflow-anchor: none;
}
/* .msg (shared_static/chat.css) provides padding / border / radius /
line-height / word-wrap / margin / background. The interactive UI