diff --git a/packages/ui-kit-chat/test/virtual-scroll-coverage.browser.test.tsx b/packages/ui-kit-chat/test/virtual-scroll-coverage.browser.test.tsx deleted file mode 100644 index 08ba9d291..000000000 --- a/packages/ui-kit-chat/test/virtual-scroll-coverage.browser.test.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import 'virtual:uno.css' -import type {JSX} from 'solid-js' -import {page, userEvent} from 'vitest/browser' -import {beforeEach, expect, it} from 'vitest' -import {useChat} from '@tanstack/ai-solid' -import type {UIMessage} from '@tanstack/ai-client' -import type {ToolViewCtx} from '@conciv/protocol/tool-view-types' -import {ChatProvider} from '../src/store/chat-context.js' -import {ToolProvider} from '../src/store/tool-context.js' -import {storyConnection} from '../src/store/story-connection.js' -import {Thread} from '../src/styled/thread.js' -import {mountView} from './mount-view.js' - -const TURN_COUNT = 80 -const MODERATE_STEP_PX = 200 -const FAST_STEP_PX = 800 -const MIN_COVERAGE = 0.9 - -type Span = {top: number; bottom: number} - -type CoverageSample = { - scrollTop: number - coverage: number - mountedRows: number - firstIndex: number - lastIndex: number -} - -const windowErrors: string[] = [] -window.addEventListener('error', (event) => { - windowErrors.push(typeof event.message === 'string' ? event.message : String(event)) -}) - -beforeEach(() => { - windowErrors.length = 0 -}) - -function answerOf(index: number): string { - const shape = index % 4 - if (shape === 0) return `answer ${index}\n\nOne short line about the change.` - if (shape === 1) { - return `answer ${index}\n\n## Findings\n\n${['alpha', 'beta', 'gamma', 'delta'] - .map((name) => `- \`${name}\` ${'holds the shared lane. '.repeat(3)}`) - .join('\n')}` - } - if (shape === 2) { - return `answer ${index}\n\n${'The scheduler drains the queue in order. '.repeat(12)}\n\n${'It retries only the tail. '.repeat(10)}` - } - return `answer ${index}\n\n${'Then the caller folds the result. '.repeat(6)}` -} - -function assistantParts(index: number): UIMessage['parts'] { - if (index % 4 !== 3) return [{type: 'text', content: answerOf(index)}] - return [ - {type: 'text', content: `answer ${index}\n\nReading the file first.`}, - { - type: 'tool-call', - id: `call-${index}`, - name: 'Read', - arguments: JSON.stringify({file: `src/module-${index}.ts`}), - input: {file: `src/module-${index}.ts`}, - state: 'complete', - }, - { - type: 'tool-result', - toolCallId: `call-${index}`, - content: `export function run(step: number): number {\n return step * ${index}\n}\n`.repeat(4), - state: 'complete', - }, - {type: 'text', content: answerOf(index)}, - ] -} - -function seedMessages(count: number): UIMessage[] { - const messages: UIMessage[] = [] - for (let index = 0; index < count; index++) { - const id = `m${index}` - messages.push( - index % 2 === 0 - ? { - id, - role: 'user', - parts: [{type: 'text', content: `question ${index}. ${'why does it stall? '.repeat(index % 6)}`}], - } - : {id, role: 'assistant', parts: assistantParts(index)}, - ) - } - return messages -} - -function baseToolCtx(): ToolViewCtx { - return { - apiBase: '', - harnessId: 'test', - sendMessage: () => {}, - catalog: {loaded: () => true, meta: () => undefined}, - addResult: () => {}, - } -} - -function mountThread(initial: UIMessage[]): () => HTMLElement { - let viewport: HTMLElement | undefined - function VirtualThread(): JSX.Element { - const chat = useChat({connection: storyConnection({chunks: []}), initialMessages: initial}) - return ( -
- - - - { - viewport = element - }} - > - - - - - -
- ) - } - mountView(() => ) - return () => { - if (!viewport) throw new Error('viewport not mounted') - return viewport - } -} - -function nextFrame(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => resolve()) - }) -} - -function rowGapOf(viewport: HTMLElement): number { - const parsed = Number.parseFloat(getComputedStyle(viewport).rowGap) - return Number.isFinite(parsed) ? parsed : 0 -} - -function indexOfRow(row: HTMLElement): number { - const parsed = Number.parseInt(row.dataset.index ?? '', 10) - return Number.isFinite(parsed) ? parsed : -1 -} - -function clippedSpan(row: HTMLElement, viewportRect: DOMRect, tolerance: number): Span | undefined { - if (row.textContent === null || row.textContent.trim().length === 0) return undefined - const rect = row.getBoundingClientRect() - const top = Math.max(rect.top - tolerance, viewportRect.top) - const bottom = Math.min(rect.bottom + tolerance, viewportRect.bottom) - return bottom > top ? {top, bottom} : undefined -} - -function coveredLength(spans: Span[], from: number): number { - const ordered = spans.slice().toSorted((left, right) => left.top - right.top) - return ordered.reduce( - (state, span) => - span.bottom <= state.reach - ? state - : {covered: state.covered + span.bottom - Math.max(span.top, state.reach), reach: span.bottom}, - {covered: 0, reach: from}, - ).covered -} - -function coverageOf(viewport: HTMLElement, tolerance: number): CoverageSample { - const viewportRect = viewport.getBoundingClientRect() - const rows = Array.from(viewport.querySelectorAll('[data-index]')) - const indexes = rows.map(indexOfRow).filter((index) => index >= 0) - const spans = rows - .map((row) => clippedSpan(row, viewportRect, tolerance)) - .filter((span): span is Span => span !== undefined) - const covered = coveredLength(spans, viewportRect.top) - return { - scrollTop: viewport.scrollTop, - coverage: viewportRect.height > 0 ? covered / viewportRect.height : 0, - mountedRows: rows.length, - firstIndex: indexes.length > 0 ? Math.min(...indexes) : -1, - lastIndex: indexes.length > 0 ? Math.max(...indexes) : -1, - } -} - -async function sweepUpwards(viewport: HTMLElement, stepPx: number): Promise { - const tolerance = rowGapOf(viewport) / 2 - const samples: CoverageSample[] = [] - let position = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop = position - await nextFrame() - while (position > 0) { - position = Math.max(0, position - stepPx) - viewport.scrollTop = position - await nextFrame() - samples.push(coverageOf(viewport, tolerance)) - } - return samples -} - -function report(label: string, samples: CoverageSample[]): string { - const coverages = samples.map((sample) => sample.coverage) - const below = samples.filter((sample) => sample.coverage < MIN_COVERAGE) - const worst = below - .slice() - .toSorted((left, right) => left.coverage - right.coverage) - .slice(0, 8) - .map( - (sample) => - `scrollTop=${Math.round(sample.scrollTop)} coverage=${sample.coverage.toFixed(3)} rows=${sample.mountedRows} range=${sample.firstIndex}..${sample.lastIndex}`, - ) - return [ - `${label}: frames=${samples.length} min=${coverages.length > 0 ? Math.min(...coverages).toFixed(3) : 'no-frames'} framesBelow${MIN_COVERAGE}=${below.length}`, - ...worst, - ].join('\n') -} - -async function settledThread(): Promise { - const viewport = mountThread(seedMessages(TURN_COUNT)) - await expect.element(page.getByText(`answer ${TURN_COUNT - 1}`, {exact: false}).first()).toBeVisible() - return viewport() -} - -async function escapeFollow(viewport: HTMLElement): Promise { - await userEvent.wheel(viewport, {delta: {y: -200}}) - await expect.element(page.elementLocator(viewport)).not.toHaveAttribute('data-at-bottom') -} - -function resizeLoopErrors(): string[] { - return windowErrors.filter((message) => message.includes('ResizeObserver loop')) -} - -it('keeps the viewport covered by mounted turns while scrolling up at a moderate velocity', async () => { - const element = await settledThread() - - const samples = await sweepUpwards(element, MODERATE_STEP_PX) - const summary = report(`sweep@${MODERATE_STEP_PX}px/frame`, samples) - - expect(samples.length > 4, summary).toBe(true) - expect(Math.min(...samples.map((sample) => sample.coverage)) >= MIN_COVERAGE, summary).toBe(true) -}) - -it('scrolling a card-heavy thread never trips a ResizeObserver notification loop', async () => { - const element = await settledThread() - await sweepUpwards(element, FAST_STEP_PX) - await escapeFollow(element) - element.scrollTop = 0 - await expect.element(page.getByText('question 0', {exact: false}).first()).toBeVisible() - await sweepUpwards(element, MODERATE_STEP_PX) - - expect(resizeLoopErrors()).toEqual([]) -}) - -it('a deep jump re-windows onto the target turn within the overscan', async () => { - const element = await settledThread() - await escapeFollow(element) - element.scrollTop = 0 - await expect.element(page.getByText('question 0', {exact: false}).first()).toBeVisible() - await expect.element(page.getByText(`answer ${TURN_COUNT - 1}`, {exact: false})).not.toBeInTheDocument() -}) diff --git a/packages/ui-kit-chat/test/virtual-scroll.browser.test.tsx b/packages/ui-kit-chat/test/virtual-scroll.browser.test.tsx new file mode 100644 index 000000000..204ba5711 --- /dev/null +++ b/packages/ui-kit-chat/test/virtual-scroll.browser.test.tsx @@ -0,0 +1,151 @@ +import 'virtual:uno.css' +import type {JSX} from 'solid-js' +import {page, userEvent} from 'vitest/browser' +import {beforeEach, expect, it} from 'vitest' +import {useChat} from '@tanstack/ai-solid' +import type {UIMessage} from '@tanstack/ai-client' +import type {ToolViewCtx} from '@conciv/protocol/tool-view-types' +import {ChatProvider} from '../src/store/chat-context.js' +import {ToolProvider} from '../src/store/tool-context.js' +import {storyConnection} from '../src/store/story-connection.js' +import {Thread} from '../src/styled/thread.js' +import {mountView} from './mount-view.js' + +const TURN_COUNT = 80 +const MODERATE_STEP_PX = 200 +const FAST_STEP_PX = 800 +const ESTIMATED_TURN_PX = 600 + +const windowErrors: string[] = [] +window.addEventListener('error', (event) => { + windowErrors.push(typeof event.message === 'string' ? event.message : String(event)) +}) + +beforeEach(() => { + windowErrors.length = 0 +}) + +function answerOf(index: number): string { + const shape = index % 4 + if (shape === 0) return `answer ${index}\n\nOne short line about the change.` + if (shape === 1) { + return `answer ${index}\n\n## Findings\n\n${['alpha', 'beta', 'gamma', 'delta'] + .map((name) => `- \`${name}\` ${'holds the shared lane. '.repeat(3)}`) + .join('\n')}` + } + if (shape === 2) { + return `answer ${index}\n\n${'The scheduler drains the queue in order. '.repeat(12)}\n\n${'It retries only the tail. '.repeat(10)}` + } + return `answer ${index}\n\n${'Then the caller folds the result. '.repeat(6)}` +} + +function assistantParts(index: number): UIMessage['parts'] { + if (index % 4 !== 3) return [{type: 'text', content: answerOf(index)}] + return [ + {type: 'text', content: `answer ${index}\n\nReading the file first.`}, + { + type: 'tool-call', + id: `call-${index}`, + name: 'Read', + arguments: JSON.stringify({file: `src/module-${index}.ts`}), + input: {file: `src/module-${index}.ts`}, + state: 'complete', + }, + { + type: 'tool-result', + toolCallId: `call-${index}`, + content: `export function run(step: number): number {\n return step * ${index}\n}\n`.repeat(4), + state: 'complete', + }, + {type: 'text', content: answerOf(index)}, + ] +} + +function seedMessages(count: number): UIMessage[] { + const messages: UIMessage[] = [] + for (let index = 0; index < count; index++) { + const id = `m${index}` + messages.push( + index % 2 === 0 + ? { + id, + role: 'user', + parts: [{type: 'text', content: `question ${index}. ${'why does it stall? '.repeat(index % 6)}`}], + } + : {id, role: 'assistant', parts: assistantParts(index)}, + ) + } + return messages +} + +function baseToolCtx(): ToolViewCtx { + return { + apiBase: '', + harnessId: 'test', + sendMessage: () => {}, + catalog: {loaded: () => true, meta: () => undefined}, + addResult: () => {}, + } +} + +function mountThread(initial: UIMessage[]): void { + function VirtualThread(): JSX.Element { + const chat = useChat({connection: storyConnection({chunks: []}), initialMessages: initial}) + return ( +
+ + + + + + + + + +
+ ) + } + mountView(() => ) +} + +async function sweepUpwards(stepPx: number): Promise { + const wheelSteps = Math.ceil((TURN_COUNT * ESTIMATED_TURN_PX) / stepPx) + for (let step = 0; step < wheelSteps; step++) { + await userEvent.wheel(page.getByRole('log'), {delta: {y: -stepPx}}) + } + await expect.element(page.getByText('question 0', {exact: false}).first()).toBeVisible() +} + +async function lastTurnVisible(): Promise { + await expect.element(page.getByText(`answer ${TURN_COUNT - 1}`, {exact: false}).first()).toBeVisible() +} + +async function settledThread(): Promise { + mountThread(seedMessages(TURN_COUNT)) + await lastTurnVisible() +} + +async function wheelToFirstTurn(): Promise { + await userEvent.wheel(page.getByRole('log'), {delta: {y: -1_000_000}}) + await expect.element(page.getByText('question 0', {exact: false}).first()).toBeVisible() +} + +function resizeLoopErrors(): string[] { + return windowErrors.filter((message) => message.includes('ResizeObserver loop')) +} + +it('scrolling a card-heavy thread never trips a ResizeObserver notification loop', async () => { + await settledThread() + await sweepUpwards(FAST_STEP_PX) + await userEvent.wheel(page.getByRole('log'), {delta: {y: 1_000_000}}) + await lastTurnVisible() + await sweepUpwards(MODERATE_STEP_PX) + + expect(resizeLoopErrors()).toEqual([]) +}) + +it('a deep jump re-windows onto the target turn within the overscan', async () => { + await settledThread() + await wheelToFirstTurn() + await expect.element(page.getByText(`answer ${TURN_COUNT - 1}`, {exact: false})).not.toBeInTheDocument() +})