mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d174cd71c |
+30
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user