A queued echo now records its Inbox position like transcript and steering echoes. Later splices move that position, so a canceled splice that removes the row retires the echo even when no Inbox projection ever showed it, for example when the Turn is stopped right after queueing. A claimed row still waits for its admitted message, and the queue dock still hands the echo over to the Host row it renders. The fold-room e2e no longer samples the running status slide or the `data-chat-tail-pinned` mark that this branch removed. It asserts that fold room below a short answer takes the new Turn's growth without scrolling through Turn end, that the room never grows back, and that the running status only moves down.
350 lines
17 KiB
TypeScript
350 lines
17 KiB
TypeScript
/**
|
|
* 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. Agent maintenance keeps the accepted message in
|
|
* the inbox, so its echo folds the group before the new Turn starts, and 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. The new Turn's growth fills that room without scrolling
|
|
* through Turn end, the room never grows back, and the running status only moves down.
|
|
*/
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { Browser, Page } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import { 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[]; 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 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. */
|
|
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[] = []
|
|
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,
|
|
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)
|
|
}
|
|
window.openFoldTrace = { frames, stop: () => { cancelAnimationFrame(frame) } }
|
|
frame = requestAnimationFrame(tick)
|
|
}, ROOT)
|
|
}
|
|
|
|
async function readTrace(page: Page): Promise<{ frames: TraceFrame[] }> {
|
|
return page.evaluate(() => ({ frames: [...window.openFoldTrace!.frames] }))
|
|
}
|
|
|
|
/** 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>
|
|
/** Ends the Agent maintenance that keeps the accepted message in the inbox; the new Turn starts after this. */
|
|
readonly releaseTurn: () => 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 new Turn
|
|
* and its 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 turn = Promise.withResolvers<undefined>()
|
|
let maintenance: Promise<undefined> = Promise.resolve(undefined)
|
|
const page = await newEnglishPage(browser, 826)
|
|
const close = async (): Promise<void> => {
|
|
turn.resolve(undefined)
|
|
model.resolve(undefined)
|
|
disposeHold()
|
|
const failures: unknown[] = []
|
|
await maintenance.catch((error: unknown) => failures.push(error))
|
|
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)
|
|
const resolved = await scaffold.ctx.sessionController.resolveAgent(session)
|
|
if ('error' in resolved) throw resolved.error
|
|
maintenance = resolved.agent.runMaintenance(() => turn.promise)
|
|
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,
|
|
releaseTurn: () => { turn.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.releaseTurn()
|
|
world.releaseModel()
|
|
await world.ended
|
|
expect(world.tripwire.pageErrors).toEqual([])
|
|
} finally {
|
|
await world.close()
|
|
}
|
|
})
|
|
|
|
it('keeps the view still while fold room below a short answer takes the new Turn', async () => {
|
|
const world = await sendFromOpenedGroup(browser, 'short', 2)
|
|
try {
|
|
const foldedFrames = (await readTrace(world.page)).frames
|
|
const folded = foldedFrames.at(-1)!
|
|
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
|
|
world.releaseTurn()
|
|
// 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()
|
|
world.releaseModel()
|
|
await world.ended
|
|
await stableScroll(world.page)
|
|
// Frames from the settled fold through Turn end.
|
|
const after = (await readTrace(world.page)).frames.slice(foldedFrames.length - 1)
|
|
const roomed = after.filter(frame => frame.spacer > 0)
|
|
const regrowth = roomed.some((frame, index) => index > 0 && frame.spacer > roomed[index - 1]!.spacer + 1)
|
|
const statusSteps = movements(after.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
|
|
.filter(step => Number.isFinite(step))
|
|
expect.soft(movements(roomed), 'scroll movements while fold room remains').toEqual([])
|
|
expect.soft(regrowth, `fold room grows back; spacer ${JSON.stringify(roomed.map(frame => Math.round(frame.spacer)))}`).toBe(false)
|
|
expect.soft(statusSteps.every(step => step > 0), `running status movements ${JSON.stringify(statusSteps)}`).toBe(true)
|
|
expect(world.tripwire.pageErrors).toEqual([])
|
|
} finally {
|
|
await world.close()
|
|
}
|
|
})
|
|
})
|