diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index ecf07f9..327ad6f 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -932,6 +932,226 @@ describe("structured connection: reconnection/error/loading", () => { }); }); +describe("structured connection: transcript scrolling", () => { + let frames: Map; + let nextFrame: number; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + frames = new Map(); + nextFrame = 1; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + const id = nextFrame++; + frames.set(id, cb); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + frames.delete(id); + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function metrics(conn: ReturnType, height: number, clientHeight: number, top = 0) { + const scroll = conn.el.querySelector(".structured-scroll")!; + const values = { height, clientHeight, top }; + Object.defineProperties(scroll, { + scrollHeight: { configurable: true, get: () => values.height }, + clientHeight: { configurable: true, get: () => values.clientHeight }, + scrollTop: { + configurable: true, + get: () => values.top, + set: (value: number) => { values.top = value; }, + }, + }); + return { scroll, values }; + } + + function runFrames(): void { + const pending = [...frames.entries()]; + frames.clear(); + for (const [, cb] of pending) cb(0); + } + + it("shows the bottom after the initial transcript snapshot is laid out", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { values } = metrics(conn, 900, 300, 40); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [ + row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }), + ], + }, + }); + + expect(values.top).toBe(40); + runFrames(); + expect(values.top).toBe(600); + conn.dispose(); + }); + + it("keeps pinned appends and patches at the bottom after layout", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { values } = metrics(conn, 600, 200); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + runFrames(); + + values.height = 720; + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }), + }, + state: idleState, + }); + runFrames(); + expect(values.top).toBe(520); + + values.height = 780; + ws.emit({ + type: "projection_update", + transcript: { + type: "patch", + row: row("agent:1", "agent_text", { + content: { type: "text", text: "a longer completion" }, + }), + }, + state: idleState, + }); + runFrames(); + expect(values.top).toBe(580); + conn.dispose(); + }); + + it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { scroll, values } = metrics(conn, 1000, 200); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } }); + runFrames(); + + values.top = 180; + scroll.dispatchEvent(new Event("scroll")); + values.height = 1120; + ws.emit({ + type: "projection_update", + transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) }, + state: idleState, + }); + runFrames(); + expect(values.top).toBe(180); + + values.height = 1180; + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }), + }, + state: { + ...idleState, + pending_approval: { + nonce: "n1", + request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions }, + }, + }, + }); + runFrames(); + expect(values.top).toBe(180); + + conn.el.querySelector(".st-tool-head")!.click(); + values.height = 1260; + runFrames(); + expect(values.top).toBe(180); + conn.dispose(); + }); + + it("resumes following when the user scrolls back near the bottom", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { scroll, values } = metrics(conn, 1000, 200); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + runFrames(); + + values.top = 750; + scroll.dispatchEvent(new Event("scroll")); + values.height = 1100; + ws.emit({ + type: "projection_update", + transcript: { + type: "append", + row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }), + }, + state: idleState, + }); + runFrames(); + expect(values.top).toBe(900); + conn.dispose(); + }); + + it("preserves a user-scrolled position across a reconnect snapshot", () => { + const first = new FakeSocket(); + const second = new FakeSocket(); + const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + const { scroll, values } = metrics(conn, 1000, 200); + first.open(); + first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + runFrames(); + + values.top = 160; + scroll.dispatchEvent(new Event("scroll")); + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + values.height = 1200; + second.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [ + row("agent:1", "agent_text", { + content: { type: "text", text: "reconnected" }, + }), + ], + }, + }); + runFrames(); + expect(values.top).toBe(160); + conn.dispose(); + }); + + it("cancels a pending transcript frame when disposed or parked", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + const { values } = metrics(conn, 900, 300, 25); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + expect(frames.size).toBe(1); + + conn.onUnmount(); + expect(frames.size).toBe(0); + conn.onMount({ focus: false }); + expect(frames.size).toBe(2); + conn.dispose(); + expect(frames.size).toBe(0); + runFrames(); + expect(values.top).toBe(25); + }); +}); + describe("structured connection: pane lifecycle", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/web/src/structured.ts b/web/src/structured.ts index c0853bb..00f2aea 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -80,6 +80,7 @@ export function createStructuredConnection( const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; + const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement; const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; const railEl = el.querySelector(".structured-rail") as HTMLElement; const bodyEl = el.querySelector(".structured-body") as HTMLElement; @@ -117,6 +118,65 @@ export function createStructuredConnection( let stateSocketGeneration = 0; let disposed = false; + // Within 64px, wheel and touch imprecision should still count as following. + const FOLLOW_BOTTOM_THRESHOLD_PX = 64; + let followingBottom = true; + let receivedSnapshot = false; + let scrollWorkActive = true; + let parkedScrollTop: number | null = null; + let scrollFrame: number | null = null; + let pendingScroll: { top: number; follow: boolean } | null = null; + + function cancelScrollFrame(): void { + if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame); + scrollFrame = null; + pendingScroll = null; + } + + function beginTranscriptLayout(forceBottom = false): void { + if (!scrollWorkActive || disposed) return; + if (pendingScroll) { + if (forceBottom) pendingScroll.follow = true; + return; + } + pendingScroll = { + top: scrollEl.scrollTop, + follow: forceBottom || followingBottom, + }; + } + + function finishTranscriptLayout(): void { + if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return; + scrollFrame = window.requestAnimationFrame(() => { + scrollFrame = null; + const pending = pendingScroll; + pendingScroll = null; + if (!pending || disposed || !scrollWorkActive) return; + scrollEl.scrollTop = pending.follow + ? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight) + : pending.top; + }); + } + + function updateTranscriptLayout(update: () => void, forceBottom = false): void { + beginTranscriptLayout(forceBottom); + update(); + finishTranscriptLayout(); + } + + function onTranscriptScroll(): void { + const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop; + followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX; + cancelScrollFrame(); + } + + function onTranscriptClick(event: Event): void { + if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout(); + } + + scrollEl.addEventListener("scroll", onTranscriptScroll); + scrollEl.addEventListener("click", onTranscriptClick, true); + function lastRowIdOfKind(kind: TranscriptRowKind): string | null { for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; return null; @@ -160,6 +220,7 @@ export function createStructuredConnection( (expanded) => { if (expanded) expandedRowIds.add(row.id); else expandedRowIds.delete(row.id); + finishTranscriptLayout(); }, ); if (!el) return null; @@ -207,44 +268,50 @@ export function createStructuredConnection( } function applySnapshot(snapshot: { rows: TranscriptRow[] }): void { - rows = [...snapshot.rows]; - const expandableIds = new Set( - rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id), - ); - for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id); - rowIndex.clear(); - rows.forEach((row, i) => rowIndex.set(row.id, i)); - rowEls.clear(); - rowsEl.replaceChildren(); - for (const row of rows) mountRow(row); + const forceBottom = !receivedSnapshot; + updateTranscriptLayout(() => { + rows = [...snapshot.rows]; + const expandableIds = new Set( + rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id), + ); + for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id); + rowIndex.clear(); + rows.forEach((row, i) => rowIndex.set(row.id, i)); + rowEls.clear(); + rowsEl.replaceChildren(); + for (const row of rows) mountRow(row); + }, forceBottom); + receivedSnapshot = true; } function applyDelta(delta: TranscriptDelta | null | undefined): void { if (!delta) return; - if (delta.type === "append") { - rowIndex.set(delta.row.id, rows.length); - rows.push(delta.row); - mountRow(delta.row); - } else if (delta.type === "patch") { - const i = rowIndex.get(delta.row.id); - if (i === undefined) { + updateTranscriptLayout(() => { + if (delta.type === "append") { rowIndex.set(delta.row.id, rows.length); rows.push(delta.row); mountRow(delta.row); + } else if (delta.type === "patch") { + const i = rowIndex.get(delta.row.id); + if (i === undefined) { + rowIndex.set(delta.row.id, rows.length); + rows.push(delta.row); + mountRow(delta.row); + } else { + rows[i] = delta.row; + remountRow(delta.row); + } } else { - rows[i] = delta.row; - remountRow(delta.row); + const i = rowIndex.get(delta.id); + if (i === undefined) return; + rows.splice(i, 1); + expandedRowIds.delete(delta.id); + rowIndex.clear(); + rows.forEach((row, idx) => rowIndex.set(row.id, idx)); + rowEls.get(delta.id)?.remove(); + rowEls.delete(delta.id); } - } else { - const i = rowIndex.get(delta.id); - if (i === undefined) return; - rows.splice(i, 1); - expandedRowIds.delete(delta.id); - rowIndex.clear(); - rows.forEach((row, idx) => rowIndex.set(row.id, idx)); - rowEls.get(delta.id)?.remove(); - rowEls.delete(delta.id); - } + }); } function toolRowCount(): number { @@ -293,10 +360,12 @@ export function createStructuredConnection( reconcileAwaitingBegin(); const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; if (newPendingId !== pendingApprovalRowId) { - const prev = pendingApprovalRowId; - pendingApprovalRowId = newPendingId; - if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]); - if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]); + updateTranscriptLayout(() => { + const prev = pendingApprovalRowId; + pendingApprovalRowId = newPendingId; + if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]); + if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]); + }); } renderPills(); renderRailNow(); @@ -493,6 +562,12 @@ export function createStructuredConnection( // `mountToken` is bumped on every mount/unmount so a stale frame can tell // it is no longer the most recent one. let mountToken = 0; + let focusFrame: number | null = null; + + function cancelFocusFrame(): void { + if (focusFrame !== null) window.cancelAnimationFrame(focusFrame); + focusFrame = null; + } return { sessionId, @@ -500,13 +575,26 @@ export function createStructuredConnection( onMount(mountOpts?: { focus?: boolean }) { const focusOnMount = mountOpts?.focus !== false; const token = ++mountToken; - requestAnimationFrame(() => { + scrollWorkActive = true; + pendingScroll = { + top: parkedScrollTop ?? scrollEl.scrollTop, + follow: followingBottom, + }; + parkedScrollTop = null; + finishTranscriptLayout(); + cancelFocusFrame(); + focusFrame = window.requestAnimationFrame(() => { + focusFrame = null; if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled if (focusOnMount && !input.disabled) input.focus(); }); }, onUnmount() { mountToken++; + parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop; + scrollWorkActive = false; + cancelScrollFrame(); + cancelFocusFrame(); }, focusTerminal() { if (!input.disabled) input.focus(); @@ -519,11 +607,16 @@ export function createStructuredConnection( if (disposed) return; disposed = true; mountToken++; + scrollWorkActive = false; + cancelScrollFrame(); + cancelFocusFrame(); cancelReconnect(); resizeObserver.disconnect(); unsubscribeStore?.(); closeCurrentSocket(); expandedRowIds.clear(); + scrollEl.removeEventListener("scroll", onTranscriptScroll); + scrollEl.removeEventListener("click", onTranscriptClick, true); el.remove(); }, };