// Browser geometry contracts for a long Chat transcript. These scenarios are // deliberately virtualizer-neutral: they assert semantic-row position, // bottom ownership, interaction state, and the real outer scroll host rather // than DOM cardinality or implementation-specific spacer markup. import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' const TOOL_SESSION_ID = 'chat-scroll-tool-e2e' const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e' const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e' const REPLAY_CONTEXT_WINDOW = 10_000_000 const STREAM_PACE_MS = 24 const GEOMETRY_TOLERANCE = 2 const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.' const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST' const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE' const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.' const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call') const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT' const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST' const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE' const TOOL_READY_FILE = '.chat-scroll-tool-ready' const TOOL_RELEASE_FILE = '.chat-scroll-tool-release' const HISTORY_FIXTURE = createChatScrollFixture({ markerPrefix: 'HISTORY', title: 'CHAT_SCROLL_HISTORY long paging session', }) const TOOL_FIXTURE = createChatScrollFixture({ markerPrefix: 'TOOL', title: 'CHAT_SCROLL_TOOL live tool session', }) const RESTORE_FIXTURE_A = createChatScrollFixture({ markerPrefix: 'RESTORE_A', title: 'CHAT_SCROLL_RESTORE_A long session', }) const RESTORE_FIXTURE_B = createChatScrollFixture({ markerPrefix: 'RESTORE_B', title: 'CHAT_SCROLL_RESTORE_B comparison session', turns: 32, }) interface ScrollGeometry { readonly distanceFromBottom: number readonly scrollTop: number } interface FlowAnchor { readonly key: string readonly top: number } interface ScrollWorld { readonly events: SessionEvent[] readonly page: Page readonly replayDir?: string readonly scaffold: WebScaffold readonly tripwire: ReturnType } interface ScrollWorldOptions { readonly failureShot: string readonly replay?: ReplayOverrideDoc readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[] } function textStream(first: string, done: string, deltaCount: number): StreamChunk[] { const deltas = Array.from({ length: deltaCount }, (_, index) => { if (index === 0) return `${first} ` if (index === deltaCount - 1) return `${done}.` return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}` }) const response = deltas.join('') return [ { type: 'block-start', index: 0, blockType: 'text' }, ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })), { type: 'block-end', index: 0, block: { type: 'text', text: response } }, { type: 'usage', usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) }, }, { type: 'finish', reason: { kind: 'stop' } }, ] } function toolStream(): StreamChunk[] { const command = [ `: > ${TOOL_READY_FILE}`, `while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`, 'line=1', `while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`, ].join('; ') const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT }) return [ { type: 'block-start', index: 0, blockType: 'tool-call' }, { type: 'tool-call-delta', index: 0, id: LIVE_TOOL_CALL_ID, name: 'bash', argumentsDelta: args, }, { type: 'block-end', index: 0, block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args }, }, { type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] } function replayEntry(chunks: StreamChunk[]): ReplayEntry { return { kind: 'chunks', chunks } } async function launchScrollWorld(options: ScrollWorldOptions): Promise { let replayDir: string | undefined let scaffold: WebScaffold | undefined let page: Page | undefined try { if (options.replay !== undefined) { replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-')) const replayOverride = join(replayDir, 'replay.override.json') await writeFile(replayOverride, JSON.stringify(options.replay)) scaffold = await launchWebScaffold({ replayFixture: join(replayDir, 'override-only.jsonl'), replayOverride, paceMs: STREAM_PACE_MS, replayContextWindow: REPLAY_CONTEXT_WINDOW, }) } else { scaffold = await launchWebScaffold({}) } for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id) const events: SessionEvent[] = [] scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) page = await newEnglishPage(browser, 900) const tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Session-list bootstrap can replace the controlled search state. Wait // for the seeded baseline before openSeed starts the lazy content query. await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 }) return { events, page, scaffold, tripwire, ...(replayDir === undefined ? {} : { replayDir }), } } catch (error) { const failures: unknown[] = [error] if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError)) if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError)) if (replayDir !== undefined) { await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError)) } if (failures.length === 1) throw error throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete') } } async function closeScrollWorld(world: ScrollWorld): Promise { const failures: unknown[] = [] // newEnglishPage/browser.newPage owns an isolated context. Close the whole // context so its SSE connection and cache cannot leak into the next world // in this file's shared Chromium process. await world.page.context().close().catch((error: unknown) => failures.push(error)) await world.scaffold.close().catch((error: unknown) => failures.push(error)) if (world.replayDir !== undefined) { await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) } if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed') } async function withScrollWorld( options: ScrollWorldOptions, run: (world: ScrollWorld) => Promise, ): Promise { const world = await launchScrollWorld(options) let runFailure: unknown try { await run(world) } catch (error) { runFailure = error try { await saveFailureShot(world.page, options.failureShot) } catch { // Best-effort evidence must never prevent cleanup of the owned world. } } let cleanupFailure: unknown try { await closeScrollWorld(world) } catch (error) { cleanupFailure = error } if (runFailure !== undefined && cleanupFailure !== undefined) { throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed') } if (runFailure !== undefined) throw runFailure if (cleanupFailure !== undefined) throw cleanupFailure } async function nextPaint(page: Page): Promise { await page.evaluate(async () => { await document.fonts.ready await new Promise(resolve => requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })) }) } function scrollGeometry(page: Page): Promise { return page.locator('[data-conversation-scroll]').evaluate(host => ({ distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop, scrollTop: host.scrollTop, })) } async function conversationTurns(page: Page): Promise { const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last() await stats.waitFor({ timeout: 15_000 }) const value = await stats.textContent() const match = value?.match(/^(\d+) turns · \d+ steps$/) if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`) return Number(match[1]) } async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise { const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) // Cold summaries initially show the temporary workspace basename, so the // persisted first-message marker is the stable user-facing identity. The // query itself triggers lazy content-index reconciliation; no transient // empty-state paint is used as a barrier. await search.fill(fixture.markers.user(1)) const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) await results.click() await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 }) if (tailMarker !== undefined) { await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 }) } await nextPaint(page) } async function wheelTranscript(page: Page, deltaY: number): Promise { const box = await page.locator('[data-conversation-scroll]').boundingBox() if (box === null) throw new Error('conversation scrollport has no layout box') await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) await page.mouse.wheel(0, deltaY) await nextPaint(page) } async function wheelToHistoryStart(page: Page): Promise { for (let attempt = 0; attempt < 12; attempt += 1) { if ((await scrollGeometry(page)).scrollTop <= 1) break await wheelTranscript(page, -2_400) } await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 }) .toBeLessThanOrEqual(1) } async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise { for (let attempt = 0; attempt < 16; attempt += 1) { if (await page.locator(selector).count() > 0) return await wheelTranscript(page, deltaY) } throw new Error(`selector did not mount during transcript wheel: ${selector}`) } async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise { const target = page.locator(selector) for (let attempt = 0; attempt < 32; attempt += 1) { if (await target.count() > 0 && await target.evaluate((row) => { const host = row.closest('[data-conversation-scroll]') if (host === null) return false const viewport = host.getBoundingClientRect() const composer = host.querySelector('[data-composer-seat]') const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom const rect = row.getBoundingClientRect() return rect.bottom > viewport.top && rect.top < visibleBottom })) return await wheelTranscript(page, deltaY) } throw new Error(`selector did not become visible during transcript wheel: ${selector}`) } function visibleFlowAnchor(page: Page): Promise { return page.locator('[data-conversation-scroll]').evaluate((host) => { const rows = [...host.querySelectorAll('[data-chat-anchor-key]')] const viewport = host.getBoundingClientRect() const composer = host.querySelector('[data-composer-seat]') const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom const visible = rows.filter((candidate) => { const rect = candidate.getBoundingClientRect() return rect.bottom > viewport.top && rect.top < visibleBottom }) const row = visible[0] if (row?.dataset.chatAnchorKey === undefined) { throw new Error(`no visible settled Chat row: ${JSON.stringify({ composerTop: visibleBottom, host: { bottom: viewport.bottom, top: viewport.top }, rows: rows.slice(0, 4).map(candidate => ({ callId: candidate.dataset.chatCallId, key: candidate.dataset.chatAnchorKey, rect: { bottom: candidate.getBoundingClientRect().bottom, top: candidate.getBoundingClientRect().top, }, })), totalRows: rows.length, })}`) } return { key: row.dataset.chatAnchorKey, top: row.getBoundingClientRect().top - viewport.top, } }) } function flowTop(page: Page, key: string): Promise { return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => { const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey) if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`) const host = row.closest('[data-conversation-scroll]') if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport') return row.getBoundingClientRect().top - host.getBoundingClientRect().top }, key) } async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise { await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), { timeout: 10_000, message: `flow row ${anchor.key} moved relative to the transcript viewport`, }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) } async function expectBottom(page: Page): Promise { await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), { timeout: 10_000, }).toBeLessThanOrEqual(1) } async function expectMarkerAboveComposer(page: Page, marker: string): Promise { const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => { const row = node.closest('[data-chat-flow-key], [data-streaming]') const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]') if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) { throw new Error('latest marker or composer geometry is unavailable') } return { composerTop: composer.getBoundingClientRect().top, rowBottom: row.getBoundingClientRect().bottom, } }) expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE) } async function loadEarlierWithAnchor(page: Page): Promise { await wheelToHistoryStart(page) const older = page.getByRole('button', { name: 'Load earlier', exact: true }) await older.waitFor({ timeout: 10_000 }) const anchor = await visibleFlowAnchor(page) const before = await conversationTurns(page) await older.click() await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before) await nextPaint(page) await expectSameFlowTop(page, anchor) } async function fileExists(path: string): Promise { try { await access(path) return true } catch { return false } } function eventCarries(event: SessionEvent, marker: string): boolean { return JSON.stringify(event).includes(marker) } function assertClean(world: ScrollWorld): void { expect(world.tripwire.pageErrors).toEqual([]) expect(world.tripwire.warnings).toEqual([]) } let browser: Browser describe('web e2e: long Chat scroll contract', () => { beforeAll(async () => { browser = await chromium.launch() }) afterAll(async () => { await browser?.close() }) it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-history-stream', replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))], seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }], }, async (world) => { await openSeed( world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns), ) await expectBottom(world.page) let releaseHistory = (): void => {} let held = false let releaseGate: (() => void) | undefined const gate = new Promise((resolve) => { releaseGate = resolve }) releaseHistory = () => { releaseGate?.() } await world.page.route('**/api/session.history', async (route) => { const request = route.request().postDataJSON() as { method?: string payload?: { beforeSeq?: number } } if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { held = true await gate } await route.continue() }) const settled = world.scaffold.whenTurnSettled(60_000) try { const composer = world.page.locator('textarea:enabled').last() await composer.fill(LIVE_TEXT_PROMPT) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 }) await wheelToHistoryStart(world.page) const beforeTurns = await conversationTurns(world.page) await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click() await expect.poll(() => held, { timeout: 10_000 }).toBe(true) await wheelTranscript(world.page, 420) const readerAnchor = await visibleFlowAnchor(world.page) const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length await expect.poll( () => world.events.filter(event => event.type === 'assistant/chunk').length, { timeout: 10_000 }, ).toBeGreaterThan(chunksAfterAnchor + 5) releaseHistory() await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns) await nextPaint(world.page) await expectSameFlowTop(world.page, readerAnchor) } finally { releaseHistory() } await settled await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) await world.page.unroute('**/api/session.history') let additionalPages = 0 while (additionalPages < 8) { await wheelToHistoryStart(world.page) if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break await loadEarlierWithAnchor(world.page) additionalPages += 1 } expect(additionalPages).toBeGreaterThan(0) expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1) expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0) assertClean(world) }) }, 180_000) it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-live-tool', replay: [ replayEntry(toolStream()), replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)), ], seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }], }, async (world) => { const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns)) const settled = world.scaffold.whenTurnSettled(60_000) let released = false try { const composer = world.page.locator('textarea:enabled').last() await composer.fill(LIVE_TOOL_PROMPT) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`) await liveRow.waitFor({ timeout: 15_000 }) expect(await liveRow.getAttribute('data-state')).toBe('running') await expectBottom(world.page) await wheelTranscript(world.page, -1_200) await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 }) const awayAnchor = await visibleFlowAnchor(world.page) const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length await writeFile(releasePath, 'release\n') released = true await expect.poll( () => world.events.some(event => event.type === 'tool/result'), { timeout: 15_000 }, ).toBe(true) await expect.poll( () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)), { timeout: 15_000 }, ).toBe(true) await expect.poll( () => world.events.filter(event => event.type === 'assistant/chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksBeforeRelease + 5) await expectSameFlowTop(world.page, awayAnchor) const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click() await expectBottom(world.page) await expect.poll( () => world.events.filter(event => event.type === 'assistant/chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksAtRepin + 5) await expectBottom(world.page) } finally { if (!released) await writeFile(releasePath, 'release\n').catch(() => {}) } await settled await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) await expectBottom(world.page) await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE) const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]` const liveRow = world.page.locator(liveRowSelector) await wheelUntilVisible(world.page, liveRowSelector, -300) const toolAnchor = await liveRow.evaluate((row) => { const flow = row.closest('[data-chat-anchor-key]') const host = row.closest('[data-conversation-scroll]') if (flow?.dataset.chatAnchorKey === undefined || host === null) { throw new Error('live tool row has no settled flow identity') } return { key: flow.dataset.chatAnchorKey, top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top, } }) await liveRow.click() await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') await expectSameFlowTop(world.page, toolAnchor) await wheelToHistoryStart(world.page) await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click() await expectBottom(world.page) await wheelUntilMounted(world.page, liveRowSelector, -1_100) const restoredRow = world.page.locator(liveRowSelector) await restoredRow.waitFor({ timeout: 10_000 }) expect(await restoredRow.getAttribute('aria-expanded')).toBe('true') expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0) assertClean(world) }) }, 180_000) it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-restore-composer', seeds: [ { fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID }, { fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID }, ], }, async (world) => { await openSeed( world.page, RESTORE_FIXTURE_A, RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), ) await loadEarlierWithAnchor(world.page) await loadEarlierWithAnchor(world.page) await wheelToHistoryStart(world.page) await wheelTranscript(world.page, 1_300) const sessionAnchor = await visibleFlowAnchor(world.page) await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click() await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) await world.page.setViewportSize({ width: 700, height: 900 }) await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await nextPaint(world.page) await expectSameFlowTop(world.page, sessionAnchor) await openSeed( world.page, RESTORE_FIXTURE_B, RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns), ) await openSeed( world.page, RESTORE_FIXTURE_A, ) await expectSameFlowTop(world.page, sessionAnchor) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) await backToBottom.evaluate((button) => { if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element') button.click() const trajectory = [...document.querySelectorAll('[role="tab"]')] .find(tab => tab.textContent?.trim() === 'Trajectory') if (!(trajectory instanceof HTMLElement)) { throw new Error('Trajectory tab is unavailable during pinned remount') } trajectory.click() }) await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await expectBottom(world.page) await openSeed( world.page, RESTORE_FIXTURE_B, RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns), ) await openSeed( world.page, RESTORE_FIXTURE_A, RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), ) await expectBottom(world.page) const composer = world.page.locator('textarea:enabled').last() const longDraft = Array.from( { length: 18 }, (_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`, ).join('\n') await composer.fill(longDraft) await nextPaint(world.page) await expectBottom(world.page) await expectMarkerAboveComposer( world.page, RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns), ) await composer.fill('short draft') await nextPaint(world.page) await wheelTranscript(world.page, -900) const resizeAnchor = await visibleFlowAnchor(world.page) await composer.fill(longDraft) await nextPaint(world.page) await expectSameFlowTop(world.page, resizeAnchor) await composer.fill('short draft') await nextPaint(world.page) await expectSameFlowTop(world.page, resizeAnchor) const beforeChain = await scrollGeometry(world.page) await composer.hover() await world.page.mouse.wheel(0, -320) await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 }) .toBeLessThan(beforeChain.scrollTop) assertClean(world) }) }, 180_000) })