Explorar o código

feat(app): prefetch adjacent sessions

Adam hai 1 mes
pai
achega
27675dfd70
Modificáronse 2 ficheiros con 190 adicións e 3 borrados
  1. 19 1
      packages/app/src/context/sync.tsx
  2. 171 2
      packages/app/src/pages/layout.tsx

+ 19 - 1
packages/app/src/context/sync.tsx

@@ -30,6 +30,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return undefined
       return undefined
     }
     }
 
 
+    const limitFor = (count: number) => {
+      if (count <= chunk) return chunk
+      return Math.ceil(count / chunk) * chunk
+    }
+
+    const hydrateMessages = (sessionID: string) => {
+      if (meta.limit[sessionID] !== undefined) return
+
+      const messages = store.message[sessionID]
+      if (!messages) return
+
+      const limit = limitFor(messages.length)
+      setMeta("limit", sessionID, limit)
+      setMeta("complete", sessionID, messages.length < limit)
+    }
+
     const loadMessages = async (sessionID: string, limit: number) => {
     const loadMessages = async (sessionID: string, limit: number) => {
       if (meta.loading[sessionID]) return
       if (meta.loading[sessionID]) return
 
 
@@ -118,7 +134,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         },
         },
         async sync(sessionID: string) {
         async sync(sessionID: string) {
           const hasSession = getSession(sessionID) !== undefined
           const hasSession = getSession(sessionID) !== undefined
-          const hasMessages = store.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
+          hydrateMessages(sessionID)
+
+          const hasMessages = store.message[sessionID] !== undefined
           if (hasSession && hasMessages) return
           if (hasSession && hasMessages) return
 
 
           const pending = inflight.get(sessionID)
           const pending = inflight.get(sessionID)

+ 171 - 2
packages/app/src/pages/layout.tsx

@@ -1,4 +1,5 @@
 import {
 import {
+  batch,
   createEffect,
   createEffect,
   createMemo,
   createMemo,
   createSignal,
   createSignal,
@@ -31,7 +32,7 @@ import { getFilename } from "@opencode-ai/util/path"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Session } from "@opencode-ai/sdk/v2/client"
 import { Session } from "@opencode-ai/sdk/v2/client"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
-import { createStore, produce } from "solid-js/store"
+import { createStore, produce, reconcile } from "solid-js/store"
 import {
 import {
   DragDropProvider,
   DragDropProvider,
   DragDropSensors,
   DragDropSensors,
@@ -47,6 +48,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
 import { useNotification } from "@/context/notification"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
 import { Binary } from "@opencode-ai/util/binary"
+import { retry } from "@opencode-ai/util/retry"
 
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -285,6 +287,146 @@ export default function Layout(props: ParentProps) {
 
 
   const currentSessions = createMemo(() => projectSessions(currentProject()))
   const currentSessions = createMemo(() => projectSessions(currentProject()))
 
 
+  type PrefetchQueue = {
+    inflight: Set<string>
+    pending: string[]
+    pendingSet: Set<string>
+    running: number
+  }
+
+  const prefetchChunk = 200
+  const prefetchConcurrency = 1
+  const prefetchPendingLimit = 6
+  const prefetchToken = { value: 0 }
+  const prefetchQueues = new Map<string, PrefetchQueue>()
+
+  createEffect(() => {
+    params.dir
+    globalSDK.url
+
+    prefetchToken.value += 1
+    for (const q of prefetchQueues.values()) {
+      q.pending.length = 0
+      q.pendingSet.clear()
+    }
+  })
+
+  const queueFor = (directory: string) => {
+    const existing = prefetchQueues.get(directory)
+    if (existing) return existing
+
+    const created: PrefetchQueue = {
+      inflight: new Set(),
+      pending: [],
+      pendingSet: new Set(),
+      running: 0,
+    }
+    prefetchQueues.set(directory, created)
+    return created
+  }
+
+  const prefetchMessages = (directory: string, sessionID: string, token: number) => {
+    const [, setStore] = globalSync.child(directory)
+
+    return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
+      .then((messages) => {
+        if (prefetchToken.value !== token) return
+
+        const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
+        const next = items
+          .map((x) => x.info)
+          .filter((m) => !!m?.id)
+          .slice()
+          .sort((a, b) => a.id.localeCompare(b.id))
+
+        batch(() => {
+          setStore("message", sessionID, reconcile(next, { key: "id" }))
+
+          for (const message of items) {
+            setStore(
+              "part",
+              message.info.id,
+              reconcile(
+                message.parts
+                  .filter((p) => !!p?.id)
+                  .slice()
+                  .sort((a, b) => a.id.localeCompare(b.id)),
+                { key: "id" },
+              ),
+            )
+          }
+        })
+      })
+      .catch(() => undefined)
+  }
+
+  const pumpPrefetch = (directory: string) => {
+    const q = queueFor(directory)
+    if (q.running >= prefetchConcurrency) return
+
+    const sessionID = q.pending.shift()
+    if (!sessionID) return
+
+    q.pendingSet.delete(sessionID)
+    q.inflight.add(sessionID)
+    q.running += 1
+
+    const token = prefetchToken.value
+
+    void prefetchMessages(directory, sessionID, token).finally(() => {
+      q.running -= 1
+      q.inflight.delete(sessionID)
+      pumpPrefetch(directory)
+    })
+  }
+
+  const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
+    const directory = session.directory
+    if (!directory) return
+
+    const [store] = globalSync.child(directory)
+    if (store.message[session.id] !== undefined) return
+
+    const q = queueFor(directory)
+    if (q.inflight.has(session.id)) return
+    if (q.pendingSet.has(session.id)) return
+
+    if (priority === "high") q.pending.unshift(session.id)
+    if (priority !== "high") q.pending.push(session.id)
+    q.pendingSet.add(session.id)
+
+    while (q.pending.length > prefetchPendingLimit) {
+      const dropped = q.pending.pop()
+      if (!dropped) continue
+      q.pendingSet.delete(dropped)
+    }
+
+    pumpPrefetch(directory)
+  }
+
+  createEffect(() => {
+    const sessions = currentSessions()
+    const id = params.id
+
+    if (!id) {
+      const first = sessions[0]
+      if (first) prefetchSession(first)
+
+      const second = sessions[1]
+      if (second) prefetchSession(second)
+      return
+    }
+
+    const index = sessions.findIndex((s) => s.id === id)
+    if (index === -1) return
+
+    const next = sessions[index + 1]
+    if (next) prefetchSession(next)
+
+    const prev = sessions[index - 1]
+    if (prev) prefetchSession(prev)
+  })
+
   function navigateSessionByOffset(offset: number) {
   function navigateSessionByOffset(offset: number) {
     const projects = layout.projects.list()
     const projects = layout.projects.list()
     if (projects.length === 0) return
     if (projects.length === 0) return
@@ -310,6 +452,19 @@ export default function Layout(props: ParentProps) {
 
 
     if (targetIndex >= 0 && targetIndex < sessions.length) {
     if (targetIndex >= 0 && targetIndex < sessions.length) {
       const session = sessions[targetIndex]
       const session = sessions[targetIndex]
+      const next = sessions[targetIndex + 1]
+      const prev = sessions[targetIndex - 1]
+
+      if (offset > 0) {
+        if (next) prefetchSession(next, "high")
+        if (prev) prefetchSession(prev)
+      }
+
+      if (offset < 0) {
+        if (prev) prefetchSession(prev, "high")
+        if (next) prefetchSession(next)
+      }
+
       if (import.meta.env.DEV) {
       if (import.meta.env.DEV) {
         navStart({
         navStart({
           dir: base64Encode(session.directory),
           dir: base64Encode(session.directory),
@@ -333,7 +488,19 @@ export default function Layout(props: ParentProps) {
       return
       return
     }
     }
 
 
-    const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
+    const index = offset > 0 ? 0 : nextProjectSessions.length - 1
+    const targetSession = nextProjectSessions[index]
+    const nextSession = nextProjectSessions[index + 1]
+    const prevSession = nextProjectSessions[index - 1]
+
+    if (offset > 0) {
+      if (nextSession) prefetchSession(nextSession, "high")
+    }
+
+    if (offset < 0) {
+      if (prevSession) prefetchSession(prevSession, "high")
+    }
+
     if (import.meta.env.DEV) {
     if (import.meta.env.DEV) {
       navStart({
       navStart({
         dir: base64Encode(targetSession.directory),
         dir: base64Encode(targetSession.directory),
@@ -696,6 +863,8 @@ export default function Layout(props: ParentProps) {
             <A
             <A
               href={`${props.slug}/session/${props.session.id}`}
               href={`${props.slug}/session/${props.session.id}`}
               class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
               class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
+              onMouseEnter={() => prefetchSession(props.session, "high")}
+              onFocus={() => prefetchSession(props.session, "high")}
             >
             >
               <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
               <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
                 <span
                 <span