Prechádzať zdrojové kódy

feat: session load perf (#17186)

Adam 5 mesiacov pred
rodič
commit
0e077f7483

+ 2 - 0
packages/app/src/context/global-sync.tsx

@@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
 import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
 import { createRefreshQueue } from "./global-sync/queue"
+import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
 import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
@@ -161,6 +162,7 @@ function createGlobalSync() {
       queue.clear(directory)
       sessionMeta.delete(directory)
       sdkCache.delete(directory)
+      clearSessionPrefetchDirectory(directory)
     },
   })
 

+ 63 - 0
packages/app/src/context/global-sync/session-prefetch.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import {
+  clearSessionPrefetch,
+  clearSessionPrefetchDirectory,
+  getSessionPrefetch,
+  runSessionPrefetch,
+  setSessionPrefetch,
+} from "./session-prefetch"
+
+describe("session prefetch", () => {
+  test("stores and clears message metadata by directory", () => {
+    clearSessionPrefetch("/tmp/a", ["ses_1"])
+    clearSessionPrefetch("/tmp/b", ["ses_1"])
+
+    setSessionPrefetch({
+      directory: "/tmp/a",
+      sessionID: "ses_1",
+      limit: 200,
+      complete: false,
+      at: 123,
+    })
+
+    expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
+    expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
+
+    clearSessionPrefetch("/tmp/a", ["ses_1"])
+
+    expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
+  })
+
+  test("dedupes inflight work", async () => {
+    clearSessionPrefetch("/tmp/c", ["ses_2"])
+
+    let calls = 0
+    const run = () =>
+      runSessionPrefetch({
+        directory: "/tmp/c",
+        sessionID: "ses_2",
+        task: async () => {
+          calls += 1
+          return { limit: 100, complete: true, at: 456 }
+        },
+      })
+
+    const [a, b] = await Promise.all([run(), run()])
+
+    expect(calls).toBe(1)
+    expect(a).toEqual({ limit: 100, complete: true, at: 456 })
+    expect(b).toEqual({ limit: 100, complete: true, at: 456 })
+  })
+
+  test("clears a whole directory", () => {
+    setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
+    setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
+    setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
+
+    clearSessionPrefetchDirectory("/tmp/d")
+
+    expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
+    expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
+    expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
+  })
+})

+ 85 - 0
packages/app/src/context/global-sync/session-prefetch.ts

@@ -0,0 +1,85 @@
+const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
+
+export const SESSION_PREFETCH_TTL = 15_000
+
+type Meta = {
+  limit: number
+  complete: boolean
+  at: number
+}
+
+const cache = new Map<string, Meta>()
+const inflight = new Map<string, Promise<Meta | undefined>>()
+const rev = new Map<string, number>()
+
+const version = (id: string) => rev.get(id) ?? 0
+
+export function getSessionPrefetch(directory: string, sessionID: string) {
+  return cache.get(key(directory, sessionID))
+}
+
+export function getSessionPrefetchPromise(directory: string, sessionID: string) {
+  return inflight.get(key(directory, sessionID))
+}
+
+export function clearSessionPrefetchInflight() {
+  inflight.clear()
+}
+
+export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
+  return version(key(directory, sessionID)) === value
+}
+
+export function runSessionPrefetch(input: {
+  directory: string
+  sessionID: string
+  task: (value: number) => Promise<Meta | undefined>
+}) {
+  const id = key(input.directory, input.sessionID)
+  const pending = inflight.get(id)
+  if (pending) return pending
+
+  const value = version(id)
+
+  const promise = input.task(value).finally(() => {
+    if (inflight.get(id) === promise) inflight.delete(id)
+  })
+
+  inflight.set(id, promise)
+  return promise
+}
+
+export function setSessionPrefetch(input: {
+  directory: string
+  sessionID: string
+  limit: number
+  complete: boolean
+  at?: number
+}) {
+  cache.set(key(input.directory, input.sessionID), {
+    limit: input.limit,
+    complete: input.complete,
+    at: input.at ?? Date.now(),
+  })
+}
+
+export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
+  for (const sessionID of sessionIDs) {
+    if (!sessionID) continue
+    const id = key(directory, sessionID)
+    rev.set(id, version(id) + 1)
+    cache.delete(id)
+    inflight.delete(id)
+  }
+}
+
+export function clearSessionPrefetchDirectory(directory: string) {
+  const prefix = `${directory}\n`
+  const keys = new Set([...cache.keys(), ...inflight.keys()])
+  for (const id of keys) {
+    if (!id.startsWith(prefix)) continue
+    rev.set(id, version(id) + 1)
+    cache.delete(id)
+    inflight.delete(id)
+  }
+}

