fix(app): auto-scroll ux

This commit is contained in:
Adam
2026-01-20 16:01:00 -06:00
committed by opencode
parent d2fcdef571
commit a0636fcd50
4 changed files with 109 additions and 87 deletions

View File

@@ -840,13 +840,27 @@ export default function Page() {
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
working: () => true, working: () => true,
overflowAnchor: "auto",
}) })
// When the user returns to the bottom, treat the active message as "latest".
createEffect(
on(
autoScroll.userScrolled,
(scrolled) => {
if (scrolled) return
setStore("messageId", undefined)
},
{ defer: true },
),
)
createEffect( createEffect(
on( on(
isWorking, isWorking,
(working, prev) => { (working, prev) => {
if (!working || prev) return if (!working || prev) return
if (autoScroll.userScrolled()) return
autoScroll.forceScrollToBottom() autoScroll.forceScrollToBottom()
}, },
{ defer: true }, { defer: true },
@@ -990,58 +1004,33 @@ export default function Page() {
const a = el.getBoundingClientRect() const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect() const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop const offset = (info()?.title ? 40 : 0) + 12
root.scrollTo({ top, behavior }) const top = a.top - b.top + root.scrollTop - offset
root.scrollTo({ top: top > 0 ? top : 0, behavior })
return true return true
} }
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
// Navigating to a specific message should always pause auto-follow.
autoScroll.pause()
setActiveMessage(message) setActiveMessage(message)
updateHash(message.id)
const msgs = visibleUserMessages() const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id) const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) { if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index) setStore("turnStart", index)
scheduleTurnBackfill() scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
})
updateHash(message.id)
return
} }
const el = document.getElementById(anchor(message.id)) const id = anchor(message.id)
if (!el) { const attempt = (tries: number) => {
updateHash(message.id) const el = document.getElementById(id)
requestAnimationFrame(() => { if (el && scrollToElement(el, behavior)) return
const next = document.getElementById(anchor(message.id)) if (tries >= 8) return
if (!next) return requestAnimationFrame(() => attempt(tries + 1))
if (!scrollToElement(next, behavior)) return
})
return
} }
if (scrollToElement(el, behavior)) { attempt(0)
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
updateHash(message.id)
} }
const applyHash = (behavior: ScrollBehavior) => { const applyHash = (behavior: ScrollBehavior) => {
@@ -1283,13 +1272,29 @@ export default function Page() {
} }
> >
<div class="relative w-full h-full min-w-0"> <div class="relative w-full h-full min-w-0">
<Show when={autoScroll.userScrolled()}>
<div class="absolute right-4 md:right-6 bottom-[calc(var(--prompt-height,8rem)+16px)] z-[60] pointer-events-none">
<Button
variant="secondary"
size="small"
icon="chevron-down"
class="pointer-events-auto shadow-sm"
onClick={() => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
}}
>
Jump to latest
</Button>
</div>
</Show>
<div <div
ref={setScrollRef} ref={setScrollRef}
onScroll={(e) => { onScroll={(e) => {
autoScroll.handleScroll() autoScroll.handleScroll()
if (isDesktop()) scheduleScrollSpy(e.currentTarget) if (isDesktop() && autoScroll.userScrolled()) scheduleScrollSpy(e.currentTarget)
}} }}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar" class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }} style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
> >

View File

@@ -817,6 +817,7 @@ ToolRegistry.register({
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
working: () => true, working: () => true,
overflowAnchor: "auto",
}) })
const childSessionId = () => props.metadata.sessionId as string | undefined const childSessionId = () => props.metadata.sessionId as string | undefined

View File

