fix(history): preserve manual timeline browsing during pending jumps

Cancel deferred active-marker reveals when the reader scrolls or interacts with the global rail, and reveal without a smooth animation. A previous selection must not move or unmount the next click target while its distant window is still loading.

Extend the Chromium latest-intent regression to keep the next destination visible beyond the reveal delay. Update the isolated native pruning UI fixture to proxy history routes and provide the same service-location inventory used by whole-session cleanup.

Validated all five browser navigation cases, the UI typecheck, packaged-resource smoke checks, and the complete isolated OpenCode 2.0.5 native/UI pruning fixture, including whole-session confirmation and persistence after reload.
This commit is contained in:
Pascal André 2026-09-20 13:05:28 +02:00
parent d9de25b360
commit 009a0aaf29
No known key found for this signature in database
3 changed files with 29 additions and 10 deletions

View file

@ -383,22 +383,27 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
let revealTimer: ReturnType<typeof setTimeout> | undefined
const cancelReveal = () => {
if (revealTimer !== undefined) clearTimeout(revealTimer)
revealTimer = undefined
}
const handleScroll = () => {
cancelReveal()
if (hoveredSegment()) clearHoverPreview()
}
createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
revealTimer = setTimeout(() => {
revealTimer = undefined
const index = segmentIndexById().get(activeId)
if (index !== undefined) virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
}, 120) : null
onCleanup(() => {
if (timer !== null && typeof window !== "undefined") {
window.clearTimeout(timer)
}
})
// The rail is globally virtualized: an animated reveal can keep moving
// under the pointer while the reader chooses another distant destination.
if (index !== undefined) virtualizerHandle()?.scrollToIndex(index, { align: "nearest" })
}, 120)
onCleanup(cancelReveal)
}))
createEffect(() => {
@ -569,6 +574,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
role="navigation"
aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll}
onWheel={cancelReveal}
onPointerDown={cancelReveal}
onKeyDown={cancelReveal}
>
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
{(segment) => {

View file

@ -74,10 +74,18 @@ async function fixture() {
}
}
async function clickTimeline(page: Page, index: number) {
async function clickTimeline(page: Page, index: number, checkReaderPosition = false) {
await page.locator(".message-timeline").evaluate((element, index) => {
element.scrollTop = element.scrollHeight * index / 1500
}, index)
if (checkReaderPosition) {
// Let the prior selection's 120ms reveal expire while the reader is
// browsing elsewhere. It must not reclaim the rail or animate the target.
await page.waitForTimeout(250)
const target = await page.locator(`.message-timeline-segment[data-message-id="${navigationMessageId(index)}"]`).boundingBox()
const rail = await page.locator(".message-timeline").boundingBox()
assert(target && rail && target.y >= rail.y && target.y < rail.y + rail.height, "reader's destination remains visible before clicking")
}
await page.locator(`.message-timeline-segment[data-message-id="${navigationMessageId(index)}"]`).click()
}
const snapshot = (page: Page) => page.evaluate(() => (window as any).fixture.snapshot())
@ -119,7 +127,7 @@ test("latest timeline intent supersedes a slow far jump, including a resident de
const release = f.hold(250)
await clickTimeline(f.page, 250)
await f.page.locator(".history-navigation-status").waitFor()
await clickTimeline(f.page, 1450)
await clickTimeline(f.page, 1450, true)
await f.page.locator(".history-navigation-status").waitFor({ state: "hidden" })
release()
await f.page.waitForTimeout(150)

View file

@ -29,6 +29,8 @@ export async function testPruningUI({ client, baseUrl, root, location, generate,
...(connection ? { getSharedServiceConnection: async () => connection } : {}),
getInstanceAuthorizationHeader: () => `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
getServiceDirectory: () => location.directory,
getServiceLocation: () => location,
getWorktrees: async () => ({ worktrees: [] }),
getSharedServiceClient: async () => client,
getSessionEnvironment: async () => {
const { sessionEnvironment } = await tsImport("../packages/server/src/workspaces/session-environment.ts", import.meta.url)
@ -71,6 +73,7 @@ export async function testPruningUI({ client, baseUrl, root, location, generate,
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: {
"/workspaces/pruning-ui/instance": { target: `http://127.0.0.1:${broker.server.address().port}` },
"/api/workspaces/pruning-ui/session-pruning": { target: `http://127.0.0.1:${broker.server.address().port}` },
"/api/workspaces/pruning-ui/session-history": { target: `http://127.0.0.1:${broker.server.address().port}` },
"/fixture-events": { target: `http://127.0.0.1:${broker.server.address().port}` },
} },
})