+ 78 - 37
packages/app/src/context/sync.tsx

@@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { createSimpleContext } from "@opencode-ai/ui/context"
+import {
+  clearSessionPrefetch,
+  getSessionPrefetch,
+  getSessionPrefetchPromise,
+  setSessionPrefetch,
+} from "./global-sync/session-prefetch"
 import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -160,6 +166,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
     const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
       if (sessionIDs.length === 0) return
+      clearSessionPrefetch(directory, sessionIDs)
       for (const sessionID of sessionIDs) {
         globalSync.todo.set(sessionID, undefined)
       }
@@ -217,6 +224,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             }
             setMeta("limit", key, input.limit)
             setMeta("complete", key, next.complete)
+            setSessionPrefetch({
+              directory: input.directory,
+              sessionID: input.sessionID,
+              limit: input.limit,
+              complete: next.complete,
+            })
           })
         })
         .finally(() => {
@@ -280,54 +293,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             parts: input.parts,
           })
         },
-        async sync(sessionID: string) {
+        async sync(sessionID: string, opts?: { force?: boolean }) {
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           const key = keyFor(directory, sessionID)
-          const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
 
           touch(directory, setStore, sessionID)
 
-          if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
-
-          const limit = meta.limit[key] ?? messagePageSize
-
-          const sessionReq = hasSession
-            ? Promise.resolve()
-            : retry(() => client.session.get({ sessionID })).then((session) => {
-                if (!tracked(directory, sessionID)) return
-                const data = session.data
-                if (!data) return
-                setStore(
-                  "session",
-                  produce((draft) => {
-                    const match = Binary.search(draft, sessionID, (s) => s.id)
-                    if (match.found) {
-                      draft[match.index] = data
-                      return
-                    }
-                    draft.splice(match.index, 0, data)
-                  }),
-                )
-              })
-
-          const messagesReq = loadMessages({
-            directory,
-            client,
-            setStore,
-            sessionID,
-            limit,
-          })
+          const seeded = getSessionPrefetch(directory, sessionID)
+          if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
+            batch(() => {
+              setMeta("limit", key, seeded.limit)
+              setMeta("complete", key, seeded.complete)
+              setMeta("loading", key, false)
+            })
+          }
 
-          return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
+          return runInflight(inflight, key, async () => {
+            const pending = getSessionPrefetchPromise(directory, sessionID)
+            if (pending) {
+              await pending
+              const seeded = getSessionPrefetch(directory, sessionID)
+              if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
+                batch(() => {
+                  setMeta("limit", key, seeded.limit)
+                  setMeta("complete", key, seeded.complete)
+                  setMeta("loading", key, false)
+                })
+              }
+            }
+
+            const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
+            const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
+            if (cached && hasSession && !opts?.force) return
+
+            const limit = meta.limit[key] ?? messagePageSize
+            const sessionReq =
+              hasSession && !opts?.force
+                ? Promise.resolve()
+                : retry(() => client.session.get({ sessionID })).then((session) => {
+                    if (!tracked(directory, sessionID)) return
+                    const data = session.data
+                    if (!data) return
+                    setStore(
+                      "session",
+                      produce((draft) => {
+                        const match = Binary.search(draft, sessionID, (s) => s.id)
+                        if (match.found) {
+                          draft[match.index] = data
+                          return
+                        }
+                        draft.splice(match.index, 0, data)
+                      }),
+                    )
+                  })
+
+            const messagesReq =
+              cached && !opts?.force
+                ? Promise.resolve()
+                : loadMessages({
+                    directory,
+                    client,
+                    setStore,
+                    sessionID,
+                    limit,
+                  })
+
+            await Promise.all([sessionReq, messagesReq])
+          })
         },
