Browse Source

fix(app): auto-scroll ux

Adam 1 month ago
parent
commit
a0636fcd50

+ 45 - 40
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() {
                     }
                   >
                     <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
                         ref={setScrollRef}
                         onScroll={(e) => {
                           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" }}
                       >

+ 1 - 0
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

+ 1 - 0
packages/ui/src/components/session-turn.tsx

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

+ 61 - 46
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<typeof setTimeout> | 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,45 +60,47 @@ 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 handleScroll = () => {
+    const el = scroll
+    if (!el) return
 
-  const handlePointerDown = () => {
-    if (down) return
-    down = true
-    window.addEventListener("pointerup", handlePointerUp)
-  }
+    if (distanceFromBottom(el) < threshold()) {
+      if (store.userScrolled) setStore("userScrolled", false)
+      return
+    }
 
-  const handleTouchEnd = () => {
-    down = false
-    window.removeEventListener("touchend", handleTouchEnd)
+    stop()
   }
 
-  const handleTouchStart = () => {
-    if (down) return
-    down = true
-    window.addEventListener("touchend", handleTouchEnd)
+  const handleInteraction = () => {
+    if (!active()) return
+    stop()
   }
 
-  const handleScroll = () => {
-    if (!active()) return
-    if (!scroll) return
+  const updateOverflowAnchor = (el: HTMLElement) => {
+    const mode = options.overflowAnchor ?? "dynamic"
 
-    if (distanceFromBottom() < 10) {
-      if (store.userScrolled) setStore("userScrolled", false)
+    if (mode === "none") {
+      el.style.overflowAnchor = "none"
       return
     }
 
-    if (down) stop()
-  }
+    if (mode === "auto") {
+      el.style.overflowAnchor = "auto"
+      return
+    }
 
-  const handleInteraction = () => {
-    stop()
+    el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
   }
 
   createResizeObserver(
@@ -103,7 +108,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
     () => {
       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,