// 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, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' 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 { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' const TOOL_SESSION_ID = SessionId('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 RESPONSIVE_REFLOW_TOLERANCE = 32 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 = ToolCallId('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 INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e' const RAIL_SESSION_ID = 'chat-scroll-rail-e2e' const FLING_SESSION_ID = SessionId('chat-scroll-fling-e2e') const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.' const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST' const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE' 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, }) const INPUTS_FIXTURE = createChatScrollFixture({ markerPrefix: 'INPUTS', title: 'CHAT_SCROLL_INPUTS non-wheel reader input session', }) interface FoldMotionSample { readonly t: number readonly ordinaryGrowth: boolean readonly top: number readonly bodyY: number readonly earlierY: number readonly messageY: number | null readonly stepY: number | null readonly flowHeight: number readonly height: number readonly spacer: number readonly folding: boolean readonly raceApplied: boolean readonly composerTop: number } interface ScrollGeometry { readonly distanceFromBottom: number readonly scrollTop: number } interface FlowAnchor { readonly key: string readonly top: number } interface ScrollWorld { readonly assistantFrames: AssistantStreamFrame[] readonly events: SessionEvent[] readonly page: Page readonly replayDir?: string readonly scaffold: WebScaffold readonly tripwire: ReturnType } interface ScrollWorldOptions { readonly failureShot: string /** Install a controllable browser clock before boot; omitted worlds use real time. */ readonly controlledClock?: boolean readonly paceMs?: number 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' } }, ] } /** Hold replay text after the initial deltas until browser setup releases it. */ function holdTextAfter(world: ScrollWorld, initialDeltas: number): () => void { const gate = Promise.withResolvers() const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { let deltas = 0 for await (const chunk of next()) { if (chunk.type === 'text-delta' && deltas++ === initialDeltas) await gate.promise yield chunk } }) return () => { gate.resolve(undefined) dispose() } } 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: options.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[] = [] const assistantFrames: AssistantStreamFrame[] = [] scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) scaffold.ctx.on('agent/assistant-stream', ({ frame }) => { assistantFrames.push(frame) }) page = await newEnglishPage(browser, 900) if (options.controlledClock) await page.clock.install() const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { 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 // (the compact layout dropped group session counts; the Ungrouped bucket // row is the barrier). await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) return { assistantFrames, 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, })) } /** * Rendered transcript rows in the loaded window. The stats strip cannot serve * as this probe: its turn/step counts ride the whole-log sessionStats * projection and stay fixed across paging by design, while the row count is * exactly what grows when an older page prepends or a live turn streams in. * @param page - the scenario page. * @returns the number of mounted chat flow rows. */ async function loadedFlowRows(page: Page): Promise { return page.locator('[data-chat-flow-key]').count() } async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise { if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) { await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click() } // Search collapsed into a header action; expand it before filling. const searchButton = page.getByRole('button', { name: 'Search sessions' }) if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() const search = page.getByRole('textbox', { name: 'Search session names', exact: true }) // Cold summaries initially show the temporary workspace basename, so the // persisted first-prompt 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) } /** * Touch-style momentum fling over the transcript. Headless Chromium in the * test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture * and Input.dispatchTouchEvent both deliver DOM events without moving any * scroller, and compositor scrollbars ignore synthetic mouse input), so the * fling replays the signature a real pan leaves on the scrollport: per-frame * decaying displacements the component never authored, carrying no wheel * events. Wheel-sign semantics: positive deltaY reads downward. */ async function flingTranscript(page: Page, deltaY: number): Promise { await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => { const direction = Math.sign(delta) let remaining = Math.abs(delta) // Fast launch decaying toward a floor speed, like a released finger. The // floor stays above the follow threshold so contended frames (streaming // writes racing the fling) still deviate far enough to read as input. let velocity = Math.max(120, remaining / 8) while (remaining > 0) { const step = Math.min(velocity, remaining) host.scrollTop += direction * step remaining -= step velocity = Math.max(48, velocity * 0.9) await new Promise(resolve => requestAnimationFrame(() => { resolve() })) } }, 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]:not([hidden])')] 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, tolerance = GEOMETRY_TOLERANCE, ): 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(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 }) const loading = page.getByRole('button', { name: 'Loading…', exact: true }) await older.waitFor({ timeout: 10_000 }) const anchor = await visibleFlowAnchor(page) const before = await loadedFlowRows(page) await older.click() await expect.poll(async () => ( await loadedFlowRows(page) > before && await loading.count() === 0 ), { timeout: 30_000 }).toBe(true) await nextPaint(page) if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) { expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0) return } 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([]) } it('generates a current-format scroll seed with a protected system head and intact references', () => { const { header, events } = parseSeedFixture(HISTORY_FIXTURE.log) expect(header.version).toBe(SESSION_FORMAT_VERSION) expect(events.slice(0, 5).map(event => event.type)).toEqual([ 'turn/start', 'step/start', 'system/message', 'user/message', 'session/title', ]) expect(events.filter(event => event.type === 'system/message')).toHaveLength(1) const firstUser = events.find(event => event.type === 'user/message')! const title = events.find(event => event.type === 'session/title')! expect(title.data.messageSeqs).toEqual([firstUser.seq]) const calls = events.filter(event => event.type === 'tool/call') const results = events.filter(event => event.type === 'tool/result') expect(calls).toHaveLength(22) expect(results).toHaveLength(calls.length) for (const result of results) { const call = calls.find(event => event.data.callId === result.data.message.source.callId)! expect(result.sourceEventSeqs).toEqual([call.seq]) expect(call.seq).toBeLessThan(result.seq) } expect(events.filter(event => event.type === 'turn/end')).toHaveLength(HISTORY_FIXTURE.turns) expect(events.at(-1)?.type).toBe('turn/end') }) let browser: Browser describe('web e2e: long Chat scroll contract', () => { beforeAll(async () => { browser = await chromium.launch() }) afterAll(async () => { await browser?.close() }) for (const scenario of [ { name: 'body-only', bodyParagraphs: 32, inputLines: 1, reduced: false }, { name: 'visible-steps', bodyParagraphs: 2, inputLines: 1, reduced: false }, { name: 'large-input', bodyParagraphs: 2, inputLines: 18, reduced: false }, { name: 'reduced-motion', bodyParagraphs: 2, inputLines: 1, reduced: true }, { name: 'cancel-all-during-fold', bodyParagraphs: 2, inputLines: 1, reduced: false }, { name: 'content-growth-during-fold', bodyParagraphs: 32, inputLines: 1, reduced: false }, ]) { it.skipIf(MODE === 'record')(`uses only visible fold room in one upward movement: ${scenario.name}`, async () => { const base = createChatScrollFixture({ markerPrefix: 'VISIBLE_FOLD', title: `CHAT_VISIBLE_FOLD_${scenario.name}`, turns: 8 }) const parsed = parseSeedFixture(base.log) const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === 8) const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = [ ...parsed.events.slice(0, boundary), { type: 'turn/start', data: { turn: 8 } }, { type: 'user/message', data: createUserMessage({ content: [{ type: 'text', text: 'VISIBLE_FOLD_EARLIER_ANCHOR' }], source: { kind: 'user' }, }), surfaceOp: 'append' }, ] for (let step = 1; step <= 6; step++) { drafts.push({ type: 'step/start', data: { turn: 8, step } }) drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { turn: 8, step, stream: [], message: createAssistantMessage({ content: [ { type: 'reasoning', text: `VISIBLE_FOLD_STEP_${step}\n\n${Array.from({ length: 18 }, (_, line) => `Reasoning stage ${step}, paragraph ${line}: retained work details explain this intermediate decision.`).join('\n\n')}` }, { type: 'text', text: `VISIBLE_FOLD_PROGRESS_${step}` }, ], source: { provider: 'fixture', model: 'fixture' }, }), } }) drafts.push({ type: 'step/end', data: { turn: 8, step } }) } const bodyMarker = 'VISIBLE_FOLD_BODY' drafts.push({ type: 'step/start', data: { turn: 8, step: 7 } }) drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { turn: 8, step: 7, stream: [], message: createAssistantMessage({ content: [{ type: 'text', text: `${bodyMarker}\n\n${Array.from({ length: scenario.bodyParagraphs }, (_, line) => `Final answer paragraph ${line}: this content remains after the preceding work details fold.`).join('\n\n')}` }], source: { provider: 'fixture', model: 'fixture' }, }), } }) drafts.push({ type: 'step/end', data: { turn: 8, step: 7 } }, { type: 'turn/end', data: { turn: 8, reason: { kind: 'completed' } } }) const fixture: ChatScrollFixture = { ...base, log: [parsed.headerLine, ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') } const input = Array.from({ length: scenario.inputLines }, (_, index) => `VISIBLE_FOLD_NEXT_${index}`).join('\n') const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url)) await mkdir(artifactRoot, { recursive: true }) const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`)) const controlledClock = !scenario.reduced await withScrollWorld({ failureShot: `visible-fold-${scenario.name}`, controlledClock, replay: [replayEntry(textStream('VISIBLE_FOLD_REPLY', 'VISIBLE_FOLD_DONE', 2))], seeds: [{ fixture, id: `visible-fold-${scenario.name}` }], }, async (world) => { const { page } = world await page.setViewportSize({ width: 1280, height: 720 }) await page.emulateMedia({ reducedMotion: scenario.reduced ? 'reduce' : 'no-preference' }) await openSeed(page, fixture, bodyMarker) await openSettings(page, 'en') const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..') await timing.getByRole('button').click() await page.getByRole('menuitem', { name: 'On next message', exact: true }).click() const details = dialog.getByText('Work details', { exact: true }).locator('../..') await details.getByRole('button').click() await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click() await dialog.getByRole('button', { name: 'Close', exact: true }).click() const thoughts = page.locator('[data-chat-turn="8"] [data-variant="think"]') await expect.poll(() => thoughts.count()).toBe(6) for (let index = 0; index < 6; index++) { const button = thoughts.nth(index).getByRole('button').first() if (await button.getAttribute('aria-expanded') !== 'true') await button.click() } await wheelTranscript(page, -600) await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() await expectBottom(page) await nextPaint(page) await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input) await nextPaint(page) if (scenario.name === 'visible-steps') { const offset = 20 await thoughts.last().getByRole('button').first().evaluate((button, offset) => { const host = button.closest('[data-conversation-scroll]')! const group = button.closest('[data-chat-group-key]')! host.scrollTop += group.getBoundingClientRect().top - host.getBoundingClientRect().top - offset host.dispatchEvent(new Event('scroll')) }, offset) await nextPaint(page) } const before = await page.locator('[data-conversation-scroll]').evaluate((host) => { const viewport = host.getBoundingClientRect() const composer = host.querySelector('[data-composer-seat]')! const body = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)! const groups = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-key]')] const candidates = [...host.querySelectorAll('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')] .filter(element => !element.parentElement?.closest('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')) return { top: host.scrollTop, height: host.scrollHeight, viewportTop: viewport.top, removed: candidates.map((element) => { const rect = element.getBoundingClientRect() const margin = Number.parseFloat(getComputedStyle(element).marginTop) || 0 return { top: rect.top - viewport.top - margin, height: rect.height + margin } }), floor: Math.max(0, host.scrollHeight - host.clientHeight), viewport: host.clientHeight, visibleHeight: composer.getBoundingClientRect().top - viewport.top, bodyTop: body.getBoundingClientRect().top, bodyHeight: body.getBoundingClientRect().height, flowHeight: host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!.getBoundingClientRect().height, groupHeight: groups.reduce((sum, group) => sum + group.getBoundingClientRect().height, 0), visibleSteps: groups.reduce((sum, group) => { const rect = group.getBoundingClientRect() return sum + Math.max(0, Math.min(rect.bottom, composer.getBoundingClientRect().top) - Math.max(rect.top, viewport.top)) }, 0) } }) expect(before.groupHeight).toBeGreaterThan(before.visibleHeight * 3) if (scenario.bodyParagraphs === 32) expect(before.visibleSteps).toBe(0) else expect(before.visibleSteps).toBeGreaterThan(0) if (scenario.name === 'cancel-all-during-fold') { await openSettings(page, 'en') const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) await settings.getByText('Work details', { exact: true }).locator('../..').getByRole('button').click() await page.getByRole('menuitem', { name: 'Verbose', exact: true }).waitFor() } const release = holdTextAfter(world, 0) const finishGate = Promise.withResolvers() const finishBlocked = Promise.withResolvers() const holdFinish = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { for await (const chunk of next()) { if (chunk.type === 'finish') { finishBlocked.resolve(undefined) await finishGate.promise } yield chunk } }) const resolved = await world.scaffold.ctx.sessionController.resolveAgent(SessionId(`visible-fold-${scenario.name}`)) if ('error' in resolved) throw resolved.error // Maintenance keeps the accepted message in the inbox, so its echo folds the earlier work before the Turn starts. const promptGate = Promise.withResolvers() const maintenance = resolved.agent.runMaintenance(() => promptGate.promise) const trace = page.evaluate(({ marker, race }) => new Promise((resolveTrace) => { const host = document.querySelector('[data-conversation-scroll]')! const column = host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')! const body = [...column.querySelectorAll('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)! const earlier = [...column.querySelectorAll('*')].find(element => element.children.length === 0 && element.textContent === 'VISIBLE_FOLD_EARLIER_ANCHOR')! const samples: FoldMotionSample[] = [] const visibleStep = [...column.querySelectorAll('[data-chat-turn="8"] [data-variant="think"] [role="button"]')].at(-1) let frame = 0 let ordinaryGrowth = false let raceApplied = false let closingHeight: number | undefined const markGrowth = (): void => { ordinaryGrowth = true } host.addEventListener('dsh-test-fold-growth', markGrowth) const stepPosition = (): number | null => { if (visibleStep === undefined || !visibleStep.isConnected || visibleStep.closest('[hidden]') !== null) return null const rect = visibleStep.getBoundingClientRect() if (rect.height === 0 || rect.width === 0) return null const walker = document.createTreeWalker(visibleStep, NodeFilter.SHOW_TEXT) let textNode = walker.nextNode() while (textNode !== null && !(textNode.textContent ?? '').trim()) textNode = walker.nextNode() if (textNode === null) return null const range = document.createRange() range.selectNodeContents(textNode) const glyph = range.getBoundingClientRect() return glyph.height === 0 ? null : glyph.top } const sample = (): void => { const closing = [...column.querySelectorAll('[data-chat-motion="collapse"]')] .filter(element => element.parentElement?.closest('[data-chat-motion="collapse"]') === null) const height = closing.reduce((sum, element) => sum + element.getBoundingClientRect().height, 0) if (closing.length > 0) closingHeight ??= height if (!raceApplied && closingHeight !== undefined && height > 0 && height < closingHeight - 1) { if (race === 'cancel-all-during-fold') { const verbose = [...document.querySelectorAll('[role="menuitem"]')] .find(element => element.textContent?.trim() === 'Verbose') if (verbose !== undefined) { raceApplied = true verbose.click() } } else if (race === 'content-growth-during-fold') { // Controlled layout growth exercises ResizeObserver overlap, not Session-event delivery. raceApplied = true column.style.paddingBottom = '320px' } if (raceApplied) host.setAttribute('data-fold-race-applied', '') } const message = [...column.querySelectorAll('[data-submission-echo], [data-chat-flow-kind="user"]')] .find(element => element.textContent?.includes(marker)) const step = stepPosition() samples.push({ t: performance.now(), ordinaryGrowth, top: host.scrollTop, bodyY: body.getBoundingClientRect().top, earlierY: earlier.getBoundingClientRect().top, messageY: message?.getBoundingClientRect().top ?? null, stepY: step, flowHeight: column.getBoundingClientRect().height, height: host.scrollHeight, spacer: host.querySelector('[data-chat-turn-spacer]')!.getBoundingClientRect().height, folding: column.querySelector('[data-chat-motion="collapse"]') !== null, raceApplied, composerTop: host.querySelector('[data-composer-seat]')!.getBoundingClientRect().top }) } const tick = (): void => { sample(); frame = requestAnimationFrame(tick) } host.addEventListener('dsh-test-fold-stop', () => { cancelAnimationFrame(frame); sample() host.removeEventListener('dsh-test-fold-growth', markGrowth) host.removeAttribute('data-fold-race-applied') host.removeAttribute('data-fold-probe-ready'); resolveTrace(samples) }, { once: true }) sample(); host.setAttribute('data-fold-probe-ready', ''); frame = requestAnimationFrame(tick) }), { marker: 'VISIBLE_FOLD_NEXT_0', race: scenario.name }) await page.locator('[data-fold-probe-ready]').waitFor() let settled: ReturnType | undefined let samples: Awaited = [] try { settled = world.scaffold.whenTurnSettled(30_000) if (controlledClock) { // Sample each animation frame, including when the CI browser misses real-time frames. await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) try { await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => { if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button') button.click() }) if (scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold') { await expect.poll(async () => { await page.clock.runFor(16) return page.locator('[data-fold-race-applied]').count() }, { timeout: 10_000 }).toBe(1) } await expect.poll(async () => { await page.clock.runFor(16) return page.locator('[data-chat-motion="collapse"]').count() }, { timeout: 10_000 }).toBe(0) } finally { await page.clock.resume() } } else await page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => page.locator('[data-composer-input]').last().textContent()).toBe('') await page.getByText(input, { exact: true }).waitFor() await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0) if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page) await page.locator('[data-conversation-scroll]').evaluate(async (host) => { let previous = host.scrollTop let stableSince = performance.now() await new Promise((resolveStable) => { const check = (): void => { const now = performance.now() if (Math.abs(host.scrollTop - previous) > 0.25) stableSince = now previous = host.scrollTop if (now - stableSince >= 240) resolveStable() else requestAnimationFrame(check) } requestAnimationFrame(check) }) }) await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth'))) promptGate.resolve(undefined) await maintenance release() await finishBlocked.promise await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor() await expectBottom(page) await nextPaint(page) } finally { promptGate.resolve(undefined) await maintenance release() await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop'))) samples = await trace finishGate.resolve(undefined) holdFinish() await settled } const allSamples = samples const ordinarySamples = samples.filter(sample => sample.ordinaryGrowth) samples = samples.filter(sample => !sample.ordinaryGrowth) const first = samples[0]! const last = samples.at(-1)! const closedBoxesHeight = before.removed.reduce((sum, box) => sum + box.height, 0) const removedHeight = first.bodyY + first.top - last.bodyY - last.top const boundaryGrowth = closedBoxesHeight - removedHeight const aboveRemoved = Math.min(removedHeight, before.removed.reduce((sum, box) => sum + Math.min(box.height, Math.max(0, -box.top)), 0)) const remainingHeight = last.height - last.spacer const newHeight = remainingHeight - before.height + removedHeight const baseTop = Math.max(0, before.top - aboveRemoved) const expectedTop = Math.max(baseTop, remainingHeight - before.viewport, 0) const expectedSpacer = expectedTop === 0 ? 0 : Math.max(0, expectedTop + before.viewport - remainingHeight) const ordinaryFollow = expectedTop - baseTop const expectedBodyShift = removedHeight - aboveRemoved + ordinaryFollow const bodyShift = first.bodyY - last.bodyY const reversalOf = (values: readonly number[]): number => { let lowest = values[0] ?? 0 let reversal = 0 for (const value of values) { lowest = Math.min(lowest, value) reversal = Math.max(reversal, value - lowest) } return reversal } const reversal = reversalOf(samples.map(sample => sample.bodyY)) const inputReversal = reversalOf(samples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) const ordinaryBodyReversal = reversalOf([samples.at(-1)!.bodyY, ...ordinarySamples.map(sample => sample.bodyY)]) const ordinaryInputReversal = reversalOf(ordinarySamples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) const stepReversal = reversalOf(samples.flatMap(sample => sample.stepY === null ? [] : [sample.stepY])) const firstFoldIdle = samples.findIndex((sample, index) => index > 0 && !sample.folding && samples[index - 1]!.folding) const afterFold = firstFoldIdle < 0 ? [] : samples.slice(firstFoldIdle) const range = (values: number[]): number => values.length === 0 ? 0 : Math.max(...values) - Math.min(...values) const foldBoundaryMovement = firstFoldIdle > 0 ? Math.abs(samples[firstFoldIdle]!.bodyY - samples[firstFoldIdle - 1]!.bodyY) : 0 const priorFrameMovements = samples.slice(1, Math.max(1, firstFoldIdle - 1)) .map((sample, index) => Math.abs(sample.bodyY - samples[index]!.bodyY)) const secondMovement = range(afterFold.map(sample => sample.bodyY)) const secondInputMovement = range(afterFold.flatMap(sample => sample.messageY === null ? [] : [sample.messageY])) const earlierShift = first.earlierY - last.earlierY await writeFile(join(evidence, 'motion.json'), JSON.stringify({ scenario, before, closedBoxesHeight, removedHeight, boundaryGrowth, aboveRemoved, remainingHeight, newHeight, expectedTop, expectedSpacer, expectedBodyShift, bodyShift, ordinaryFollow, earlierShift, reversal, inputReversal, stepReversal, secondMovement, secondInputMovement, ordinaryBodyReversal, ordinaryInputReversal, samples: allSamples }, null, 2)) console.log(`visible-fold ${scenario.name}: spacer=${last.spacer}/${expectedSpacer}, top=${last.top}/${expectedTop}, reversal=${reversal}, secondMovement=${secondMovement}, evidence=${evidence}`) if (scenario.name === 'cancel-all-during-fold') { expect(samples.some(sample => sample.raceApplied)).toBe(true) expect(last.folding).toBe(false) expect(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect(await page.getByRole('dialog', { name: 'Settings', exact: true }) .getByText('Work details', { exact: true }).locator('../..') .getByRole('button', { name: 'Verbose', exact: true }).count()).toBe(1) expect(await page.locator('[data-input-fold]').count()).toBe(0) assertClean(world) return } if (scenario.name === 'content-growth-during-fold') { expect(samples.some(sample => sample.raceApplied && sample.folding)).toBe(true) expect.soft(foldBoundaryMovement).toBeLessThanOrEqual(Math.max(...priorFrameMovements) + GEOMETRY_TOLERANCE) } expect.soft(removedHeight).toBeGreaterThan(before.visibleHeight * 3) if (!scenario.reduced) { expect.soft(samples.some(sample => sample.folding)).toBe(true) const movingSamples = samples.filter((sample, index) => index > 0 && Math.abs(sample.bodyY - samples[index - 1]!.bodyY) > 0.25) expect.soft(movingSamples.length).toBeGreaterThan(1) expect.soft(firstFoldIdle).toBeGreaterThan(0) } if (scenario.name === 'visible-steps') { expect.soft(first.stepY).not.toBeNull() expect.soft(first.stepY!).toBeGreaterThanOrEqual(before.viewportTop) expect.soft(first.stepY!).toBeLessThan(first.composerTop) const initial = samples.filter(sample => sample.stepY !== null && sample.t <= samples[firstFoldIdle - 1]!.t) expect.soft(Math.max(...initial.map(sample => sample.stepY!)) - first.stepY!).toBeLessThanOrEqual(0.5) } expect.soft(Math.abs(last.top - expectedTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(Math.abs(last.spacer - expectedSpacer)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(Math.abs(bodyShift - expectedBodyShift)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(inputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(stepReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(ordinarySamples.length).toBeGreaterThan(0) expect.soft(ordinaryBodyReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(ordinaryInputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(secondMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(secondInputMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) if (first.earlierY >= before.viewportTop) { expect.soft(Math.abs(earlierShift - ordinaryFollow)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) } if (scenario.name === 'body-only' || scenario.name === 'large-input') expect.soft(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) expect.soft(last.messageY).not.toBeNull() expect.soft(last.messageY!).toBeLessThan(last.composerTop) assertClean(world) }) }, 180_000) } it.skipIf(MODE === 'record')('resets transcript height when switching from Verbose back to Standard during a reply', async () => { const fixture = createChatScrollFixture({ markerPrefix: 'MODE', title: 'CHAT_MODE_HEIGHT', turns: 8 }) await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/chat-mode-height/', import.meta.url)), { recursive: true }) await withScrollWorld({ failureShot: 'screenshots/chat-mode-height/web-e2e-chat-mode-height', replay: [replayEntry(textStream('CHAT_MODE_REPLY', 'CHAT_MODE_DONE', 2))], seeds: [{ fixture, id: 'chat-mode-height-e2e' }], }, async (world) => { const { page } = world await page.setViewportSize({ width: 1280, height: 720 }) await page.emulateMedia({ reducedMotion: 'no-preference' }) const selectPreference = async (title: string, choice: string): Promise => { await openSettings(page, 'en') const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) const row = dialog.getByText(title, { exact: true }).locator('../..') await row.getByRole('button').click() await page.getByRole('menuitem', { name: choice, exact: true }).click() await row.getByRole('button', { name: choice, exact: true }).waitFor() if (title === 'Work details') { await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) .toMatchObject({ transcriptView: choice.toLowerCase() }) } await dialog.getByRole('button', { name: 'Close', exact: true }).click() await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0) await nextPaint(page) } const returnToBottom = async (): Promise => { await wheelTranscript(page, -600) await page.getByRole('button', { name: 'Back to bottom', exact: true }).click() await expectBottom(page) } const geometry = () => page.locator('[data-conversation-scroll]').evaluate(host => ({ height: host.scrollHeight, viewport: host.clientHeight, spacer: host.querySelector('[data-chat-turn-spacer]')!.getBoundingClientRect().height, })) await selectPreference('Work details', 'Standard') await openSeed(page, fixture, fixture.markers.assistant(8)) // Hold a live reply so preference changes exercise the same fold animation as incoming content. const release = holdTextAfter(world, 1) let replyStarted = false try { await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_MODE_NEXT_INPUT') await page.getByRole('button', { name: 'Send message', exact: true }).click() await page.getByText('CHAT_MODE_REPLY', { exact: false }).waitFor() replyStarted = true await selectPreference('When to Collapse Work Details', 'On next message') await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor() await returnToBottom() const standard = await geometry() expect(standard.height).toBeGreaterThan(standard.viewport) expect(standard.spacer).toBe(0) for (let cycle = 0; cycle < 2; cycle += 1) { await selectPreference('Work details', 'Verbose') await expect.poll(async () => (await geometry()).height) .toBeGreaterThan(standard.height + GEOMETRY_TOLERANCE) await returnToBottom() await selectPreference('Work details', 'Standard') await expect.poll(async () => (await geometry()).spacer).toBe(0) await expect.poll(async () => Math.abs((await geometry()).height - standard.height)) .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) await expectBottom(page) await expectMarkerAboveComposer(page, 'CHAT_MODE_REPLY') } } finally { const settled = replyStarted ? world.scaffold.whenTurnSettled() : undefined release() await settled } assertClean(world) }) }) it.skipIf(MODE === 'record')('keeps a running Session stationary when returning with output held', async () => { const fixtureA = createChatScrollFixture({ markerPrefix: 'RETURN_A', title: 'CHAT_RUNNING_RETURN_A' }) const fixtureB = createChatScrollFixture({ markerPrefix: 'RETURN_B', title: 'CHAT_RUNNING_RETURN_B', turns: 32 }) const heldMarker = 'CHAT_RUNNING_RETURN_HELD' const grownMarker = 'CHAT_RUNNING_RETURN_GROWN' const doneMarker = 'CHAT_RUNNING_RETURN_DONE' const paragraphs = (count: number, prefix: string): string => Array.from({ length: count }, (_, index) => `${prefix} paragraph ${index + 1}: ${'The recorded response stays readable while this Session is open. '.repeat(3)}`, ).join('\n\n') const parts = [ `${paragraphs(32, 'Initial output')}\n\n${heldMarker}\n\n`, `${paragraphs(12, 'Resumed output')}\n\n${grownMarker}\n\n`, doneMarker, ] const chunks: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'text' }, ...parts.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })), { type: 'block-end', index: 0, block: { type: 'text', text: parts.join('') } }, { type: 'usage', usage: { inputTokens: 512, outputTokens: 2_500 } }, { type: 'finish', reason: { kind: 'stop' } }, ] const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/chat-running-return/', import.meta.url)) await mkdir(artifactRoot, { recursive: true }) const evidence = await mkdtemp(join(artifactRoot, 'run-')) await withScrollWorld({ failureShot: 'screenshots/chat-running-return/web-e2e-chat-running-return', replay: [replayEntry(chunks)], seeds: [ { fixture: fixtureA, id: 'chat-running-return-a-e2e' }, { fixture: fixtureB, id: 'chat-running-return-b-e2e' }, ], }, async (world) => { const { page } = world await page.setViewportSize({ width: 1280, height: 720 }) await page.emulateMedia({ reducedMotion: 'no-preference' }) await openSettings(page, 'en') const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) for (const [title, choice] of [ ['Work details', 'Standard'], ['When to Collapse Work Details', 'On next message'], ] as const) { await dialog.getByText(title, { exact: true }).locator('../..').getByRole('button').click() await page.getByRole('menuitem', { name: choice, exact: true }).click() } await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) .toMatchObject({ transcriptView: 'standard' }) await dialog.getByRole('button', { name: 'Close', exact: true }).click() await openSeed(page, fixtureA, fixtureA.markers.assistant(fixtureA.turns)) await expectBottom(page) interface ReturnFrame { frame: number t: number sessionA: boolean markerY: number | null markerVisible: boolean scrollTop: number floor: number viewportHeight: number columnHeight: number spacer: number motionEnabled: boolean motion: string[] following: boolean running: boolean responseLength: number | null } const sampleReturn = async (marker: string): Promise => { await openSeed(page, fixtureB, fixtureB.markers.assistant(fixtureB.turns)) await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixtureA.markers.user(1)) const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await expect.poll(() => result.count()).toBe(1) // Start before the click; the first visible A frame is part of the assertion, not a setup wait. const trace = page.evaluate(({ marker, identity }) => new Promise((resolve) => { const samples: ReturnFrame[] = [] let frame = 0 let timer = 0 let visibleFrames = 0 let ordinal = 0 const finish = (): void => { cancelAnimationFrame(frame) window.clearTimeout(timer) document.removeEventListener('dsh-test-return-stop', finish) document.documentElement.dataset.returnProbe = 'done' resolve(samples) } const sample = (): void => { const host = [...document.querySelectorAll('[data-conversation-scroll]')] .find(element => !element.closest('[inert], [hidden]') && element.checkVisibility()) const column = host?.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])') if (host !== undefined && column !== null && column !== undefined) { const viewport = host.getBoundingClientRect() const composerTop = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top ?? viewport.bottom const node = [...column.querySelectorAll('p')] .find(element => element.textContent?.trim() === marker) const rect = node?.getBoundingClientRect() const sessionA = column.textContent.includes(identity) samples.push({ frame: ordinal++, t: performance.now(), sessionA, markerY: rect === undefined ? null : rect.top - viewport.top, markerVisible: rect !== undefined && rect.bottom > viewport.top && rect.top < composerTop, scrollTop: host.scrollTop, floor: host.scrollHeight - host.clientHeight, viewportHeight: host.clientHeight, columnHeight: column.getBoundingClientRect().height, spacer: host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0, motionEnabled: column.hasAttribute('data-chat-motion'), motion: [...column.querySelectorAll('[data-chat-motion]')] .map(element => element.dataset.chatMotion ?? ''), following: host.querySelector('[data-chat-following-tail]') !== null, running: host.querySelector('[data-chat-running]') !== null, responseLength: node?.closest('[data-chat-flow-key]')?.textContent?.length ?? null, }) if (sessionA) visibleFrames += 1 } if (visibleFrames >= 60) finish() else frame = requestAnimationFrame(tick) } // A zero-delay task samples after this frame's animation callbacks, including native scrolling. const tick = (): void => { timer = window.setTimeout(sample, 0) } document.addEventListener('dsh-test-return-stop', finish, { once: true }) document.documentElement.dataset.returnProbe = 'ready' frame = requestAnimationFrame(tick) }), { marker, identity: 'CHAT_SCROLL_RETURN_A_' }) try { await page.locator('html[data-return-probe="ready"]').waitFor() await result.click() await page.locator('html[data-return-probe="done"]').waitFor({ timeout: 15_000 }) } finally { await page.evaluate(() => { document.dispatchEvent(new Event('dsh-test-return-stop')) }) await trace await page.evaluate(() => { delete document.documentElement.dataset.returnProbe }) } return trace } const gates = [Promise.withResolvers(), Promise.withResolvers()] const held = [false, false] const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { let index = -1 for await (const chunk of next()) { if (chunk.type === 'text-delta') { const gate = gates[index++] if (gate !== undefined) { held[index - 1] = true await gate.promise } } yield chunk } }) let settled: ReturnType | undefined await page.context().tracing.start({ screenshots: true, snapshots: true }) try { await page.locator('[data-composer-input][contenteditable="true"]').last().fill('CHAT_RUNNING_RETURN_NEXT_INPUT') await page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => held[0]).toBe(true) await page.getByText(heldMarker, { exact: true }).waitFor() await page.locator('[data-chat-running]').waitFor() await page.locator('[data-chat-flow][data-chat-motion=""]').waitFor() await expect.poll(() => page.locator('[data-chat-motion="collapse"], [data-chat-motion="reveal"]').count()).toBe(0) await expectBottom(page) const before = await scrollGeometry(page) const chunkCount = () => world.assistantFrames.filter(frame => frame.type === 'chunk').length const heldChunks = chunkCount() const running = await sampleReturn(heldMarker) const chunksAfterReturn = chunkCount() await writeFile(join(evidence, 'running-frames.json'), JSON.stringify({ before, heldChunks, chunksAfterReturn, samples: running }, null, 2)) await page.screenshot({ path: join(evidence, 'running-held.png') }) expect(chunksAfterReturn).toBe(heldChunks) expect(world.events.some(event => event.type === 'turn/end')).toBe(false) await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor() const beforeGrowth = await scrollGeometry(page) gates[0]!.resolve(undefined) await expect.poll(() => held[1]).toBe(true) await page.getByText(grownMarker, { exact: true }).waitFor() await expectBottom(page) const afterGrowth = await scrollGeometry(page) expect(afterGrowth.scrollTop - beforeGrowth.scrollTop).toBeGreaterThan(200) expect(await page.locator('[data-chat-following-tail]').count()).toBe(1) await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor() await expectMarkerAboveComposer(page, grownMarker) settled = world.scaffold.whenTurnSettled() gates[1]!.resolve(undefined) await settled await page.getByText(doneMarker, { exact: true }).waitFor() await expect.poll(() => page.locator('[data-chat-running], [data-streaming="true"]').count()).toBe(0) await expectBottom(page) const completed = await sampleReturn(doneMarker) await page.screenshot({ path: join(evidence, 'completed.png') }) const summarize = (samples: ReturnFrame[]) => { const visible = samples.filter(sample => sample.sessionA) const first = visible[0]! const markerMovement = Math.max(...visible.map(sample => sample.markerY === null || first.markerY === null ? Infinity : Math.abs(sample.markerY - first.markerY))) return { frames: visible.length, markerMovement, first, last: visible.at(-1)! } } const summary = { running: summarize(running), completed: summarize(completed) } await writeFile(join(evidence, 'return-trace.json'), JSON.stringify({ viewport: { width: 1280, height: 720 }, mode: 'standard', collapseTiming: 'next-input', historyTurns: { a: fixtureA.turns, b: fixtureB.turns }, initialTextLength: parts[0]!.length, before, heldChunks, chunksAfterReturn, beforeGrowth, afterGrowth, summary, running, completed, }, null, 2)) console.log(`running-return e2e: ${JSON.stringify(summary)}; evidence=${evidence}`) for (const [name, samples] of [['completed', completed], ['running', running]] as const) { const visible = samples.filter(sample => sample.sessionA) expect(visible.length, `${name} return did not capture its first 60 visible frames`).toBe(60) expect(visible.every(sample => sample.markerY !== null), `${name} return omitted the response in a visible frame`).toBe(true) expect(visible.every(sample => sample.running === (name === 'running'))).toBe(true) expect(new Set(visible.map(sample => sample.responseLength)).size, `${name} response changed while output was held`).toBe(1) expect(summary[name].markerMovement, `${name} Session moved after its first visible frame; trace=${evidence}`) .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) } await expectBottom(page) assertClean(world) } finally { settled ??= world.events.some(event => event.type === 'turn/start') && !world.events.some(event => event.type === 'turn/end') ? world.scaffold.whenTurnSettled() : undefined for (const gate of gates) gate.resolve(undefined) dispose() try { await settled } finally { await page.context().tracing.stop({ path: join(evidence, 'browser-trace.zip') }) } } }) }) it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-floating-controls', seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }], }, async (world) => { await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) const host = world.page.locator('[data-conversation-scroll]') const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) const expectControls = async (): Promise => { await expect.poll(() => host.evaluate((element) => { const rail = element.querySelector('nav[aria-label="Turn navigation"]') const button = element.querySelector('button[aria-label="Back to bottom"]') const composer = element.querySelector('[data-composer-seat]') if (rail === null || button === null || composer === null) return Infinity const viewport = element.getBoundingClientRect() const railBox = rail.getBoundingClientRect() const buttonBox = button.getBoundingClientRect() const composerBox = composer.getBoundingClientRect() const top = viewport.top + element.clientTop return Math.max( Math.abs((railBox.top + railBox.bottom) / 2 - (top + composerBox.top) / 2), Math.abs(viewport.left + element.clientLeft + element.clientWidth - railBox.right - 12), Math.abs(composerBox.top - buttonBox.bottom - 16), ) }), { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) } await wheelTranscript(world.page, -1_200) await backToBottom.waitFor({ timeout: 10_000 }) await expectControls() await wheelTranscript(world.page, -1_200) await expectControls() const seat = world.page.locator('[data-composer-seat]') const initialHeight = await seat.evaluate(element => element.getBoundingClientRect().height) const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(Array.from({ length: 8 }, (_, index) => `draft line ${index}`).join('\n')) await expect.poll(() => seat.evaluate(element => element.getBoundingClientRect().height)) .toBeGreaterThan(initialHeight + 40) await expectControls() await composer.fill('') await expectControls() const rail = world.page.getByRole('navigation', { name: 'Turn navigation', includeHidden: true }) const frame = rail.locator('../..') const originalStyle = await frame.getAttribute('style') try { for (const clearance of [16, 8]) { for (const contentWidth of [901, 900, 899, 901]) { await frame.evaluate((element, { clearance, contentWidth }) => { element.style.width = `${contentWidth + 2 * (clearance + 16)}px` element.style.setProperty('--dsh-composer-side-clearance', `${clearance}px`) }, { clearance, contentWidth }) await expect.poll(() => rail.isVisible()).toBe(contentWidth > 900) } } } finally { await frame.evaluate((element, style) => { if (style === null) element.removeAttribute('style') else element.setAttribute('style', style) }, originalStyle) } await expectControls() await backToBottom.click() await expectBottom(world.page) assertClean(world) }) }) 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/page', async (route) => { const request = route.request().postDataJSON() as { method?: string payload?: { args?: { request?: { beforeSeq?: number } } } } if (!held && request.method === 'session/page' && request.payload?.args?.request?.beforeSeq !== undefined) { held = true await gate } await route.continue() }) const settled = world.scaffold.whenTurnSettled(60_000) const releaseText = holdTextAfter(world, 1) try { const composer = world.page.locator('[data-composer-input][contenteditable="true"]').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 beforeRows = await loadedFlowRows(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.assistantFrames.filter(frame => frame.type === 'chunk').length releaseText() await expect.poll( () => world.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 10_000 }, ).toBeGreaterThan(chunksAfterAnchor + 5) releaseHistory() await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows) await nextPaint(world.page) await expectSameFlowTop(world.page, readerAnchor) } finally { releaseText() 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/page') 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) // The whole log is loaded: turn 1's unique marker renders in the // transcript (scoped: the sidebar search row also carries it) and no // page remains. expect(await world.page.locator('[data-conversation-scroll]') .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1) expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0) assertClean(world) }) }, 180_000) it.skipIf(MODE === 'record')('follows a growing Standard-mode process group independently of the outer transcript', async () => { const parts = [ 'GROUP_SCROLL_START\n\n', ...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) => `Group batch ${batch} paragraph ${row}: inspect the next recorded operation.\n\n`).join('')), 'GROUP_SCROLL_END\n\n', ] const chunks: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'reasoning' }, ...parts.map((text): StreamChunk => ({ type: 'reasoning-delta', index: 0, text })), { type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } }, { type: 'block-start', index: 1, blockType: 'text' }, { type: 'text-delta', index: 1, text: 'GROUP_SCROLL_DONE' }, { type: 'block-end', index: 1, block: { type: 'text', text: 'GROUP_SCROLL_DONE' } }, { type: 'usage', usage: { inputTokens: 256, outputTokens: 512 } }, { type: 'finish', reason: { kind: 'stop' } }, ] await withScrollWorld({ failureShot: 'web-e2e-chat-process-follow', replay: [replayEntry(chunks)], seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }], }, async (world) => { await world.page.emulateMedia({ reducedMotion: 'no-preference' }) await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) await openSettings(world.page, 'en') await world.page.getByRole('dialog', { name: 'Settings', exact: true }) .getByText('Work details', { exact: true }).locator('../..') .getByRole('button', { name: 'Detailed', exact: true }).click() await world.page.getByRole('menuitem', { name: 'Standard', exact: true }).click() await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value) .toMatchObject({ transcriptView: 'standard' }) await world.page.keyboard.press('Escape') const gates = parts.slice(1).map(() => Promise.withResolvers()) const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { let index = -1 for await (const chunk of next()) { if (chunk.type === 'reasoning-delta') await gates[index++]?.promise yield chunk } }) const settled = world.scaffold.whenTurnSettled(60_000) try { await world.page.locator('[data-composer-input][contenteditable="true"]').last().fill( 'Inspect the recorded operations while I scroll the conversation.', ) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() const group = world.page.locator('[data-step-process]:has([data-variant="think"][data-state="running"])') await group.locator('[data-process-activity]').click() await group.locator('[data-variant="think"][data-state="running"] [data-disclosure-row]').click() const body = group.locator('[data-step-process-body]') await nextPaint(world.page) expect(await body.evaluate(element => element.scrollHeight <= element.clientHeight)).toBe(true) const height = () => body.evaluate(element => element.scrollHeight) const top = () => body.evaluate(element => element.scrollTop) const expectGroupBottom = async (): Promise => { await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)) .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) } const grow = async (index: number): Promise => { const before = await height() gates[index]!.resolve(undefined) await expect.poll(height).toBeGreaterThan(before + 200) } // Outer following is a precondition independent of opening the inner disclosure. const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) await wheelTranscript(world.page, -400) await backToBottom.click() await expectBottom(world.page) await grow(0) await expectGroupBottom() await expectBottom(world.page) // Per-frame growth exercises the real observer and native animation // independently of transport batching while the next model chunk waits. const content = group.locator('[data-step-process-content]') const heightStyle = await content.evaluate(element => element.style.height) const beforeGrowth = await top() try { const duringGrowth = await body.evaluate(async (scroller) => { const content = scroller.querySelector('[data-step-process-content]')! const initialHeight = content.getBoundingClientRect().height let halfway = scroller.scrollTop for (let frame = 1; frame <= 60; frame++) { content.style.height = `${initialHeight + frame * 20}px` await new Promise(resolve => requestAnimationFrame(() => { resolve() })) if (frame === 30) halfway = scroller.scrollTop } return halfway }) expect(duringGrowth).toBeGreaterThan(beforeGrowth + 20) await expectGroupBottom() } finally { await content.evaluate((element, previous) => { element.style.height = previous }, heightStyle) } await expectGroupBottom() await wheelTranscript(world.page, -800) await backToBottom.waitFor() const outerTop = (await scrollGeometry(world.page)).scrollTop await grow(1) await expectGroupBottom() expect(Math.abs((await scrollGeometry(world.page)).scrollTop - outerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) await backToBottom.click() await expectBottom(world.page) const pinnedTop = await top() await body.hover() await world.page.mouse.wheel(0, -160) await expect.poll(top).toBeLessThan(pinnedTop - 100) await nextPaint(world.page) const readerTop = await top() await grow(2) expect(Math.abs(await top() - readerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) await expectBottom(world.page) await group.locator('[data-process-activity]').click() await group.locator('[data-process-activity]').click() await expectGroupBottom() await body.hover() await world.page.mouse.wheel(0, 10_000) await expectGroupBottom() await grow(3) await expectGroupBottom() assertClean(world) } finally { for (const gate of gates) gate.resolve(undefined) dispose() await settled } }) }) it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => { await withScrollWorld({ failureShot: 'web-e2e-turn-rail-jump', seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }], }, async (world) => { await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) await expectBottom(world.page) const rail = world.page.getByRole('navigation', { name: 'Turn navigation' }) const latest = rail.getByRole('button', { name: `Jump to turn ${String(HISTORY_FIXTURE.turns)}`, exact: true }) await expect.poll(() => latest.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') expect(await rail.getByRole('button').count()).toBeLessThan(HISTORY_FIXTURE.turns) const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true }) expect(await firstUnloaded.count()).toBe(0) const railScroller = rail.locator('[class*="scroller"]') await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true) await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1) const bodyBeforeRailScroll = await scrollGeometry(world.page) await railScroller.hover() await world.page.mouse.wheel(0, -HISTORY_FIXTURE.turns * 10) await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) await firstUnloaded.waitFor({ state: 'visible' }) expect((await scrollGeometry(world.page)).scrollTop).toBe(bodyBeforeRailScroll.scrollTop) const beforeRows = await loadedFlowRows(world.page) await firstUnloaded.focus() const tooltip = world.page.getByRole('tooltip') await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => tooltip.textContent(), { timeout: 5_000 }).toContain(HISTORY_FIXTURE.markers.user(1)) expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1)) const previewId = await tooltip.getAttribute('id') const hoveredMark = () => rail.evaluate(element => element.querySelector('button:hover')?.dataset.index ?? null) await expect.poll(hoveredMark).not.toBeNull() const hoveredBefore = await hoveredMark() await world.page.mouse.wheel(0, 80) await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(80) await expect.poll(hoveredMark).toBe(String(Number(hoveredBefore) + 8)) await nextPaint(world.page) expect(await firstUnloaded.evaluate(element => document.activeElement === element)).toBe(true) expect(await firstUnloaded.getAttribute('aria-describedby')).toBe(previewId) expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1)) await world.page.mouse.wheel(0, -80) await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) const transcriptLayers = await tooltip.evaluate((preview) => { const railSlot = preview.closest('nav')?.parentElement const codeBanner = document.querySelector('.md-code-block > :first-child') if (!(railSlot instanceof HTMLElement) || codeBanner === null) { throw new Error('turn preview or code-block banner stacking context is unavailable') } return { codeBanner: Number(getComputedStyle(codeBanner).zIndex), rail: Number(getComputedStyle(railSlot).zIndex), } }) expect(transcriptLayers.rail).toBeGreaterThan(transcriptLayers.codeBanner) await world.page.keyboard.press('Enter') // The jump pages history in and lands on turn 1: its mark flips to the // loaded label and becomes current, the window grew, and the turn-1 // user row sits at the reading line. const firstLoaded = rail.getByRole('button', { name: 'Jump to turn 1', exact: true }) await expect.poll(() => firstLoaded.count(), { timeout: 60_000 }).toBe(1) await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') expect(await loadedFlowRows(world.page)).toBeGreaterThan(beforeRows) // Drop mark focus so its hover/focus preview (which echoes the prompt // marker) leaves the DOM before the transcript count below. await firstLoaded.evaluate((el) => { (el as HTMLElement).blur() }) await expect.poll(() => world.page.getByRole('tooltip').count(), { timeout: 15_000 }).toBe(0) await nextPaint(world.page) const marker = world.page.locator('[data-conversation-scroll]') .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }) expect(await marker.count()).toBe(1) const scrollport = await world.page.locator('[data-conversation-scroll]').boundingBox() const row = await marker.boundingBox() if (scrollport === null || row === null) throw new Error('turn-1 row or scrollport has no layout box') expect(row.y - scrollport.y).toBeGreaterThanOrEqual(0) expect(row.y - scrollport.y).toBeLessThanOrEqual(160) // The rail followed the landing to the ladder top, so the fade now // marks the other (downward) end. await expect.poll(() => rail.locator('[class*="fadeBottom"]').count(), { timeout: 15_000 }).toBe(1) 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 resolved = await world.scaffold.ctx.sessionController.resolveAgent(TOOL_SESSION_ID) if ('error' in resolved) throw resolved.error world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') const settled = world.scaffold.whenTurnSettled(60_000) let released = false try { const composer = world.page.locator('[data-composer-input][contenteditable="true"]').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 expandOwningTurnProcess(world.page, liveRow) await liveRow.waitFor({ timeout: 15_000 }) expect(await liveRow.getAttribute('data-state')).toBe('running') await expectBottom(world.page) expect(await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).count()).toBe(0) 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.assistantFrames.filter(frame => frame.type === 'chunk').length await writeFile(releasePath, 'release\n') released = true await expect.poll( () => world.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksBeforeRelease + 5) await nextPaint(world.page) // The loop appends tool/result before starting the next model request; // these synchronous Host listeners therefore observe it before later chunks. expect(world.events.some(event => event.type === 'tool/result')).toBe(true) expect(Math.abs((await flowTop(world.page, awayAnchor.key)) - awayAnchor.top)) .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click() await expect.poll( () => world.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksAtRepin + 5) await nextPaint(world.page) expect(Math.abs((await scrollGeometry(world.page)).distanceFromBottom)).toBeLessThanOrEqual(1) } finally { if (!released) await writeFile(releasePath, 'release\n').catch(() => {}) } await settled expect(world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST))).toBe(true) 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 expandOwningTurnProcess(world.page, liveRow) 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')('keeps composer resizing on the correct scroll owner across reopened Sessions', 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, RESPONSIVE_REFLOW_TOLERANCE) await openSeed( world.page, RESTORE_FIXTURE_B, RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns), ) await openSeed( world.page, RESTORE_FIXTURE_A, ) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) await backToBottom.waitFor({ timeout: 15_000 }) 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('[data-composer-input][contenteditable="true"]').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) // Keyboard is the only non-wheel device this lane's Chromium can drive for // real (see flingTranscript for the probe results on touch and scrollbars), // so it stands in for the whole hardware input pipeline here. it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-keyboard', seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }], }, async (world) => { await openSeed( world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns), ) await expectBottom(world.page) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) // Focus rides the last seeded tool row (a tabbable button whose keydown // handler passes scrolling keys through). End first normalizes the // focus-driven scrollIntoView back to the floor. const lastToolRow = world.page.locator( `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`, ) await expandOwningTurnProcess(world.page, lastToolRow) await lastToolRow.focus() await world.page.keyboard.press('End') await expectBottom(world.page) await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) for (let press = 0; press < 3; press += 1) { await world.page.keyboard.press('PageUp') await nextPaint(world.page) } await backToBottom.waitFor({ timeout: 10_000 }) await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 }) .toBeGreaterThan(100) await world.page.keyboard.press('End') await expectBottom(world.page) await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) assertClean(world) }) }, 180_000) it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-fling-stream', paceMs: 0, replay: [ replayEntry(toolStream()), replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)), ], seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_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, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns)) const resolved = await world.scaffold.ctx.sessionController.resolveAgent(FLING_SESSION_ID) if ('error' in resolved) throw resolved.error world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) const settled = world.scaffold.whenTurnSettled(60_000) const releaseText = holdTextAfter(world, 16) let released = false try { const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(LIVE_FLING_PROMPT) await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) await expectBottom(world.page) // Fling away while the turn is mid-flight: the scroll burst alone must // release bottom ownership, exactly like a wheel scroll would, even // while streaming keeps re-asserting the floor between frames. await flingTranscript(world.page, -900) await backToBottom.waitFor({ timeout: 10_000 }) const awayAnchor = await visibleFlowAnchor(world.page) const chunksBeforeRelease = world.assistantFrames.filter(frame => frame.type === '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.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksBeforeRelease + 5) await expectSameFlowTop(world.page, awayAnchor) // Fling back to the floor: re-pin must come from the reader's scroll // itself, and follow must then own the still-streaming tail. The // retry loop chases the floor that streaming keeps pushing down. for (let attempt = 0; attempt < 8; attempt += 1) { if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break await flingTranscript(world.page, 1_600) } await expectBottom(world.page) await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length releaseText() await expect.poll( () => world.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksAtRepin + 5) await expectBottom(world.page) } finally { releaseText() 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_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) await expectBottom(world.page) assertClean(world) }) }, 180_000) })