-        async diff(sessionID: string) {
+        async diff(sessionID: string, opts?: { force?: boolean }) {
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           touch(directory, setStore, sessionID)
-          if (store.session_diff[sessionID] !== undefined) return
+          if (store.session_diff[sessionID] !== undefined && !opts?.force) return
 
           const key = keyFor(directory, sessionID)
           return runInflight(inflightDiff, key, () =>
@@ -337,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             }),
           )
         },
-        async todo(sessionID: string) {
+        async todo(sessionID: string, opts?: { force?: boolean }) {
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
@@ -348,7 +389,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             if (cached === undefined) {
               globalSync.todo.set(sessionID, existing)
             }
-            return
+            if (!opts?.force) return
           }
 
           if (cached !== undefined) {

+ 125 - 85
packages/app/src/pages/layout.tsx

@@ -35,6 +35,15 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { clearWorkspaceTerminals } from "@/context/terminal"
 import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
+import {
+  clearSessionPrefetchInflight,
+  clearSessionPrefetch,
+  getSessionPrefetch,
+  isSessionPrefetchCurrent,
+  runSessionPrefetch,
+  SESSION_PREFETCH_TTL,
+  setSessionPrefetch,
+} from "@/context/global-sync/session-prefetch"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
@@ -662,8 +671,9 @@ export default function Layout(props: ParentProps) {
   }
 
   const prefetchChunk = 200
-  const prefetchConcurrency = 1
-  const prefetchPendingLimit = 6
+  const prefetchConcurrency = 2
+  const prefetchPendingLimit = 10
+  const span = 4
   const prefetchToken = { value: 0 }
   const prefetchQueues = new Map<string, PrefetchQueue>()
 
@@ -688,14 +698,30 @@ export default function Layout(props: ParentProps) {
     })
   }
 
