Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/ui/src/components/virtual-follow-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Show, createEffect, createMemo, createSignal, type Accessor, type JSX, on, onCleanup } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { attachScrollGestureRouting } from "../lib/scroll-gesture-routing"
import { remapVirtualMeasurements } from "./virtual-follow-measurements"
import { createVirtualReaderSettlement } from "./virtual-reader-settlement"
import { advanceBottomPinSettlement, AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, canScrollInDirection, classifyVirtualItemKeyChange, getBottomAnchoredViewportOffset, getFollowSnapshotState, getKeyboardScrollIntent, getPrimaryPointerDragDirection, isAtBottom, isAutoFollowing, isMiddleButtonScrollIntent, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, ScrollRestoreTokenGuard, selectTopViewportAnchor, shouldAdvanceBottomPin, shouldNavigateAtBoundary, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts"
Expand Down Expand Up @@ -157,6 +158,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let explicitBottomPinResolver: ((settlement: VirtualBottomSettlement) => void) | null = null
let localBottomPinSequence = 0
let programmaticScrollUntil = 0
let pendingVirtuaScroll = false
let virtualItemKeys = virtualItems().map((item, index) => props.getKey(item, index))
let plannedItems = virtualItems().slice()
let plannedKeys = virtualItemKeys.slice()
Expand Down Expand Up @@ -230,10 +232,10 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
dispatchFollowEvent({ type: "user-scroll", direction: "down", atBottom: true })
}
const element = scrollElement()
if (element) {
// Publish intent before notifying scroll listeners: otherwise old follow
// state can arm a programmatic pin and suppress user boundary navigation.
// Synchronize the live offset before replacing Virtua's pending operation.
if (element && pendingVirtuaScroll) {
// Replace an outstanding imperative operation once, not on every wheel
// or key repeat: scrollBy(0) itself creates a new measurement-time pin.
pendingVirtuaScroll = false
element.dispatchEvent(new Event("scroll"))
virtuaHandle()?.scrollBy(0)
}
Expand Down Expand Up @@ -286,6 +288,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const nextOffset = Math.min(Math.max(offset, 0), maxOffset)
markProgrammaticScroll()
if (handle) {
pendingVirtuaScroll = true
handle.scrollTo(nextOffset)
} else {
element.scrollTop = nextOffset
Expand Down Expand Up @@ -333,6 +336,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
if (!handle) return
markProgrammaticScroll()
pendingVirtuaScroll = true
handle.scrollToIndex(0, { align: "start", smooth: true })
}

Expand All @@ -342,6 +346,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
markProgrammaticScroll()
// Large smooth jumps over dynamically measured items can leave Virtua's
// mounted range behind the viewport. Semantic navigation must land first.
pendingVirtuaScroll = true
virtuaHandle()?.scrollToIndex(index, { align: opts.block, smooth: false })
}

