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

chore(desktop): auto scroll utility

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

+ 9 - 85
packages/desktop/src/pages/session.tsx

@@ -12,7 +12,7 @@ import {
   createRenderEffect,
   batch,
 } from "solid-js"
-import { createResizeObserver } from "@solid-primitives/resize-observer"
+
 import { Dynamic } from "solid-js/web"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
@@ -27,6 +27,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import {
@@ -93,13 +94,6 @@ export default function Page() {
     userInteracted: false,
     stepsExpanded: true,
     mobileStepsExpanded: {} as Record<string, boolean>,
-    mobileLastScrollTop: 0,
-    mobileLastScrollHeight: 0,
-    mobileAutoScrolled: false,
-    mobileUserScrolled: false,
-    mobileContentRef: undefined as HTMLDivElement | undefined,
-    mobileLastContentWidth: 0,
-    mobileReflowing: false,
     messageId: undefined as string | undefined,
   })
 
@@ -541,90 +535,20 @@ export default function Page() {
 
   const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
 
-  let mobileScrollRef: HTMLDivElement | undefined
   const mobileWorking = createMemo(() => status().type !== "idle")
-
-  function handleMobileScroll() {
-    if (!mobileScrollRef || store.mobileAutoScrolled) return
-
-    const scrollTop = mobileScrollRef.scrollTop
-    const scrollHeight = mobileScrollRef.scrollHeight
-
-    if (store.mobileReflowing) {
-      batch(() => {
-        setStore("mobileLastScrollTop", scrollTop)
-        setStore("mobileLastScrollHeight", scrollHeight)
-      })
-      return
-    }
-
-    const scrolledUp = scrollTop < store.mobileLastScrollTop - 50
-    if (scrolledUp && mobileWorking()) {
-      setStore("mobileUserScrolled", true)
-      setStore("userInteracted", true)
-    }
-
-    batch(() => {
-      setStore("mobileLastScrollTop", scrollTop)
-      setStore("mobileLastScrollHeight", scrollHeight)
-    })
-  }
-
-  function handleMobileInteraction() {
-    if (mobileWorking()) {
-      setStore("mobileUserScrolled", true)
-      setStore("userInteracted", true)
-    }
-  }
-
-  function scrollMobileToBottom() {
-    if (!mobileScrollRef || store.mobileUserScrolled || !mobileWorking()) return
-    setStore("mobileAutoScrolled", true)
-    requestAnimationFrame(() => {
-      mobileScrollRef?.scrollTo({ top: mobileScrollRef.scrollHeight, behavior: "smooth" })
-      requestAnimationFrame(() => {
-        batch(() => {
-          setStore("mobileLastScrollTop", mobileScrollRef?.scrollTop ?? 0)
-          setStore("mobileLastScrollHeight", mobileScrollRef?.scrollHeight ?? 0)
-          setStore("mobileAutoScrolled", false)
-        })
-      })
-    })
-  }
-
-  createEffect(() => {
-    if (!mobileWorking()) setStore("mobileUserScrolled", false)
+  const mobileAutoScroll = createAutoScroll({
+    working: mobileWorking,
+    onUserInteracted: () => setStore("userInteracted", true),
   })
 
-  createResizeObserver(
-    () => store.mobileContentRef,
-    ({ width }) => {
-      const widthChanged = Math.abs(width - store.mobileLastContentWidth) > 5
-      if (widthChanged && store.mobileLastContentWidth > 0) {
-        setStore("mobileReflowing", true)
-        requestAnimationFrame(() => {
-          requestAnimationFrame(() => {
-            setStore("mobileReflowing", false)
-            if (mobileWorking() && !store.mobileUserScrolled) {
-              scrollMobileToBottom()
-            }
-          })
-        })
-      } else if (!store.mobileReflowing) {
-        scrollMobileToBottom()
-      }
-      setStore("mobileLastContentWidth", width)
-    },
-  )
-
   const MobileTurns = () => (
     <div
-      ref={mobileScrollRef}
-      onScroll={handleMobileScroll}
-      onClick={handleMobileInteraction}
+      ref={mobileAutoScroll.scrollRef}
+      onScroll={mobileAutoScroll.handleScroll}
+      onClick={mobileAutoScroll.handleInteraction}
       class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
     >
-      <div ref={(el) => setStore("mobileContentRef", el)} class="flex flex-col gap-45 items-start justify-start mt-4">
+      <div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
         <For each={visibleUserMessages()}>
           {(message) => (
             <SessionTurn

+ 9 - 2
packages/ui/src/components/message-part.tsx

@@ -23,6 +23,7 @@ import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
+import { createAutoScroll } from "../hooks"
 
 interface Diagnostic {
   range: {
@@ -330,6 +331,7 @@ export interface ToolProps {
   metadata: Record<string, any>
   tool: string
   output?: string
+  status?: string
   hideDetails?: boolean
   defaultOpen?: boolean
 }
@@ -398,6 +400,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
             tool={part.tool}
             metadata={metadata}
             output={part.state.status === "completed" ? part.state.output : undefined}
+            status={part.state.status}
             hideDetails={props.hideDetails}
             defaultOpen={props.defaultOpen}
           />
@@ -561,6 +564,10 @@ ToolRegistry.register({
     const summary = () =>
       (props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[]
 
+    const autoScroll = createAutoScroll({
+      working: () => true,
+    })
+
     return (
       <BasicTool
         icon="task"
@@ -571,8 +578,8 @@ ToolRegistry.register({
           subtitle: props.input.description,
         }}
       >
-        <div data-component="tool-output" data-scrollable>
-          <div data-component="task-tools">
+        <div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
+          <div ref={autoScroll.contentRef} data-component="task-tools">
             <For each={summary()}>
               {(item) => {
                 const info = getToolInfo(item.tool)

+ 1 - 1
packages/ui/src/components/session-message-rail.css

@@ -17,7 +17,7 @@
   display: none;
 }
 
-@container (min-width: 72rem) {
+@container (min-width: 88rem) {
   [data-slot="session-message-rail-compact"] {
     display: none;
   }

+ 15 - 110
packages/ui/src/components/session-turn.tsx

@@ -3,7 +3,7 @@ import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
-import { batch, createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { DiffChanges } from "./diff-changes"
 import { Typewriter } from "./typewriter"
@@ -19,6 +19,7 @@ import { Button } from "./button"
 import { Spinner } from "./spinner"
 import { createStore } from "solid-js/store"
 import { DateTime, DurationUnit, Interval } from "luxon"
+import { createAutoScroll } from "../hooks"
 
 function computeStatusFromPart(part: PartType | undefined): string | undefined {
   if (!part) return undefined
@@ -233,17 +234,14 @@ export function SessionTurn(
     })
   }
 
-  let scrollRef: HTMLDivElement | undefined
+  const autoScroll = createAutoScroll({
+    working,
+    onUserInteracted: props.onUserInteracted,
+  })
+
   const [store, setStore] = createStore({
-    contentRef: undefined as HTMLDivElement | undefined,
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
-    lastScrollTop: 0,
-    lastScrollHeight: 0,
-    lastContainerWidth: 0,
-    autoScrolled: false,
-    userScrolled: false,
-    reflowing: false,
     stickyHeaderHeight: 0,
     retrySeconds: 0,
     status: rawStatus(),
@@ -265,104 +263,6 @@ export function SessionTurn(
     onCleanup(() => clearInterval(timer))
   })
 
-  function handleScroll() {
-    if (!scrollRef || store.autoScrolled) return
-
-    const scrollTop = scrollRef.scrollTop
-    const scrollHeight = scrollRef.scrollHeight
-
-    if (store.reflowing) {
-      batch(() => {
-        setStore("lastScrollTop", scrollTop)
-        setStore("lastScrollHeight", scrollHeight)
-      })
-      return
-    }
-
-    const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
-    const scrollTopDelta = scrollTop - store.lastScrollTop
-
-    if (scrollHeightChanged && scrollTopDelta < 0) {
-      const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
-      const expectedScrollTop = store.lastScrollTop * heightRatio
-      if (Math.abs(scrollTop - expectedScrollTop) < 100) {
-        batch(() => {
-          setStore("lastScrollTop", scrollTop)
-          setStore("lastScrollHeight", scrollHeight)
-        })
-        return
-      }
-    }
-
-    const reset = scrollTop <= 0 && store.lastScrollTop > 0 && working() && !store.userScrolled
-    if (reset) {
-      batch(() => {
-        setStore("lastScrollTop", scrollTop)
-        setStore("lastScrollHeight", scrollHeight)
-      })
-      requestAnimationFrame(scrollToBottom)
-      return
-    }
-
-    const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
-    if (scrolledUp && working()) {
-      setStore("userScrolled", true)
-      props.onUserInteracted?.()
-    }
-
-    batch(() => {
-      setStore("lastScrollTop", scrollTop)
-      setStore("lastScrollHeight", scrollHeight)
-    })
-  }
-
-  function handleInteraction() {
-    if (working()) {
-      setStore("userScrolled", true)
-      props.onUserInteracted?.()
-    }
-  }
-
-  function scrollToBottom() {
-    if (!scrollRef || store.userScrolled || !working()) return
-    setStore("autoScrolled", true)
-    requestAnimationFrame(() => {
-      scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
-      requestAnimationFrame(() => {
-        batch(() => {
-          setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
-          setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
-          setStore("autoScrolled", false)
-        })
-      })
-    })
-  }
-
-  createResizeObserver(
-    () => store.contentRef,
-    ({ width }) => {
-      const widthChanged = Math.abs(width - store.lastContainerWidth) > 5
-      if (widthChanged && store.lastContainerWidth > 0) {
-        setStore("reflowing", true)
-        requestAnimationFrame(() => {
-          requestAnimationFrame(() => {
-            setStore("reflowing", false)
-            if (working() && !store.userScrolled) {
-              scrollToBottom()
-            }
-          })
-        })
-      } else if (!store.reflowing) {
-        scrollToBottom()
-      }
-      setStore("lastContainerWidth", width)
-    },
-  )
-
-  createEffect(() => {
-    if (!working()) setStore("userScrolled", false)
-  })
-
   createResizeObserver(
     () => store.stickyTitleRef,
     ({ height }) => {
@@ -412,12 +312,17 @@ export function SessionTurn(
 
   return (
     <div data-component="session-turn" class={props.classes?.root}>
-      <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
-        <div onClick={handleInteraction}>
+      <div
+        ref={autoScroll.scrollRef}
+        onScroll={autoScroll.handleScroll}
+        data-slot="session-turn-content"
+        class={props.classes?.content}
+      >
+        <div onClick={autoScroll.handleInteraction}>
           <Show when={message()}>
             {(msg) => (
               <div
-                ref={(el) => setStore("contentRef", el)}
+                ref={autoScroll.contentRef}
                 data-message={msg().id}
                 data-slot="session-turn-message-container"
                 class={props.classes?.container}

+ 135 - 0
packages/ui/src/hooks/create-auto-scroll.tsx

@@ -0,0 +1,135 @@
+import { batch, createEffect } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+
+export interface AutoScrollOptions {
+  working: () => boolean
+  onUserInteracted?: () => void
+}
+
+export function createAutoScroll(options: AutoScrollOptions) {
+  let scrollRef: HTMLElement | undefined
+  const [store, setStore] = createStore({
+    contentRef: undefined as HTMLElement | undefined,
+    lastScrollTop: 0,
+    lastScrollHeight: 0,
+    lastContentWidth: 0,
+    autoScrolled: false,
+    userScrolled: false,
+    reflowing: false,
+  })
+
+  function scrollToBottom() {
+    if (!scrollRef || store.userScrolled || !options.working()) return
+    setStore("autoScrolled", true)
+    requestAnimationFrame(() => {
+      scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
+      requestAnimationFrame(() => {
+        batch(() => {
+          setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
+          setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
+          setStore("autoScrolled", false)
+        })
+      })
+    })
+  }
+
+  function handleScroll() {
+    if (!scrollRef || store.autoScrolled) return
+
+    const scrollTop = scrollRef.scrollTop
+    const scrollHeight = scrollRef.scrollHeight
+
+    if (store.reflowing) {
+      batch(() => {
+        setStore("lastScrollTop", scrollTop)
+        setStore("lastScrollHeight", scrollHeight)
+      })
+      return
+    }
+
+    const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
+    const scrollTopDelta = scrollTop - store.lastScrollTop
+
+    // Handle reflow-caused scroll position changes
+    if (scrollHeightChanged && scrollTopDelta < 0) {
+      const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
+      const expectedScrollTop = store.lastScrollTop * heightRatio
+      if (Math.abs(scrollTop - expectedScrollTop) < 100) {
+        batch(() => {
+          setStore("lastScrollTop", scrollTop)
+          setStore("lastScrollHeight", scrollHeight)
+        })
+        return
+      }
+    }
+
+    // Handle reset to top while working
+    const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
+    if (reset) {
+      batch(() => {
+        setStore("lastScrollTop", scrollTop)
+        setStore("lastScrollHeight", scrollHeight)
+      })
+      requestAnimationFrame(scrollToBottom)
+      return
+    }
+
+    // Detect intentional scroll up
+    const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
+    if (scrolledUp && options.working()) {
+      setStore("userScrolled", true)
+      options.onUserInteracted?.()
+    }
+
+    batch(() => {
+      setStore("lastScrollTop", scrollTop)
+      setStore("lastScrollHeight", scrollHeight)
+    })
+  }
+
+  function handleInteraction() {
+    if (options.working()) {
+      setStore("userScrolled", true)
+      options.onUserInteracted?.()
+    }
+  }
+
+  // Reset userScrolled when work completes
+  createEffect(() => {
+    if (!options.working()) setStore("userScrolled", false)
+  })
+
+  // Handle content resize
+  createResizeObserver(
+    () => store.contentRef,
+    ({ width }) => {
+      const widthChanged = Math.abs(width - store.lastContentWidth) > 5
+      if (widthChanged && store.lastContentWidth > 0) {
+        setStore("reflowing", true)
+        requestAnimationFrame(() => {
+          requestAnimationFrame(() => {
+            setStore("reflowing", false)
+            if (options.working() && !store.userScrolled) {
+              scrollToBottom()
+            }
+          })
+        })
+      } else if (!store.reflowing) {
+        scrollToBottom()
+      }
+      setStore("lastContentWidth", width)
+    },
+  )
+
+  return {
+    scrollRef: (el: HTMLElement | undefined) => {
+      scrollRef = el
+    },
+    contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
+    handleScroll,
+    handleInteraction,
+    scrollToBottom,
+    userScrolled: () => store.userScrolled,
+  }
+}

+ 1 - 0
packages/ui/src/hooks/index.ts

@@ -1 +1,2 @@
 export * from "./use-filtered-list"
+export * from "./create-auto-scroll"