Attribute reader scroll input through the observed-top ledger
ChatView's bottom-follow recognized only wheel gestures as reader input, so touch panning, scrollbar dragging, and keyboard paging could not leave the bottom of a streaming transcript. Replace the wheel listener with device-agnostic attribution: a scroll position deviating from the observed-top ledger of the last delivered or written scrollTop is reader input. Adds keyboard-paging and touch-style fling e2e scenarios (red under the old implementation) and the bilingual Agent Note triplet.
This commit is contained in:
@@ -40,6 +40,11 @@ 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 FLING_SESSION_ID = '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',
|
||||
@@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({
|
||||
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 ScrollGeometry {
|
||||
readonly distanceFromBottom: number
|
||||
@@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
}
|
||||
}, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelToHistoryStart(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
@@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
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 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',
|
||||
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 backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
let released = false
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').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.events.filter(event => event.type === 'assistant/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.events.filter(event => event.type === 'assistant/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.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksAtRepin + 5)
|
||||
await expectBottom(world.page)
|
||||
} finally {
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user