fix(app): simplify timeline anchoring (#44147)

This commit is contained in:
Brendan Allan
2026-08-22 22:09:00 +08:00
committed by GitHub
parent ada4f1bee6
commit 2bbaeb0d4b
15 changed files with 365 additions and 358 deletions
+4 -4
View File
@@ -1050,7 +1050,7 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
@@ -1100,7 +1100,7 @@
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@standard-schema/spec": "1.1.0",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.32",
"@tanstack/solid-virtual": "3.13.37",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13",
@@ -2938,9 +2938,9 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.37", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-RxJC8n/6nnFHcPvDGMg7ZkqSzCVUco4JNw3ctIChj1HjoKs5I9wt5nsU+DUVtMVEiMfizUxOAx4TPIN8SHjxUQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+2 -2
View File
@@ -52,7 +52,7 @@
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@tanstack/solid-virtual": "3.13.32",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
@@ -174,7 +174,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
}
}
@@ -1,5 +1,12 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
@@ -49,8 +56,101 @@ test("renders a completed single-file patch", async ({ page }) => {
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const file = wrapper.locator('[data-scope="apply-patch"]')
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("keeps an expanded file diff header at the same viewport position", async ({ page }) => {
const id = "prt_file_projection_anchored_patch"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
id,
"patch",
"completed",
{ patchText: "Update src/anchored.ts" },
{
metadata: {
files: [
{
file: "src/anchored.ts",
status: "modified",
patch: createTwoFilesPatch("a/src/anchored.ts", "b/src/anchored.ts", before, after),
additions: 80,
deletions: 80,
},
],
},
},
),
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
]),
],
viewport: { width: 1200, height: 600 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const trigger = wrapper.getByRole("button")
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
})
await expect(trigger).toBeInViewport()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(0.5)
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
await trigger.dispatchEvent("pointerdown")
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.dispatchEvent("click")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
.toBeLessThanOrEqual(5)
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, 200)
await timeline.settle(40)
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
expect(scrolled).toBeGreaterThan(50)
expect(scrolled).toBeLessThan(400)
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await expect
.poll(() =>
scroller.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
)
.toBeLessThanOrEqual(1)
await expect.poll(() => trigger.evaluate((element) => element.getBoundingClientRect().y)).toBeGreaterThan(expandedY)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
+1 -1
View File
@@ -133,7 +133,7 @@ export function createActiveSessionRegion(input: {
const scroller = input.timeline.scroller()
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
input.timeline.view.markGesture(scroller)
input.timeline.view.markUserScroll(scroller)
return
}
if (event.key.length !== 1 || event.key === "Unidentified" || event.ctrlKey || event.metaKey) return
+4 -6
View File
@@ -78,20 +78,18 @@ export function SessionScreen(props: { session: SessionModel }) {
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onAutoScrollHandleScroll={timeline.autoScroll.handleScroll}
onMarkScrollGesture={timeline.view.markGesture}
hasScrollGesture={timeline.view.hasGesture()}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onAutoScrollInteraction={timeline.autoScroll.handleInteraction}
shouldAnchorBottom={timeline.view.shouldAnchorBottom()}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
setHistoryAnchor={timeline.view.setHistoryAnchor}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
@@ -1,8 +1,7 @@
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocation } from "@solidjs/router"
import { createEffect, on, onCleanup } from "solid-js"
import { createEffect, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLayout } from "@/shell/state/layout"
import type { SessionModel } from "../model"
@@ -16,7 +15,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
const [state, setState] = createStore({
messageID: undefined as string | undefined,
pendingMessage: undefined as string | undefined,
gestureAt: 0,
scroll: {
overflow: false,
jump: false,
@@ -26,13 +24,18 @@ export function createSessionTimelineInteraction(session: SessionModel) {
dock: undefined as HTMLDivElement | undefined,
},
})
const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "none" })
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
// it from scroll geometry; everything else only expresses explicit intent.
const [pinned, setPinned] = createSignal(true)
const pin = () => setPinned(true)
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setPinned(false)
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
let revealMessage = (_id: string) => {}
let scrollToEnd = () => {}
let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {}
let scrollMark = 0
let messageMark = 0
let scrollStateFrame: number | undefined
@@ -102,10 +105,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
pendingMessage: () => state.pendingMessage,
setPendingMessage: (value) => setState("pendingMessage", value),
setActiveMessage,
autoScroll: {
pause: autoScroll.pause,
forceScrollToBottom: () => {
autoScroll.resume()
follow: {
unpin,
toBottom: () => {
pin()
scrollToEnd()
},
},
@@ -117,7 +120,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
})
const resume = () => {
setState("messageID", undefined)
autoScroll.resume()
pin()
scrollToEnd()
clearMessageHash()
if (scroller) scheduleScrollState(scroller)
@@ -133,21 +136,23 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resume()
return
}
autoScroll.pause()
unpin()
scrollToMessage(messages[target], "auto")
}
const shouldAnchorBottom = () =>
!location.hash && !state.messageID && !state.pendingMessage && !autoScroll.userScrolled()
const markGesture = (target?: EventTarget | null) => {
// A gesture inside a nested scrollable region scrolls that region, not the timeline.
const markUserScroll = (target?: EventTarget | null) => {
if (!scroller) return
const element = target instanceof Element ? target : undefined
const nested = element?.closest("[data-scrollable]")
if (nested && nested !== scroller) return
setState("gestureAt", Date.now())
scrollMark += 1
}
const selectionInteraction = () => {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) unpin()
}
const setScrollRef = (element: HTMLDivElement | undefined) => {
scroller = element
autoScroll.scrollRef(element)
if (!element) return
scheduleScrollState(element)
fill()
@@ -158,15 +163,12 @@ export function createSessionTimelineInteraction(session: SessionModel) {
historyRequests.add(owner.key)
const before = timeline.messages().length
try {
await timeline.history.loadOlder({
before: () => owner.run(captureHistoryAnchor),
after: (done) => owner.run(() => restoreHistoryAnchor(done)),
})
await timeline.history.loadOlder()
} finally {
historyRequests.delete(owner.key)
}
if (!owner.current() || timeline.messages().length <= before) return
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
if (pinned() || !scroller || scroller.scrollTop >= 200 || !timeline.history.more()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
@@ -177,7 +179,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (
historyRequests.has(session.ownership.key()) ||
timeline.history.loading() ||
!autoScroll.userScrolled() ||
pinned() ||
!scroller ||
scroller.scrollTop >= 200
)
@@ -189,7 +191,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!session.identity.params.id || !timeline.ready()) return
if (autoScroll.userScrolled() || timeline.history.loading() || !scroller) return
if (!pinned() || timeline.history.loading() || !scroller) return
if (scroller.scrollHeight > scroller.clientHeight + 1 || !timeline.history.more()) return
void loadOlder()
})
@@ -218,15 +220,16 @@ export function createSessionTimelineInteraction(session: SessionModel) {
() => session.identity.params.id,
(id, previous) => {
if (!id || !previous || id === previous || state.messageID || state.pendingMessage || location.hash) return
autoScroll.resume()
pin()
scrollToEnd()
},
),
)
createEffect(
on(
autoScroll.userScrolled,
(scrolled) => {
if (scrolled) return
pinned,
(value) => {
if (!value) return
setState("messageID", undefined)
clearMessageHash()
},
@@ -241,11 +244,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
timeline.ready(),
timeline.history.more(),
timeline.history.loading(),
autoScroll.userScrolled(),
pinned(),
visibleUserMessages().length,
] as const,
([id, ready, more, loading, scrolled]) => {
if (id && ready && more && !loading && !scrolled) fill()
([id, ready, more, loading, following]) => {
if (id && ready && more && !loading && following) fill()
},
{ defer: true },
),
@@ -264,8 +267,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
if (next === dockHeight) return
const delta = next - dockHeight
const stick = scroller
? !autoScroll.userScrolled() ||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
? pinned() || scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next
if (stick) scrollToEnd()
@@ -285,7 +287,6 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resume,
setActiveMessage,
},
autoScroll,
lastUserMessage: timeline.lastUserMessage,
resource: timeline.resource,
ready: timeline.ready,
@@ -293,25 +294,19 @@ export function createSessionTimelineInteraction(session: SessionModel) {
scroller: () => scroller,
view: {
anchor,
hasGesture: () => Date.now() - state.gestureAt < 250,
markGesture,
markUserScroll: () => {
scrollMark += 1
},
markUserScroll,
onHistoryScroll,
pin,
pinned,
selectionInteraction,
scheduleScrollState,
setContentRef: (element: HTMLDivElement | undefined) => {
setState("refs", "content", element)
autoScroll.contentRef(element)
if (scroller) scheduleScrollState(scroller)
},
setDockRef: (element: HTMLDivElement | undefined) => {
setState("refs", "dock", element)
},
setHistoryAnchor: (handlers: { capture: () => void; restore: (done: boolean) => void }) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
},
setRevealMessage: (reveal: (id: string) => void) => {
revealMessage = reveal
},
@@ -319,7 +314,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
setScrollToEnd: (scroll: () => void) => {
scrollToEnd = scroll
},
shouldAnchorBottom,
unpin,
},
}
}
@@ -1,30 +0,0 @@
import { expect, test } from "bun:test"
import { scheduleConnectedMeasure } from "./measure"
test("does not measure an element detached before the frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
element.remove()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(0)
})
test("measures a connected element on the next frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(1)
element.remove()
})
@@ -1,5 +0,0 @@
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
return requestAnimationFrame(() => {
if (element.isConnected) measure(element)
})
}
@@ -323,13 +323,12 @@ type MessageTimelineProps = {
onResumeScroll: () => void
setScrollRef: (el: HTMLDivElement | undefined) => void
onScheduleScrollState: (el: HTMLDivElement) => void
onAutoScrollHandleScroll: () => void
onMarkScrollGesture: (target?: EventTarget | null) => void
hasScrollGesture: boolean
onUserScroll: () => void
onPin: () => void
onUnpin: () => void
onUserScroll: (target?: EventTarget | null) => void
onHistoryScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
shouldAnchorBottom: boolean
onSelectionInteraction: (event: MouseEvent) => void
pinned: boolean
centered: boolean
setContentRef: (el: HTMLDivElement) => void
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
@@ -339,7 +338,6 @@ type MessageTimelineProps = {
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
export function MessageTimeline(props: MessageTimelineProps) {
@@ -396,28 +394,25 @@ function MessageTimelineView(
)
const turnPadding = () => "px-4 md:px-5"
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: props.data.sessionKey,
projection,
showHeader,
shouldAnchorBottom,
hasScrollGesture,
pinned,
scroll: () => props.scroll,
onResumeScroll: props.onResumeScroll,
setScrollRef: props.setScrollRef,
setContentRef: props.setContentRef,
onScheduleScrollState: props.onScheduleScrollState,
onAutoScrollHandleScroll: props.onAutoScrollHandleScroll,
onAutoScrollInteraction: props.onAutoScrollInteraction,
onMarkScrollGesture: props.onMarkScrollGesture,
onPin: props.onPin,
onUnpin: props.onUnpin,
onSelectionInteraction: props.onSelectionInteraction,
onUserScroll: props.onUserScroll,
onHistoryScroll: props.onHistoryScroll,
setRevealMessage: props.setRevealMessage,
setScrollToEnd: props.setScrollToEnd,
setHistoryAnchor: props.setHistoryAnchor,
})
const VirtualizedTimeline = virtualized.View
const [title, setTitle] = createStore({
@@ -3,6 +3,7 @@ import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
instance: Virtualizer<TScrollElement, TItemElement>,
callback: (offset: number, isScrolling: boolean) => void,
onReconnect?: () => void,
) {
let active = true
const deliver = (offset: number, isScrolling: boolean) => {
@@ -54,6 +55,7 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
}
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
removed = false
onReconnect?.()
startCheck()
})
})
+91 -232
View File
@@ -1,7 +1,6 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/session/message-gesture"
import { useLanguage } from "@/runtime/i18n/language"
import {
createEffect,
@@ -17,11 +16,14 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import type { createTimelineProjection } from "./projection"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
type Projection = Pick<
@@ -33,21 +35,20 @@ type Input = {
sessionKey: Accessor<string>
projection: Projection
showHeader: Accessor<boolean>
shouldAnchorBottom: Accessor<boolean>
hasScrollGesture: Accessor<boolean>
/** True while the timeline follows the newest content. Drives every anchoring decision. */
pinned: Accessor<boolean>
scroll: Accessor<{ overflow: boolean; jump: boolean }>
onResumeScroll: () => void
setScrollRef: (element: HTMLDivElement | undefined) => void
setContentRef: (element: HTMLDivElement) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onAutoScrollHandleScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onMarkScrollGesture: (target?: EventTarget | null) => void
onUserScroll: () => void
onPin: () => void
onUnpin: () => void
onSelectionInteraction: (event: MouseEvent) => void
onUserScroll: (target?: EventTarget | null) => void
onHistoryScroll: () => void
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
type ViewProps = {
@@ -62,90 +63,31 @@ export function createTimelineVirtualizer(input: Input) {
const ownerSessionKey = input.sessionKey()
const cached = cache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && input.shouldAnchorBottom()
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
let touchGesture: number | undefined
let prependAnchor: { key: string; offset: number } | undefined
let prependAnchorFrame: number | undefined
let prependLoading = false
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let gestureAnchorFrame: number | undefined
const knownKeys = new Set(rows().map(TimelineRow.key))
const addedKeys = new Set<string>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
const clearPrependAnchor = () => {
prependLoading = false
prependAnchor = undefined
if (prependAnchorFrame === undefined) return
cancelAnimationFrame(prependAnchorFrame)
prependAnchorFrame = undefined
}
const capturePrependAnchor = () => {
prependLoading = true
updatePrependAnchor()
}
const updatePrependAnchor = () => {
const root = listRoot()
if (!root) return
const view = root.getBoundingClientRect()
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return
if (!anchor.element.dataset.timelineKey) return
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
}
const restorePrependAnchor = (done: boolean) => {
if (done) prependLoading = false
applyPrependAnchor()
}
const applyPrependAnchor = () => {
const root = listRoot()
if (!root || !prependAnchor) return
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
let frames = 0
let stable = 0
const apply = () => {
prependAnchorFrame = undefined
const anchor = prependAnchor
if (!anchor) return
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
const delta = element
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
: undefined
if (delta !== undefined && Math.abs(delta) > 0.5) {
root.scrollTop += delta
stable = 0
} else {
stable += 1
}
frames += 1
if (stable >= 30 || frames >= 180) {
if (!prependLoading) prependAnchor = undefined
return
}
prependAnchorFrame = requestAnimationFrame(apply)
}
prependAnchorFrame = requestAnimationFrame(apply)
}
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (input.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) =>
observeElementOffsetReconnectAware(instance, callback, () => {
if (input.pinned()) virtualizer.scrollToEnd()
}),
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
scrollToFn: (offset, options, instance) => {
@@ -154,83 +96,62 @@ export function createTimelineVirtualizer(input: Input) {
},
get getItemKey() {
const items = rows()
items
.map(TimelineRow.key)
.filter((key) => !knownKeys.has(key))
.forEach((key) => {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => {
const row = items[index]
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
anchorTo: "end",
followOnAppend: true,
get anchorTo() {
return input.pinned() ? "end" : "start"
},
get followOnAppend() {
return input.pinned()
},
scrollEndThreshold: 80,
get scrollMargin() {
return input.showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
rangeExtractor: (range) => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorAfterGesture = () => {
if (gestureAnchorFrame !== undefined) return
const apply = () => {
gestureAnchorFrame = undefined
if (input.hasScrollGesture()) {
gestureAnchorFrame = requestAnimationFrame(apply)
return
}
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
}
gestureAnchorFrame = requestAnimationFrame(apply)
}
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
// reconciles the end. Coalesce one correction per measurement batch, before paint.
const anchorResizedBottom = () => {
if (resizeAnchorScheduled) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (input.hasScrollGesture()) {
anchorAfterGesture()
return
}
if (!input.shouldAnchorBottom()) return
if (!input.pinned()) return
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
const root = listRoot()
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
const view = root.getBoundingClientRect()
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => Number(element.dataset.index))
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = undefined
resizePinnedIndexes = []
})
})
}
resizeItem(index, size)
if (root && input.shouldAnchorBottom()) anchorResizedBottom()
if (listRoot() && input.pinned()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (input.shouldAnchorBottom()) return false
const first = virtualizer.range?.startIndex
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) {
return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments
}
const first = instance.range?.startIndex
return first !== undefined && item.index < first
}
const virtualItemByKey = createMemo(
@@ -244,97 +165,72 @@ export function createTimelineVirtualizer(input: Input) {
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => virtualizer.scrollToEnd())
input.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
input.setScrollToEnd?.(() => {
input.onPin()
virtualizer.scrollToEnd()
})
})
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
})
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
})
})
const maybeAnchorBottom = () => {
if (rows().length === 0) return
if (input.hasScrollGesture()) {
anchorAfterGesture()
return
}
if (!input.shouldAnchorBottom()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
virtualizer.scrollToEnd()
}
let measuredSessionKey = input.sessionKey()
createEffect(() => {
const key = input.sessionKey()
rows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
}
maybeAnchorBottom()
})
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
root.style.overflowAnchor = "none"
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
input.setScrollRef(root)
}
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
const root = event.currentTarget
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
deltaMode: event.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: input.onMarkScrollGesture })
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
}
const handleListTouchStart = (event: TouchEvent) => {
if (!prependLoading) clearPrependAnchor()
touchGesture = event.touches[0]?.clientY
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
const next = event.touches[0]?.clientY
const previous = touchGesture
touchGesture = next
if (next === undefined || previous === undefined) return
const delta = previous - next
if (!delta) return
markBoundaryGesture({
root: event.currentTarget,
target: event.target,
delta,
onMarkScrollGesture: input.onMarkScrollGesture,
})
}
const handleListTouchEnd = () => {
touchGesture = undefined
const current = event.touches[0]?.clientY
if (current === undefined || touchStart === undefined) return
// Dragging the content downward reveals earlier messages.
if (current <= touchStart) return
touchStart = current
input.onUnpin()
}
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.target)
}
const handleListPointerMove = (event: PointerEvent) => {
if (event.buttons !== 1) return
input.onMarkScrollGesture(event.target)
input.onUserScroll(event.target)
pointerHeld = true
const release = () => {
pointerHeld = false
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
}
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
}
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
@@ -342,22 +238,25 @@ export function createTimelineVirtualizer(input: Input) {
if (!key) return
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
if (!prependLoading) clearPrependAnchor()
input.onMarkScrollGesture(event.currentTarget)
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
const root = event.currentTarget
const movedUp = root.scrollTop < scrollTop - 0.5
const previousTop = scrollTop
const previousMaxScroll = maxScroll
scrollTop = root.scrollTop
if (prependLoading) updatePrependAnchor()
maxScroll = root.scrollHeight - root.clientHeight
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
input.onScheduleScrollState(root)
input.onHistoryScroll()
if (!input.hasScrollGesture()) return
if (!movedUp && root.scrollHeight - root.clientHeight - root.scrollTop >= 10) return
input.onUserScroll()
input.onAutoScrollHandleScroll()
input.onMarkScrollGesture(root)
}
function View(props: ViewProps) {
@@ -368,7 +267,6 @@ export function createTimelineVirtualizer(input: Input) {
const item = createMemo(() => virtualItemByKey().get(rowProps.rowKey) ?? initialItem)
const row = createMemo(() => rowByKey().get(rowProps.rowKey) ?? rows()[item().index] ?? initialRow)
const [ready, setReady] = createSignal(initialItem.size <= fallbackItemSize || !props.deferred(initialRow))
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
@@ -380,10 +278,7 @@ export function createTimelineVirtualizer(input: Input) {
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
})
onCleanup(() => virtualizer.measureElement(null))
return (
<div
@@ -407,8 +302,6 @@ export function createTimelineVirtualizer(input: Input) {
>
{props.renderRow(row, () => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
})}
</div>
</div>
@@ -450,13 +343,10 @@ export function createTimelineVirtualizer(input: Input) {
onWheel={handleListWheel}
onTouchStart={handleListTouchStart}
onTouchMove={handleListTouchMove}
onTouchEnd={handleListTouchEnd}
onTouchCancel={handleListTouchEnd}
onPointerDown={handleListPointerDown}
onPointerMove={handleListPointerMove}
onKeyDown={handleListKeyDown}
onScroll={handleListScroll}
onClick={input.onAutoScrollInteraction}
onClick={input.onSelectionInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
>
@@ -485,17 +375,13 @@ export function createTimelineVirtualizer(input: Input) {
}
onCleanup(() => {
clearPrependAnchor()
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (gestureAnchorFrame !== undefined) cancelAnimationFrame(gestureAnchorFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
input.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
})
return {
@@ -506,30 +392,3 @@ export function createTimelineVirtualizer(input: Input) {
View,
}
}
function boundaryTarget(root: HTMLElement, target: EventTarget | null) {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
if (!(nested instanceof HTMLElement) || nested === root) return undefined
return nested
}
function markBoundaryGesture(input: {
root: HTMLElement
target: EventTarget | null
delta: number
onMarkScrollGesture: (target?: EventTarget | null) => void
}) {
const target = boundaryTarget(input.root, input.target)
if (
target &&
!shouldMarkBoundaryGesture({
delta: input.delta,
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
})
)
return
input.onMarkScrollGesture(input.root)
}
@@ -15,7 +15,7 @@ export const useSessionHashScroll = (input: {
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: SessionMessageUser | undefined) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
follow: { unpin: () => void; toBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
revealMessage?: (id: string) => void
@@ -101,7 +101,7 @@ export const useSessionHashScroll = (input: {
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
if (!hash) {
input.autoScroll.forceScrollToBottom()
input.follow.toBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -109,7 +109,7 @@ export const useSessionHashScroll = (input: {
const messageId = messageIdFromHash(hash)
if (messageId) {
input.autoScroll.pause()
input.follow.unpin()
const msg = messageById().get(messageId)
if (msg) {
scrollToMessage(msg, behavior)
@@ -120,12 +120,12 @@ export const useSessionHashScroll = (input: {
const target = document.getElementById(hash)
if (target) {
input.autoScroll.pause()
input.follow.unpin()
scrollToElement(target, behavior)
return
}
input.autoScroll.forceScrollToBottom()
input.follow.toBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
@@ -166,7 +166,7 @@ export const useSessionHashScroll = (input: {
if (pending) input.setPendingMessage(undefined)
if (input.currentMessageId() === targetId && !pending) return
input.autoScroll.pause()
input.follow.unpin()
cancel()
queue(() => scrollToMessage(msg, "auto"))
})
@@ -27,6 +27,34 @@ test("end anchoring survives consecutive resizes when the first scroll write is
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("start anchoring preserves a stable visible item across prepends", () => {
const root = document.createElement("div")
const writes: number[] = []
const options = (keys: string[]) => ({
count: keys.length,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 100 },
anchorTo: "start" as const,
getItemKey: (index: number) => keys[index]!,
getScrollElement: () => root,
scrollToFn: (offset: number) => writes.push(offset),
observeElementRect: () => {},
observeElementOffset: (_element: HTMLDivElement, callback: (offset: number, isScrolling: boolean) => void) => {
callback(50, false)
},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(["c", "d", "e"]))
virtualizer._willUpdate()
virtualizer.getVirtualItems()
virtualizer.setOptions(options(["a", "b", "c", "d", "e"]))
virtualizer._willUpdate()
expect(virtualizer.getScrollOffset()).toBe(150)
expect(writes.at(-1)).toBe(150)
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
@@ -1295,7 +1295,7 @@ ToolRegistry.register({
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={i18n.t("ui.tool.shell")}
active={streaming()}
active={streaming() || (props.status === "completed" && props.metadata.status === "running")}
/>
</span>
<Show when={!open()}>
@@ -1,8 +1,26 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6c508630d 100644
index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281ee30ba28a 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -716,17 +716,20 @@ class Virtualizer {
@@ -289,7 +289,7 @@ class Virtualizer {
let anchor = null;
let followOnAppend = null;
let edgeKeysChanged = false;
- if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && merged.anchorTo === "end" && this.scrollElement !== null) {
+ if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && this.scrollElement !== null) {
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
@@ -303,7 +303,7 @@ class Virtualizer {
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
}
- const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
+ const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
@@ -725,17 +725,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
@@ -27,7 +45,7 @@ index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e9be287b7 100644
index cc1001b8a14d06aa73a3a9e8f59870557ee9e7d6..70b202ed436ce1f3283a8465a81faf2e6e2e7983 100644
--- a/dist/cjs/index.d.cts
+++ b/dist/cjs/index.d.cts
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
@@ -40,7 +58,7 @@ index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index b03abab604eb6578f6f56ff92c489259cfaf8f19..940310f9215ba5bf666c5b71e92dddbbbc85635d 100644
index 6b43c0aea7ed9eeef75cbfb1351fcbd243913bdd..7be2680967934ddfbc4583a210a3d11ee2cddc94 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
@@ -53,10 +71,28 @@ index b03abab604eb6578f6f56ff92c489259cfaf8f19..940310f9215ba5bf666c5b71e92dddbb
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 3032c0ca457582be3f47923cba1f7d92c848745c..6e52124048fb3bc7b7ee37e763354b33593a6285 100644
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39a5b0b899 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -714,17 +714,20 @@ class Virtualizer {
@@ -287,7 +287,7 @@ class Virtualizer {
let anchor = null;
let followOnAppend = null;
let edgeKeysChanged = false;
- if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && merged.anchorTo === "end" && this.scrollElement !== null) {
+ if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && this.scrollElement !== null) {
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
@@ -301,7 +301,7 @@ class Virtualizer {
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
}
- const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
+ const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
@@ -723,17 +723,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
@@ -81,10 +117,33 @@ index 3032c0ca457582be3f47923cba1f7d92c848745c..6e52124048fb3bc7b7ee37e763354b33
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/src/index.ts b/src/index.ts
index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9a5f694e2 100644
index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed4cf9a00f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1367,16 +1367,19 @@ export class Virtualizer<
@@ -575,7 +575,6 @@ export class Virtualizer<
prevOptions !== undefined &&
prevOptions.enabled &&
merged.enabled &&
- merged.anchorTo === 'end' &&
this.scrollElement !== null
) {
const prevCount = prevOptions.count
@@ -611,9 +610,11 @@ export class Virtualizer<
}
const behavior =
- merged.followOnAppend === true
- ? 'auto'
- : merged.followOnAppend || null
+ merged.anchorTo === 'end'
+ ? merged.followOnAppend === true
+ ? 'auto'
+ : merged.followOnAppend || null
+ : null
if (
behavior &&
@@ -1410,16 +1411,25 @@ export class Virtualizer<
this.getSize(),
this.getScrollOffset(),
this.options.lanes,
@@ -96,8 +155,14 @@ index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9
this.range = null
return null
}
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0)
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset)
+ const maxScrollOffset = Math.max(
+ this.options.scrollMargin + this.getTotalSize() - outerSize,
+ 0,
+ )
+ const effectiveScrollOffset = Math.min(
+ Math.max(scrollOffset, 0),
+ maxScrollOffset,
+ )
this.range = calculateRangeImpl(
measurements,
outerSize,