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

fix(desktop): more performance/scrolling fixes

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

+ 37 - 16
packages/app/src/pages/session.tsx

@@ -63,6 +63,12 @@ import { SessionLspIndicator } from "@/components/session-lsp-indicator"
 import { usePermission } from "@/context/permission"
 import { showToast } from "@opencode-ai/ui/toast"
 
+function same<T>(a: readonly T[], b: readonly T[]) {
+  if (a === b) return true
+  if (a.length !== b.length) return false
+  return a.every((x, i) => x === b[i])
+}
+
 export default function Page() {
   const layout = useLayout()
   const local = useLocal()
@@ -82,13 +88,22 @@ export default function Page() {
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
-  const userMessages = createMemo(() => messages().filter((m) => m.role === "user"))
-  const visibleUserMessages = createMemo(() => {
-    const revert = revertMessageID()
-    if (!revert) return userMessages()
-    return userMessages().filter((m) => m.id < revert)
-  })
-  const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
+  const emptyUserMessages: UserMessage[] = []
+  const userMessages = createMemo(
+    () => messages().filter((m) => m.role === "user") as UserMessage[],
+    emptyUserMessages,
+    { equals: same },
+  )
+  const visibleUserMessages = createMemo(
+    () => {
+      const revert = revertMessageID()
+      if (!revert) return userMessages()
+      return userMessages().filter((m) => m.id < revert)
+    },
+    emptyUserMessages,
+    { equals: same },
+  )
+  const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
 
   createEffect(
     on(
@@ -170,16 +185,22 @@ export default function Page() {
     ),
   )
 
-  createEffect(() => {
-    params.id
-    const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
-    batch(() => {
-      setStore("userInteracted", false)
-      setStore("stepsExpanded", status.type !== "idle")
-    })
-  })
+  const idle = { type: "idle" as const }
+
+  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 ?? ""] ?? { type: "idle" })
+  const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
   const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
 
   createRenderEffect((prev) => {

+ 28 - 22
packages/ui/src/components/message-part.tsx

@@ -95,6 +95,12 @@ export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
 
 const TEXT_RENDER_THROTTLE_MS = 250
 
+function same<T>(a: readonly T[], b: readonly T[]) {
+  if (a === b) return true
+  if (a.length !== b.length) return false
+  return a.every((x, i) => x === b[i])
+}
+
 function createThrottledValue(getValue: () => string) {
   const [value, setValue] = createSignal(getValue())
   let timeout: ReturnType<typeof setTimeout> | undefined
@@ -257,11 +263,15 @@ export function Message(props: MessageProps) {
 }
 
 export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
-  const filteredParts = createMemo(() => {
-    return props.parts?.filter((x) => {
-      return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
-    })
-  })
+  const emptyParts: PartType[] = []
+  const filteredParts = createMemo(
+    () =>
+      props.parts.filter((x) => {
+        return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
+      }),
+    emptyParts,
+    { equals: same },
+  )
   return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
 }
 
@@ -425,9 +435,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
   const part = props.part as ToolPart
 
   const permission = createMemo(() => {
-    const sessionID = props.message.sessionID
-    const permissions = data.store.permission?.[sessionID] ?? []
-    const next = permissions[0]
+    const next = data.store.permission?.[props.message.sessionID]?.[0]
     if (!next) return undefined
     if (next.callID !== part.callID) return undefined
     return next
@@ -448,13 +456,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
     })
   }
 
-  const component = createMemo(() => {
-    const render = ToolRegistry.render(part.tool) ?? GenericTool
-    // @ts-expect-error
-    const metadata = part.state?.metadata ?? {}
-    const input = part.state?.input ?? {}
+  const emptyInput: Record<string, any> = {}
+  const emptyMetadata: Record<string, any> = {}
 
-    return (
+  const input = () => part.state?.input ?? emptyInput
+  // @ts-expect-error
+  const metadata = () => part.state?.metadata ?? emptyMetadata
+
+  const render = ToolRegistry.render(part.tool) ?? GenericTool
+
+  return (
+    <div data-component="tool-part-wrapper" data-permission={!!permission()}>
       <Switch>
         <Match when={part.state.status === "error" && part.state.error}>
           {(error) => {
@@ -483,9 +495,9 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
         <Match when={true}>
           <Dynamic
             component={render}
-            input={input}
+            input={input()}
             tool={part.tool}
-            metadata={metadata}
+            metadata={metadata()}
             // @ts-expect-error
             output={part.state.output}
             status={part.state.status}
@@ -495,12 +507,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
           />
         </Match>
       </Switch>
-    )
-  })
-
-  return (
-    <div data-component="tool-part-wrapper" data-permission={!!permission()}>
-      <Show when={component()}>{component()}</Show>
       <Show when={permission()}>
         {(perm) => (
           <div data-component="permission-prompt">

+ 58 - 38
packages/ui/src/components/session-turn.tsx

@@ -1,4 +1,11 @@
-import { AssistantMessage, Part as PartType, TextPart, ToolPart } from "@opencode-ai/sdk/v2/client"
+import {
+  AssistantMessage,
+  Message as MessageType,
+  Part as PartType,
+  type Permission,
+  TextPart,
+  ToolPart,
+} from "@opencode-ai/sdk/v2/client"
 import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -61,13 +68,20 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined {
   return undefined
 }
 
+function same<T>(a: readonly T[], b: readonly T[]) {
+  if (a === b) return true
+  if (a.length !== b.length) return false
+  return a.every((x, i) => x === b[i])
+}
+
 function AssistantMessageItem(props: {
   message: AssistantMessage
   responsePartId: string | undefined
   hideResponsePart: boolean
 }) {
   const data = useData()
-  const msgParts = createMemo(() => data.store.part[props.message.id] ?? [])
+  const emptyParts: PartType[] = []
+  const msgParts = createMemo(() => data.store.part[props.message.id] ?? emptyParts)
   const lastTextPart = createMemo(() => {
     const parts = msgParts()
     for (let i = parts.length - 1; i >= 0; i--) {
@@ -109,7 +123,14 @@ export function SessionTurn(
   const data = useData()
   const diffComponent = useDiffComponent()
 
-  const allMessages = createMemo(() => data.store.message[props.sessionID] ?? [])
+  const emptyMessages: MessageType[] = []
+  const emptyParts: PartType[] = []
+  const emptyAssistant: AssistantMessage[] = []
+  const emptyPermissions: Permission[] = []
+  const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
+  const idle = { type: "idle" as const }
+
+  const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
 
   const messageIndex = createMemo(() => {
     const messages = allMessages()
@@ -147,27 +168,31 @@ export function SessionTurn(
 
   const parts = createMemo(() => {
     const msg = message()
-    if (!msg) return []
-    return data.store.part[msg.id] ?? []
+    if (!msg) return emptyParts
+    return data.store.part[msg.id] ?? emptyParts
   })
 
-  const assistantMessages = createMemo(() => {
-    const msg = message()
-    if (!msg) return [] as AssistantMessage[]
-
-    const messages = allMessages()
-    const index = messageIndex()
-    if (index < 0) return [] as AssistantMessage[]
-
-    const result: AssistantMessage[] = []
-    for (let i = index + 1; i < messages.length; i++) {
-      const item = messages[i]
-      if (!item) continue
-      if (item.role === "user") break
-      if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
-    }
-    return result
-  })
+  const assistantMessages = createMemo(
+    () => {
+      const msg = message()
+      if (!msg) return emptyAssistant
+
+      const messages = allMessages()
+      const index = messageIndex()
+      if (index < 0) return emptyAssistant
+
+      const result: AssistantMessage[] = []
+      for (let i = index + 1; i < messages.length; i++) {
+        const item = messages[i]
+        if (!item) continue
+        if (item.role === "user") break
+        if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
+      }
+      return result
+    },
+    emptyAssistant,
+    { equals: same },
+  )
 
   const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
 
@@ -176,7 +201,7 @@ export function SessionTurn(
   const lastTextPart = createMemo(() => {
     const msgs = assistantMessages()
     for (let mi = msgs.length - 1; mi >= 0; mi--) {
-      const msgParts = data.store.part[msgs[mi].id] ?? []
+      const msgParts = data.store.part[msgs[mi].id] ?? emptyParts
       for (let pi = msgParts.length - 1; pi >= 0; pi--) {
         const part = msgParts[pi]
         if (part?.type === "text") return part as TextPart
@@ -196,25 +221,25 @@ export function SessionTurn(
     return false
   })
 
-  const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? [])
+  const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions)
   const permissionCount = createMemo(() => permissions().length)
   const nextPermission = createMemo(() => permissions()[0])
 
   const permissionParts = createMemo(() => {
-    if (props.stepsExpanded) return [] as { part: ToolPart; message: AssistantMessage }[]
+    if (props.stepsExpanded) return emptyPermissionParts
 
     const next = nextPermission()
-    if (!next) return [] as { part: ToolPart; message: AssistantMessage }[]
+    if (!next) return emptyPermissionParts
 
     for (const message of assistantMessages()) {
-      const parts = data.store.part[message.id] ?? []
+      const parts = data.store.part[message.id] ?? emptyParts
       for (const part of parts) {
         if (part?.type !== "tool") continue
         const tool = part as ToolPart
         if (tool.callID === next.callID) return [{ part: tool, message }]
       }
     }
-    return [] as { part: ToolPart; message: AssistantMessage }[]
+    return emptyPermissionParts
   })
 
   const shellModePart = createMemo(() => {
@@ -224,7 +249,7 @@ export function SessionTurn(
     const msgs = assistantMessages()
     if (msgs.length !== 1) return
 
-    const msgParts = data.store.part[msgs[0].id] ?? []
+    const msgParts = data.store.part[msgs[0].id] ?? emptyParts
     if (msgParts.length !== 1) return
 
     const assistantPart = msgParts[0]
@@ -239,7 +264,7 @@ export function SessionTurn(
     let currentTask: ToolPart | undefined
 
     for (let mi = msgs.length - 1; mi >= 0; mi--) {
-      const msgParts = data.store.part[msgs[mi].id] ?? []
+      const msgParts = data.store.part[msgs[mi].id] ?? emptyParts
       for (let pi = msgParts.length - 1; pi >= 0; pi--) {
         const part = msgParts[pi]
         if (!part) continue
@@ -266,12 +291,12 @@ export function SessionTurn(
         : undefined
 
     if (taskSessionId) {
-      const taskMessages = data.store.message[taskSessionId] ?? []
+      const taskMessages = data.store.message[taskSessionId] ?? emptyMessages
       for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
         const msg = taskMessages[mi]
         if (!msg || msg.role !== "assistant") continue
 
-        const msgParts = data.store.part[msg.id] ?? []
+        const msgParts = data.store.part[msg.id] ?? emptyParts
         for (let pi = msgParts.length - 1; pi >= 0; pi--) {
           const part = msgParts[pi]
           if (part) return computeStatusFromPart(part)
@@ -282,12 +307,7 @@ export function SessionTurn(
     return computeStatusFromPart(last)
   })
 
-  const status = createMemo(
-    () =>
-      data.store.session_status[props.sessionID] ?? {
-        type: "idle",
-      },
-  )
+  const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
   const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
   const retry = createMemo(() => {
     const s = status()

+ 75 - 11
packages/ui/src/hooks/create-auto-scroll.tsx

@@ -19,38 +19,88 @@ export function createAutoScroll(options: AutoScrollOptions) {
   let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
   let isMouseDown = false
   let cleanupListeners: (() => void) | undefined
+  let scheduledScroll = false
+  let scheduledForce = false
 
-  function scrollToBottom() {
-    if (!scrollRef || store.userScrolled || !options.working()) return
+  function distanceFromBottom() {
+    if (!scrollRef) return 0
+    return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop
+  }
 
+  function startAutoScroll() {
     isAutoScrolling = true
     if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
     autoScrollTimeout = setTimeout(() => {
       isAutoScrolling = false
     }, 1000)
+  }
+
+  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: "smooth",
+      behavior,
     })
   }
 
-  function forceScrollToBottom() {
+  function forceScrollToBottomNow() {
     if (!scrollRef) return
 
-    setStore("userScrolled", false)
-    isAutoScrolling = true
-    if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
-    autoScrollTimeout = setTimeout(() => {
-      isAutoScrolling = false
-    }, 1000)
+    if (store.userScrolled) setStore("userScrolled", false)
 
+    const distance = distanceFromBottom()
+    if (distance < 2) return
+
+    startAutoScroll()
     scrollRef.scrollTo({
       top: scrollRef.scrollHeight,
-      behavior: "smooth",
+      behavior: "auto",
     })
   }
 
+  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
+      }
+
+      scrollToBottomNow()
+    })
+  }
+
+  function scrollToBottom() {
+    scheduleScrollToBottom(false)
+  }
+
+  function forceScrollToBottom() {
+    scheduleScrollToBottom(true)
+  }
+
   function handleScroll() {
     if (!scrollRef) return
 
@@ -132,6 +182,20 @@ export function createAutoScroll(options: AutoScrollOptions) {
     }
   })
 
+  // 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,