Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions scripts/livepass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 = [
Expand All @@ -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}"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions tests/test_interactive_pane_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 50 additions & 16 deletions turnstone/shared_static/interactive.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +55 to +57
/* 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;
Expand Down
135 changes: 134 additions & 1 deletion turnstone/shared_static/interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
});
Comment on lines +1207 to +1217
}

_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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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") {
Expand Down
Loading
Loading