fix(app): simplify timeline anchoring (#44147)
This commit is contained in:
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
+75
-10
@@ -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,
|
||||
Reference in New Issue
Block a user