Repository navigation
Description
Summary
Wheeling up through a long session drags the rows that are currently on screen: a row that is still carrying the fallbackItemSize estimate (60px) is measured as it enters the viewport, and the size change is not absorbed by the scroll offset, so everything below it moves.
Measured on dev (654b79b), Chromium, no overlay/extension — this is stock app code driven through packages/app/e2e.
| action | wheel asked for | scrollTop moved |
visible rows drifted |
|---|---|---|---|
| wheel up from the bottom of a long session | 480px | 457px | +33px |
| wheel up after leaving follow-the-bottom | 480px | 511px | −31px |
The second row is the clearest form: content above the reader shrank by 62px, but only 31px of that was absorbed by the scroll, so the remaining 31px pulled the rows being read upward.
Reproduction
The contract asserted is: a row that was on screen must travel exactly as far as the wheel asked for. The virtualizer is free to move scrollTop further than that — that is how it keeps the viewport still while off-screen rows settle — so the test compares against the wheel distance, not against the resulting scroll delta.
Add to packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts:
test("keeps visible rows locked to the scroll while wheeling up through history", async ({ page }) => {
await setupTimeline(page, {
messages: history(60),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const wheels = 480
const sample = () =>
scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return {
scrollTop: element.scrollTop,
view: { top: view.top, bottom: view.bottom },
rows: Object.fromEntries(
[...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].map((row) => {
const rect = row.getBoundingClientRect()
return [row.dataset.timelineKey!, { top: rect.top, bottom: rect.bottom }]
}),
),
}
})
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
// Leave the follow-the-bottom state first: while the timeline is still pinned to the end
// the reader's scroll is deliberately overridden, which is not what this test is about.
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -2000 }))
element.scrollTop -= 2000
})
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(1000)
await page.waitForTimeout(400)
for (let step = 0; step < 6; step++) {
const before = await sample()
await scroller.evaluate((element, delta) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -delta }))
element.scrollTop -= delta
}, wheels)
await page.waitForTimeout(300)
const after = await sample()
const scrollDelta = before.scrollTop - after.scrollTop
expect(scrollDelta).toBeGreaterThan(0)
const drifted = Object.entries(after.rows)
.filter(([key]) => {
const prior = before.rows[key]
return prior !== undefined && prior.bottom > before.view.top && prior.top < before.view.bottom
})
.map(([key, rect]) => ({
key,
drift: Math.round((rect.top - before.rows[key]!.top - wheels) * 10) / 10,
}))
.filter((row) => Math.abs(row.drift) > 2)
expect(
drifted,
`step ${step + 1} wheels=${wheels} scrollDelta=${scrollDelta} contentDelta=${scrollDelta - wheels} drifted: ${JSON.stringify(drifted)}`,
).toEqual([])
}
})Observed on unmodified dev:
Error: step 1 wheels=480 scrollDelta=511 contentDelta=31 drifted:
[{"key":"assistant-part:...:msg_0046_scroll_b_assistant:text:0","drift":128},
{"key":"turn-gap:msg_0047_scroll_a_user","drift":128}, ...]
Why it happens
packages/app/src/session/timeline/virtualizer.tsx
estimateSize: () => fallbackItemSize(line 243) withfallbackItemSize = 60(line 32) — a row that has never been measured is accounted at 60.- Row geometry comes entirely from that ledger:
top: item().start - topOffset(),height: item().size(~line 791). When a row is measured and its size changes bydelta, every row after it moves bydelta. shouldAdjustScrollPositionOnItemSizeChange(lines 364-380) only absorbs the change in two cases:
const adjust = addedKeys.has(String(item.key))
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
: first !== undefined && item.index < firstscrollMargin is topOffset() (line 288), and rows are positioned at item.start - topOffset(), so the first branch means "the row's bottom edge is already above the top of the viewport". A row that is inside the viewport — or that only straddles its top edge because it is still 60px tall in the ledger — is not absorbed, and the rows the reader is looking at move with it.
Attempted fixes and their outcomes
I tried four variants. None of them both fixes the reproduction and keeps the existing suite green, which is why this is an issue rather than a PR.
| # | change | reproduction | existing suite |
|---|---|---|---|
| 1 | widen only the added-row branch: item.end <= fold → item.end <= fold + scrollRect.height |
still fails | — |
| 2 | widen both branches to item.end <= fold + scrollRect.height |
passes | breaks does not claim keyboard scrolling owned by a nested scrollable — the timeline's scrollTop is moved from 300 to 637 while the reader is not scrolling |
| 3 | only absorb rows that give height back: delta < 0 && item.end <= fold + scrollRect.height |
still fails (drift +128 comes from a row growing) | unchanged |
| 4 | absorb when item.start <= fold (covers straddling rows, leaves rows that start inside the viewport anchored to their own top edge) |
still fails (scrollDelta = 393 for a 480px wheel) |
unchanged |
Baseline note: on this machine bunx playwright test --config e2e/performance/timeline-stability/playwright.config.ts reports 12 failures / 27 passing on unmodified dev (Windows, headless Chromium), so the suite is not a clean signal here — CI may differ.
The design question this runs into
Absorbing a size change is unambiguously right when the resized row is above the viewport: the reader's content stays put.
When the row itself is partly on screen and grows, absorbing the change keeps the content below fixed but slides that row's own content upward under the reader. Not absorbing it keeps the row's top edge fixed and pushes everything below down. Neither edge can be held. Variant 2 hit exactly this: it fixed the wheel-up drift and, in exchange, moved scrollTop while the reader was idle.
So the question worth deciding first is which edge should be authoritative for a row that resizes while it is (partly) visible, and whether that answer should depend on whether the resize is user-initiated (expanding a tool card) or machine-initiated (a deferred row settling from its estimate).
Environment
opencode dev @ 654b79b, bun 1.4.2, Windows 11, headless Chromium 1217 via Playwright 1.59.1. Found while building a local web shell around the official UI; the reproduction above is stock app code and does not depend on that shell.
Happy to turn any of the four variants into a PR once the edge choice above is settled.
Activity
opencode-agent commentedon Oct 16, 2026
Thanks for the detailed report and the ready-to-run test. I ran your test unchanged on the latest
dev(582c974) in headless Chromium on Linux, and it fails the same way. On the first 480px wheel step,scrollTopmoved 511px (contentDelta=31, the same as your numbers). Three rows that were on screen (themsg_0047turn gap, user message and assistant text) moved 51px more than the wheel asked for. You saw +128px on Windows, so the amount seems to depend on the machine, but the problem is the same.Your test is attached below. I've passed this to the maintainers along with your design question: which edge of a partly visible row should stay fixed when it resizes, and whether that should differ between a row settling from its estimate and a user expanding a card.
Reproduction script (wheel-drift.spec.ts)
Run it:
bun wheel-drift.spec.tsxiaobright commentedon Oct 18, 2026
Follow-up with additional validation on 2026-10-18. The original stock-app reproduction remains the main reproduction for this issue; the modified-UI results below are exploratory evidence, not a proposed fix or a claim about current
dev.I compared the saved 2.0.30 release UI with an experimental frontend patch using the same assets and replayed messages, without my shell's CSS/DOM overlay. The control retained the official projection, layout and native tool folding, with measurement instrumentation added. Windows, headless Chromium, 1280×760, reduced motion, one worker, two independent runs per mode. No backend changes or model calls were involved.
Projection-level folding does not eliminate the remaining displacement
The experiment removes completed intermediate assistant rows before they enter the virtualizer and replaces them with a real, fixed 40px summary row. Final answer text remains visible. Expanding restores the original process rows as virtualized siblings. Hidden content is not rendered and then compressed to zero height. Ordinary rows still use the official virtualizer; this does not eagerly mount the entire history or change its ordinary size-change compensation predicate.
These are operation-boundary measurements: first wheel up 1,800px to leave the bottom, then 18 steps of 240px. For each step, compare the same row visible both before and after and calculate
after.top - before.top - 240. They are not per-frame maxima or CLS. Equal scroll distances expose different content in the two modes, and two repeats are not a general performance ranking. In particular, the patched synthetic case was worse by this metric. Accurate summary heights alone did not solve ordinary history scrolling, and this does not establish that every displacement in the modified UI has exactly the same cause as the stock reproduction.Explicit expansion anchoring could be kept stable in the tested cases
unpin()returns early while the old content still fits the viewport, and expansion can remain pinned to the bottom. This is an integration finding, not a blanket recommendation to change unpin behavior globally.session.step.endedandsession.execution.succeededevents, it folded and retained the final answer. That test checks state/DOM behavior, not frame-by-frame continuity during automatic folding.This supports treating an explicit expand/collapse anchor separately from ordinary deferred measurement and follow-the-bottom behavior; it does not validate broadening the compensation predicate to every visible row.
Measurement details and an estimate-consistency finding
The new scroll measurements use real
page.mouse.wheelinput. Before each action the harness arms listeners, then requires that action'swheel, a subsequentscroll, andscrollend, rather than accepting a delayedscrollendfrom a previous action. Earlier exploratory runs that only awaitedscrollendare excluded from the table. Initial readiness checks wait for fonts and agreement between the mounted row boxes and their content heights; this does not premeasure off-screen rows.There was also a patch-induced measurement loop when introducing a 40px estimate: the core did not cache a measured size equal to its estimate, while the wrapper's deduplication still used
(itemSizeCache.get(key) ?? fallbackItemSize) === sizewith a 60px fallback. Updating onlyestimateSizewas insufficient; the wrapper had to use the same per-row fallback. This was observed with heterogeneous estimates in the experiment, not as an infinite-loop bug in the unchanged release. It may be relevant if different estimates are introduced upstream.Loading, memory and validation scope
The 1,393-message case was also replayed through normal history pagination: 40 initial messages, then 20 per page, 69 pages total.
Readiness includes fixture setup, navigation and layout checks. Paging duration includes automated scrolling and readiness waits, not just data transfer. Memory values are snapshots, not peaks. The original message bodies remain in client state, and both modes retain virtualization, so fewer logical rows did not produce a proportional memory reduction.
The extra grouping stage took 1.3–2.1ms cumulatively for the first 40 messages, 45.0–46.6ms over two calls during an all-at-once initialization, and 559.3–604.3ms across 70 projection calls while paging through all history. The prototype still scans loaded structural data on updates; it is not a complete incremental index. Across each 8-cycle expand/collapse test, post-GC heap increased by 0.54 / 0.47 MiB; this is not a long-term leak test.
All four runs passed their three benchmark cases and two functional cases (20 test executions, with fold-specific assertions conditional on the patched mode). All benchmark cases recorded no page errors. The scroll benchmarks record displacement rather than asserting zero drift; passing these tests does not mean scrolling was fixed. Four unit tests also passed. Nested keyboard scroll ownership, touch momentum, session switching, deep links, and resize/reflow regressions have not been revalidated for this patch, so it is not a replacement for the existing stability suite.
Release asset:
/_assets/route-Bm7Nm7t-.js, SHA-256a78015e20f722756818d02ac85641848a202e0afe2518bd51b09ac6db4280f66. Source references were checked against654b79b; these runs used saved release assets, not a fresh build of today'sdev. The private history contents are not included here.