Expand Down Expand Up @@ -653,6 +658,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const index = virtualItems().findIndex((item, i) => props.getKey(item, i) === snapshot.anchorKey)
if (index !== -1) {
markProgrammaticScroll()
pendingVirtuaScroll = true
virtuaHandle()?.scrollToIndex(index, { align: "start", smooth: opts?.behavior === "smooth" })
const stabilizer = new AnchorRestoreStabilizer()
restartAnchorRestore = () => {
Expand Down Expand Up @@ -680,6 +686,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const index = virtualItems().findIndex((item, i) => props.getKey(item, i) === key)
if (index === -1) return false
markProgrammaticScroll()
pendingVirtuaScroll = true
virtuaHandle()?.scrollToIndex(index, { align: "start", smooth: false })
return true
}
Expand Down Expand Up @@ -801,7 +808,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
lastTouchY = null
}
const handleKeyIntent = (event: KeyboardEvent) => {
if (!isActive()) return
if (!isActive() || event.defaultPrevented) return
if (!SCROLL_INTENT_KEYS.has(event.key)) return
const target = event.target as HTMLElement | null
const intent = getKeyboardScrollIntent({
Expand All @@ -811,6 +818,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
textEditing: Boolean(target?.closest(TEXT_EDITING_KEY_TARGET_SELECTOR)),
})
if (!intent) return
const direction = intent.type === "top" ? "up" : intent.type === "bottom" ? "down" : intent.direction
if (nestedScrollerConsumes(event.target, direction)) return
if (intent.type === "bottom") {
event.preventDefault()
jumpToBottom(true)
Expand All @@ -823,6 +832,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
markUserScrollIntent(intent.direction)
}
const detachGestureRouting = attachScrollGestureRouting(element)
element.addEventListener("wheel", handleWheelIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("pointermove", handlePointerMove)
Expand All @@ -834,6 +844,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
element.addEventListener("touchcancel", handleTouchEnd, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
detachGestureRouting()
element.removeEventListener("wheel", handleWheelIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("pointermove", handlePointerMove)
Expand Down
137 changes: 137 additions & 0 deletions packages/ui/src/lib/scroll-gesture-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const WHEEL_GESTURE_IDLE_MS = 180
const MIDDLE_DEAD_ZONE_PX = 8

function scrollAxes(node: HTMLElement) {
const style = getComputedStyle(node)
const scrolls = (overflow: string) => /^(auto|scroll|overlay)$/.test(overflow)
return {
x: scrolls(style.overflowX) && node.scrollWidth > node.clientWidth + 1,
y: scrolls(style.overflowY) && node.scrollHeight > node.clientHeight + 1,
}
}

function wheelTarget(root: HTMLElement, target: EventTarget | null, dy: number): HTMLElement {
let node = target instanceof Element ? target : null
while (node && node !== root) {
if (node instanceof HTMLElement) {
if (scrollAxes(node).y && (dy < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1)) return node
}
node = node.parentElement
}
return root
}

function middleTargets(root: HTMLElement, target: EventTarget | null) {
const owners: { x?: HTMLElement; y?: HTMLElement } = {}
for (let node = target instanceof Element ? target : null; node; node = node.parentElement) {
if (node instanceof HTMLElement) {
const axes = scrollAxes(node)
if (axes.x) owners.x ??= node
if (axes.y) owners.y ??= node
}
if (node === root) break
}
return owners
}

/** Keep an in-progress gesture on its starting scroller, not whatever row
* virtualization or pointer movement happens to place under the cursor next. */
export function attachScrollGestureRouting(root: HTMLElement): () => void {
let wheelOwner: HTMLElement | undefined
let wheelAt = -Infinity
let middle: { owners: ReturnType<typeof middleTargets>; pointerId: number; x: number; y: number; dx: number; dy: number; time: number } | undefined
let frame: number | undefined

const wheel = (event: WheelEvent) => {
// Redispatched intent below is only for the existing follow controllers.
if (!event.isTrusted) return
// Leave zoom, native horizontal/Shift-wheel and uncancellable input alone.
if (event.ctrlKey || event.shiftKey || event.deltaX !== 0 || !event.deltaY || !event.cancelable || event.defaultPrevented) {
wheelOwner = undefined
return
}
const now = performance.now()
const target = wheelTarget(root, event.target, event.deltaY)
if (!wheelOwner?.isConnected || !root.contains(wheelOwner) || now - wheelAt > WHEEL_GESTURE_IDLE_MS) wheelOwner = target
// At an edge, chain only to an ancestor of the owner, never a new shell
// passing under the cursor during the same gesture.
wheelOwner = wheelTarget(root, wheelOwner, event.deltaY)
wheelAt = now
const owner = wheelOwner
if (owner === target) return
event.preventDefault()
event.stopPropagation()
owner.dispatchEvent(new WheelEvent("wheel", { deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, bubbles: true }))
const unit = event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? owner.clientHeight
: event.deltaMode === WheelEvent.DOM_DELTA_LINE ? parseFloat(getComputedStyle(owner).lineHeight) || 16 : 1
owner.scrollBy({ left: event.deltaX * unit, top: event.deltaY * unit, behavior: "instant" })
}

const stopMiddle = () => {
const active = middle
middle = undefined
if (frame !== undefined) cancelAnimationFrame(frame)
frame = undefined
if (active && root.hasPointerCapture(active.pointerId)) root.releasePointerCapture(active.pointerId)
if (active) root.style.cursor = previousCursor
}
let previousCursor = ""
const tick = (time: number) => {
frame = undefined
const active = middle
if (!active) return
if (Object.values(active.owners).some(owner => !root.contains(owner) || !owner.getClientRects().length)) return stopMiddle()
const elapsed = Math.min(time - active.time, 32) / 16.67
active.time = time
const speed = (distance: number) => Math.sign(distance) * Math.max(0, Math.abs(distance) - MIDDLE_DEAD_ZONE_PX) / 6
active.owners.x?.scrollBy({ left: speed(active.dx) * elapsed, behavior: "instant" })
active.owners.y?.scrollBy({ top: speed(active.dy) * elapsed, behavior: "instant" })
frame = requestAnimationFrame(tick)
}
const down = (event: PointerEvent) => {
if (!event.isTrusted || event.button !== 1 || event.defaultPrevented) return
// Preserve middle-click link opening and editable/control behavior.
if (event.target instanceof Element && event.target.closest("a[href],button,input,textarea,select,[contenteditable]:not([contenteditable='false']),[role='button'],[role='textbox']")) return
stopMiddle()
wheelOwner = undefined
const owners = middleTargets(root, event.target)
if (!owners.x && !owners.y) return
event.preventDefault()
middle = { owners, pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, dy: 0, time: performance.now() }
previousCursor = root.style.cursor
root.style.cursor = "all-scroll"
root.setPointerCapture(event.pointerId)
frame = requestAnimationFrame(tick)
// Do not stop propagation: both nested and transcript follow controllers
// must relinquish automatic writes before the first drag frame.
}
const move = (event: PointerEvent) => {
if (!middle || event.pointerId !== middle.pointerId) return
if ((event.buttons & 4) === 0) return stopMiddle()
middle.dx = event.clientX - middle.x
middle.dy = event.clientY - middle.y
}
const end = (event: PointerEvent) => {
if (middle && event.pointerId === middle.pointerId) stopMiddle()
}
const visibility = () => { if (document.hidden) stopMiddle() }
root.addEventListener("wheel", wheel, { capture: true, passive: false })
root.addEventListener("pointerdown", down, true)
root.addEventListener("pointermove", move)
root.addEventListener("pointerup", end)
root.addEventListener("pointercancel", end)
root.addEventListener("lostpointercapture", end)
window.addEventListener("blur", stopMiddle)
document.addEventListener("visibilitychange", visibility)
return () => {
stopMiddle()
root.removeEventListener("wheel", wheel, true)
root.removeEventListener("pointerdown", down, true)
root.removeEventListener("pointermove", move)
root.removeEventListener("pointerup", end)
root.removeEventListener("pointercancel", end)
root.removeEventListener("lostpointercapture", end)
window.removeEventListener("blur", stopMiddle)
document.removeEventListener("visibilitychange", visibility)
}
}
33 changes: 33 additions & 0 deletions packages/ui/tests/browser/fixtures/scroll-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import ToolCall from "../../../src/components/tool-call"
import VirtualFollowList, { type VirtualFollowListApi } from "../../../src/components/virtual-follow-list"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import type { ToolCallPart } from "../../../src/components/tool-call/types"
import "../../../src/index.css"

const output = Array.from({ length: 160 }, (_, i) => `Shell output line ${i}`).join("\n")
const [items, setItems] = createSignal(Array.from({ length: 30 }, (_, i): ToolCallPart => ({
id: `tool-${i}`, type: "tool", callID: `call-${i}`, tool: "shell",
state: { status: "completed", input: {}, output },
})))
let api: VirtualFollowListApi | undefined
function Transcript() {
return <VirtualFollowList items={items} getKey={item => item.id}
registerApi={value => { api = value }}
renderItem={item => <div style={{ width: "650px", padding: "30px 0" }}>
<ToolCall toolCall={item} messageId={`message-${item.id}`} instanceId="scroll-fixture"
sessionId="session-fixture" onContentRendered={() => api?.notifyContentRendered()} />
</div>} />
}
render(() => <ConfigProvider><I18nProvider><ThemeProvider>
<Transcript />
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = {
bottom: () => api?.scrollToBottom({ immediate: true }),
top: () => api?.scrollToTop({ immediate: true }),
append: () => setItems(items => [...items, { ...items[0], id: `tool-${items.length}`, callID: `call-${items.length}` }]),
snapshot: () => ({ outerFollow: api?.getAutoScroll(), outerTop: api?.getScrollElement()?.scrollTop }),
}
Loading
Loading