Open the transcript at the window's edge and say where that edge is
A transcript that starts somewhere other than the session's beginning has to say so, in three places. The first row is now the window's own edge: a control that asks the daemon for the records behind it, and that names the compaction when the window opens on one, because that fold is the whole reason the transcript starts there. A turn is numbered by its place in the session — the sequence of its opening record — rather than by its rank in the window, which would number turn 361 as "#1" and tell the reader the session began where their window does. And a turn the window cut in half makes no summary claims at all: a count of the records that happen to be loaded is not a summary of the turn, and the failure that is above the edge would not be in it. Pressing load-earlier lands the reader at the top of the page that just arrived rather than leaving them still, since records arriving above a viewport that does not move look like nothing happened. It also fixes the follow, which the bigger sessions exposed: the list adjusts the scroll itself when a row measures, after the app's own hold has written the end and from the size that row had before it measured. The correction left the reader a row short of the end, and the scroll event it caused read as the reader leaving — so the follow disarmed and the transcript never caught up with itself. Measured at 400 turns: 93px short, permanently. The end is now written twice per size, once after the list has had its turn in the frame.
3 unmodified lines456788910111252 unmodified lines65666768697071727374757677787980818269838485868719 unmodified lines10710810911097989910011111211311411511611711811910610710810911012012112212312412512612712829 unmodified lines15815916016114814915015115216216316416516616716816917015 unmodified lines18618718818917619019119219319419519619719819920020120220320420520620720820921021121221321421521621721818518618721922022122222322422522622717 unmodified lines24524624724821324925025125225325425518 unmodified lines27427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631760 unmodified lines3783793803813083823833843853863873883893903913923933943953963973983994004017 unmodified lines409410411412413414415416417418325419420421422423142 unmodified linesExpand all56656756856957057157257357457557657757857958020 unmodified lines3 unmodified lines import ScratchpadAccordion from "./ScratchpadAccordion.svelte"; import CellTerminal from "./CellTerminal.svelte"; import SpeechBubble from "./SpeechBubble.svelte"; import type { StreamingClientV2, TimelineItem } from "../lib/streaming-v2"; import { Activity, ChevronDown, ChevronRight, CircleAlert, ShieldAlert, Terminal } from "lucide-svelte"; import { Activity, ChevronDown, ChevronRight, CircleAlert, History, ShieldAlert, Terminal } from "lucide-svelte"; import { buildRows, computeInputOutcomes, filterItems,52 unmodified lines $: workItems = $workItemsStore;
$: visible = filterItems(timelineItems, selection); $: turns = groupTurns(visible); /** * The top row is the loaded window's own edge, and it is only there when the daemon says there is * something behind it. It stays while a page is in flight, so the control a reader just pressed is * the one that reports the answer. */ $: earlier = $conn.hasEarlier === true || $conn.loadingEarlier === true; /** * When the window opens on a compaction, that is the whole reason it opens there, and it is the one * thing about the boundary a reader cannot infer from the records: a turn is not obviously the first * one of anything. The preset hides lifecycle rows, so the edge says it instead. */ $: windowOpensAtFold = timelineItems[0]?.lane === "lifecycle" && (timelineItems[0].data as { kind?: string }).kind === "context.compacted"; $: rows = buildRows(turns, openRuns); $: rows = buildRows(turns, openRuns, earlier);
/** The newest turn is the one that is still being written to. */ $: liveTurnId = turns.length > 0 ? turns[turns.length - 1].id : undefined;
19 unmodified lines entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height, /** the rows either side of the fold, so a flick does not land on a blank band */ overscan: OVERSCAN, /** * A session is read from its live end. The virtualizer holds the bottom while the reader is * at the bottom — through appends, through measurement, through rows above growing — and * leaves them alone the moment they scroll away. `AT_END` is the same slack `handleScroll` * uses, so the stickiness and the "jump to live" button cannot disagree about the reader. * A session is read from its live end, and the list owns that end: it keeps the bottom pinned * through appends and through measurement, and its own bookkeeping of how far a measurement * moved the content is the only one that adds up. It is told where the reader is, not moved * behind its back. */ anchorTo: "end" as const, followOnAppend: true, scrollEndThreshold: AT_END, /** * Measurement is the virtualizer's own business: with `anchorTo: "end"` it keeps the bottom * pinned when a row above the fold turns out to be taller than its estimate, and it does that * without the app's help. The app pins on appends. Re-pinning from this callback instead * made the two fight: the pin writes a scroll offset, the write changes the window, the * change calls back, and the transcript pinned itself hundreds of times a second. * Measurement is the list's own business: it compensates for a row above the fold that turns * out to be taller than its estimate, and it does that without being told. Re-pinning from * this callback instead made the two fight — the pin writes a scroll offset, the write changes * the window, the change calls back, and the transcript pinned itself hundreds of times a * second. The app's own hold is a statement (below), not a callback from here. */ }; }
29 unmodified lines }; }
/** * Getting the reader to the live end, and keeping them there. The virtualizer owns the follow: with * `anchorTo: "end"` and `followOnAppend` it holds the bottom while the reader is standing there, * through appends and through rows above growing, and lets go the moment they are not. Two things it * cannot do alone, and both are the app's, gated on `autoScroll` — which only the reader's own scroll * can take away. * Getting the reader to the live end, and keeping them there. The list holds the bottom while the * reader is standing there — through appends, through measurement, through rows above growing — and * lets go the moment they are not. Two things it cannot do alone, and both are the app's, gated on * `autoScroll`, which only the reader's own scroll can take away: the first placement, and the write * that answers its own measurement adjustment. */ function place() { tick().then(() => { if (!scroller || !autoScroll) return;15 unmodified lines */ $: if (autoScroll && rows.length > 0 && (!placed || $conn.isSyncing)) place();
/** * Second: staying. The follow lands on the size the transcript had when the append arrived, and a row * Second: staying. The follow lands on the size the transcript had when an append arrived, and a row * that then measures taller than its estimate leaves the newest lines under the fold. So while the * reader is standing at the live end the console holds the end itself — a plain write of the size it * already knows, not a scroll command. Going through `scrollToEnd` here instead made the console and * the virtualizer correct each other in a loop, sixty times a second; a write of the position the * scroller is already at costs nothing. * * It is written twice per size, and the second write is the point. The list adjusts the scroll itself * when a row measures, after this statement has run and from the size the row had *before* it * measured; left there, the reader is a row short of the end, and the scroll event that correction * causes looks exactly like a reader leaving. The follow disarms and the transcript never catches up * with itself. So the end is held once now and once after the list has had its turn in the frame. */ let holdFrame = 0; function holdAgain(end: number) { if (holdFrame) cancelAnimationFrame(holdFrame); holdFrame = requestAnimationFrame(() => { holdFrame = 0; if (!autoScroll || !scroller) return; if (Math.abs(end - scroller.scrollTop) > 0.5) { scroller.scrollTop = end; wroteAt = scroller.scrollTop; } }); }
$: if (autoScroll && placed && scroller && totalSize > 0) { const end = totalSize - ($virtualizer.scrollRect?.height ?? 0); if (end > 0) { if (end > 0 && Math.abs(end - scroller.scrollTop) > 0.5) { scroller.scrollTop = end; wroteAt = scroller.scrollTop; if (Math.abs(end - scroller.scrollTop) > 0.5) { scroller.scrollTop = end; wroteAt = scroller.scrollTop; } holdAgain(end); } }
/**17 unmodified lines /** The horizon is the row at the top edge of the viewport: one binary search, not a DOM sweep. */ function updateViewingAt() { if (!scroller) return; const item = $virtualizer.getVirtualItemForOffset(scroller.scrollTop); viewingAt = item ? rows[item.index]?.at : undefined; const row = item ? rows[item.index] : undefined; // The window's own edge is a control, not a record: it has no moment to report. viewingAt = row && row.kind !== "history" ? row.at : undefined; }
function handleScroll() { if (!scroller) return;18 unmodified lines function toggleRun(id: string) { openRuns = { ...openRuns, [id]: !openRuns[id] }; }
/** * Loading earlier history, which is the one scroll this console makes that is not the live end. * * Keeping the reader still would hide the thing they asked for: the records arrive above a viewport * that does not move, so a press would look like nothing happened. They land on the top of the page * that just arrived instead, and read downward from there — back to where they were. */ let waitingForHistory = false; let historyRequested = false;
function loadEarlier() { if (!scroller) return; // Asking for the past is a decision about where to stand: the follow must not fight the landing. autoScroll = false; waitingForHistory = true; historyRequested = client.loadEarlier(); if (!historyRequested) waitingForHistory = false; }
$: if (waitingForHistory && !$conn.loadingEarlier) { waitingForHistory = false; landOnHistory(); }
function landOnHistory() { if (!historyRequested) return; historyRequested = false; tick().then(() => { if (!scroller || rows.length === 0) return; // Row 0 is the window's edge; row 1 is the first record the daemon sent for it. $virtualizer.scrollToIndex(Math.min(1, rows.length - 1), { align: "start" }); wroteAt = scroller.scrollTop; updateViewingAt(); }); }
function toggleStream(cellId: string, index: number) { const cell = streamState[cellId] ?? {}; streamState = { ...streamState, [cellId]: { ...cell, [index]: !cell[index] } }; }60 unmodified lines {:else} <div class="sizer" style="height: {Math.round(totalSize)}px"> {#each virtualItems as v (v.key)} {@const row = rows[v.index]} {#if row.kind === "head"} {#if row.kind === "history"} <div class="row history-row" data-index={v.index} use:measure class:first={v.index === 0} style="transform: translateY({v.start}px)"> <div class="col"> <button class="btn history-btn" type="button" on:click={loadEarlier} disabled={$conn.loadingEarlier} title="ask the daemon for the records behind this window"> <History size={13} stroke-width={1.5} /> {#if $conn.loadingEarlier}loading earlier history{:else}load earlier history{/if} </button> {#if windowOpensAtFold} <span class="mute hist-note">the loaded window starts at a context compaction</span> {/if} </div> </div> {:else if row.kind === "head"} <div class="row head-row" data-index={v.index} use:measure class:first={v.index === 0} class:last={row.last} class:arrive={row.turnId === newestTurnId} style="transform: translateY({v.start}px)"> <div class="col">7 unmodified lines <span class="turn-who">{row.turn.input.data.source || "operator"}</span> {:else} <span class="turn-who faint">session</span> {/if} {#if row.partial} <span class="mute partial-note">starts above the window</span> {/if} <span class="spacer"></span> {#if row.partial} <!-- a count of the records loaded is not a count for the turn --> {#if row.turn.summary.cells > 0} {:else if row.turn.summary.cells > 0} <span class="chip lane-cell" title="{row.turn.summary.cells} cells in this turn"> <Terminal size={12} stroke-width={1.5} /><span class="num">{row.turn.summary.cells}</span> </span> {/if}142 unmodified linesExpand all .row.last { padding-bottom: var(--s-5); } .row.record.last { padding-bottom: calc(var(--s-3) + var(--s-5)); } .row.cell-row.last { padding-bottom: calc(var(--s-3) + var(--s-5)); }
/* the top of the loaded window: a control, drawn as one, at the edge of the transcript */ .row.history-row { padding-top: var(--s-5); padding-bottom: var(--s-3); } .row.history-row > .col { display: flex; align-items: center; gap: var(--s-4); } .history-btn { font-size: var(--fs-2); } .hist-note { font-size: var(--fs-2); } .partial-note { font-size: var(--fs-1); }
.turn-head { display: flex; align-items: center; gap: var(--s-4); } .turn-when { font-size: var(--fs-2); } .turn-no { font-size: var(--fs-1); color: var(--fg-mute); } .turn-who { font-family: var(--sans); font-size: var(--fs-2); font-weight: 500; color: var(--fg-mute); }20 unmodified lines1223456225 unmodified linesExpand all232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274import { describe, test, expect } from "bun:test";import { computeInputOutcomes } from "./turns";import { buildRows, groupTurns, computeInputOutcomes } from "./turns";import type { TimelineItem } from "./streaming-v2";import type { InputOutcome, WorkItemDiagnostic } from "./protocol-v2";
// Fixtures for the outcomes of a turn: each builder produces the record the runtime would send.225 unmodified linesExpand all // transcript doubled (52, 102, 199, 398 at those same sizes) — 199x the reads at the largest. expect(large.reads / large.items).toBeLessThan(small.reads / small.items * 1.6); });});
describe("turn coordinates and the window's own edge", () => { test("a turn is numbered by its place in the session, not by its rank in the loaded window", () => { // The same three turns, loaded as a window that starts at the third: their numbers are the same // whether the console holds all of them or the last one. A count from one would say the session // began at the window's edge, which is exactly the claim a window cannot make. const items = [input(100), speech(101, "confirmed"), input(120), speech(121, "confirmed"), input(140), cell(141, { status: "ok", exit_code: 0 })]; const all = buildRows(groupTurns(items.filter((i) => i.lane === "speech" || i.lane === "input" || i.lane === "cell"))); const heads = all.filter((row) => row.kind === "head").map((row: any) => row.no); expect(heads).toEqual([100, 120, 140]);
const windowed = buildRows(groupTurns(items.slice(4))); expect(windowed.filter((row) => row.kind === "head").map((row: any) => row.no)).toEqual([140]); });
test("a turn the window cut in half claims nothing about the turn", () => { // These records are the middle of a turn: the input that opened it is above the loaded window, so // this summary would be a count of what happens to be loaded, in the shape of a count of the turn. const orphanTail = [cell(300, { status: "error", exit_code: 1 }), speech(301, "confirmed")]; const cut = buildRows(groupTurns(orphanTail), {}, true); const head = cut.find((row) => row.kind === "head") as any; expect(head.partial).toBe(true);
// The same records with nothing above them are a turn of a session that started without an input, // and there is no cut to report: it is numbered and summarised like any other. const whole = buildRows(groupTurns(orphanTail), {}, false); expect((whole.find((row) => row.kind === "head") as any).partial).toBe(false); });
test("the window's own edge is the first row, and it is not a turn", () => { const items = [input(100), speech(101, "confirmed")]; const rows = buildRows(groupTurns(items), {}, true); expect(rows[0].kind).toBe("history"); expect(rows[0].at).toBe(0); // A transcript with nothing behind it has no edge to draw. expect(buildRows(groupTurns(items), {}, false)[0].kind).toBe("head"); });});
47 unmodified lines484950515253545556575859606162245 unmodified linesExpand all30830931031131231331431531627 unmodified lines34434534634734834935035135235335435535635735834035936036136236315 unmodified lines37938038138236438338438538638738838939039139236739339439539639739839940040118 unmodified lines47 unmodified lines id: string; /** The human input that opened this turn, when there was one. */ input?: InputItem; startedAt: number; /** * Where this turn sits in the session's own log: the sequence of its opening record. A console that * loads a window cannot count turns from one — that would number turn 361 as "#1" and tell the * reader the session began where their window does — so a turn is named by the coordinate that is * the same whatever part of the session is loaded. */ seq?: number; children: TurnChild[]; summary: TurnSummary;}
245 unmodified linesExpand all turns.push({ id: input?.id ?? current[0]?.id ?? `turn-${turns.length}`, input, startedAt: input?.created_ms ?? current[0]?.created_ms ?? Date.now(), seq: input?.seq ?? current[0]?.seq, children: collapseRuns(current), summary: summarise(current), }); current = [];27 unmodified lines last: boolean; /** A guess until the row has been measured. Only ever used for rows off screen. */ estimate: number;} & ( /** * The top of the loaded window. A transcript is read from its live end, so the records above it are * on the daemon, not here: this row is what asks for them, and it is a row rather than a floating * control so it can never be mistaken for a record of the session. */ | { kind: "history" } /** * `partial` marks the one turn a window can cut in half: the first, when the records that opened it * are above the window. Its counts are counts of the records that are loaded, which is not the same * claim as a turn's summary, so it makes none. */ | { kind: "head"; turn: Turn; no: number } | { kind: "head"; turn: Turn; no: number; partial?: boolean } | { kind: "input"; turn: Turn; item: InputItem } | { kind: "child"; turn: Turn; child: TurnChild } | { kind: "cell"; turn: Turn; cell: CellItem; endOfRun: boolean });15 unmodified lines * `open` is the set of expanded cell runs. An expanded run emits one row per cell, which is the whole * point of building rows here: a turn that ran two hundred cells is two hundred rows the scroller can * drop, not one enormous element it must keep. */export function buildRows(turns: Turn[], open: Record<string, boolean> = {}): Row[] {export function buildRows(turns: Turn[], open: Record<string, boolean> = {}, earlier = false): Row[] { const rows: Row[] = []; // Only the top of the loaded window can cut a turn: every page below it joins exactly where the // page above it ended, so a turn split by one page is whole again once the next one arrives. const cut = earlier && turns.length > 0 && !turns[0].input; if (earlier) { // `at: 0` keeps it out of the horizon readout, which reports the moment the reader is looking at. rows.push({ kind: "history", key: "history", turnId: "", at: 0, last: false, estimate: 40 }); } for (const [i, turn] of turns.entries()) { rows.push({ kind: "head", key: `${turn.id}:head`, turnId: turn.id, at: turn.startedAt, last: false, estimate: ESTIMATE.head, turn, no: i + 1 }); // A record with no sequence is synthetic (the tests build them): rank is all it has. rows.push({ kind: "head", key: `${turn.id}:head`, turnId: turn.id, at: turn.startedAt, last: false, estimate: ESTIMATE.head, turn, no: turn.seq ?? i + 1, partial: cut && i === 0, }); if (turn.input) { rows.push({ kind: "input", key: turn.input.id, turnId: turn.id, at: turn.input.created_ms, last: false, estimate: ESTIMATE.input, turn, item: turn.input }); } for (const child of turn.children) {18 unmodified lines