diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml index fede5ed837..d6931dfe47 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -15,14 +15,14 @@ en: 45cb2d17e4d15b09 zh: e11e75c7bcc6bd8e /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: - en: f048652e38757699 - zh: fe02896384df467a + en: a28a833311d64223 + zh: 187e640331d9c906 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: en: ca6a5c372bb3cad1 zh: 1cd5028269b58c65 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing: - en: a304b52774b7676d - zh: e99683e30f0c1173 + en: 0423a607ee0589b5 + zh: 61df892a01c5aa43 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered: en: d8e1ea66e293cd93 zh: 0aaa9e693f441673 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md index b0199720c6..bd86ab9833 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -48,7 +48,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin | Landing | When | Target scrollTop | After the batch | |---|---|---|---| -| **Tail** | This client's own input, or the reader was following the tail when the batch began | `max(base, bottom)` | Commit follow ownership | +| **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership | | **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position | - Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. @@ -56,7 +56,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin - Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following. - A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still. - For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it. -- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top. Scrolling forward therefore never crosses space that is already transparent but not yet closed. +- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top; a row that has already faded becomes opaque again for its remaining height. Scrolling forward therefore never crosses space that is already transparent but not yet closed. ### Own input @@ -72,7 +72,7 @@ Let `start` be the scrollTop when the batch begins, `above` the height of closin ## Testing -[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. Blank means the tallest band between the scrollport top and the composer that no visible row covers: at most 120 px once settled, and over that for at most one animation frame, because a closing row fading in place can expose its space for one frame before it finishes shrinking. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. The layout gap is the tallest band between the scrollport top and the composer that no visible row's box covers: at most 120 px once settled, and over that for at most one animation frame. It counts boxes, so a closing row that has faded but not finished shrinking still covers its space. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md index ebb163560c..0a5c7ff339 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -48,7 +48,7 @@ Status: implemented | 落点 | 何时 | 目标 scrollTop | 结束后 | |---|---|---|---| -| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部 | `max(base, bottom)` | 提交跟随状态 | +| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 | | **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 | - 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。 @@ -56,7 +56,7 @@ Status: implemented - 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。 - 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。 - 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。 -- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行。这样向前滚动不会穿过一片已经透明、还没收完的空位。 +- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行;已经淡出的行会以剩余高度重新变为不透明。这样向前滚动不会穿过一片已经透明、还没收完的空位。 ### 自己输入的判定 @@ -72,7 +72,7 @@ Status: implemented ## Testing -[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。空白指视口顶部到输入框之间没有任何行覆盖的最大连续高度:落定后不超过 120 px;动画中至多 1 帧超过,因为原地淡出的收起行可能在收完之前露出一帧空位。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 ## Alternatives considered diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index 750817d3ae..3422c15238 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -20,6 +20,7 @@ import { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' +import { sessionFixtureName } from '@deepseek-ai/dsh-session-snapshot' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' @@ -40,8 +41,8 @@ const LIVE_STEPS = 6 const STEER_STEPS = 3 const BLANK_LIMIT = 120 /** - * A closing row fading in place can expose its emptied space for one frame before it finishes - * shrinking; a longer blank is a defect. + * Frames whose layout gap may exceed {@link BLANK_LIMIT}. The gap counts rows by their boxes, so a + * closing row that has faded but not finished shrinking still covers its space. */ const TRANSIENT_FRAMES = 1 const EVIDENCE = fileURLToPath(new URL('../../../.artifacts/screenshots/fold-blank-matrix/', import.meta.url)) @@ -115,7 +116,8 @@ const CASES: readonly Case[] = [ { ...MAIN, id: 'main/other-tab/previous-turn', origin: 'other-tab', reader: PREVIOUS_TURN, landing: 'reader' }, { ...MAIN, id: 'main/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, { ...MAIN, id: 'main/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, - { ...MAIN, id: 'main/steer/bottom', origin: 'steer', reader: AT_BOTTOM }, + // Control: steering joins the running Turn, which opens no exchange and folds nothing. + { ...MAIN, id: 'main/steer/no-fold-control', origin: 'steer', reader: AT_BOTTOM }, { ...MAIN, id: 'main/composer/standard/mid-body', reader: SEEDED_MID_BODY, mode: 'Standard' }, { ...MAIN, id: 'main/composer/completion/mid-body', reader: SEEDED_MID_BODY, timing: 'On completion' }, { ...SIDE, id: 'side/composer/bottom', reader: AT_BOTTOM }, @@ -281,14 +283,15 @@ function replayFor(scenario: Case): ReplayEntry[] { case 'composer': case 'other-tab': return [replyEntry('FOLDMX_REPLY')] - case 'error': - return [] case 'queued': return [...liveLongTurn('FOLDMX_LIVE'), replyEntry('FOLDMX_REPLY')] case 'steer': return [...liveLongTurn('FOLDMX_LIVE').slice(0, STEER_STEPS), replyEntry('FOLDMX_STEERED')] case 'parent-message': return [replyEntry('FOLDMX_PARENT_REPLY')] + case 'error': + // The Turn fails before its model call; launchScaffold mounts only the provider catalog. + return [] case 'big-exchange': return [...liveLongTurn('FOLDMX_WAKE_A'), ...liveLongTurn('FOLDMX_WAKE_B'), replyEntry('FOLDMX_REPLY')] } @@ -538,15 +541,43 @@ async function createChild(scaffold: WebScaffold, parentId: SessionId, scenario: } async function openWorld(browser: Browser, scenario: Case, replayDir: string): Promise { + const scaffold = await launchScaffold(scenario, replayDir) + let page: Page | undefined + try { + page = await newEnglishPage(browser, 900) + return await prepareWorld(browser, scenario, scaffold, page) + } catch (error) { + const failures: unknown[] = [error] + if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError)) + await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length === 1) throw error + throw new AggregateError(failures, `${scenario.id}: world setup failed and cleanup was incomplete`) + } +} + +/** + * Launch the scaffold with the case's replay script. A Turn that fails before its model call consumes + * no script, so that case mounts only the provider catalog from a header-only fixture. + */ +async function launchScaffold(scenario: Case, replayDir: string): Promise { + if (scenario.origin === 'error') { + const header = seededFixture(scenario.id, scenario.bodyParagraphs).log.split('\n')[0] ?? '' + const fixture = join(replayDir, sessionFixtureName(0, SESSION_FORMAT_VERSION)) + await writeFile(fixture, `${header}\n`) + return launchWebScaffold({ replayFixture: fixture, replayProvidersOnly: true }) + } await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify(replayFor(scenario))) - const scaffold = await launchWebScaffold({ + return launchWebScaffold({ replayFixture: join(replayDir, 'override-only.jsonl'), replayOverride: join(replayDir, 'replay.override.json'), paceMs: 16, replayContextWindow: 10_000_000, }) +} + +/** Seed the case's Sessions, open the surface, and choose the display preferences. */ +async function prepareWorld(browser: Browser, scenario: Case, scaffold: WebScaffold, page: Page): Promise { await writeFile(join(scaffold.workspaceCwd, READ_FILE), 'fold matrix notes\n') - const page = await newEnglishPage(browser, 900) const tripwire = watchConsole(page) await page.addInitScript(installMeasure) await page.setViewportSize(scenario.surface === 'main' ? { width: 1280, height: 720 } : { width: 1680, height: 900 }) @@ -810,14 +841,14 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi // Per-frame evidence is written for failures, or for every case with FOLDMX_EVIDENCE=1. if (process.env.FOLDMX_EVIDENCE === '1') await evidence() else onTestFailed(evidence) - if (scenario.origin === 'parent-message') { - expect.soft(samples.some(sample => sample.collapsing), 'a notice folds nothing').toBe(false) + if (scenario.origin === 'parent-message' || scenario.origin === 'steer') { + expect.soft(samples.some(sample => sample.collapsing), 'a control case folds nothing').toBe(false) } if (scenario.roomUntilReply !== true) { - expect.soft(afterFold.maxGap, 'blank after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) - expect.soft(transient.length, 'animation frames showing a blank').toBeLessThanOrEqual(TRANSIENT_FRAMES) + expect.soft(afterFold.maxGap, 'layout gap after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(transient.length, 'animation frames with a layout gap').toBeLessThanOrEqual(TRANSIENT_FRAMES) } - expect.soft(afterTurn.maxGap, 'blank after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(afterTurn.maxGap, 'layout gap after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) if (scenario.landing === 'tail') { expect.soft(afterFold.floor - afterFold.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1) expect.soft(afterTurn.floor - afterTurn.top, 'distance from the floor after the Turn').toBeLessThanOrEqual(1) @@ -829,9 +860,13 @@ describe.skipIf(MODE === 'record')('web e2e: blank area after next-message foldi } expect.soft(world.tripwire.pageErrors).toEqual([]) } finally { - await world?.page.context().close().catch(() => undefined) - await world?.scaffold.close().catch((error: unknown) => { console.log(`FOLDMX close ${scenario.id}: ${String(error)}`) }) - await rm(replayDir, { recursive: true, force: true }) + // Scaffold close also asserts that every replay entry was consumed; its failures fail the case. + const failures: unknown[] = [] + await world?.page.context().close().catch((error: unknown) => failures.push(error)) + await world?.scaffold.close().catch((error: unknown) => failures.push(error)) + await rm(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, `${scenario.id}: cleanup failed`) } }) } diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 91c712c0df..74f9c965e2 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -129,9 +129,14 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh if (batch) cancelled = false } + /** Rows inside a closing ancestor change with it and never join a batch on their own. */ + function batchMember(box: InputFoldBox): boolean { + return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null + } + function runBatch(current: InputFoldDriver): void { const owned = [...boxes.values()] - const members = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) + const members = owned.filter(batchMember) .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) if (!members.some(box => box.closing)) return batch = true @@ -383,7 +388,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, - inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing)), + inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing && batchMember(box))), } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts index 4d1f9f98d5..cca1c11a39 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts @@ -107,6 +107,7 @@ export class ChatReading { this.cancelPending() this.commit(landing, true, this.viewport.latestTurn) }, + pending: () => this.pending, }) } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 976ab2b811..9890188110 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -43,6 +43,8 @@ export interface FoldReader { readonly follow: ScrollFollow /** @param landing - the actual geometry after a batch landed at the tail. */ readonly landedAtTail: (landing: ViewportLanding) => void + /** @returns whether reader movement awaits sampling; until then follow intent is undecided. */ + readonly pending: () => boolean } interface PagingPosition { @@ -116,6 +118,8 @@ export class ChatViewport { this.observer = null this.elements = null this.events = null + this.tailRequested = false + this.retarget = null this.turns = [] this.observation = { top: 0, landing: null } } @@ -240,7 +244,8 @@ export class ChatViewport { ) return { begin: (boxes) => { - tail = this.tailRequested || follow.active + // Own input lands at the tail; otherwise an unsampled reading movement outranks follow intent. + tail = this.tailRequested || (follow.active && !reader.pending()) this.tailRequested = false viewportTop = elements.scroller.getBoundingClientRect().top start = elements.scroller.scrollTop @@ -302,6 +307,8 @@ export class ChatViewport { if (Math.abs(drift) > 0.5) { base = Math.max(0, base + drift) segmentTop += drift + // The reference moves on screen with the reader's scroll; only layout changes are corrected. + referenceTop -= drift aim() } } diff --git a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx index 258d2683d1..ad337a426e 100644 --- a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx @@ -21,7 +21,7 @@ function viewport() { document.body.append(list) const owner = new ChatViewport() owner.attach(list, column) - const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn() }) + const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn(), pending: () => false }) return { list, row, spacer, owner, dispose: () => { unbind(); owner.detach(); list.remove() } } } diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 8bca075841..3852786025 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -313,6 +313,20 @@ describe('flow motion', () => { expect(motion.inputActive()).toBe(false) }) + it('does not report a batch when every closing row sits inside an ancestor that closes on its own', async () => { + const calls = batchDriver() + const parent = rowWithHeight(180) + // The ancestor's motion is not owned by this batch, so its descendants cannot start one. + parent.dataset.chatMotion = 'collapse' + const child = rowWithHeight(60, parent) + motion.collapse(child, vi.fn()) + expect(motion.inputActive()).toBe(false) + await Promise.resolve() + expect(calls.begin).not.toHaveBeenCalled() + motion.cancelMotion(child) + delete parent.dataset.chatMotion + }) + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { vi.useFakeTimers() const calls = batchDriver() diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index 8226ada2e9..b4200ce7d7 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -24,7 +24,6 @@ const frames = new Map() let frameClock = 0 let nextFrame = 0 -/** Run queued animation frames until the batch stops requesting more. */ /** Run a fixed number of queued animation frames. */ function runFrameCount(count: number): void { for (let index = 0; index < count && frames.size > 0; index++) { @@ -35,6 +34,7 @@ function runFrameCount(count: number): void { } } +/** Run queued animation frames until the batch stops requesting more. */ function runFrames(): void { for (let guard = 0; frames.size > 0 && guard < 100; guard++) { const pending = [...frames.values()] @@ -162,6 +162,18 @@ it('adds a reader\'s own scrolling during the fold to the position it keeps stil expect(h.reading.followingTail).toBe(false) }) +it('keeps the reader landing while reader movement awaits sampling', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: true }) + h.list.dispatchEvent(new Event('wheel')) + h.scroll(1_990) + expect(h.reading.pending).toBe(true) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(190) + vi.advanceTimersByTime(500) +}) + it('keeps a reader below folding rows on the same content without room or follow', async () => { const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) await h.close()