fix(app): auto-scroll ux
This commit is contained in:
@@ -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" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user