+  createEffect(() => {
+    const active = new Set(visibleSessionDirs())
+    for (const directory of [...prefetchedByDir.keys()]) {
+      if (active.has(directory)) continue
+      prefetchedByDir.delete(directory)
+    }
+  })
+
   createEffect(() => {
     params.dir
     globalSDK.url
 
     prefetchToken.value += 1
-    for (const q of prefetchQueues.values()) {
+    clearSessionPrefetchInflight()
+    prefetchQueues.clear()
+  })
+
+  createEffect(() => {
+    const visible = new Set(visibleSessionDirs())
+    for (const [directory, q] of prefetchQueues) {
+      if (visible.has(directory)) continue
       q.pending.length = 0
       q.pendingSet.clear()
+      if (q.running === 0) prefetchQueues.delete(directory)
     }
   })
 
@@ -731,36 +757,67 @@ export default function Layout(props: ParentProps) {
   async function prefetchMessages(directory: string, sessionID: string, token: number) {
     const [store, setStore] = globalSync.child(directory, { bootstrap: false })
 
-    return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
-      .then((messages) => {
-        if (prefetchToken.value !== token) return
-        if (!lruFor(directory).has(sessionID)) return
-
-        const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
-        const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
-        const sorted = mergeByID([], next)
-
-        const current = store.message[sessionID] ?? []
-        const merged = mergeByID(
-          current.filter((item): item is Message => !!item?.id),
-          sorted,
-        )
-
-        batch(() => {
-          setStore("message", sessionID, reconcile(merged, { key: "id" }))
-
-          for (const message of items) {
-            const currentParts = store.part[message.info.id] ?? []
-            const mergedParts = mergeByID(
-              currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
-              message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
+    return runSessionPrefetch({
+      directory,
+      sessionID,
+      task: (rev) =>
+        retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
+          .then((messages) => {
+            if (prefetchToken.value !== token) return
+            if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
+
+            const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
+            const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
+            const sorted = mergeByID([], next)
+            const stale = markPrefetched(directory, sessionID)
+            const meta = {
+              limit: prefetchChunk,
+              complete: sorted.length < prefetchChunk,
+              at: Date.now(),
+            }
+
+            if (stale.length > 0) {
+              clearSessionPrefetch(directory, stale)
+              for (const id of stale) {
+                globalSync.todo.set(id, undefined)
+              }
+            }
+
+            const current = store.message[sessionID] ?? []
+            const merged = mergeByID(
+              current.filter((item): item is Message => !!item?.id),
+              sorted,
             )
 
-            setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
-          }
-        })
-      })
-      .catch(() => undefined)
+            if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
+
+            batch(() => {
+              if (stale.length > 0) {
+                setStore(
+                  produce((draft) => {
+                    dropSessionCaches(draft, stale)
+                  }),
+                )
+              }
+
+              setStore("message", sessionID, reconcile(merged, { key: "id" }))
+              setSessionPrefetch({ directory, sessionID, ...meta })
+
+              for (const message of items) {
+                const currentParts = store.part[message.info.id] ?? []
+                const mergedParts = mergeByID(
+                  currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
+                  message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
+                )
+
+                setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
+              }
+            })
+
+            return meta
+          })
+          .catch(() => undefined),
+    })
   }
 
   const pumpPrefetch = (directory: string) => {
@@ -788,28 +845,29 @@ export default function Layout(props: ParentProps) {
     if (!directory) return
 
     const [store] = globalSync.child(directory, { bootstrap: false })
-    const cached = untrack(() => store.message[session.id] !== undefined)
+    const cached = untrack(() => {
+      if (store.message[session.id] === undefined) return false
+      const info = getSessionPrefetch(directory, session.id)
+      if (!info) return false
+      return Date.now() - info.at < SESSION_PREFETCH_TTL
+    })
     if (cached) return
 
     const q = queueFor(directory)
     if (q.inflight.has(session.id)) return
-    if (q.pendingSet.has(session.id)) return
+    if (q.pendingSet.has(session.id)) {
+      if (priority !== "high") return
+      const index = q.pending.indexOf(session.id)
+      if (index > 0) {
+        q.pending.splice(index, 1)
+        q.pending.unshift(session.id)
+      }
+      return
+    }
 
     const lru = lruFor(directory)
     const known = lru.has(session.id)
     if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
-    const stale = markPrefetched(directory, session.id)
-    if (stale.length > 0) {
-      const [, setStore] = globalSync.child(directory, { bootstrap: false })
-      for (const id of stale) {
-        globalSync.todo.set(id, undefined)
-      }
-      setStore(
-        produce((draft) => {
-          dropSessionCaches(draft, stale)
-        }),
-      )
-    }
 
     if (priority === "high") q.pending.unshift(session.id)
     if (priority !== "high") q.pending.push(session.id)
@@ -824,27 +882,29 @@ export default function Layout(props: ParentProps) {
     pumpPrefetch(directory)
   }
 
-  createEffect(() => {
-    const sessions = currentSessions()
-    const id = params.id
-
-    if (!id) {
-      const first = sessions[0]
-      if (first) prefetchSession(first)
+  const warm = (sessions: Session[], index: number) => {
+    for (let offset = 1; offset <= span; offset++) {
+      const next = sessions[index + offset]
+      if (next) prefetchSession(next, offset === 1 ? "high" : "low")
 
-      const second = sessions[1]
-      if (second) prefetchSession(second)
-      return
+      const prev = sessions[index - offset]
+      if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
     }
+  }
 
-    const index = sessions.findIndex((s) => s.id === id)
+  createEffect(() => {
+    const sessions = currentSessions()
+    if (sessions.length === 0) return
+
+    const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
     if (index === -1) return
 
-    const next = sessions[index + 1]
-    if (next) prefetchSession(next)
+    if (!params.id) {
+      const first = sessions[index]
+      if (first) prefetchSession(first, "high")
+    }
 
-    const prev = sessions[index - 1]
-    if (prev) prefetchSession(prev)
+    warm(sessions, index)
   })
 
   function navigateSessionByOffset(offset: number) {
@@ -863,18 +923,8 @@ export default function Layout(props: ParentProps) {
     const session = sessions[targetIndex]
     if (!session) return
 
-    const next = sessions[(targetIndex + 1) % sessions.length]
-    const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
-
-    if (offset > 0) {
-      if (next) prefetchSession(next, "high")
-      if (prev) prefetchSession(prev)
-    }
-
-    if (offset < 0) {
-      if (prev) prefetchSession(prev, "high")
-      if (next) prefetchSession(next)
-    }
+    prefetchSession(session, "high")
+    warm(sessions, targetIndex)
 
     navigateToSession(session)
   }
@@ -896,19 +946,7 @@ export default function Layout(props: ParentProps) {
       if (notification.session.unseenCount(session.id) === 0) continue
 
       prefetchSession(session, "high")
-
-      const next = sessions[(index + 1) % sessions.length]
-      const prev = sessions[(index - 1 + sessions.length) % sessions.length]
-
-      if (offset > 0) {
-        if (next) prefetchSession(next, "high")
-        if (prev) prefetchSession(prev)
-      }
-
-      if (offset < 0) {
-        if (prev) prefetchSession(prev, "high")
-        if (next) prefetchSession(next)
-      }
+      warm(sessions, index)
 
       navigateToSession(session)
       return
@@ -1842,6 +1880,7 @@ export default function Layout(props: ParentProps) {
 
   const workspaceSidebarCtx: WorkspaceSidebarContext = {
     currentDir,
+    navList: currentSessions,
     sidebarExpanded,
     sidebarHovering,
     nav: () => state.nav,
@@ -1887,6 +1926,7 @@ export default function Layout(props: ParentProps) {
     workspaceIds,
     workspaceLabel,
     sessionProps: {
+      navList: currentSessions,
       sidebarExpanded,
       sidebarHovering,
       nav: () => state.nav,

+ 42 - 11
packages/app/src/pages/layout/sidebar-items.tsx

@@ -10,6 +10,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { getFilename } from "@opencode-ai/util/path"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
+import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -67,6 +68,8 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
 
 export type SessionItemProps = {
   session: Session
+  list: Session[]
+  navList?: Accessor<Session[]>
   slug: string
   mobile?: boolean
   dense?: boolean
@@ -95,18 +98,18 @@ const SessionRow = (props: {
   setHoverSession: (id: string | undefined) => void
   clearHoverProjectSoon: () => void
   sidebarOpened: Accessor<boolean>
-  prefetchSession: (session: Session, priority?: "high" | "low") => void
-  scheduleHoverPrefetch: () => void
+  warmHover: () => void
+  warmPress: () => void
+  warmFocus: () => void
   cancelHoverPrefetch: () => void
 }): JSX.Element => (
   <A
     href={`/${props.slug}/session/${props.session.id}`}
     class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
-    onPointerEnter={props.scheduleHoverPrefetch}
+    onPointerDown={props.warmPress}
+    onPointerEnter={props.warmHover}
     onPointerLeave={props.cancelHoverPrefetch}
-    onMouseEnter={props.scheduleHoverPrefetch}
-    onMouseLeave={props.cancelHoverPrefetch}
-    onFocus={() => props.prefetchSession(props.session, "high")}
+    onFocus={props.warmFocus}
     onClick={() => {
       props.setHoverSession(undefined)
       if (props.sidebarOpened()) return
@@ -225,11 +228,37 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
   const hoverMessages = createMemo(() =>
     sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
   )
-  const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
+  const hoverReady = createMemo(() => {
+    if (sessionStore.message[props.session.id] === undefined) return false
+    if (props.session.id === params.id) return true
+    const info = getSessionPrefetch(props.session.directory, props.session.id)
+    if (!info) return false
+    return Date.now() - info.at < SESSION_PREFETCH_TTL
+  })
   const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
   const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
   const isActive = createMemo(() => props.session.id === params.id)
 
+  const warm = (span: number, priority: "high" | "low") => {
+    const nav = props.navList?.()
+    const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
+      ? nav
+      : props.list
+
+    props.prefetchSession(props.session, priority)
+
+    const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
+    if (idx === -1) return
+
+    for (let step = 1; step <= span; step++) {
+      const next = list[idx + step]
+      if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
+
+      const prev = list[idx - step]
+      if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
+    }
+  }
+
   const hoverPrefetch = {
     current: undefined as ReturnType<typeof setTimeout> | undefined,
   }
@@ -239,11 +268,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
     hoverPrefetch.current = undefined
   }
   const scheduleHoverPrefetch = () => {
+    warm(1, "high")
     if (hoverPrefetch.current !== undefined) return
     hoverPrefetch.current = setTimeout(() => {
       hoverPrefetch.current = undefined
-      props.prefetchSession(props.session)
-    }, 200)
+      warm(2, "low")
+    }, 80)
   }
 
   onCleanup(cancelHoverPrefetch)
@@ -267,8 +297,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
       setHoverSession={props.setHoverSession}
       clearHoverProjectSoon={props.clearHoverProjectSoon}
       sidebarOpened={layout.sidebar.opened}
-      prefetchSession={props.prefetchSession}
-      scheduleHoverPrefetch={scheduleHoverPrefetch}
+      warmHover={scheduleHoverPrefetch}
+      warmPress={() => warm(2, "high")}
+      warmFocus={() => warm(2, "high")}
       cancelHoverPrefetch={cancelHoverPrefetch}
     />
   )

+ 7 - 5
packages/app/src/pages/layout/sidebar-project.tsx

@@ -30,7 +30,7 @@ export type ProjectSidebarContext = {
   workspacesEnabled: (project: LocalProject) => boolean
   workspaceIds: (project: LocalProject) => string[]
   workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
-  sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
+  sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
   setHoverSession: (id: string | undefined) => void
 }
 
@@ -204,11 +204,12 @@ const ProjectPreviewPanel = (props: {
       <Show
         when={props.workspaceEnabled()}
         fallback={
-          <For each={props.projectSessions()}>
+          <For each={props.projectSessions().slice(0, 2)}>
             {(session) => (
               <SessionItem
                 {...props.ctx.sessionProps}
                 session={session}
+                list={props.projectSessions()}
                 slug={base64Encode(props.project.worktree)}
                 dense
                 mobile={props.mobile}
@@ -231,11 +232,12 @@ const ProjectPreviewPanel = (props: {
                   </div>
                   <span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
                 </div>
-                <For each={sessions()}>
+                <For each={sessions().slice(0, 2)}>
                   {(session) => (
                     <SessionItem
                       {...props.ctx.sessionProps}
                       session={session}
+                      list={sessions()}
                       slug={base64Encode(directory)}
                       dense
                       mobile={props.mobile}
@@ -317,11 +319,11 @@ export const SortableProject = (props: {
   }
 
   const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
-  const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2))
+  const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
   const projectChildren = createMemo(() => childMapByParent(projectStore().session))
   const workspaceSessions = (directory: string) => {
     const [data] = globalSync.child(directory, { bootstrap: false })
-    return sortedRootSessions(data, props.sortNow()).slice(0, 2)
+    return sortedRootSessions(data, props.sortNow())
   }
   const workspaceChildren = (directory: string) => {
     const [data] = globalSync.child(directory, { bootstrap: false })

+ 3 - 0
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -32,6 +32,7 @@ type InlineEditorComponent = (props: {
 
 export type WorkspaceSidebarContext = {
   currentDir: Accessor<string>
+  navList: Accessor<Session[]>
   sidebarExpanded: Accessor<boolean>
   sidebarHovering: Accessor<boolean>
   nav: Accessor<HTMLElement | undefined>
@@ -265,6 +266,8 @@ const WorkspaceSessionList = (props: {
       {(session) => (
         <SessionItem
           session={session}
+          list={props.sessions()}
+          navList={props.ctx.navList}
           slug={props.slug()}
           mobile={props.mobile}
           popover={props.popover}

+ 69 - 2
packages/app/src/pages/session.tsx

@@ -27,6 +27,7 @@ import { base64Encode, checksum } from "@opencode-ai/util/encode"
 import { useNavigate, useSearchParams } from "@solidjs/router"
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { useComments } from "@/context/comments"
+import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
@@ -437,7 +438,6 @@ export default function Page() {
       (next, prev) => {
         if (!prev) return
         if (next.dir === prev.dir && next.id === prev.id) return
-        if (prev.id) sync.session.evict(prev.id, prev.dir)
         if (!next.id) resetSessionModel(local)
       },
       { defer: true },
@@ -464,6 +464,10 @@ export default function Page() {
   }, sessionKey())
 
   let reviewFrame: number | undefined
+  let refreshFrame: number | undefined
+  let refreshTimer: number | undefined
+  let diffFrame: number | undefined
+  let diffTimer: number | undefined
 
   createComputed((prev) => {
     const open = desktopReviewOpen()
@@ -623,10 +627,36 @@ export default function Page() {
 
   createEffect(
     on([() => sdk.directory, () => params.id] as const, ([, id]) => {
+      if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
+      if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
+      refreshFrame = undefined
+      refreshTimer = undefined
       if (!id) return
+
+      const cached = untrack(() => sync.data.message[id] !== undefined)
+      const stale = !cached
+        ? false
+        : (() => {
+            const info = getSessionPrefetch(sdk.directory, id)
+            if (!info) return true
+            return Date.now() - info.at > SESSION_PREFETCH_TTL
+          })()
+      const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
+
       untrack(() => {
         void sync.session.sync(id)
-        void sync.session.todo(id)
+      })
+
+      refreshFrame = requestAnimationFrame(() => {
+        refreshFrame = undefined
+        refreshTimer = window.setTimeout(() => {
+          refreshTimer = undefined
+          if (params.id !== id) return
+          untrack(() => {
+            if (stale) void sync.session.sync(id, { force: true })
+            void sync.session.todo(id, todos ? { force: true } : undefined)
+          })
+        }, 0)
       })
     }),
   )
@@ -1064,6 +1094,39 @@ export default function Page() {
     void sync.session.diff(id)
   })
 
+  createEffect(
+    on(
+      () =>
+        [
+          sessionKey(),
+          isDesktop()
+            ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
+            : store.mobileTab === "changes",
+        ] as const,
+      ([key, wants]) => {
+        if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
+        if (diffTimer !== undefined) window.clearTimeout(diffTimer)
+        diffFrame = undefined
+        diffTimer = undefined
+        if (!wants) return
+
+        const id = params.id
+        if (!id) return
+        if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
+
+        diffFrame = requestAnimationFrame(() => {
+          diffFrame = undefined
+          diffTimer = window.setTimeout(() => {
+            diffTimer = undefined
+            if (sessionKey() !== key) return
+            void sync.session.diff(id, { force: true })
+          }, 0)
+        })
+      },
+      { defer: true },
+    ),
+  )
+
   let treeDir: string | undefined
   createEffect(() => {
     const dir = sdk.directory
@@ -1326,6 +1389,10 @@ export default function Page() {
   onCleanup(() => {
     document.removeEventListener("keydown", handleKeyDown)
     if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
+    if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
+    if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
+    if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
+    if (diffTimer !== undefined) window.clearTimeout(diffTimer)
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
   })