diff --git a/scripts/livepass.py b/scripts/livepass.py index 50ed5507f..07e9b46a1 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -1267,6 +1267,12 @@ class names) so the harness inherits production scroll geometry: 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 diff --git a/tests/test_interactive_pane_js.py b/tests/test_interactive_pane_js.py index 0608167fd..026f27a44 100644 --- a/tests/test_interactive_pane_js.py +++ b/tests/test_interactive_pane_js.py @@ -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 diff --git a/turnstone/shared_static/interactive.css b/turnstone/shared_static/interactive.css index 9148564f6..904de606d 100644 --- a/turnstone/shared_static/interactive.css +++ b/turnstone/shared_static/interactive.css @@ -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; -} -/* 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; + /* 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; +} +.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; diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js index 835767af9..636926068 100644 --- a/turnstone/shared_static/interactive.js +++ b/turnstone/shared_static/interactive.js @@ -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") { diff --git a/turnstone/ui/static/style.css b/turnstone/ui/static/style.css index a6c80b743..cb38482ec 100644 --- a/turnstone/ui/static/style.css +++ b/turnstone/ui/static/style.css @@ -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