fix(ui): stabilize workplace chat scrolling

This commit is contained in:
LZH-YS1998
2026-07-13 23:02:39 +08:00
parent 4e7aa75ba5
commit 4bc18dcd27
46 changed files with 5937 additions and 1258 deletions
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MessageList scroll regression fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/tests/message-list-scroll.tsx"></script>
</body>
</html>
@@ -0,0 +1,734 @@
import assert from 'node:assert/strict'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { createServer, type ViteDevServer } from 'vite'
// Run from frontend_src:
// node --import tsx ./tests/message-list-scroll.spec.ts
const __dirname = dirname(fileURLToPath(import.meta.url))
const FRONTEND_ROOT = resolve(__dirname, '..')
interface ScrollMetrics {
scrollTop: number
scrollHeight: number
clientHeight: number
bottomGap: number
}
interface FixtureTelemetry {
markReadCalls: number
renders: number
scrollEvents: number
scrollTopWrites: number
}
async function settle(page: Page, milliseconds = 80): Promise<void> {
await page.evaluate(() => new Promise<void>((resolveFrame) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()))
}))
await page.waitForTimeout(milliseconds)
}
async function metrics(page: Page): Promise<ScrollMetrics> {
return page.locator('.msg-list').evaluate((element) => {
const list = element as HTMLElement
return {
scrollTop: list.scrollTop,
scrollHeight: list.scrollHeight,
clientHeight: list.clientHeight,
bottomGap: list.scrollHeight - list.clientHeight - list.scrollTop,
}
})
}
async function gotoFixture(
page: Page,
baseUrl: string,
policy: string,
extraQuery = '',
): Promise<void> {
const suffix = extraQuery ? `&${extraQuery}` : ''
await page.goto(`${baseUrl}tests/message-list-scroll.html?policy=${policy}${suffix}`)
await page.waitForFunction(() => window.__messageListFixtureReady === true)
await page.waitForSelector('.msg-list')
await settle(page, 150)
}
async function resetTelemetry(page: Page): Promise<void> {
await page.evaluate(() => window.__messageListFixture?.resetTelemetry())
}
async function telemetry(page: Page): Promise<FixtureTelemetry> {
return page.evaluate(() => {
const value = window.__messageListFixture?.telemetry()
if (!value) throw new Error('fixture telemetry is unavailable')
return value
})
}
async function appendMessages(page: Page, count: number): Promise<void> {
await page.evaluate((amount) => window.__messageListFixture?.appendMessages(amount), count)
await page.waitForSelector(`text=fixture-marker-${String(225 + count - 1).padStart(4, '0')}`)
await settle(page)
}
async function firstFullyVisibleMarker(page: Page): Promise<{ marker: string; top: number }> {
return page.locator('.msg-list').evaluate((element) => {
const list = element as HTMLElement
const listRect = list.getBoundingClientRect()
for (const row of Array.from(list.querySelectorAll<HTMLElement>('.msg-row'))) {
const rect = row.getBoundingClientRect()
const markerMatch = /fixture-marker-(\d+)/.exec(row.textContent || '')
const marker = markerMatch?.[0]
if (markerMatch && Number(markerMatch[1]) % 5 === 0) continue
if (marker && rect.top >= listRect.top + 1 && rect.bottom <= listRect.bottom - 1) {
return { marker, top: rect.top }
}
}
throw new Error('no fully visible fixture row found')
})
}
async function markerTop(page: Page, marker: string): Promise<number> {
return page.locator('.msg-list').evaluate((element, expectedMarker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-row'))
.find((candidate) => candidate.textContent?.includes(expectedMarker))
if (!row) throw new Error(`marker row not found: ${expectedMarker}`)
return row.getBoundingClientRect().top
}, marker)
}
async function markerViewportOffset(page: Page, marker: string): Promise<number> {
return page.locator('.msg-list').evaluate((element, expectedMarker) => {
const list = element as HTMLElement
const row = Array.from(list.querySelectorAll<HTMLElement>('.msg-row'))
.find((candidate) => candidate.textContent?.includes(expectedMarker))
if (!row) throw new Error(`marker row not found: ${expectedMarker}`)
return row.getBoundingClientRect().top - list.getBoundingClientRect().top
}, marker)
}
async function nearestMarkerAbove(page: Page, anchorMarker: string): Promise<{ marker: string; index: number }> {
return page.locator('.msg-list').evaluate((element, expectedAnchor) => {
const rows = Array.from(element.querySelectorAll<HTMLElement>('.msg-row'))
const anchor = rows.find((row) => row.textContent?.includes(expectedAnchor))
if (!anchor) throw new Error(`anchor row not found: ${expectedAnchor}`)
const anchorTop = anchor.getBoundingClientRect().top
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index]
if (row.getBoundingClientRect().bottom > anchorTop - 1) continue
const marker = /fixture-marker-(\d+)/.exec(row.textContent || '')
if (marker) return { marker: marker[0], index: Number(marker[1]) }
}
throw new Error('no rendered fixture row exists above the viewport anchor')
}, anchorMarker)
}
async function clickHistoryWithoutScrolling(page: Page): Promise<void> {
await page.locator('.msg-history-load-btn').evaluate((element) => {
;(element as HTMLButtonElement).click()
})
}
async function runFollowAndBrowsingCases(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
const initial = await metrics(page)
assert.ok(initial.scrollHeight > initial.clientHeight * 8, 'fixture must exercise a long, 200+ row transcript')
assert.ok(Math.abs(initial.bottomGap) <= 2, `follow policy should initially settle at bottom; gap=${initial.bottomGap}`)
// Regression for scrollToEnd -> onMarkRead -> state -> changed callback ->
// layout effect -> scrollToEnd. Once settled, an idle transcript must not
// keep invoking markRead, re-rendering itself, or even assigning scrollTop.
await resetTelemetry(page)
await page.waitForTimeout(2_000)
const idle = await telemetry(page)
assert.equal(idle.markReadCalls, 0, 'idle bottom transcript must not run a markRead feedback loop')
assert.equal(idle.renders, 0, 'idle bottom transcript must not re-render from markRead feedback')
assert.equal(idle.scrollEvents, 0, 'idle bottom transcript must not keep writing its scroll position')
assert.equal(idle.scrollTopWrites, 0, 'idle bottom transcript must perform zero direct scrollTop writes for two seconds')
await resetTelemetry(page)
await appendMessages(page, 5)
const afterAppend = await metrics(page)
const followTelemetry = await telemetry(page)
assert.ok(Math.abs(afterAppend.bottomGap) <= 2, `follow policy must stay at bottom after append; gap=${afterAppend.bottomGap}`)
assert.ok(followTelemetry.markReadCalls <= 1, `one append batch may mark read at most once; calls=${followTelemetry.markReadCalls}`)
assert.ok(followTelemetry.scrollEvents <= 1, `one append batch may scroll at most once; events=${followTelemetry.scrollEvents}`)
assert.ok(followTelemetry.scrollTopWrites <= 1, `one append batch may assign scrollTop at most once; writes=${followTelemetry.scrollTopWrites}`)
await page.evaluate(() => window.__messageListFixture?.growDraft(4_000))
await page.waitForSelector('.msg-row-draft')
await settle(page, 150)
const afterDraftGrowth = await metrics(page)
assert.ok(
Math.abs(afterDraftGrowth.bottomGap) <= 2,
`follow policy must absorb a large live-reply height change; gap=${afterDraftGrowth.bottomGap}`,
)
// Use a real browser wheel input, not a synthetic scrollTop-only change, to
// transition from FOLLOWING to BROWSING.
const list = page.locator('.msg-list')
await list.hover()
await page.mouse.wheel(0, -1_100)
await settle(page)
const detached = await metrics(page)
assert.ok(detached.bottomGap > 400, `wheel-up must detach from bottom immediately; gap=${detached.bottomGap}`)
const anchorBefore = await firstFullyVisibleMarker(page)
await resetTelemetry(page)
await page.evaluate(() => {
window.__messageListFixture?.appendMessages(15)
window.__messageListFixture?.growDraft(5_000)
})
await page.waitForSelector('text=fixture-marker-0244')
await settle(page, 150)
const anchorAfterTop = await markerTop(page, anchorBefore.marker)
const browsingTelemetry = await telemetry(page)
assert.ok(
Math.abs(anchorAfterTop - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterTop - anchorBefore.top}px during append storm`,
)
assert.equal(browsingTelemetry.markReadCalls, 0, 'browsing updates must not mark the detached transcript read')
assert.equal(browsingTelemetry.scrollEvents, 0, 'browsing updates must not write the transcript scroll position')
// A Markdown row which grows above the anchor is the same geometry change
// produced by an image load or an expanded approval card. Native anchoring
// plus the single controller must hold the visible row exactly in place.
const rowAbove = await nearestMarkerAbove(page, anchorBefore.marker)
await page.evaluate(({ index }) => window.__messageListFixture?.growMessage(index, 6_000), rowAbove)
await settle(page, 150)
const anchorAfterHeightChange = await markerTop(page, anchorBefore.marker)
const afterHeightChange = await metrics(page)
assert.ok(
Math.abs(anchorAfterHeightChange - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterHeightChange - anchorBefore.top}px when ${rowAbove.marker} grew above it`,
)
assert.ok(afterHeightChange.bottomGap > 400, 'height growth above a browsing anchor must not pull the transcript to bottom')
assert.equal(
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
'browsing',
'height growth must retain browsing mode',
)
// Result surfaces can be replaced by a higher-priority durable projection
// and full-sync payloads rebuild every message object. Neither operation may
// give JavaScript permission to write the browsing scroll position.
await resetTelemetry(page)
await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error(`result anchor row is missing: ${marker}`)
row.dataset.identityProbe = 'stable-result-row'
}, anchorBefore.marker)
const anchorIndex = Number(anchorBefore.marker.slice('fixture-marker-'.length))
await page.evaluate((index) => window.__messageListFixture?.upgradeResultSurface(index), anchorIndex)
await settle(page, 120)
const resultReplacement = await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error(`replaced result anchor row is missing: ${marker}`)
return {
top: row.getBoundingClientRect().top,
probe: row.dataset.identityProbe,
key: row.dataset.timelineKey,
}
}, anchorBefore.marker)
assert.ok(Math.abs(resultReplacement.top - anchorBefore.top) <= 1, 'result-surface replacement must preserve the browsing anchor')
assert.equal(resultReplacement.probe, 'stable-result-row', 'result-surface replacement must reuse the anchored DOM node')
assert.equal(resultReplacement.key, `turn:assistant:fixture-turn-${anchorIndex}`, 'result replacement must retain the first surface timeline key')
await page.evaluate(() => window.__messageListFixture?.repeatFullSync())
await settle(page, 120)
assert.ok(
Math.abs(await markerTop(page, anchorBefore.marker) - anchorBefore.top) <= 1,
'repeated full sync must preserve the browsing anchor',
)
const syncTelemetry = await telemetry(page)
assert.equal(syncTelemetry.scrollTopWrites, 0, 'result upgrade and repeated full sync must not directly write browsing scrollTop')
// Resizing the panel changes the viewport rather than the message content.
// The controller restores the same row offset through its sole observer.
const offsetBeforeResize = await markerViewportOffset(page, anchorBefore.marker)
await page.setViewportSize({ width: 1280, height: 620 })
await settle(page, 150)
const offsetAfterResize = await markerViewportOffset(page, anchorBefore.marker)
assert.ok(
Math.abs(offsetAfterResize - offsetBeforeResize) <= 1,
`panel resize moved the browsing anchor by ${offsetAfterResize - offsetBeforeResize}px`,
)
await page.setViewportSize({ width: 1280, height: 800 })
await settle(page, 150)
// First expose the 25 locally-windowed rows, then exercise the real remote
// history callback which prepends 40 rows. DOM clicks avoid Playwright
// scrolling the history button into view before the assertion.
await clickHistoryWithoutScrolling(page)
await settle(page, 120)
const anchorAfterLocalHistory = await markerTop(page, anchorBefore.marker)
assert.ok(
Math.abs(anchorAfterLocalHistory - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterLocalHistory - anchorBefore.top}px when the local window expanded`,
)
await clickHistoryWithoutScrolling(page)
await page.waitForSelector('text=history-marker-00-039')
await settle(page, 150)
const anchorAfterPrepend = await markerTop(page, anchorBefore.marker)
const afterPrepend = await metrics(page)
assert.ok(
Math.abs(anchorAfterPrepend - anchorBefore.top) <= 1,
`browsing anchor moved by ${anchorAfterPrepend - anchorBefore.top}px after history prepend`,
)
assert.ok(afterPrepend.bottomGap > 400, 'history prepend must not pull a browsing transcript to bottom')
assert.equal(
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
'browsing',
'history prepend must retain browsing mode',
)
const jumpToLatest = page.getByRole('button', { name: /(?:latest|最新)/i })
await jumpToLatest.waitFor({ state: 'visible' })
await jumpToLatest.click()
await settle(page)
const resumed = await metrics(page)
assert.ok(Math.abs(resumed.bottomGap) <= 2, `jump-to-latest must resume follow mode; gap=${resumed.bottomGap}`)
}
async function runInputIntentCases(page: Page, baseUrl: string): Promise<void> {
const list = page.locator('.msg-list')
await gotoFixture(page, baseUrl, 'follow')
await list.focus()
await page.keyboard.press('PageUp')
await settle(page)
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'PageUp must immediately enter browsing mode')
assert.ok((await metrics(page)).bottomGap > 100, 'PageUp must detach the transcript from the tail')
await gotoFixture(page, baseUrl, 'follow')
const listBox = await list.boundingBox()
if (!listBox) throw new Error('scrollbar fixture has no bounding box')
const beforeDrag = await metrics(page)
const scrollbarWidth = await list.evaluate((element) => {
const viewport = element as HTMLElement
return viewport.offsetWidth - viewport.clientWidth
})
const scrollbarX = listBox.x + listBox.width - Math.max(2, scrollbarWidth / 2)
const scrollbarTrackY = listBox.y + beforeDrag.clientHeight / 2
await page.mouse.click(scrollbarX, scrollbarTrackY)
await settle(page)
const afterDrag = await metrics(page)
assert.equal(
await list.getAttribute('data-viewport-mode'),
'browsing',
`scrollbar interaction must immediately enter browsing mode; width=${scrollbarWidth} before=${JSON.stringify(beforeDrag)} after=${JSON.stringify(afterDrag)}`,
)
assert.ok(afterDrag.bottomGap > 100, 'scrollbar interaction must detach the transcript from the tail')
// Some assistive/native scroll paths expose only the resulting scroll
// event. A downward move while already browsing must replace the stored
// anchor so a later resize cannot restore an older viewport position.
await gotoFixture(page, baseUrl, 'follow')
await list.hover()
await page.mouse.wheel(0, -2_400)
await settle(page)
await list.evaluate((element) => {
const viewport = element as HTMLElement
viewport.scrollTop += 1_000
})
await settle(page)
const nativeDownAnchor = await firstFullyVisibleMarker(page)
const nativeDownOffset = await markerViewportOffset(page, nativeDownAnchor.marker)
await page.setViewportSize({ width: 1280, height: 650 })
await settle(page, 150)
assert.ok(
Math.abs(await markerViewportOffset(page, nativeDownAnchor.marker) - nativeDownOffset) <= 1,
'untagged downward browsing scroll must become the anchor used by a later viewport resize',
)
await page.setViewportSize({ width: 1280, height: 800 })
await settle(page, 120)
}
async function runTouchIntentCase(browser: Browser, baseUrl: string): Promise<void> {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, hasTouch: true })
try {
await gotoFixture(page, baseUrl, 'follow')
const list = page.locator('.msg-list')
const box = await list.boundingBox()
if (!box) throw new Error('touch fixture has no bounding box')
const cdp = await page.context().newCDPSession(page)
const x = box.x + box.width / 2
const startY = box.y + box.height * 0.45
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x, y: startY }],
})
for (const delta of [35, 75, 120, 170]) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x, y: startY + delta }],
})
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await settle(page, 150)
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'native touch swipe must enter browsing mode')
assert.ok((await metrics(page)).bottomGap > 100, 'native touch swipe must detach the transcript from the tail')
} finally {
await page.close()
}
}
async function runTimelineIdentityCases(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
await page.evaluate(() => window.__messageListFixture?.addSharedTurnCompanyRows([4]))
await page.waitForSelector('text=shared-company-row-4')
await settle(page)
await page.locator('.msg-list').evaluate((element) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('shared-company-row-4'))
if (!row) throw new Error('mounted shared-turn owner is missing')
row.dataset.identityProbe = 'shared-turn-owner'
})
await page.evaluate(() => window.__messageListFixture?.addSharedTurnCompanyRows([0, 1, 2, 3]))
await page.waitForSelector('text=shared-company-row-3')
await settle(page)
const sharedTurnKeys = await page.evaluate(() => [0, 1, 2, 3, 4].map((index) => {
const row = Array.from(document.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find((candidate) => candidate.textContent?.includes(`shared-company-row-${index}`))
if (!row?.dataset.timelineKey) throw new Error(`shared company row ${index} is missing a timeline key`)
return row.dataset.timelineKey
}))
assert.equal(new Set(sharedTurnKeys).size, 5, `company rows sharing a canonical turn need unique DOM keys: ${sharedTurnKeys.join(', ')}`)
assert.equal(
await page.locator('.msg-list').evaluate((element) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('shared-company-row-4'))
return row?.dataset.identityProbe
}),
'shared-turn-owner',
'inserting an older duplicate-turn row must not remount the existing owner',
)
await gotoFixture(page, baseUrl, 'follow')
const resultMarker = 'fixture-marker-0211'
const initialResultIdentity = await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error('cross-channel result fixture row is missing')
row.dataset.identityProbe = 'cross-channel-result-owner'
return { key: row.dataset.timelineKey }
}, resultMarker)
await page.evaluate(() => window.__messageListFixture?.mergeResultGroupOrder(211, true))
await settle(page, 120)
await page.evaluate(() => window.__messageListFixture?.mergeResultGroupOrder(211, false))
await settle(page, 120)
const reorderedResult = await page.locator('.msg-list').evaluate((element, marker) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes(marker))
if (!row) throw new Error('reordered cross-channel result row is missing')
return { key: row.dataset.timelineKey, probe: row.dataset.identityProbe }
}, resultMarker)
assert.equal(reorderedResult.key, initialResultIdentity.key, 'cross-channel result group order must not change its mounted timeline key')
assert.equal(reorderedResult.probe, 'cross-channel-result-owner', 'cross-channel result group order must reuse the mounted DOM row')
await gotoFixture(page, baseUrl, 'follow')
await page.evaluate(() => window.__messageListFixture?.growDraft(9_000))
await page.waitForSelector('.msg-row-draft')
await settle(page)
const draftGeometry = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.msg-list')
window.__rememberedDraftTimelineRow = document.querySelector(
'[data-timeline-key="turn:assistant:fixture-live-turn"]',
)
const row = window.__rememberedDraftTimelineRow as HTMLElement | null
if (!list || !row) throw new Error('live draft timeline wrapper is missing')
list.scrollTop = Math.max(0, row.offsetTop + row.offsetHeight / 2 - list.clientHeight / 2)
return { height: row.getBoundingClientRect().height }
})
await settle(page, 120)
assert.equal(
await page.locator('.msg-list').getAttribute('data-viewport-mode'),
'browsing',
'moving into the middle of a long draft must enter browsing mode',
)
const draftScrollTop = (await metrics(page)).scrollTop
await resetTelemetry(page)
await page.evaluate(() => window.__messageListFixture?.finalizeDraft())
await page.waitForFunction(() => {
const row = document.querySelector('[data-timeline-key="turn:assistant:fixture-live-turn"]')
return !!row && !row.querySelector('.msg-row-draft')
})
await settle(page)
const finalizedDraft = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.msg-list')
const committed = document.querySelector<HTMLElement>('[data-timeline-key="turn:assistant:fixture-live-turn"]')
if (!list || !committed) throw new Error('committed live turn is missing')
return {
reused: committed === window.__rememberedDraftTimelineRow,
height: committed.getBoundingClientRect().height,
scrollTop: list.scrollTop,
mode: list.dataset.viewportMode,
collapsed: !!committed.querySelector('.msg-collapse-toggle'),
}
})
assert.equal(finalizedDraft.reused, true, 'draft -> runtime_v2_assistant final must reuse the same outer timeline DOM node')
assert.equal(finalizedDraft.collapsed, false, 'a mounted expanded draft must not auto-collapse when its final arrives')
assert.ok(
finalizedDraft.height >= draftGeometry.height - 4,
`draft -> final must not collapse the long turn (${draftGeometry.height}px -> ${finalizedDraft.height}px)`,
)
assert.ok(Math.abs(finalizedDraft.scrollTop - draftScrollTop) <= 1, 'draft -> final must preserve a browsing viewport inside the turn')
assert.equal(finalizedDraft.mode, 'browsing', 'draft -> final must not resume following while the user is browsing')
assert.equal((await telemetry(page)).scrollTopWrites, 0, 'draft -> final must not programmatically write browsing scrollTop')
}
async function runProgressCapIdentityCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow', 'progress=1')
await page.waitForSelector('text=progress-marker-0099')
const list = page.locator('.msg-list')
const before = await list.evaluate((element) => {
const viewport = element as HTMLElement
const row = Array.from(viewport.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
if (!row) throw new Error('progress cap anchor row is missing')
viewport.scrollTop += row.getBoundingClientRect().top - viewport.getBoundingClientRect().top - 80
row.dataset.identityProbe = 'stable-progress-row'
return { key: row.dataset.timelineKey }
})
await settle(page, 120)
assert.equal(await list.getAttribute('data-viewport-mode'), 'browsing', 'scrolling to an old progress row must enter browsing mode')
const topBefore = await list.evaluate((element) => {
const row = Array.from(element.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
if (!row) throw new Error('progress cap anchor row disappeared before append')
return row.getBoundingClientRect().top
})
await resetTelemetry(page)
await page.evaluate(() => window.__messageListFixture?.appendProgressEntries(1))
await page.waitForSelector('text=progress-marker-0100')
await settle(page, 150)
const after = await list.evaluate((element) => {
const viewport = element as HTMLElement
const row = Array.from(viewport.querySelectorAll<HTMLElement>('.msg-timeline-row'))
.find(candidate => candidate.textContent?.includes('progress-marker-0040'))
if (!row) throw new Error('progress cap anchor row disappeared after append')
return {
key: row.dataset.timelineKey,
probe: row.dataset.identityProbe,
top: row.getBoundingClientRect().top,
mode: viewport.dataset.viewportMode,
}
})
assert.equal(after.key, before.key, 'progress key must survive the 100-entry window shift')
assert.equal(after.probe, 'stable-progress-row', 'React must retain the anchored progress DOM node')
assert.ok(Math.abs(after.top - topBefore) <= 1, `progress cap moved browsing anchor by ${after.top - topBefore}px`)
assert.equal(after.mode, 'browsing', 'progress cap append must retain browsing mode')
assert.equal((await telemetry(page)).scrollTopWrites, 0, 'progress cap append must not programmatically write browsing scrollTop')
assert.equal(await page.getByText('progress-marker-0000').count(), 0, 'the fixture must actually evict the oldest progress row')
}
async function runExternalProgressGeometryCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow', 'externalProgress=1')
const list = page.locator('.msg-list')
await list.hover()
await page.mouse.wheel(0, -1_300)
await settle(page)
const anchor = await firstFullyVisibleMarker(page)
const offsetBefore = await markerViewportOffset(page, anchor.marker)
const heightBefore = await list.evaluate(element => (element as HTMLElement).clientHeight)
await resetTelemetry(page)
await page.evaluate(() => window.__messageListFixture?.setExternalRoleCount(12))
await page.waitForSelector('text=Fixture Role 11')
await settle(page, 150)
const heightAfter = await list.evaluate(element => (element as HTMLElement).clientHeight)
const offsetAfter = await markerViewportOffset(page, anchor.marker)
const progressTelemetry = await telemetry(page)
assert.equal(heightAfter, heightBefore, 'role additions must not resize the message viewport')
assert.ok(
Math.abs(offsetAfter - offsetBefore) <= 1,
`external Execution Progress moved the browsing anchor by ${offsetAfter - offsetBefore}px`,
)
assert.equal(progressTelemetry.scrollTopWrites, 0, 'external role additions must not write browsing scrollTop')
assert.equal(
await list.getAttribute('data-viewport-mode'),
'browsing',
'external role additions must retain browsing mode',
)
}
async function runHiddenPendingCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
const initiallyHidden = await page.evaluate(() => !Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.some((element) => element.textContent?.trim() === 'Approval checkpoint 0'))
assert.equal(initiallyHidden, true, 'the oldest pending checkpoint must begin outside the 200-row DOM window')
await page.getByRole('button', { name: /Pending actions/i }).click()
await page.waitForFunction(() => Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.some((element) => element.textContent?.trim() === 'Approval checkpoint 0'))
await settle(page, 120)
const focused = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>('.msg-list')
const row = document.querySelector<HTMLElement>('[data-timeline-key="checkpoint:fixture-checkpoint-0"]')
if (!list || !row) throw new Error('focused pending checkpoint row is missing')
const listRect = list.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
return {
mode: list.dataset.viewportMode,
visible: rowRect.bottom > listRect.top && rowRect.top < listRect.bottom,
bottomGap: list.scrollHeight - list.clientHeight - list.scrollTop,
}
})
assert.equal(focused.mode, 'browsing', 'locating an old pending checkpoint must enter browsing mode')
assert.equal(focused.visible, true, 'pending reminder must locate the checkpoint outside the initial 200-row window')
assert.ok(focused.bottomGap > 100, 'locating an old pending checkpoint must not jump back to latest')
}
async function runCheckpointCase(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'follow')
await page.waitForSelector('text=Approval checkpoint 110')
await page.waitForSelector('text=fixture-marker-0111')
const chronological = await page.evaluate(() => {
const checkpointTitle = Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.find((element) => element.textContent?.includes('Approval checkpoint 110'))
const checkpointRow = checkpointTitle?.closest('.msg-row')
const nextRow = Array.from(document.querySelectorAll<HTMLElement>('.msg-row'))
.find((element) => element.textContent?.includes('fixture-marker-0111'))
if (!checkpointRow || !nextRow) throw new Error('checkpoint chronology nodes are missing')
window.__rememberedCheckpointRow = checkpointRow
return !!(checkpointRow.compareDocumentPosition(nextRow) & Node.DOCUMENT_POSITION_FOLLOWING)
})
assert.equal(chronological, true, 'pending checkpoint must remain before its chronological successor')
await page.evaluate(() => window.__messageListFixture?.resolveCheckpoint())
await page.waitForSelector('text=Approved fixture checkpoint')
await settle(page)
const retainedNode = await page.evaluate(() => {
const checkpointTitle = Array.from(document.querySelectorAll<HTMLElement>('.ckpt-title'))
.find((element) => element.textContent?.includes('Approval checkpoint 110'))
const currentRow = checkpointTitle?.closest('.msg-row')
return !!currentRow
&& currentRow === window.__rememberedCheckpointRow
&& document.contains(window.__rememberedCheckpointRow ?? null)
})
assert.equal(retainedNode, true, 'resolving a checkpoint must update the same chronological DOM row')
}
async function runPolicyCases(page: Page, baseUrl: string): Promise<void> {
await gotoFixture(page, baseUrl, 'manual')
const manual = await metrics(page)
assert.ok(manual.scrollTop <= 2, `manual policy must not perform initial scrolling; scrollTop=${manual.scrollTop}`)
await gotoFixture(page, baseUrl, 'initial-bottom')
const initial = await metrics(page)
assert.ok(Math.abs(initial.bottomGap) <= 2, `initial-bottom must initially reach bottom; gap=${initial.bottomGap}`)
await page.evaluate(() => window.__messageListFixture?.repeatFullSync())
await settle(page, 100)
await page.evaluate(() => window.__messageListFixture?.growTailLayout(1_000))
await settle(page, 150)
const afterEquivalentSyncLayout = await metrics(page)
assert.ok(
Math.abs(afterEquivalentSyncLayout.bottomGap) <= 2,
`equivalent full sync must not end initial-bottom late-layout following; gap=${afterEquivalentSyncLayout.bottomGap}`,
)
await gotoFixture(page, baseUrl, 'initial-bottom')
await page.evaluate(() => window.__messageListFixture?.appendMessages(6))
await page.waitForSelector('text=fixture-marker-0230')
await settle(page)
const afterAppend = await metrics(page)
assert.ok(afterAppend.bottomGap > 100, 'initial-bottom policy must not follow later appends')
// initial-bottom remains a browsing policy after its first layout. The
// return affordance must exist even before new data, and reaching the tail
// through explicit keyboard input must mark the latest durable row once.
await gotoFixture(page, baseUrl, 'initial-bottom')
const list = page.locator('.msg-list')
await list.hover()
await page.mouse.wheel(0, -1_100)
await settle(page)
const latestButton = page.locator('.msg-list-latest-btn')
await latestButton.waitFor({ state: 'visible' })
assert.equal((await latestButton.textContent())?.trim(), 'Back to latest', 'browsing needs a return control before new messages arrive')
await resetTelemetry(page)
await appendMessages(page, 1)
assert.equal((await telemetry(page)).markReadCalls, 0, 'detached initial-bottom append must remain unread')
assert.match((await latestButton.textContent()) ?? '', /1 new/, 'a durable tail append must increment the detached counter')
await list.focus()
await page.keyboard.press('End')
await settle(page, 120)
assert.ok(Math.abs((await metrics(page)).bottomGap) <= 2, 'End must reach the strict bottom under initial-bottom policy')
assert.equal((await telemetry(page)).markReadCalls, 1, 'user reaching strict bottom must mark the latest row exactly once')
await page.waitForFunction(() => !document.querySelector('.msg-list-latest-btn'))
// Old history is earlier than the detach boundary and must never be counted
// as a new tail delivery, including after the local 200-row window expands.
await gotoFixture(page, baseUrl, 'initial-bottom')
await list.hover()
await page.mouse.wheel(0, -1_100)
await settle(page)
await clickHistoryWithoutScrolling(page)
await settle(page, 100)
await clickHistoryWithoutScrolling(page)
await page.waitForSelector('text=history-marker-00-039')
await settle(page, 120)
assert.equal(
(await page.locator('.msg-list-latest-btn').textContent())?.trim(),
'Back to latest',
'prepended history must not increment the detached new-message counter',
)
await gotoFixture(page, baseUrl, 'initial-bottom', 'empty=1')
assert.equal(await page.locator('.msg-timeline-row').count(), 0, 'empty-summary fixture must begin without a visible timeline row')
await page.getByRole('button', { name: 'Load older messages' }).click()
await page.waitForSelector('text=history-marker-00-039')
assert.ok(await page.locator('.msg-timeline-row').count() > 0, 'an empty filtered summary must still continue history pagination')
}
async function main(): Promise<void> {
let server: ViteDevServer | undefined
let browser: Browser | undefined
const pageErrors: string[] = []
const consoleErrors: string[] = []
try {
server = await createServer({
root: FRONTEND_ROOT,
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, strictPort: false },
})
await server.listen()
const address = server.httpServer?.address()
if (!address || typeof address === 'string') throw new Error('Vite did not expose a TCP port')
const baseUrl = `http://127.0.0.1:${address.port}/`
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
page.on('pageerror', (error) => pageErrors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await runFollowAndBrowsingCases(page, baseUrl)
await runInputIntentCases(page, baseUrl)
await runTouchIntentCase(browser, baseUrl)
await runTimelineIdentityCases(page, baseUrl)
await runProgressCapIdentityCase(page, baseUrl)
await runExternalProgressGeometryCase(page, baseUrl)
await runHiddenPendingCase(page, baseUrl)
await runCheckpointCase(page, baseUrl)
await runPolicyCases(page, baseUrl)
assert.deepEqual(pageErrors, [], `browser page errors:\n${pageErrors.join('\n')}`)
assert.deepEqual(consoleErrors, [], `browser console errors:\n${consoleErrors.join('\n')}`)
console.log('message-list-scroll.spec.ts: OK (scroll writes, follow, browsing geometry/history, stable keys, checkpoints, policies)')
} finally {
await browser?.close()
await server?.close()
}
}
await main()
@@ -0,0 +1,534 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import '../index.css'
import { MessageList } from '../chat/MessageList'
import { WorkItemProgressCard } from '../chat/WorkItemProgressCard'
import { __chatStoreTestUtils } from '../chat/ChatStore'
import { mergeConversationMessages } from '../lib/workItemSessions'
import type { ChatMessage } from '../types/chat'
import type { ProgressEntry, RoleWorkItemSummary } from '../types/kanban'
type ScrollPolicy = 'follow' | 'initial-bottom' | 'manual'
interface FixtureTelemetry {
markReadCalls: number
renders: number
scrollEvents: number
scrollTopWrites: number
}
interface MessageListFixtureApi {
appendMessages(count: number): void
appendProgressEntries(count: number): void
addSharedTurnCompanyRows(indices?: number[]): void
finalizeDraft(): void
growDraft(characters: number): void
growMessage(index: number, characters: number): void
growTailLayout(pixels: number): void
mergeResultGroupOrder(index: number, parentFirst: boolean): void
repeatFullSync(): void
resolveCheckpoint(): void
resetTelemetry(): void
setExternalRoleCount(count: number): void
telemetry(): FixtureTelemetry
upgradeResultSurface(index: number): void
}
declare global {
interface Window {
__messageListFixture?: MessageListFixtureApi
__messageListFixtureReady?: boolean
__rememberedCheckpointRow?: Element | null
__rememberedDraftTimelineRow?: Element | null
}
}
const CHANNEL_ID = 'session:scroll-regression'
const BASE_TIMESTAMP = Date.UTC(2026, 6, 13, 8, 0, 0)
const INITIAL_MESSAGE_COUNT = 225
const CHECKPOINT_INDEX = 110
const CHECKPOINT_ID = 'fixture-checkpoint-110'
const CHECKPOINT_INDICES = new Set([0, 50, 70, 90, CHECKPOINT_INDEX, 130, 150, 170, 190])
const SHARED_COMPANY_TURN_ID = 'fixture-shared-company-turn'
const LIVE_TURN_ID = 'fixture-live-turn'
const INITIAL_PROGRESS_COUNT = 100
// Count direct JS writes, including no-op writes which do not dispatch a
// scroll event. This catches the historical idle scrollToEnd feedback loop.
const scrollTopProbe = { writes: 0 }
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
if (scrollTopDescriptor?.get && scrollTopDescriptor.set && scrollTopDescriptor.configurable) {
Object.defineProperty(Element.prototype, 'scrollTop', {
...scrollTopDescriptor,
set(value: number) {
if ((this as Element).classList?.contains('msg-list')) scrollTopProbe.writes += 1
scrollTopDescriptor.set!.call(this, value)
},
})
}
function messageContent(index: number): string {
const detail = index % 37 === 0
? `\n\n${'Long-form company result with citations, caveats, and acceptance evidence. '.repeat(85)}`
: index % 9 === 0
? '\n\nThis deliberately longer paragraph exercises dynamic Markdown height without relying on a simplified test-only row. '.repeat(3)
: '\n\nA stable production transcript row.'
return `fixture-marker-${String(index).padStart(4, '0')} ${detail}`
}
function buildMessage(index: number): ChatMessage {
if (CHECKPOINT_INDICES.has(index)) {
const checkpointId = `fixture-checkpoint-${index}`
return {
id: `fixture-message-${index}`,
channelId: CHANNEL_ID,
sender: 'system',
senderName: 'OPC',
content: 'Checkpoint fixture payload',
timestamp: BASE_TIMESTAMP + index * 1_000,
mentions: [],
metadata: {
checkpoint_type: 'human_escalation',
checkpoint_id: checkpointId,
escalation_id: checkpointId,
escalation_type: 'decision_needed',
prompt: `Approval checkpoint ${index}\nKeep this card at its chronological position.`,
summary: 'Chronological checkpoint regression fixture',
options: [
{ id: 'approve', label: 'Approve' },
{ id: 'deny', label: 'Deny' },
],
},
}
}
const isUser = index % 5 === 0
return {
id: `fixture-message-${index}`,
channelId: CHANNEL_ID,
sender: isUser ? 'user' : `fixture-agent-${index % 4}`,
senderName: isUser ? 'You' : `Fixture Agent ${index % 4}`,
content: messageContent(index),
timestamp: BASE_TIMESTAMP + index * 1_000,
mentions: [],
metadata: isUser
? { ui_message_id: `fixture-ui-${index}` }
: {
source: 'engine',
canonical_turn_id: `fixture-turn-${index}`,
transcript_kind: 'runtime_v2_assistant',
},
}
}
function buildInitialMessages(): ChatMessage[] {
return Array.from({ length: INITIAL_MESSAGE_COUNT }, (_, index) => buildMessage(index))
}
function buildHistoryMessage(batch: number, offset: number): ChatMessage {
return {
id: `history-message-${batch}-${offset}`,
channelId: CHANNEL_ID,
sender: `history-agent-${offset % 3}`,
senderName: `History Agent ${offset % 3}`,
content: `history-marker-${String(batch).padStart(2, '0')}-${String(offset).padStart(3, '0')}\n\nA prepended historical row.`,
timestamp: BASE_TIMESTAMP - ((batch + 1) * 100_000) + offset * 1_000,
mentions: [],
metadata: { canonical_turn_id: `history-turn-${batch}-${offset}` },
}
}
function buildSharedTurnCompanyMessage(index: number, transcriptKind: string): ChatMessage {
return {
id: `shared-company-message-${index}`,
channelId: CHANNEL_ID,
sender: `company-role-${index}`,
senderName: `Company Role ${index}`,
content: `shared-company-row-${index} — independent committed company surface`,
timestamp: BASE_TIMESTAMP + 2_000_000 + index,
mentions: [],
metadata: {
canonical_turn_id: SHARED_COMPANY_TURN_ID,
transcript_kind: transcriptKind,
},
}
}
function buildProgressEntry(index: number): ProgressEntry {
return {
type: 'status_change',
summary: `progress-marker-${String(index).padStart(4, '0')}`,
detail: `Stable no-id status event ${index}`,
timestamp: BASE_TIMESTAMP + 4_000_000 + index * 1_000,
}
}
function buildRoleWorkItems(count: number): Record<string, RoleWorkItemSummary> {
return Object.fromEntries(Array.from({ length: count }, (_, index) => {
const roleId = `fixture-role-${index}`
return [roleId, {
roleKey: roleId,
roleId,
roleName: `Fixture Role ${index}`,
runtimeStatus: index % 3 === 0 ? 'reflecting' : 'idle',
aggregatedStatus: index % 3 === 0 ? 'active' : 'pending',
workItems: [{
workItemId: `fixture-work-item-${index}`,
workItemProjectionId: `fixture-projection-${index}`,
phase: index % 3 === 0 ? 'running' : 'queued',
kanbanColumn: index % 3 === 0 ? 'in_progress' : 'todo',
title: `Fixture Work Item ${index}`,
executorRoleId: roleId,
executorRoleName: `Fixture Role ${index}`,
createdAt: BASE_TIMESTAMP + index,
updatedAt: BASE_TIMESTAMP + index,
executionTurnId: `fixture-role-turn-${index}`,
progressLog: [],
}],
} satisfies RoleWorkItemSummary]
}))
}
function Fixture() {
const query = useMemo(() => new URLSearchParams(window.location.search), [])
const policy = (query.get('policy') || 'follow') as ScrollPolicy
const progressFixture = query.get('progress') === '1'
const externalProgressFixture = query.get('externalProgress') === '1'
const emptySummaryFixture = query.get('empty') === '1'
const [messages, setMessages] = useState<ChatMessage[]>(() => {
const initial = buildInitialMessages()
if (!emptySummaryFixture) return initial
return initial.map(message => ({
...message,
metadata: { ...(message.metadata ?? {}), detail_visibility: 'full' },
}))
})
const [progressLog, setProgressLog] = useState<ProgressEntry[]>(() => (
progressFixture
? Array.from({ length: INITIAL_PROGRESS_COUNT }, (_, index) => buildProgressEntry(index))
: []
))
const [externalRoleWorkItems, setExternalRoleWorkItems] = useState<Record<string, RoleWorkItemSummary>>({})
const [draftText, setDraftText] = useState('')
const draftTextRef = useRef(draftText)
draftTextRef.current = draftText
const historyBatchRef = useRef(0)
// This state deliberately changes from markRead. The former implementation
// accidentally made that state update part of a scroll-to-bottom feedback
// loop; a correct MessageList must not care that this callback is recreated.
const [, setReadVersion] = useState(0)
const telemetryRef = useRef<FixtureTelemetry>({ markReadCalls: 0, renders: 0, scrollEvents: 0, scrollTopWrites: 0 })
telemetryRef.current.renders += 1
const appendMessages = useCallback((count: number) => {
setMessages((current) => {
const start = current.reduce((max, message) => {
const match = /^fixture-message-(\d+)$/.exec(message.id)
return match ? Math.max(max, Number(match[1]) + 1) : max
}, INITIAL_MESSAGE_COUNT)
return [
...current,
...Array.from({ length: count }, (_, offset) => buildMessage(start + offset)),
]
})
}, [])
const appendProgressEntries = useCallback((count: number) => {
setProgressLog((current) => {
const start = current.reduce((max, entry) => {
const match = /^progress-marker-(\d+)$/.exec(entry.summary)
return match ? Math.max(max, Number(match[1]) + 1) : max
}, INITIAL_PROGRESS_COUNT)
return [
...current,
...Array.from({ length: count }, (_, offset) => buildProgressEntry(start + offset)),
].slice(-INITIAL_PROGRESS_COUNT)
})
}, [])
const growDraft = useCallback((characters: number) => {
setDraftText((current) => `${current}${' live-reply-content'.repeat(Math.max(1, Math.ceil(characters / 19)))}`)
}, [])
const growMessage = useCallback((index: number, characters: number) => {
setMessages((current) => current.map((message) => message.id === `fixture-message-${index}`
? {
...message,
content: `${message.content}\n\n${'Expanded content above the viewport anchor. '.repeat(Math.max(1, Math.ceil(characters / 44)))}`,
}
: message))
}, [])
const repeatFullSync = useCallback(() => {
setMessages((current) => current.map((message) => ({
...message,
metadata: message.metadata ? { ...message.metadata } : undefined,
})))
}, [])
const growTailLayout = useCallback((pixels: number) => {
const tail = Array.from(document.querySelectorAll<HTMLElement>('.msg-timeline-row')).at(-1)
if (!tail) throw new Error('tail timeline row is missing')
let spacer = tail.querySelector<HTMLElement>('[data-late-layout-spacer]')
if (!spacer) {
spacer = document.createElement('div')
spacer.dataset.lateLayoutSpacer = 'true'
tail.appendChild(spacer)
}
spacer.style.height = `${pixels}px`
}, [])
const upgradeResultSurface = useCallback((index: number) => {
setMessages((current) => {
const existing = current.find(message => message.id === `fixture-message-${index}`)
if (!existing || existing.sender === 'user') return current
return __chatStoreTestUtils.dedupeMessages([
...current,
{
...existing,
id: `fixture-upgraded-result-${index}`,
sender: 'fixture-company-role',
senderName: 'Fixture Company Role',
metadata: {
...existing.metadata,
source: 'engine',
canonical_turn_id: `fixture-upgraded-turn-${index}`,
transcript_kind: 'child_task_result',
detail_visibility: 'summary',
},
},
])
})
}, [])
const mergeResultGroupOrder = useCallback((index: number, parentFirst: boolean) => {
const native = buildMessage(index)
if (native.sender === 'user') throw new Error('result group fixture needs an assistant row')
const authoritative: ChatMessage = {
...native,
id: `fixture-cross-channel-result-${index}`,
channelId: 'session:scroll-regression-child',
sender: 'fixture-company-role',
senderName: 'Fixture Company Role',
timestamp: native.timestamp + 10,
metadata: {
...native.metadata,
canonical_turn_id: `fixture-authoritative-turn-${index}`,
transcript_kind: 'child_task_result',
},
}
const selected = mergeConversationMessages(parentFirst
? [[authoritative], [native]]
: [[native], [authoritative]])
setMessages((current) => [
...current.filter(message => (
message.id !== native.id
&& message.id !== authoritative.id
)),
...selected,
])
}, [])
const addSharedTurnCompanyRows = useCallback((indices = [0, 1, 2, 3, 4]) => {
const transcriptKinds = [
'company_role_result',
'child_result',
'runtime_v2_intermediate_assistant',
'runtime_v2_assistant',
'runtime_v2_assistant',
]
setMessages((current) => {
const existingIds = new Set(current.map(message => message.id))
return [
...current,
...indices
.filter(index => !existingIds.has(`shared-company-message-${index}`))
.map(index => buildSharedTurnCompanyMessage(index, transcriptKinds[index] ?? 'runtime_v2_assistant')),
]
})
}, [])
const finalizeDraft = useCallback(() => {
const committedContent = draftTextRef.current.trim() || 'fixture-live-final-content — committed assistant response'
setDraftText('')
setMessages((current) => current.some((message) => message.id === 'fixture-live-final')
? current
: [
...current,
{
id: 'fixture-live-final',
channelId: CHANNEL_ID,
sender: 'fixture-agent-final',
senderName: 'Fixture Final Agent',
content: committedContent,
timestamp: BASE_TIMESTAMP + 3_000_000,
mentions: [],
metadata: {
canonical_turn_id: LIVE_TURN_ID,
transcript_kind: 'runtime_v2_assistant',
},
},
])
}, [])
const loadOlderHistory = useCallback(() => {
const batch = historyBatchRef.current
historyBatchRef.current += 1
setMessages((current) => [
...Array.from({ length: 40 }, (_, offset) => buildHistoryMessage(batch, offset)),
...current,
])
}, [])
const resolveCheckpoint = useCallback(() => {
setMessages((current) => {
if (current.some((message) => message.id === 'fixture-checkpoint-response')) return current
return [
...current,
{
id: 'fixture-checkpoint-response',
channelId: CHANNEL_ID,
sender: 'user',
senderName: 'You',
content: 'Approved fixture checkpoint',
timestamp: BASE_TIMESTAMP + 1_000_000,
mentions: [],
metadata: {
ui_message_id: 'fixture-checkpoint-response-ui',
response_to_checkpoint_id: CHECKPOINT_ID,
response_to_checkpoint_type: 'human_escalation',
checkpoint_reply_kind: 'approve',
},
},
]
})
}, [])
const resetTelemetry = useCallback(() => {
telemetryRef.current.markReadCalls = 0
telemetryRef.current.renders = 0
telemetryRef.current.scrollEvents = 0
scrollTopProbe.writes = 0
}, [])
window.__messageListFixture = {
appendMessages,
appendProgressEntries,
addSharedTurnCompanyRows,
finalizeDraft,
growDraft,
growMessage,
growTailLayout,
mergeResultGroupOrder,
repeatFullSync,
resolveCheckpoint,
resetTelemetry,
setExternalRoleCount: (count: number) => setExternalRoleWorkItems(buildRoleWorkItems(count)),
telemetry: () => ({ ...telemetryRef.current, scrollTopWrites: scrollTopProbe.writes }),
upgradeResultSurface,
}
const handleMarkRead = () => {
telemetryRef.current.markReadCalls += 1
setReadVersion((value) => value + 1)
}
// Use an intersection type so the fixture can be committed alongside the
// implementation change: the old component ignores scrollPolicy and fails
// these checks; the new public contract consumes it.
const ProductionMessageList = MessageList as React.ComponentType<
React.ComponentProps<typeof MessageList> & { scrollPolicy: ScrollPolicy }
>
React.useEffect(() => {
window.__messageListFixtureReady = true
return () => {
window.__messageListFixtureReady = false
delete window.__messageListFixture
}
}, [])
React.useEffect(() => {
const list = document.querySelector('.msg-list')
const countScroll = () => {
telemetryRef.current.scrollEvents += 1
}
list?.addEventListener('scroll', countScroll, { passive: true })
return () => list?.removeEventListener('scroll', countScroll)
}, [])
return (
<main className="app-shell theme-paper message-list-scroll-fixture">
<div className="message-list-scroll-fixture-header">
Production MessageList fixture {policy}
</div>
<section className="message-list-scroll-fixture-body">
{externalProgressFixture && (
<div className="ctx-work-item-progress">
<WorkItemProgressCard
workItemLog={[]}
roleWorkItems={externalRoleWorkItems}
isCompanyRuntime
/>
</div>
)}
<ProductionMessageList
messages={messages}
channelName="Scroll regression"
viewKind="session"
detailMode={progressFixture ? 'full' : 'summary'}
scrollPolicy={policy}
draftAssistantText={draftText}
draftUpdatedAt={BASE_TIMESTAMP + 900_000}
draftTurnId={LIVE_TURN_ID}
isCompanyRuntime
onSend={() => undefined}
onMarkRead={handleMarkRead}
hasOlderHistory
totalMessageCount={messages.length + 100}
onLoadOlderHistory={loadOlderHistory}
progressLog={progressLog}
showWorkItemRuntimeCard={false}
showRuntimeProgress={progressFixture}
/>
</section>
</main>
)
}
const style = document.createElement('style')
style.textContent = `
.message-list-scroll-fixture {
display: flex;
flex-direction: column;
width: 620px;
height: 720px;
max-height: 92vh;
margin: 4vh auto;
border: 1px solid var(--border);
border-radius: 12px;
}
.message-list-scroll-fixture-header {
flex: 0 0 44px;
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text-secondary);
font-size: 12px;
}
.message-list-scroll-fixture-body {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
`
document.head.appendChild(style)
const root = document.getElementById('root')
if (!root) throw new Error('MessageList fixture root is missing')
createRoot(root).render(<Fixture />)