Merge pull request #6638 from deepseek-harness/worktree-sendjump
fix(chat): keep the scroll and status steady when folding an opened group
This commit is contained in:
commit
c23b1632dc
10 files changed
+504
-18
No files matched your search
@@ -12,8 +12,8 @@
|
||||
en: 972bbe45fe8ac28e
|
||||
zh: 593b851c142894e1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
|
||||
en: f44b784545c6d9a2
|
||||
zh: 3eb6fcb9f348dec4
|
||||
en: 913d1dceab36d4a1
|
||||
zh: 049521c5127f3478
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
|
||||
en: b8278678170f1507
|
||||
zh: 113a51ba5cf27de1
|
||||
|
||||
@@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su
|
||||
- `finish`: install the final room.
|
||||
|
||||
Content added during the batch updates the remaining movement by starting a new segment.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation, and so does a row whose ancestor finished closing, because `hidden="until-found"` keeps that row's own size while removing it from the flow.
|
||||
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
|
||||
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
|
||||
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
|
||||
|
||||
@@ -38,7 +38,7 @@ Status: implemented
|
||||
- `finish`:安装最终补白。
|
||||
|
||||
批次中途新增的内容按分段规则更新剩余运动。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画,祖先已收起完成的行同样如此,因为 `hidden="until-found"` 让这类行保留自身尺寸,却已不占文档流。
|
||||
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
|
||||
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
|
||||
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Scroll continuity when "On next message" folds a process group the reader opened. The latest Turn has
|
||||
* one Step; the reader opened its group and expanded the reasoning inside, so the group body is capped and
|
||||
* scrollable, then sends the next message from the floor. A held prompt keeps the new Turn from starting
|
||||
* until the fold settles, so only the fold moves the view while the scrollport is sampled on every
|
||||
* animation frame.
|
||||
*
|
||||
* - A long answer leaves the folded group above the viewport. The scroll position changes in one direction
|
||||
* only: the fold never scrolls the view away and then snaps it back.
|
||||
* - A short answer leaves fold room below the tail. Growth fills that room without scrolling, so the running
|
||||
* status keeps its entrance slide when the new Step's first row appears instead of moving in one frame.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, Route } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, openSettings } from './support.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
openFoldTrace?: { readonly frames: TraceFrame[]; readonly slides: number[]; stop(): void }
|
||||
}
|
||||
}
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])'
|
||||
/** Settled Turns before the latest one, so the transcript overflows the scrollport. */
|
||||
const TURNS = 7
|
||||
const NEXT_INPUT = 'OPENFOLD_NEXT_INPUT'
|
||||
const REPLY = 'OPENFOLD_REPLY'
|
||||
|
||||
/** One painted frame of the main scrollport. */
|
||||
interface TraceFrame {
|
||||
readonly top: number
|
||||
readonly floor: number
|
||||
/** Height of the fold room below the transcript. */
|
||||
readonly spacer: number
|
||||
/** The column marks the tail as pinned, which runs the status slide at zero duration. */
|
||||
readonly pinned: boolean
|
||||
/** The running status's top relative to the scrollport, when it is mounted. */
|
||||
readonly status: number | null
|
||||
readonly folding: boolean
|
||||
}
|
||||
|
||||
function paragraphs(marker: string, count: number, text: string): string {
|
||||
return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}`
|
||||
}
|
||||
|
||||
/** Settled history whose latest Turn is one Step: long reasoning, then an answer of `answerParagraphs`. */
|
||||
function seededLog(id: string, answerParagraphs: number): { log: string; search: string } {
|
||||
const base = createChatScrollFixture({ markerPrefix: `OPENFOLD${id.toUpperCase()}`, title: `OPENFOLD ${id}`, turns: TURNS })
|
||||
const parsed = parseSeedFixture(base.log)
|
||||
const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === TURNS)
|
||||
const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary)
|
||||
drafts.push(
|
||||
{ type: 'turn/start', data: { turn: TURNS } },
|
||||
{ type: 'step/start', data: { turn: TURNS, step: 1 } },
|
||||
{ type: 'user/message', data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'OPENFOLD_LAST_USER' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn: TURNS, step: 1, stream: [], message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: paragraphs('OPENFOLD_REASONING', 30, 'Reasoning weighs one more consideration, paragraph') },
|
||||
{ type: 'text', text: paragraphs('OPENFOLD_ANSWER', answerParagraphs, 'Final answer paragraph of the single Step') },
|
||||
],
|
||||
source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
} },
|
||||
{ type: 'step/end', data: { turn: TURNS, step: 1 } },
|
||||
{ type: 'turn/end', data: { turn: TURNS, reason: { kind: 'completed' } } },
|
||||
)
|
||||
return {
|
||||
log: [parsed.headerLine, ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n'),
|
||||
search: base.markers.user(1),
|
||||
}
|
||||
}
|
||||
|
||||
function reply(): ReplayEntry {
|
||||
const text = `${REPLY} short answer.`
|
||||
const finish: StreamChunk[] = [
|
||||
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
return { kind: 'chunks', chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
...finish,
|
||||
] }
|
||||
}
|
||||
|
||||
async function stableScroll(page: Page): Promise<void> {
|
||||
await page.evaluate(async (selector) => {
|
||||
const host = document.querySelector<HTMLElement>(selector)!
|
||||
let previous = host.scrollTop
|
||||
const started = performance.now()
|
||||
let stableSince = started
|
||||
await new Promise<void>((resolveStable, reject) => {
|
||||
const check = (): void => {
|
||||
const now = performance.now()
|
||||
const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null
|
||||
if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now
|
||||
previous = host.scrollTop
|
||||
if (now - stableSince >= 300) resolveStable()
|
||||
else if (now - started >= 20_000) reject(new Error(`scroll did not settle: top ${String(host.scrollTop)}`))
|
||||
else requestAnimationFrame(check)
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
})
|
||||
}, ROOT)
|
||||
}
|
||||
|
||||
async function toBottom(page: Page): Promise<void> {
|
||||
const button = page.locator(ROOT).getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
if (await button.count() > 0) await button.click()
|
||||
await stableScroll(page)
|
||||
await expect.poll(() => page.locator(`${ROOT} [data-chat-following-tail]`).count()).toBe(1)
|
||||
}
|
||||
|
||||
async function openSessionBySearch(page: Page, query: string): Promise<void> {
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query)
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
||||
await results.click()
|
||||
}
|
||||
|
||||
async function choosePreferences(page: Page): Promise<void> {
|
||||
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: 'Standard', exact: true }).click()
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
}
|
||||
|
||||
/** Open the latest Turn's process group and expand its reasoning until the group body caps its height. */
|
||||
async function openLatestGroup(page: Page): Promise<void> {
|
||||
const group = page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS)}"]`).last()
|
||||
const header = group.locator('button[aria-expanded]').first()
|
||||
if (await header.getAttribute('aria-expanded') !== 'true') await header.click()
|
||||
const thought = group.locator('[data-variant="think"]').getByRole('button').first()
|
||||
if (await thought.getAttribute('aria-expanded') !== 'true') await thought.click()
|
||||
const body = group.locator('[data-step-process-body]')
|
||||
await expect.poll(() => body.evaluate(element => element.scrollHeight > element.clientHeight + 100)).toBe(true)
|
||||
// Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there.
|
||||
await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() })
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the scrollport on every animation frame, and record the duration of every status entrance slide
|
||||
* that a flow change starts.
|
||||
*/
|
||||
async function startTrace(page: Page): Promise<void> {
|
||||
await page.evaluate((selector) => {
|
||||
const host = document.querySelector<HTMLElement>(selector)!
|
||||
const column = host.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const spacer = host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
|
||||
const frames: TraceFrame[] = []
|
||||
const slides: number[] = []
|
||||
let frame = 0
|
||||
const tick = (): void => {
|
||||
const status = host.querySelector<HTMLElement>('[data-chat-running]')
|
||||
frames.push({
|
||||
top: host.scrollTop,
|
||||
floor: Math.max(0, host.scrollHeight - host.clientHeight),
|
||||
spacer: spacer.getBoundingClientRect().height,
|
||||
pinned: column.hasAttribute('data-chat-tail-pinned'),
|
||||
status: status === null ? null : status.getBoundingClientRect().top - host.getBoundingClientRect().top,
|
||||
folding: column.hasAttribute('data-input-fold') || host.querySelector('[data-chat-motion="collapse"]') !== null,
|
||||
})
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
// The slide starts at the style update after the flow change; reading animations there sees its duration.
|
||||
const observer = new MutationObserver(() => {
|
||||
const status = host.querySelector<HTMLElement>('[data-chat-running]')
|
||||
for (const animation of status?.getAnimations() ?? []) {
|
||||
if (animation instanceof CSSAnimation && animation.animationName.includes('running-follows-output')) {
|
||||
slides.push(Number(animation.effect?.getComputedTiming().duration ?? 0))
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(column, { childList: true, subtree: true })
|
||||
window.openFoldTrace = { frames, slides, stop: () => { cancelAnimationFrame(frame); observer.disconnect() } }
|
||||
frame = requestAnimationFrame(tick)
|
||||
}, ROOT)
|
||||
}
|
||||
|
||||
async function readTrace(page: Page): Promise<{ frames: TraceFrame[]; slides: number[] }> {
|
||||
return page.evaluate(() => {
|
||||
const trace = window.openFoldTrace!
|
||||
return { frames: [...trace.frames], slides: [...trace.slides] }
|
||||
})
|
||||
}
|
||||
|
||||
/** Per-frame scroll movements larger than subpixel rounding. */
|
||||
function movements(frames: readonly TraceFrame[]): number[] {
|
||||
const steps: number[] = []
|
||||
for (let index = 1; index < frames.length; index++) {
|
||||
const step = frames[index]!.top - frames[index - 1]!.top
|
||||
if (Math.abs(step) > 1) steps.push(Math.round(step))
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
interface World {
|
||||
readonly scaffold: WebScaffold
|
||||
readonly page: Page
|
||||
readonly session: SessionId
|
||||
readonly tripwire: ReturnType<typeof watchConsole>
|
||||
/** Releases the held prompt; the new Turn starts after this. */
|
||||
readonly releasePrompt: () => void
|
||||
/** Releases the held model stream; the new Step's output arrives after this. */
|
||||
readonly releaseModel: () => void
|
||||
readonly ended: Promise<void>
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed and open the case, open the latest group, and send the next message from the floor with the prompt
|
||||
* and the model stream held.
|
||||
*/
|
||||
async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraphs: number): Promise<World> {
|
||||
const replayDir = await mkdtemp(join(tmpdir(), 'dsh-open-fold-'))
|
||||
await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify([reply()]))
|
||||
const scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride: join(replayDir, 'replay.override.json'),
|
||||
paceMs: 16,
|
||||
replayContextWindow: 10_000_000,
|
||||
})
|
||||
const model = Promise.withResolvers<undefined>()
|
||||
const disposeHold = scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
await model.promise
|
||||
for await (const chunk of next()) yield chunk
|
||||
})
|
||||
const prompt = Promise.withResolvers<undefined>()
|
||||
const holdPrompt = async (route: Route): Promise<void> => {
|
||||
await prompt.promise
|
||||
await route.continue()
|
||||
}
|
||||
const page = await newEnglishPage(browser, 826)
|
||||
const close = async (): Promise<void> => {
|
||||
prompt.resolve(undefined)
|
||||
model.resolve(undefined)
|
||||
disposeHold()
|
||||
const failures: unknown[] = []
|
||||
await page.context().close().catch((error: unknown) => failures.push(error))
|
||||
await 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, `${id}: cleanup failed`)
|
||||
}
|
||||
try {
|
||||
const tripwire = watchConsole(page)
|
||||
await page.setViewportSize({ width: 1170, height: 826 })
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
const session = SessionId(`open-fold-${id}`)
|
||||
const { log, search } = seededLog(id, answerParagraphs)
|
||||
await seedSession(scaffold, log, session)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await openSessionBySearch(page, search)
|
||||
await page.locator(ROOT).getByText('OPENFOLD_ANSWER', { exact: false }).last().waitFor({ timeout: 30_000 })
|
||||
await choosePreferences(page)
|
||||
await toBottom(page)
|
||||
await openLatestGroup(page)
|
||||
await toBottom(page)
|
||||
await page.route('**/api/session/prompt', holdPrompt)
|
||||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||||
const timer = setTimeout(() => { off(); reject(new Error(`${id}: no turn/end within 60000ms`)) }, 60_000)
|
||||
const off = scaffold.ctx.on('session/event', (owner: { id: SessionId }, event: SessionEvent) => {
|
||||
if (owner.id !== session || event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
resolveEnded()
|
||||
})
|
||||
})
|
||||
// A failure before the release awaits nothing; the success path awaits and rethrows this rejection.
|
||||
ended.catch(() => undefined)
|
||||
await startTrace(page)
|
||||
await page.locator(`${ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT)
|
||||
await page.locator(ROOT).getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.locator(`${ROOT} [data-submission-echo]`).waitFor()
|
||||
await stableScroll(page)
|
||||
return {
|
||||
scaffold, page, session, tripwire, ended, close,
|
||||
releasePrompt: () => { prompt.resolve(undefined) },
|
||||
releaseModel: () => { model.resolve(undefined) },
|
||||
}
|
||||
} catch (error) {
|
||||
await close().catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened process group', () => {
|
||||
let browser: Browser
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it('folds the opened group above a long answer without scrolling away and back', async () => {
|
||||
const world = await sendFromOpenedGroup(browser, 'long', 30)
|
||||
try {
|
||||
const { frames } = await readTrace(world.page)
|
||||
const steps = movements(frames)
|
||||
const reversals = steps.filter((step, index) => index > 0 && Math.sign(step) !== Math.sign(steps[index - 1]!)).length
|
||||
// Soft assertions let the held Turn finish, so a failure reports the curve instead of replay leftovers.
|
||||
expect.soft(frames.some(frame => frame.folding), 'the message folds the opened group').toBe(true)
|
||||
expect.soft(reversals, `scroll direction changes; per-frame movements ${JSON.stringify(steps)}`).toBe(0)
|
||||
const settled = frames.at(-1)!
|
||||
expect.soft(settled.floor - settled.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1)
|
||||
world.releasePrompt()
|
||||
world.releaseModel()
|
||||
await world.ended
|
||||
expect(world.tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await world.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the running status slide while fold room below a short answer takes the new Step', async () => {
|
||||
const world = await sendFromOpenedGroup(browser, 'short', 2)
|
||||
try {
|
||||
const folded = (await readTrace(world.page)).frames.at(-1)!
|
||||
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
|
||||
expect.soft(folded.pinned, 'tail pin while fold room remains').toBe(false)
|
||||
world.releasePrompt()
|
||||
// The running Step's group row is the first row after the user message; output stays held until then.
|
||||
await world.page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS + 1)}"]`).waitFor({ timeout: 30_000 })
|
||||
await world.page.locator(`${ROOT} [data-chat-running]`).waitFor()
|
||||
await world.page.waitForTimeout(300)
|
||||
const { frames, slides } = await readTrace(world.page)
|
||||
const statusSteps = movements(frames.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
|
||||
.filter(step => Number.isFinite(step))
|
||||
// The fold runs status moves at zero duration on its own, so only settled frames show the pin's effect.
|
||||
const pinnedOverRoom = frames.filter(frame => !frame.folding && frame.pinned && frame.spacer > 0)
|
||||
expect.soft(pinnedOverRoom, 'settled frames pinned over fold room').toEqual([])
|
||||
expect.soft(slides.some(duration => duration > 0), `status slide durations ${JSON.stringify(slides)}; status movements ${JSON.stringify(statusSteps)}`).toBe(true)
|
||||
world.releaseModel()
|
||||
await world.ended
|
||||
expect(world.tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await world.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/collapse-timing.e2e.ts",
|
||||
"tests/notice-wake-tail.e2e.ts",
|
||||
"tests/fold-blank-matrix.e2e.ts",
|
||||
"tests/chat-fold-opened-group.e2e.ts",
|
||||
"tests/chat-flow-layout.e2e.ts",
|
||||
"tests/layout-bottom.e2e.ts",
|
||||
"tests/shortcuts-desktop.e2e.ts",
|
||||
|
||||
@@ -167,12 +167,19 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null
|
||||
}
|
||||
|
||||
function runBatch(current: InputFoldDriver): void {
|
||||
/** @returns whether a batch started; without a closing member the caller settles held rows itself. */
|
||||
function runBatch(current: InputFoldDriver): boolean {
|
||||
// Every member continues from the size it has now, so the driver's measured layout and its change
|
||||
// amounts describe the same boxes: a closing box may have moved on its own transition or sit inside a
|
||||
// row that finished closing, and a revealing box may have grown part of the way on its own transition.
|
||||
// amounts describe the same boxes: a closing box may have moved on its own transition, and a revealing
|
||||
// box may have grown part of the way on its own transition.
|
||||
for (const box of [...boxes.values()]) {
|
||||
if (!batchMember(box)) continue
|
||||
// A row that finished closing is `hidden="until-found"`, which skips its contents without removing
|
||||
// their boxes: a box under it still reports its own size but contributes nothing to the flow.
|
||||
if (box.element.parentElement?.closest('[hidden]') != null) {
|
||||
box.finish()
|
||||
continue
|
||||
}
|
||||
const height = box.element.offsetHeight
|
||||
const marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0
|
||||
if (!box.closing) {
|
||||
@@ -187,7 +194,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
const owned = [...boxes.values()]
|
||||
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
|
||||
if (!members.some(box => box.closing)) return false
|
||||
batch = true
|
||||
cancelled = false
|
||||
const travelling = current.begin(members.map(box => box.closing ? box : {
|
||||
@@ -284,6 +291,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
}
|
||||
if (reduced) tick(0)
|
||||
else frameRequest = requestAnimationFrame(tick)
|
||||
return true
|
||||
}
|
||||
|
||||
function foldActive(): boolean { return folding > 0 }
|
||||
@@ -299,10 +307,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
|
||||
// Rows that change during a batch are held until it settles, which flushes again.
|
||||
if (batch) return
|
||||
const current = [...boxes.values()].some(box => box.closing) ? driver() : null
|
||||
if (current !== null) {
|
||||
runBatch(current)
|
||||
return
|
||||
}
|
||||
if (current !== null && runBatch(current)) return
|
||||
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
// Without a batch, a held closing row finishes at once and a held revealing row starts its own transition.
|
||||
for (const box of [...boxes.values()]) {
|
||||
|
||||
@@ -781,11 +781,13 @@ export class ChatViewport {
|
||||
|
||||
/**
|
||||
* Mark the column while the tail is pinned: the reader follows the floor with no unsampled reader
|
||||
* movement, the test fold batches use for a tail landing, and the content overflows, so following scrolls
|
||||
* absorb output growth and the status keeps its on-screen position. The first-output slide runs at zero
|
||||
* duration there. A resize calls this after its own follow scroll, before paint, so a slide that starts in
|
||||
* that frame finishes before it is drawn; scroll and scrollend call it after delivering the sample, and
|
||||
* follow-intent commits call it with the latest measurement. The attribute changes only with the pin.
|
||||
* movement, the test fold batches use for a tail landing, and the content overflows with no fold room
|
||||
* below it, so following scrolls absorb output growth and the status keeps its on-screen position. Fold
|
||||
* room, like the empty space below content that fits, takes growth before any scroll, so the status moves
|
||||
* there. The first-output slide runs at zero duration while pinned. A resize calls this after its own
|
||||
* follow scroll, before paint, so a slide that starts in that frame finishes before it is drawn; scroll and
|
||||
* scrollend call it after delivering the sample, and follow-intent commits call it with the latest
|
||||
* measurement. The attribute changes only with the pin.
|
||||
* @param overflowing - a fresh overflow measurement; omitted, the latest one is reused.
|
||||
*/
|
||||
refreshTailPin(overflowing = this.overflowing): void {
|
||||
@@ -793,7 +795,7 @@ export class ChatViewport {
|
||||
const elements = this.elements
|
||||
if (elements === null) return
|
||||
const reader = this.reader
|
||||
const pinned = overflowing && reader !== null && reader.follow.active && !reader.pending()
|
||||
const pinned = overflowing && this.spacerHeight() === 0 && reader !== null && reader.follow.active && !reader.pending()
|
||||
if (pinned === this.tailPinned) return
|
||||
this.tailPinned = pinned
|
||||
if (pinned) elements.column.dataset.chatTailPinned = ''
|
||||
|
||||
@@ -655,3 +655,73 @@ it('pins the tail only while sampled follow intent holds over overflowing conten
|
||||
viewport.detach()
|
||||
expect(pinned()).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the tail unpinned while fold room takes growth, then pins once the room is used', () => {
|
||||
let notifyResize = () => {}
|
||||
class Observer implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } }
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', Observer)
|
||||
const scroller = document.createElement('div')
|
||||
const column = document.createElement('div')
|
||||
const spacer = document.createElement('div')
|
||||
spacer.dataset.chatTurnSpacer = ''
|
||||
scroller.append(column, spacer)
|
||||
document.body.append(scroller)
|
||||
let content = 900
|
||||
let top = 0
|
||||
const room = () => Number.parseFloat(spacer.style.height) || 0
|
||||
const floor = () => Math.max(0, content + room() - 300)
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { configurable: true, get: () => 300 },
|
||||
scrollHeight: { configurable: true, get: () => Math.max(300, content + room()) },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => Math.min(top, floor()),
|
||||
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
|
||||
},
|
||||
})
|
||||
const viewport = new ChatViewport()
|
||||
viewport.attach(scroller, column)
|
||||
const follow = new ScrollFollow(true, 4)
|
||||
const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, {
|
||||
initialized: true, followingTail: true, activeTurn: 1,
|
||||
}, vi.fn(), follow)
|
||||
const unbind = reading.bindFolds()
|
||||
viewport.connect({
|
||||
scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {},
|
||||
resize: () => { reading.onResize() },
|
||||
})
|
||||
onTestFinished(() => {
|
||||
unbind()
|
||||
reading.dispose()
|
||||
viewport.detach()
|
||||
scroller.remove()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
const pinned = () => column.hasAttribute('data-chat-tail-pinned')
|
||||
|
||||
notifyResize()
|
||||
expect(scroller.scrollTop).toBe(600)
|
||||
expect(pinned()).toBe(true)
|
||||
// A fold landing leaves room below the content and commits the tail landing.
|
||||
spacer.style.height = '200px'
|
||||
scroller.scrollTop = 800
|
||||
reading.followTail()
|
||||
expect(pinned()).toBe(false)
|
||||
// Growth fills the room without scrolling, so the status moves on screen and keeps its slide.
|
||||
content = 940
|
||||
notifyResize()
|
||||
expect(room()).toBe(160)
|
||||
expect(scroller.scrollTop).toBe(800)
|
||||
expect(pinned()).toBe(false)
|
||||
// Once growth has used the room, following scrolls absorb it again.
|
||||
content = 1_100
|
||||
notifyResize()
|
||||
expect(room()).toBe(0)
|
||||
expect(scroller.scrollTop).toBe(800)
|
||||
expect(pinned()).toBe(true)
|
||||
})
|
||||
@@ -430,6 +430,46 @@ describe('flow motion', () => {
|
||||
expectCleared(opening)
|
||||
})
|
||||
|
||||
it('finishes a held row whose ancestor finished closing instead of driving it in another batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = batchDriver()
|
||||
const parent = rowWithHeight(180)
|
||||
const idle = vi.fn()
|
||||
motion.collapse(parent, () => { parent.setAttribute('hidden', 'until-found') })
|
||||
await Promise.resolve()
|
||||
subscriptions.add(motion.onFoldIdle(idle))
|
||||
vi.advanceTimersByTime(32)
|
||||
// A box under a `hidden="until-found"` ancestor keeps reporting its own size in the browser.
|
||||
const child = rowWithHeight(60, parent)
|
||||
const childDone = vi.fn()
|
||||
motion.collapse(child, childDone)
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(calls.begin).toHaveBeenCalledTimes(1)
|
||||
expect(calls.finish).toHaveBeenCalledTimes(1)
|
||||
expect(childDone).toHaveBeenCalledTimes(1)
|
||||
expectCleared(child)
|
||||
expect(idle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts a held reveal on its own transition when no held closing row still occupies the flow', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = batchDriver()
|
||||
const parent = rowWithHeight(180)
|
||||
motion.collapse(parent, () => { parent.setAttribute('hidden', 'until-found') })
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(32)
|
||||
motion.collapse(rowWithHeight(60, parent), vi.fn())
|
||||
const opening = rowWithHeight(40)
|
||||
motion.reveal(opening)
|
||||
expect(opening.style.height).toBe('0px')
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(calls.begin).toHaveBeenCalledTimes(1)
|
||||
expect(opening.style.transition).toContain('height')
|
||||
expect(opening.style.height).toBe('40px')
|
||||
vi.runAllTimers()
|
||||
expectCleared(opening)
|
||||
})
|
||||
|
||||
it('reports reader cancellation to the driver and lets a newer request resume the target', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = batchDriver()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"apps/web/tests/collapse-timing.e2e.ts",
|
||||
"apps/web/tests/notice-wake-tail.e2e.ts",
|
||||
"apps/web/tests/fold-blank-matrix.e2e.ts",
|
||||
"apps/web/tests/chat-fold-opened-group.e2e.ts",
|
||||
"apps/web/tests/chat-flow-layout.e2e.ts",
|
||||
"apps/web/product-isolation.ts",
|
||||
"apps/web/katex-fonts.ts",
|
||||
|
||||
Reference in new issue
Block a user