Просмотр исходного кода

fix(app): auto-scroll behaviors

Adam 1 месяц назад
Родитель
Сommit
7ce0520f8d

+ 1 - 1
packages/app/src/context/platform.tsx

@@ -3,7 +3,7 @@ import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
 
 export type Platform = {
   /** Platform discriminator */
-  platform: "web" | "tauri"
+  platform: "web" | "desktop"
 
   /** App version */
   version?: string

+ 68 - 214
packages/app/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
+import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js"
 import { createMediaQuery } from "@solid-primitives/media"
 import { Dynamic } from "solid-js/web"
 import { useLocal } from "@/context/local"
@@ -47,6 +47,7 @@ import {
   SortableTerminalTab,
   NewSessionView,
 } from "@/components/session"
+import { usePlatform } from "@/context/platform"
 
 function same<T>(a: readonly T[], b: readonly T[]) {
   if (a === b) return true
@@ -147,6 +148,7 @@ export default function Page() {
   const dialog = useDialog()
   const codeComponent = useCodeComponent()
   const command = useCommand()
+  const platform = usePlatform()
   const params = useParams()
   const navigate = useNavigate()
   const sdk = useSDK()
@@ -241,13 +243,9 @@ export default function Page() {
   const [store, setStore] = createStore({
     activeDraggable: undefined as string | undefined,
     activeTerminalDraggable: undefined as string | undefined,
-    userInteracted: false,
-    stepsExpanded: true,
-    mobileStepsExpanded: {} as Record<string, boolean>,
+    expanded: {} as Record<string, boolean>,
     messageId: undefined as string | undefined,
     mobileTab: "session" as "session" | "review",
-    ignoreScrollSpy: false,
-    initialScrollDone: !params.id,
     newSessionWorktree: "main",
   })
 
@@ -309,47 +307,24 @@ export default function Page() {
     ),
   )
 
-  createEffect(
-    on(
-      () => params.id,
-      (id) => {
-        const status = sync.data.session_status[id ?? ""] ?? idle
-        batch(() => {
-          setStore("userInteracted", false)
-          setStore("stepsExpanded", status.type !== "idle")
-        })
-      },
-    ),
-  )
-
   const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
 
   createEffect(
     on(
-      () => status().type,
-      (type) => {
-        if (type !== "idle") return
-        batch(() => {
-          setStore("userInteracted", false)
-          setStore("stepsExpanded", false)
-        })
+      () => params.id,
+      () => {
+        setStore("messageId", undefined)
+        setStore("expanded", {})
       },
       { defer: true },
     ),
   )
 
-  const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
-
-  createRenderEffect((prev) => {
-    const isWorking = working()
-    if (!prev && isWorking) {
-      setStore("stepsExpanded", true)
-    }
-    if (prev && !isWorking && !store.userInteracted) {
-      setStore("stepsExpanded", false)
-    }
-    return isWorking
-  }, working())
+  createEffect(() => {
+    const id = lastUserMessage()?.id
+    if (!id) return
+    setStore("expanded", id, status().type !== "idle")
+  })
 
   command.register(() => [
     {
@@ -398,12 +373,16 @@ export default function Page() {
     {
       id: "steps.toggle",
       title: "Toggle steps",
-      description: "Show or hide the steps",
+      description: "Show or hide steps for the current message",
       category: "View",
       keybind: "mod+e",
       slash: "steps",
       disabled: !params.id,
-      onSelect: () => setStore("stepsExpanded", (x) => !x),
+      onSelect: () => {
+        const msg = activeMessage()
+        if (!msg) return
+        setStore("expanded", msg.id, (open: boolean | undefined) => !open)
+      },
     },
     {
       id: "message.previous",
@@ -673,204 +652,76 @@ export default function Page() {
   const isWorking = createMemo(() => status().type !== "idle")
   const autoScroll = createAutoScroll({
     working: isWorking,
-    onUserInteracted: () => setStore("userInteracted", true),
   })
 
-  let scrollContainer: HTMLDivElement | undefined
-  let initialScrollFrame: number | undefined
-  let initialScrollTarget: string | undefined
-
-  const cancelInitialScroll = () => {
-    if (initialScrollFrame === undefined) return
-    cancelAnimationFrame(initialScrollFrame)
-    initialScrollFrame = undefined
-  }
+  let scrollSpyFrame: number | undefined
+  let scrollSpyTarget: HTMLDivElement | undefined
 
-  const ensureInitialScroll = () => {
-    cancelInitialScroll()
-    initialScrollFrame = requestAnimationFrame(() => {
-      initialScrollFrame = undefined
-      if (!params.id) {
-        initialScrollTarget = undefined
-        setStore("initialScrollDone", true)
-        return
-      }
-      const msgs = visibleUserMessages()
-      if (msgs.length === 0) {
-        if (!messagesReady()) {
-          ensureInitialScroll()
-          return
-        }
-        initialScrollTarget = undefined
-        setStore("initialScrollDone", true)
-        return
-      }
-      const last = msgs[msgs.length - 1]
-      const el = messageRefs.get(last.id)
-      if (!el || !scrollContainer) {
-        ensureInitialScroll()
-        return
-      }
-      scrollToMessage(last, "auto")
-      initialScrollTarget = last.id
-      setStore("initialScrollDone", true)
-    })
-  }
+  const anchor = (id: string) => `message-${id}`
 
   const setScrollRef = (el: HTMLDivElement | undefined) => {
-    scrollContainer = el
     autoScroll.scrollRef(el)
   }
 
-  const messageRefs = new Map<string, HTMLDivElement>()
-  let scrollTimer: number | undefined
-
-  createEffect(() => {
-    const msgs = visibleUserMessages()
-    if (msgs.length === 0) {
-      messageRefs.clear()
-      return
-    }
-    const ids = new Set(msgs.map((m) => m.id))
-    for (const id of messageRefs.keys()) {
-      if (ids.has(id)) continue
-      messageRefs.delete(id)
-    }
-  })
-
-  let scrollSpyIndex = 0
+  const updateHash = (id: string) => {
+    window.history.replaceState(null, "", `#${anchor(id)}`)
+  }
 
   const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
-    setStore("ignoreScrollSpy", true)
     setActiveMessage(message)
 
-    const msgs = visibleUserMessages()
-    const idx = msgs.findIndex((m) => m.id === message.id)
-    if (idx >= 0) scrollSpyIndex = idx
+    const el = document.getElementById(anchor(message.id))
+    if (el) el.scrollIntoView({ behavior, block: "start" })
+    updateHash(message.id)
+  }
 
-    const el = messageRefs.get(message.id)
-    if (el) {
-      el.scrollIntoView({ behavior, block: "start" })
+  const getActiveMessageId = (container: HTMLDivElement) => {
+    const cutoff = container.scrollTop + 100
+    const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
+    let id: string | undefined
+
+    for (const node of nodes) {
+      const next = node.dataset.messageId
+      if (!next) continue
+      if (node.offsetTop > cutoff) break
+      id = next
     }
 
-    if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
-    scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
+    return id
   }
 
-  let scrollSpyFrame: number | undefined
-  let scrollSpyTarget: HTMLDivElement | undefined
-
   const scheduleScrollSpy = (container: HTMLDivElement) => {
-    if (store.ignoreScrollSpy) return
     scrollSpyTarget = container
     if (scrollSpyFrame !== undefined) return
 
     scrollSpyFrame = requestAnimationFrame(() => {
       scrollSpyFrame = undefined
+
       const target = scrollSpyTarget
       scrollSpyTarget = undefined
       if (!target) return
-      if (store.ignoreScrollSpy) return
-
-      const msgs = visibleUserMessages()
-      const scrollTop = target.scrollTop
-      const threshold = 100
-      const cutoff = scrollTop + threshold
-
-      if (msgs.length === 0) return
-
-      if (scrollSpyIndex >= msgs.length) scrollSpyIndex = msgs.length - 1
-      if (scrollSpyIndex < 0) scrollSpyIndex = 0
-
-      while (scrollSpyIndex + 1 < msgs.length) {
-        const next = msgs[scrollSpyIndex + 1]
-        if (!next) break
-
-        const el = messageRefs.get(next.id)
-        if (!el) break
-        if (el.offsetTop <= cutoff) {
-          scrollSpyIndex += 1
-          continue
-        }
-        break
-      }
-
-      while (scrollSpyIndex > 0) {
-        const cur = msgs[scrollSpyIndex]
-        if (!cur) break
-
-        const el = messageRefs.get(cur.id)
-        if (!el) break
-        if (el.offsetTop > cutoff) {
-          scrollSpyIndex -= 1
-          continue
-        }
-        break
-      }
 
-      const msg = msgs[scrollSpyIndex]
-      if (!msg) return
-      if (msg.id === activeMessage()?.id) return
+      const id = getActiveMessageId(target)
+      if (!id) return
+      if (id === store.messageId) return
 
-      setActiveMessage(msg)
+      setStore("messageId", id)
     })
   }
 
-  createEffect(
-    on(
-      () => params.id,
-      (id) => {
-        cancelInitialScroll()
-        if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
-        scrollTimer = undefined
-        if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
-        scrollSpyFrame = undefined
-        scrollSpyTarget = undefined
-        messageRefs.clear()
-        scrollSpyIndex = 0
-        initialScrollTarget = undefined
-        setStore("initialScrollDone", !id)
-      },
-      { defer: true },
-    ),
-  )
-
   createEffect(() => {
-    const msgs = visibleUserMessages()
-    const target = msgs.at(-1)?.id
+    const sessionID = params.id
     const ready = messagesReady()
+    if (!sessionID || !ready) return
 
-    if (!params.id) {
-      setStore("initialScrollDone", true)
-      initialScrollTarget = undefined
-      return
-    }
-
-    if (!ready) {
-      setStore("initialScrollDone", false)
-      ensureInitialScroll()
-      return
-    }
-
-    if (!store.initialScrollDone) {
-      ensureInitialScroll()
-      return
-    }
-
-    if (!initialScrollTarget && target) {
-      setStore("initialScrollDone", false)
-      ensureInitialScroll()
-    }
-  })
-
-  createEffect(() => {
-    const msgs = visibleUserMessages()
-    if (msgs.length === 0) return
     requestAnimationFrame(() => {
-      if (!scrollContainer) return
-      if (!isDesktop()) return
-      // Manually trigger spy once to set initial active message based on scroll position
-      scheduleScrollSpy(scrollContainer)
+      const id = window.location.hash.slice(1)
+      const hashTarget = id ? document.getElementById(id) : undefined
+      if (hashTarget) {
+        hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
+        return
+      }
+      autoScroll.forceScrollToBottom()
     })
   })
 
@@ -880,8 +731,6 @@ export default function Page() {
 
   onCleanup(() => {
     document.removeEventListener("keydown", handleKeyDown)
-    cancelInitialScroll()
-    if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
     if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
   })
 
@@ -962,13 +811,10 @@ export default function Page() {
                         }}
                         onClick={autoScroll.handleInteraction}
                         class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
-                        classList={{
-                          "opacity-0 pointer-events-none": !store.initialScrollDone,
-                        }}
                       >
                         <div
                           ref={autoScroll.contentRef}
-                          class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
+                          class="flex flex-col gap-32 items-start justify-start pb-32 md:pb-40 transition-[margin]"
                           classList={{
                             "mt-0.5": !showTabs(),
                             "mt-0": showTabs(),
@@ -977,16 +823,24 @@ export default function Page() {
                           <For each={visibleUserMessages()}>
                             {(message) => (
                               <div
-                                ref={(el) => messageRefs.set(message.id, el)}
-                                class="min-w-0 w-full max-w-full last:min-h-[80vh]"
+                                id={anchor(message.id)}
+                                data-message-id={message.id}
+                                classList={{
+                                  "min-w-0 w-full max-w-full": true,
+                                  "last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]":
+                                    platform.platform !== "desktop",
+                                  "last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]":
+                                    platform.platform === "desktop",
+                                }}
                               >
                                 <SessionTurn
                                   sessionID={params.id!}
                                   messageID={message.id}
                                   lastUserMessageID={lastUserMessage()?.id}
-                                  stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
-                                  onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
-                                  onUserInteracted={() => setStore("userInteracted", true)}
+                                  stepsExpanded={store.expanded[message.id] ?? false}
+                                  onStepsExpandedToggle={() =>
+                                    setStore("expanded", message.id, (open: boolean | undefined) => !open)
+                                  }
                                   classes={{
                                     root: "min-w-0 w-full relative",
                                     content:

+ 1 - 1
packages/desktop/src/index.tsx

@@ -27,7 +27,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
 let update: Update | null = null
 
 const platform: Platform = {
-  platform: "tauri",
+  platform: "desktop",
   version: pkg.version,
 
   async openDirectoryPickerDialog(opts) {

+ 61 - 192
packages/ui/src/hooks/create-auto-scroll.tsx

@@ -1,4 +1,4 @@
-import { createEffect, onCleanup } from "solid-js"
+import { createEffect, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 
@@ -8,240 +8,109 @@ export interface AutoScrollOptions {
 }
 
 export function createAutoScroll(options: AutoScrollOptions) {
-  let scrollRef: HTMLElement | undefined
+  let scroll: HTMLElement | undefined
+  let settling = false
+  let settleTimer: ReturnType<typeof setTimeout> | undefined
+
   const [store, setStore] = createStore({
     contentRef: undefined as HTMLElement | undefined,
     userScrolled: false,
   })
 
-  let lastScrollTop = 0
-  let isAutoScrolling = false
-  let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
-  let isMouseDown = false
-  let cleanupListeners: (() => void) | undefined
-  let scheduledScroll = false
-  let scheduledForce = false
-
-  function distanceFromBottom() {
-    if (!scrollRef) return 0
-    return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop
-  }
+  const active = () => options.working() || settling
 
-  function startAutoScroll() {
-    isAutoScrolling = true
-    if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
-    autoScrollTimeout = setTimeout(() => {
-      isAutoScrolling = false
-    }, 1000)
+  const distanceFromBottom = () => {
+    const el = scroll
+    if (!el) return 0
+    return el.scrollHeight - el.clientHeight - el.scrollTop
   }
 
-  function scrollToBottomNow() {
-    if (!scrollRef || store.userScrolled || !options.working()) return
-
-    const distance = distanceFromBottom()
-    if (distance < 2) return
-
-    const behavior = distance > 96 ? "auto" : "smooth"
-    startAutoScroll()
-    scrollRef.scrollTo({
-      top: scrollRef.scrollHeight,
-      behavior,
-    })
+  const scrollToBottomNow = (behavior: ScrollBehavior) => {
+    const el = scroll
+    if (!el) return
+    el.scrollTo({ top: el.scrollHeight, behavior })
   }
 
-  function forceScrollToBottomNow() {
-    if (!scrollRef) return
+  const scrollToBottom = (force: boolean) => {
+    if (!force && !active()) return
+    if (!scroll) return
 
-    if (store.userScrolled) setStore("userScrolled", false)
+    if (!force && store.userScrolled) return
+    if (force && store.userScrolled) setStore("userScrolled", false)
 
     const distance = distanceFromBottom()
     if (distance < 2) return
 
-    startAutoScroll()
-    scrollRef.scrollTo({
-      top: scrollRef.scrollHeight,
-      behavior: "auto",
-    })
+    const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth"
+    scrollToBottomNow(behavior)
   }
 
-  function scheduleScrollToBottom(force = false) {
-    if (typeof requestAnimationFrame === "undefined") {
-      if (force) {
-        forceScrollToBottomNow()
-        return
-      }
-      scrollToBottomNow()
-      return
-    }
-
-    if (force) scheduledForce = true
-    if (scheduledScroll) return
-
-    scheduledScroll = true
-    requestAnimationFrame(() => {
-      scheduledScroll = false
-
-      const shouldForce = scheduledForce
-      scheduledForce = false
-
-      if (shouldForce) {
-        forceScrollToBottomNow()
-        return
-      }
+  const handleScroll = () => {
+    if (!options.working()) return
+    if (!scroll) return
 
-      scrollToBottomNow()
-    })
-  }
-
-  function scrollToBottom() {
-    scheduleScrollToBottom(false)
-  }
-
-  function forceScrollToBottom() {
-    scheduleScrollToBottom(true)
-  }
-
-  function handleScroll() {
-    if (!scrollRef) return
-
-    const { scrollTop, scrollHeight, clientHeight } = scrollRef
-    const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
-
-    if (isAutoScrolling) {
-      if (atBottom) {
-        isAutoScrolling = false
-        if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
-      }
-      lastScrollTop = scrollTop
-      return
-    }
-
-    if (atBottom) {
-      if (store.userScrolled) {
-        setStore("userScrolled", false)
-      }
-      lastScrollTop = scrollTop
+    if (distanceFromBottom() < 10) {
+      if (store.userScrolled) setStore("userScrolled", false)
       return
     }
 
-    const delta = scrollTop - lastScrollTop
-    if (delta < 0) {
-      if (isMouseDown && !store.userScrolled && options.working()) {
-        setStore("userScrolled", true)
-        options.onUserInteracted?.()
-      }
-    }
-
-    lastScrollTop = scrollTop
-  }
+    if (store.userScrolled) return
 
-  function handleInteraction() {
-    if (options.working()) {
-      setStore("userScrolled", true)
-      options.onUserInteracted?.()
-    }
+    setStore("userScrolled", true)
+    options.onUserInteracted?.()
   }
 
-  function handleWheel(e: WheelEvent) {
-    if (e.deltaY < 0 && !store.userScrolled && options.working()) {
-      setStore("userScrolled", true)
-      options.onUserInteracted?.()
-    }
-  }
+  const handleInteraction = () => {
+    if (!options.working()) return
+    if (store.userScrolled) return
 
-  function handleTouchStart() {
-    if (!store.userScrolled && options.working()) {
-      setStore("userScrolled", true)
-      options.onUserInteracted?.()
-    }
-  }
-
-  function handleKeyDown(e: KeyboardEvent) {
-    if (["ArrowUp", "PageUp", "Home"].includes(e.key)) {
-      if (!store.userScrolled && options.working()) {
-        setStore("userScrolled", true)
-        options.onUserInteracted?.()
-      }
-    }
+    setStore("userScrolled", true)
+    options.onUserInteracted?.()
   }
 
-  function handleMouseDown() {
-    isMouseDown = true
-    window.addEventListener("mouseup", handleMouseUp)
-  }
+  createResizeObserver(
+    () => store.contentRef,
+    () => {
+      if (!active()) return
+      if (store.userScrolled) return
+      scrollToBottom(false)
+    },
+  )
 
-  function handleMouseUp() {
-    isMouseDown = false
-    window.removeEventListener("mouseup", handleMouseUp)
-  }
+  createEffect(
+    on(options.working, (working) => {
+      settling = false
+      if (settleTimer) clearTimeout(settleTimer)
+      settleTimer = undefined
 
-  // Reset userScrolled when work completes
-  createEffect(() => {
-    if (!options.working()) {
       setStore("userScrolled", false)
-    }
-  })
-
-  // Ensure pinned-to-bottom stays pinned during heavy DOM updates
-  createEffect(() => {
-    const el = store.contentRef
-    if (!el) return
 
-    const observer = new MutationObserver(() => {
-      if (store.userScrolled) return
-      if (!options.working()) return
-      scheduleScrollToBottom(false)
-    })
-    observer.observe(el, { childList: true, subtree: true, characterData: true })
-    onCleanup(() => observer.disconnect())
-  })
-
-  // Handle content resize
-  createResizeObserver(
-    () => store.contentRef,
-    () => {
-      if (options.working() && !store.userScrolled) {
-        scrollToBottom()
+      if (working) {
+        scrollToBottom(true)
+        return
       }
-    },
+
+      settling = true
+      settleTimer = setTimeout(() => {
+        settling = false
+      }, 300)
+    }),
   )
 
   onCleanup(() => {
-    if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
-    if (cleanupListeners) cleanupListeners()
+    if (settleTimer) clearTimeout(settleTimer)
   })
 
   return {
     scrollRef: (el: HTMLElement | undefined) => {
-      if (cleanupListeners) {
-        cleanupListeners()
-        cleanupListeners = undefined
-      }
-
-      scrollRef = el
-      if (el) {
-        lastScrollTop = el.scrollTop
-        el.style.overflowAnchor = "none"
-
-        el.addEventListener("wheel", handleWheel, { passive: true })
-        el.addEventListener("touchstart", handleTouchStart, { passive: true })
-        el.addEventListener("keydown", handleKeyDown)
-        el.addEventListener("mousedown", handleMouseDown)
-
-        cleanupListeners = () => {
-          el.removeEventListener("wheel", handleWheel)
-          el.removeEventListener("touchstart", handleTouchStart)
-          el.removeEventListener("keydown", handleKeyDown)
-          el.removeEventListener("mousedown", handleMouseDown)
-          window.removeEventListener("mouseup", handleMouseUp)
-        }
-      }
+      scroll = el
+      if (el) el.style.overflowAnchor = "none"
     },
     contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
     handleScroll,
     handleInteraction,
-    scrollToBottom,
-    forceScrollToBottom,
+    scrollToBottom: () => scrollToBottom(false),
+    forceScrollToBottom: () => scrollToBottom(true),
     userScrolled: () => store.userScrolled,
   }
 }