diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index e794b8b8f..670e8ff02 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -840,13 +840,27 @@ export default function Page() {
const autoScroll = createAutoScroll({
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(
on(
isWorking,
(working, prev) => {
if (!working || prev) return
+ if (autoScroll.userScrolled()) return
autoScroll.forceScrollToBottom()
},
{ defer: true },
@@ -990,58 +1004,33 @@ export default function Page() {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
- const top = a.top - b.top + root.scrollTop
- root.scrollTo({ top, behavior })
+ const offset = (info()?.title ? 40 : 0) + 12
+ const top = a.top - b.top + root.scrollTop - offset
+ root.scrollTo({ top: top > 0 ? top : 0, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+ // Navigating to a specific message should always pause auto-follow.
+ autoScroll.pause()
setActiveMessage(message)
+ updateHash(message.id)
const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index)
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))
- if (!el) {
- updateHash(message.id)
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- return
+ const id = anchor(message.id)
+ const attempt = (tries: number) => {
+ const el = document.getElementById(id)
+ if (el && scrollToElement(el, behavior)) return
+ if (tries >= 8) return
+ requestAnimationFrame(() => attempt(tries + 1))
}
- if (scrollToElement(el, behavior)) {
- updateHash(message.id)
- return
- }
-
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- updateHash(message.id)
+ attempt(0)
}
const applyHash = (behavior: ScrollBehavior) => {
@@ -1283,13 +1272,29 @@ export default function Page() {
}
>
+
+
+
+
+
{
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"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
>
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 401613ff5..4087705d1 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -817,6 +817,7 @@ ToolRegistry.register({
const autoScroll = createAutoScroll({
working: () => true,
+ overflowAnchor: "auto",
})
const childSessionId = () => props.metadata.sessionId as string | undefined
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 360589f41..21d00cf00 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -379,6 +379,7 @@ export function SessionTurn(
const autoScroll = createAutoScroll({
working,
onUserInteracted: props.onUserInteracted,
+ overflowAnchor: "auto",
})
createResizeObserver(
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index b9eae5488..b74fb699d 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -5,14 +5,18 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
export interface AutoScrollOptions {
working: () => boolean
onUserInteracted?: () => void
+ overflowAnchor?: "none" | "auto" | "dynamic"
+ bottomThreshold?: number
}
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType | undefined
- let down = false
let cleanup: (() => void) | undefined
+ let resizeFrame: number | undefined
+
+ const threshold = () => options.bottomThreshold ?? 10
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
@@ -21,9 +25,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
- const distanceFromBottom = () => {
- const el = scroll
- if (!el) return 0
+ const distanceFromBottom = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight - el.scrollTop
}
@@ -35,20 +37,21 @@ export function createAutoScroll(options: AutoScrollOptions) {
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
- if (!scroll) return
+ const el = scroll
+ if (!el) return
if (!force && store.userScrolled) return
if (force && store.userScrolled) setStore("userScrolled", false)
- const distance = distanceFromBottom()
+ const distance = distanceFromBottom(el)
if (distance < 2) return
- const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth"
- scrollToBottomNow(behavior)
+ // For auto-following content we prefer immediate updates to avoid
+ // visible "catch up" animations while content is still settling.
+ scrollToBottomNow("auto")
}
const stop = () => {
- if (!active()) return
if (store.userScrolled) return
setStore("userScrolled", true)
@@ -57,53 +60,59 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleWheel = (e: WheelEvent) => {
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()
}
- 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 = () => {
- if (!active()) return
- if (!scroll) return
+ const el = scroll
+ if (!el) return
- if (distanceFromBottom() < 10) {
+ if (distanceFromBottom(el) < threshold()) {
if (store.userScrolled) setStore("userScrolled", false)
return
}
- if (down) stop()
+ stop()
}
const handleInteraction = () => {
+ if (!active()) return
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(
() => store.contentRef,
() => {
if (!active()) 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)
settleTimer = undefined
- setStore("userScrolled", false)
-
if (working) {
- scrollToBottom(true)
+ if (!store.userScrolled) scrollToBottom(true)
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(() => {
if (settleTimer) clearTimeout(settleTimer)
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
if (cleanup) cleanup()
})
@@ -140,26 +157,24 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
scroll = el
- down = false
if (!el) return
- el.style.overflowAnchor = "none"
+ updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
- el.addEventListener("pointerdown", handlePointerDown)
- el.addEventListener("touchstart", handleTouchStart, { passive: true })
cleanup = () => {
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),
handleScroll,
handleInteraction,
+ pause: stop,
+ resume: () => {
+ if (store.userScrolled) setStore("userScrolled", false)
+ scrollToBottom(true)
+ },
scrollToBottom: () => scrollToBottom(false),
forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,