diff --git a/packages/tui/src/routes/session/history.ts b/packages/tui/src/routes/session/history.ts index aa154fd91f0b..c5b6cc4eba5e 100644 --- a/packages/tui/src/routes/session/history.ts +++ b/packages/tui/src/routes/session/history.ts @@ -2,7 +2,6 @@ export function createHistoryPrepend(input: { sessionID: () => string more: (sessionID: string) => boolean loadMore: (sessionID: string) => Promise - height: () => number afterLayout: (continuation: () => void) => void active: (sessionID: string) => boolean scrollBy: (amount: number) => void @@ -23,7 +22,6 @@ export function createHistoryPrepend(input: { if (!input.more(sessionID)) return false const current = { scrollBy, continuation } pending = current - const before = input.height() void input.loadMore(sessionID).then( () => input.afterLayout(() => { @@ -31,7 +29,7 @@ export function createHistoryPrepend(input: { const after = pending.after pending = undefined if (!input.active(sessionID)) return - input.scrollBy(input.height() - before + current.scrollBy) + input.scrollBy(current.scrollBy) current.continuation?.() after?.() }), @@ -52,7 +50,7 @@ export function createHistoryPrepend(input: { }, after(continuation: () => void) { if (!pending) return continuation() - // A jump supersedes deferred scrolling, but must wait for anchor compensation. + // A jump supersedes deferred scrolling, but must wait until the prepended page is laid out. pending.scrollBy = 0 pending.after = continuation }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ce5d3cbc293a..0e685134a479 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -290,6 +290,8 @@ export function Session(props: { (id) => { if (id === sessionID) setRowsSynced(true) }, + // A scroll-triggered history load joins this boundary load instead of publishing its own page. + () => pinAbove(), ) const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) const anchors = createTimelineAnchors() @@ -404,6 +406,15 @@ export function Session(props: { }) } + // Pin an existing row before publication. Total height also counts streamed output below the + // reader, which must not move their viewport when it arrives alongside the older rows. + const pinAbove = () => { + if (!scroll || scroll.isDestroyed) return + if (scroll.stickyScroll && scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height) return + const node = scroll.getChildren().find((node) => node.height > 0 && node.y + node.height > scroll.viewport.y) + if (node) afterLayout(scroll.anchor(node)) + } + // Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows // mount on demand near the top, keeping inactive tabs cheap to tear down. While the reader stays // at the bottom the hidden span follows appends; leaving the bottom pins it to preserve the viewport. @@ -426,16 +437,18 @@ export function Session(props: { sessionID: () => route.sessionID, more: (id) => data.session.message.more(id), loadMore: async (id) => { - await data.session.message.loadMore(id) + const beforePublish = () => { + if (route.sessionID === id) pinAbove() + } + await data.session.message.loadMore(id, { beforePublish }) await completeGroupBoundary({ rows, messages: () => data.session.message.list(id).length, more: () => data.session.message.more(id), - loadMore: () => data.session.message.loadMore(id), + loadMore: () => data.session.message.loadMore(id, { beforePublish }), active: () => route.sessionID === id, }) }, - height: () => scroll.scrollHeight, afterLayout, active: (id) => route.sessionID === id && Boolean(scroll && !scroll.isDestroyed), scrollBy: (amount) => { @@ -449,11 +462,11 @@ export function Session(props: { if (revealingOlderRows || !scroll || scroll.isDestroyed || scroll.scrollTop > scroll.viewport.height) return false if (current === 0) return prependHistory(scrollBy) revealingOlderRows = true - const before = scroll.scrollHeight scroll.stickyScroll = false + pinAbove() setHiddenRows(rowsBefore(weights(), current, TRANSCRIPT_BACKFILL_CHUNK)) afterLayout(() => { - scroll.scrollBy(scroll.scrollHeight - before + scrollBy) + scroll.scrollBy(scrollBy) scroll.stickyScroll = !navigationMessage() revealingOlderRows = false }) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index c40cfb309793..ed966402fc45 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -44,7 +44,11 @@ export async function completeGroupBoundary(input: { } } -export function createSessionRows(sessionID: Accessor, onSynced?: (sessionID: string) => void) { +export function createSessionRows( + sessionID: Accessor, + onSynced?: (sessionID: string) => void, + beforePublish?: () => void, +) { const data = useData() const client = useClient() const config = useConfig() @@ -131,7 +135,7 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi rows, messages: () => data.session.message.list(id).length, more: () => data.session.message.more(id), - loadMore: () => data.session.message.loadMore(id), + loadMore: () => data.session.message.loadMore(id, { beforePublish }), active: () => sessionID() === id, }).catch(() => undefined) if (sessionID() === id) onSynced?.(id) diff --git a/packages/tui/test/cli/tui/session-history.test.ts b/packages/tui/test/cli/tui/session-history.test.ts index 81bf5a869dd0..4e401d3f44df 100644 --- a/packages/tui/test/cli/tui/session-history.test.ts +++ b/packages/tui/test/cli/tui/session-history.test.ts @@ -1,8 +1,7 @@ import { expect, test } from "bun:test" import { createHistoryPrepend } from "../../../src/routes/session/history" -test("loads older history and preserves the visible scroll anchor", async () => { - let height = 100 +test("loads older history and applies the deferred scroll after layout", async () => { let resolveLoad: (() => void) | undefined const scrolled: number[] = [] const prepend = createHistoryPrepend({ @@ -10,12 +9,8 @@ test("loads older history and preserves the visible scroll anchor", async () => more: () => true, loadMore: () => new Promise((resolve) => { - resolveLoad = () => { - height = 160 - resolve() - } + resolveLoad = resolve }), - height: () => height, afterLayout: (continuation) => continuation(), active: (sessionID) => sessionID === "session-1", scrollBy: (amount) => scrolled.push(amount), @@ -27,7 +22,7 @@ test("loads older history and preserves the visible scroll anchor", async () => await Promise.resolve() await Promise.resolve() - expect(scrolled).toEqual([56]) + expect(scrolled).toEqual([-4]) }) test("releases the history load after a failed request", async () => { @@ -39,7 +34,6 @@ test("releases the history load after a failed request", async () => { attempts++ return Promise.reject(new Error("offline")) }, - height: () => 100, afterLayout: (continuation) => continuation(), active: () => true, scrollBy: () => undefined, @@ -63,7 +57,6 @@ test("does not move a different session after history loads", async () => { new Promise((resolve) => { resolveLoad = resolve }), - height: () => 160, afterLayout: (continuation) => continuation(), active: (sessionID) => current === sessionID, scrollBy: (amount) => scrolled.push(amount), @@ -86,32 +79,29 @@ test("continues navigation after the prepended page is laid out", async () => { loadMore: async () => { events.push("loaded") }, - height: () => 100, afterLayout: (continuation) => { events.push("layout") continuation() }, active: () => true, - scrollBy: () => events.push("anchored"), + scrollBy: () => events.push("scrolled"), }) expect(prepend(0, () => events.push("continued"))).toBe(true) await Promise.resolve() await Promise.resolve() - expect(events).toEqual(["loaded", "layout", "anchored", "continued"]) + expect(events).toEqual(["loaded", "layout", "scrolled", "continued"]) }) test.each(["success", "failure", "cancel", "takeover"])("settles superseded prepend navigation (%s)", async (mode) => { const load = Promise.withResolvers() const layout: (() => void)[] = [] const events: (number | string)[] = [] - let height = 100 const prepend = createHistoryPrepend({ sessionID: () => "session-1", more: () => true, loadMore: () => load.promise, - height: () => height, afterLayout: (continuation) => layout.push(continuation), active: () => true, scrollBy: (amount) => events.push(amount), @@ -122,15 +112,12 @@ test.each(["success", "failure", "cancel", "takeover"])("settles superseded prep if (mode === "cancel" || mode === "takeover") prepend.cancel() if (mode === "takeover") expect(prepend(-8)).toBe(true) if (mode === "failure") load.reject(new Error("offline")) - if (mode !== "failure") { - height = 160 - load.resolve() - } + if (mode !== "failure") load.resolve() await Promise.resolve() expect(events).toEqual(mode === "failure" ? ["jump"] : []) layout.shift()?.() expect(events).toEqual( - mode === "failure" ? ["jump"] : mode === "success" ? [60, "jump"] : mode === "takeover" ? [52] : [60], + mode === "failure" ? ["jump"] : mode === "success" ? [0, "jump"] : mode === "takeover" ? [-8] : [0], ) prepend.after(() => events.push("idle")) expect(events.at(-1)).toBe("idle") diff --git a/packages/tui/test/session-history-interactions.test.tsx b/packages/tui/test/session-history-interactions.test.tsx new file mode 100644 index 000000000000..71036ec3a446 --- /dev/null +++ b/packages/tui/test/session-history-interactions.test.tsx @@ -0,0 +1,305 @@ +import { expect, test } from "bun:test" +import { ScrollBoxRenderable, type Renderable } from "@opentui/core" +import { TestRecorder } from "@opentui/core/testing" +import type { SessionMessageInfo } from "@opencode/client" +import { mkdir } from "node:fs/promises" +import { createAppFixture } from "./fixture/app" +import { tmpdir } from "./fixture/fixture" +import { directory, json } from "./fixture/tui-client" +import { waitForHighlightedFrame } from "./fixture/frame" + +const modes = [ + "waiting", + "streaming", + "stream-race", + "streaming-bottom", + "resize", + "tab", + "tab-publish", + "end", + "end-publish", + "home", + "home-publish", + "failure", + "reconnect", + "close", + "group-boundary", + "merged-boundary", + "disclosure", +] as const + +// These whole-app cases share the package's budget for a cold app import and reconnect. +test.each(modes.flatMap((mode) => [100, 48].map((width) => ({ mode, width }))))( + "pending history preserves the reader: $mode, $width columns", + async ({ mode, width }) => { + await using state = await tmpdir() + const session = { + id: "ses_history_interactions", + title: "History interactions", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const other = { ...session, id: "ses_history_other", title: "Other session" } + const id = (turn: number, role: number) => `msg_${String(turn).padStart(6, "0")}_${role}` + const messages: SessionMessageInfo[] = Array.from({ length: 60 }, (_, turn) => [ + { type: "user" as const, id: id(turn, 0), text: `History prompt ${turn}`, time: { created: turn * 10 } }, + { + type: "assistant" as const, + id: id(turn, 1), + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + finish: "stop" as const, + time: { created: turn * 10 + 1, completed: turn * 10 + 2 }, + content: + ["group-boundary", "merged-boundary", "disclosure"].includes(mode) && turn >= 30 && turn <= 40 + ? [ + { + type: "reasoning" as const, + text: `**History thought ${turn}**\n\nThought body ${turn}`, + time: { created: turn * 10 + 1, completed: turn * 10 + 2 }, + }, + ] + : [{ type: "text" as const, text: `History answer ${turn}` }], + }, + ]) + .flat() + .filter( + (message) => + mode !== "merged-boundary" || + message.type !== "user" || + Number(message.id.split("_")[1]) < 30 || + Number(message.id.split("_")[1]) > 40, + ) + await mkdir(`${state.path}/test/tui`, { recursive: true }) + await Bun.write( + `${state.path}/test/tui/tabs.json`, + JSON.stringify({ + global: { tabs: [{ sessionID: session.id }, { sessionID: other.id }], unread: {} }, + cwd: {}, + }), + ) + const release = Promise.withResolvers() + const requested = Promise.withResolvers() + const reconnected = Promise.withResolvers() + const rejected = Promise.withResolvers() + const pages: number[] = [] + let initial = 0 + let failed = false + await using app = await createAppFixture({ + width, + height: 30, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { enabled: true, scope: "global" } }, + fetch: async (url) => { + if (url.pathname === "/api/session") return json({ data: [session, other], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${other.id}`) return json({ data: other }) + if (url.pathname === `/api/session/${other.id}/message`) + return json({ + data: [{ type: "user", id: "msg_other", text: "OTHER SESSION MARKER", time: { created: 0 } }], + cursor: {}, + }) + if (url.pathname === `/api/session/${session.id}/message`) { + const cursor = url.searchParams.get("cursor") + const end = Number(cursor ?? messages.length) + const start = cursor + ? Math.max(0, end - Number(url.searchParams.get("limit"))) + : mode === "merged-boundary" + ? 70 + : mode === "group-boundary" || mode === "disclosure" + ? 81 + : 80 + if (!cursor) { + initial++ + if (initial > 1) reconnected.resolve() + } + if (cursor) { + pages.push(end) + requested.resolve() + await release.promise + if (mode === "failure" && !failed) { + failed = true + rejected.resolve() + return json({ message: "fixture offline" }, { status: 503 }) + } + } + return json({ data: messages.slice(start, end).toReversed(), cursor: start ? { next: String(start) } : {} }) + } + if (url.pathname.endsWith("/inbox") || url.pathname.endsWith("/permission")) return json({ data: [] }) + }, + }) + const find = (node: Renderable): ScrollBoxRenderable | undefined => + node instanceof ScrollBoxRenderable && node.getRenderable(id(59, 0)) + ? node + : node.getChildren().map(find).find(Boolean) + const anchor = () => + app + .captureCharFrame() + .split("\n") + .flatMap((line, y) => { + const match = line.match(/History prompt \d+/) + return match ? [{ text: match[0], y }] : [] + })[0] + const stream = () => + app.events.emit({ + id: "evt_history_stream", + created: 1000, + type: "session.text.delta", + data: { + sessionID: session.id, + assistantMessageID: id(59, 1), + ordinal: 0, + delta: "\nSTREAM ONE\nSTREAM TWO\nSTREAM THREE\nSTREAM FOUR", + }, + }) + try { + await app.ready + await waitForHighlightedFrame(app, (frame) => frame.includes("History answer 59")) + await app.waitForVisualIdle({ quietFrames: 3 }) + const scroll = find(app.renderer.root) + if (!scroll) throw new Error("Missing transcript") + for (let tick = 0; tick < 300 && (pages.length === 0 || scroll.scrollTop > scroll.viewport.height); tick++) { + await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up") + await app.renderOnce() + } + await requested.promise + if (mode === "disclosure" || mode === "merged-boundary") { + for (let tick = 0; tick < 300 && !app.captureCharFrame().includes("Thought:"); tick++) { + await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up") + await app.renderOnce() + } + await waitForHighlightedFrame(app, (frame) => frame.includes("Thought:")) + } + await app.waitForVisualIdle({ quietFrames: 3 }) + const before = anchor() + expect(before).toBeDefined() + const recorder = new TestRecorder(app.renderer) + recorder.rec() + const thoughtLine = () => + app + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Thought") && !line.includes("Thought body")) + const thought = thoughtLine() + + if (mode === "close") { + app.renderer.destroy() + release.resolve() + return + } + if (mode === "resize") { + app.resize(width === 100 ? 48 : 100, 24) + await app.waitForVisualIdle({ quietFrames: 3 }) + } + if (mode === "disclosure") { + await app.mockMouse.click(8, thought) + await waitForHighlightedFrame(app, (frame) => frame.includes("Thought body 40")) + expect(thoughtLine()).toBe(thought) + } + if (mode === "streaming") { + const height = scroll.scrollHeight + stream() + await app.waitForFrame(() => scroll.scrollHeight > height) + expect(anchor()).toEqual(before) + } + if (mode === "tab") { + app.mockInput.pressKey("2", { ctrl: true }) + await app.waitForFrame((frame) => frame.includes("OTHER SESSION MARKER")) + } + if (mode === "end" || mode === "streaming-bottom") { + app.mockInput.pressKey("END") + await waitForHighlightedFrame(app, (frame) => frame.includes("History answer 59")) + if (mode === "streaming-bottom") { + stream() + await waitForHighlightedFrame(app, (frame) => frame.includes("STREAM FOUR")) + } + } + if (mode === "home") app.mockInput.pressKey("HOME") + if (mode === "reconnect") { + app.events.disconnect() + await reconnected.promise + await app.waitForVisualIdle({ quietFrames: 3 }) + } + const held = anchor() + let navigatedOnPublish = false + if (mode.endsWith("-publish")) { + const afterLayout = scroll.afterLayout.bind(scroll) + scroll.afterLayout = (continuation) => { + afterLayout(continuation) + if (navigatedOnPublish) return + navigatedOnPublish = true + if (mode === "tab-publish") app.mockInput.pressKey("2", { ctrl: true }) + if (mode !== "tab-publish") app.mockInput.pressKey(mode === "end-publish" ? "END" : "HOME") + } + } + release.resolve() + if (mode === "stream-race") stream() + if (mode === "failure") { + await rejected.promise + await app.waitForVisualIdle({ quietFrames: 3 }) + expect(anchor()).toEqual(held) + for (let tick = 0; tick < 100 && pages.length < 2; tick++) { + await app.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up") + await app.renderOnce() + } + } + if (mode === "tab" || mode === "tab-publish") { + await app.waitForFrame((frame) => frame.includes("OTHER SESSION MARKER")) + await app.waitForVisualIdle({ quietFrames: 3 }) + expect(app.captureCharFrame()).toContain("OTHER SESSION MARKER") + expect(app.captureCharFrame()).not.toContain("History prompt") + app.mockInput.pressKey("1", { ctrl: true }) + await app.waitForFrame((frame) => frame.includes(before!.text)) + } + if (mode === "home" || mode === "home-publish") { + await app.waitForFrame( + (frame) => frame.includes("History prompt 0") && !frame.includes("Loading session history"), + ) + } + if (!["home", "home-publish", "reconnect", "end", "end-publish", "streaming-bottom"].includes(mode)) + await app.waitFor(() => !!find(app.renderer.root)?.getRenderable(id(mode === "merged-boundary" ? 25 : 30, 0))) + await app.waitForVisualIdle({ quietFrames: 3 }) + recorder.stop() + if (["waiting", "streaming", "stream-race", "group-boundary", "merged-boundary"].includes(mode)) { + expect(anchor()).toEqual(before) + expect( + recorder.recordedFrames.every((sample) => { + const lines = sample.frame.split("\n") + return lines[before!.y]?.includes(before!.text) + }), + ).toBe(true) + } + if (mode === "resize") expect(anchor()).toEqual(held) + if (mode === "disclosure") { + expect(thoughtLine()).toBe(thought) + expect(app.captureCharFrame()).toContain("Thought body 40") + expect(recorder.recordedFrames.every((sample) => sample.frame.split("\n")[thought]?.includes("Thought"))).toBe( + true, + ) + } + if (mode === "tab" || mode === "tab-publish") expect(anchor()).toEqual(before) + if (mode === "end" || mode === "end-publish" || mode === "streaming-bottom") { + const current = find(app.renderer.root)! + expect(current.scrollTop).toBe(Math.max(0, current.scrollHeight - current.viewport.height)) + expect(app.captureCharFrame()).not.toContain("Jump to latest") + } + if (mode.endsWith("-publish")) expect(navigatedOnPublish).toBe(true) + if (mode === "group-boundary" || mode === "disclosure") expect(pages).toEqual([81, 61]) + if (mode === "merged-boundary") expect(pages).toEqual([70]) + if (mode === "failure") expect(pages).toEqual([80, 80]) + if (mode === "reconnect") { + expect(initial).toBeGreaterThanOrEqual(2) + expect(app.captureCharFrame()).not.toContain("Unable to connect") + app.mockInput.pressKey("END") + await waitForHighlightedFrame(app, (frame) => frame.includes("History answer 59")) + } + } finally { + release.resolve() + } + }, + 30000, +) diff --git a/packages/tui/test/session-history-reveal.test.tsx b/packages/tui/test/session-history-reveal.test.tsx new file mode 100644 index 000000000000..9e3e8c431721 --- /dev/null +++ b/packages/tui/test/session-history-reveal.test.tsx @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test" +import { createTestRenderer, TestRecorder } from "@opentui/core/testing" +import { Effect, FileSystem } from "effect" +import { Global } from "@opencode/util/global" +import type { SessionMessageInfo } from "@opencode/client" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" +import { waitForHighlightedFrame } from "./fixture/frame" + +// The whole-app fixture needs the package's 30-second budget for its cold app import. +test.each([100, 48].flatMap((width) => [false, true].map((child) => ({ width, child }))))( + "scrolling up into older history never flashes: $width columns, child=$child", + async ({ width, child }) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width, height: 30, kittyKeyboard: true }) + await setup.renderer.setupTerminal() + setup.renderer.start() + const session = { + id: "ses_history_reveal", + title: "History reveal", + projectID: "proj_test", + parentID: child ? "ses_history_parent" : undefined, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const parent = { ...session, id: "ses_history_parent", parentID: undefined, title: "Parent fixture" } + const model = { providerID: "fixture", id: "fixture" } + const messages: SessionMessageInfo[] = Array.from({ length: 60 }, (_, turn) => [ + { type: "user" as const, id: `user-${turn}`, text: `Turn ${turn} prompt`, time: { created: turn * 10 } }, + { + type: "assistant" as const, + id: `answer-${turn}`, + agent: "build", + model, + finish: "stop" as const, + time: { created: turn * 10 + 1, completed: turn * 10 + 2 }, + content: [{ type: "text" as const, text: `Turn ${turn} answer` }], + }, + ]).flat() + // The first page (turns 40-59) overfills the mounted tail, so scrolling up first mounts its hidden + // rows, then loads turns 30-39 from the server. Both insert rows above the reader. + const calls = createFetch((url) => { + if (url.pathname === "/api/session") return json({ data: child ? [parent, session] : [session], cursor: {} }) + if (url.pathname === `/api/session/${parent.id}`) return json({ data: parent }) + if ( + url.pathname === `/api/session/${parent.id}/message` || + url.pathname === `/api/session/${parent.id}/inbox` || + url.pathname === `/api/session/${parent.id}/permission` + ) + return json({ data: [], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) { + const cursor = url.searchParams.get("cursor") + const end = Number(cursor ?? messages.length) + const start = Math.max(0, end - (cursor ? Number(url.searchParams.get("limit")) : 40)) + return json({ data: messages.slice(start, end).toReversed(), cursor: start ? { next: String(start) } : {} }) + } + if ( + url.pathname === `/api/session/${session.id}/inbox` || + url.pathname === `/api/session/${session.id}/permission` + ) + return json({ data: [] }) + }, createEventStream()) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) }, + packages: { prepare: async () => ({ directory: "" }) }, + args: { sessionID: session.id }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + await waitForHighlightedFrame(setup, (frame) => frame.includes("Turn 59 answer")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const recorder = new TestRecorder(setup.renderer) + recorder.rec() + for (let step = 0; step < 200 && !setup.captureCharFrame().includes("Turn 39 answer"); step++) { + await setup.mockMouse.scroll(30, 10, "up") + await setup.renderOnce() + } + await setup.waitForVisualIdle({ quietFrames: 3 }) + recorder.stop() + + // A wheel tick moves less than one turn, so the earliest turn on screen steps down by at most one + // per frame. A frame drawn before the inserted rows are scrolled past shows their first turn, and + // the next frame jumps back to the reader. + const turns = recorder.recordedFrames.map((frame) => + Math.min(...[...frame.frame.matchAll(/Turn (\d+) (?:prompt|answer)/g)].map((match) => Number(match[1]))), + ) + expect(turns.at(-1)).toBeLessThanOrEqual(39) + expect( + turns.filter((turn, index) => index > 0 && (turn > turns[index - 1] || turn < turns[index - 1] - 1)), + ).toEqual([]) + } finally { + setup.renderer.destroy() + await task + await server.stop() + } + }, + 30000, +)