@@ -379,6 +379,7 @@ export function SessionTurn(
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
working, working,
onUserInteracted: props.onUserInteracted, onUserInteracted: props.onUserInteracted,
overflowAnchor: "auto",
}) })
createResizeObserver( createResizeObserver(

View File

@@ -5,14 +5,18 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
export interface AutoScrollOptions { export interface AutoScrollOptions {
working: () => boolean working: () => boolean
onUserInteracted?: () => void onUserInteracted?: () => void
overflowAnchor?: "none" | "auto" | "dynamic"
bottomThreshold?: number
} }
export function createAutoScroll(options: AutoScrollOptions) { export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined let scroll: HTMLElement | undefined
let settling = false let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined let settleTimer: ReturnType<typeof setTimeout> | undefined
let down = false
let cleanup: (() => void) | undefined let cleanup: (() => void) | undefined
let resizeFrame: number | undefined
const threshold = () => options.bottomThreshold ?? 10
const [store, setStore] = createStore({ const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined, contentRef: undefined as HTMLElement | undefined,
@@ -21,9 +25,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling const active = () => options.working() || settling
const distanceFromBottom = () => { const distanceFromBottom = (el: HTMLElement) => {
const el = scroll
if (!el) return 0
return el.scrollHeight - el.clientHeight - el.scrollTop return el.scrollHeight - el.clientHeight - el.scrollTop
} }
@@ -35,20 +37,21 @@ export function createAutoScroll(options: AutoScrollOptions) {
const scrollToBottom = (force: boolean) => { const scrollToBottom = (force: boolean) => {
if (!force && !active()) return if (!force && !active()) return
if (!scroll) return const el = scroll
if (!el) return
if (!force && store.userScrolled) return if (!force && store.userScrolled) return
if (force && store.userScrolled) setStore("userScrolled", false) if (force && store.userScrolled) setStore("userScrolled", false)
const distance = distanceFromBottom() const distance = distanceFromBottom(el)
if (distance < 2) return if (distance < 2) return
const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth" // For auto-following content we prefer immediate updates to avoid
scrollToBottomNow(behavior) // visible "catch up" animations while content is still settling.
scrollToBottomNow("auto")
} }
const stop = () => { const stop = () => {
if (!active()) return
if (store.userScrolled) return if (store.userScrolled) return
setStore("userScrolled", true) setStore("userScrolled", true)
@@ -57,53 +60,59 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
if (e.deltaY >= 0) return if (e.deltaY >= 0) return
// If the user is scrolling within a nested scrollable region (tool output,
// code block, etc), don't treat it as leaving the "follow bottom" mode.
// Those regions opt in via `data-scrollable`.
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
if (el && nested && nested !== el) return
stop() stop()
} }
const handlePointerUp = () => {
down = false
window.removeEventListener("pointerup", handlePointerUp)
}
const handlePointerDown = () => {
if (down) return
down = true
window.addEventListener("pointerup", handlePointerUp)
}
const handleTouchEnd = () => {
down = false
window.removeEventListener("touchend", handleTouchEnd)
}
const handleTouchStart = () => {
if (down) return
down = true
window.addEventListener("touchend", handleTouchEnd)
}
const handleScroll = () => { const handleScroll = () => {
if (!active()) return const el = scroll
if (!scroll) return if (!el) return
if (distanceFromBottom() < 10) { if (distanceFromBottom(el) < threshold()) {
if (store.userScrolled) setStore("userScrolled", false) if (store.userScrolled) setStore("userScrolled", false)
return return
} }
if (down) stop() stop()
} }
const handleInteraction = () => { const handleInteraction = () => {
if (!active()) return
stop() stop()
} }
const updateOverflowAnchor = (el: HTMLElement) => {
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
el.style.overflowAnchor = "none"
return
}
if (mode === "auto") {
el.style.overflowAnchor = "auto"
return
}
el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
}
createResizeObserver( createResizeObserver(
() => store.contentRef, () => store.contentRef,
() => { () => {
if (!active()) return if (!active()) return
if (store.userScrolled) return if (store.userScrolled) return
scrollToBottom(false) if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
scrollToBottom(false)
})
}, },
) )
@@ -113,10 +122,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (settleTimer) clearTimeout(settleTimer) if (settleTimer) clearTimeout(settleTimer)
settleTimer = undefined settleTimer = undefined
setStore("userScrolled", false)
if (working) { if (working) {
scrollToBottom(true) if (!store.userScrolled) scrollToBottom(true)
return return
} }
@@ -127,8 +134,18 @@ export function createAutoScroll(options: AutoScrollOptions) {
}), }),
) )
createEffect(() => {
// Track `userScrolled` even before `scrollRef` is attached, so we can
// update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
updateOverflowAnchor(el)
})
onCleanup(() => { onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer) if (settleTimer) clearTimeout(settleTimer)
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
if (cleanup) cleanup() if (cleanup) cleanup()
}) })
@@ -140,26 +157,24 @@ export function createAutoScroll(options: AutoScrollOptions) {
} }
scroll = el scroll = el
down = false
if (!el) return if (!el) return
el.style.overflowAnchor = "none" updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true }) el.addEventListener("wheel", handleWheel, { passive: true })
el.addEventListener("pointerdown", handlePointerDown)
el.addEventListener("touchstart", handleTouchStart, { passive: true })
cleanup = () => { cleanup = () => {
el.removeEventListener("wheel", handleWheel) el.removeEventListener("wheel", handleWheel)
el.removeEventListener("pointerdown", handlePointerDown)
el.removeEventListener("touchstart", handleTouchStart)
window.removeEventListener("pointerup", handlePointerUp)
window.removeEventListener("touchend", handleTouchEnd)
} }
}, },
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll, handleScroll,
handleInteraction, handleInteraction,
pause: stop,
resume: () => {
if (store.userScrolled) setStore("userScrolled", false)
scrollToBottom(true)
},
scrollToBottom: () => scrollToBottom(false), scrollToBottom: () => scrollToBottom(false),
forceScrollToBottom: () => scrollToBottom(true), forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled, userScrolled: () => store.userScrolled,