Переглянути джерело

fix(app): 78x faster Home cold loading (#36214)

Luke Parker 1 місяць тому
батько
коміт
449c64928b

+ 29 - 0
packages/app/e2e/utils/mock-server.ts

@@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     const path = url.pathname
     if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
     if (path === "/global/health") return json(route, { healthy: true })
+    if (path === "/api/session")
+      return json(route, {
+        data: config.sessions.map((session) => v2Session(session, config.directory)),
+        cursor: {},
+      })
     if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
     if (path === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -132,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
   })
 }
 
+function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
+  const time = session.time && typeof session.time === "object" ? session.time : {}
+  return {
+    id: session.id,
+    parentID: session.parentID,
+    projectID: session.projectID ?? "project",
+    cost: session.cost ?? 0,
+    tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    time: {
+      created: "created" in time && typeof time.created === "number" ? time.created : 0,
+      updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
+      ...(session.time && typeof session.time === "object" && "archived" in session.time
+        ? { archived: session.time.archived }
+        : {}),
+    },
+    title: session.title ?? session.id,
+    location: {
+      directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
+      ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
+    },
+    ...(typeof session.path === "string" ? { subpath: session.path } : {}),
+  }
+}
+
 function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
   return route.fulfill({
     status,

+ 52 - 0
packages/app/src/context/global-sync/child-store.test.ts

@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
       dispose()
     }
   })
+
+  test("keeps non-bootstrapping children passive until a real directory access", () => {
+    let manager: ReturnType<typeof createChildStoreManager> | undefined
+    const offset = querySingles.length
+    const bootstraps: string[] = []
+
+    const dispose = createOwner((owner) => {
+      manager = createChildStoreManager({
+        owner,
+        scope: ServerScope.local,
+        persist,
+        isBooting: () => false,
+        isLoadingSessions: () => false,
+        onBootstrap(directory) {
+          bootstraps.push(directory)
+        },
+        onMcp() {},
+        onDispose() {},
+        translate: (key) => key,
+        queryOptions: queryOptionsApi,
+        global: { provider },
+      })
+    })
+
+    try {
+      if (!manager) throw new Error("manager required")
+      const [store] = manager.child("/project", { bootstrap: false })
+      const queries = querySingles.slice(offset)
+
+      expect(queries).toHaveLength(6)
+      expect(queries[0]?.().enabled).toBe(false)
+      expect(queries[3]?.().enabled).toBe(false)
+      expect(queries[4]?.().enabled).toBe(false)
+      expect(queries[5]?.().enabled).toBe(false)
+      expect(store.path.directory).toBe("/project")
+      expect(store.provider_ready).toBe(false)
+      expect(store.lsp_ready).toBe(false)
+      expect(bootstraps).toEqual([])
+
+      manager.child("/project")
+      expect(queries[0]?.().enabled).toBe(true)
+      expect(queries[3]?.().enabled).toBe(true)
+      expect(queries[4]?.().enabled).toBe(true)
+      expect(queries[5]?.().enabled).toBe(true)
+      expect(bootstraps).toEqual(["/project"])
+
+      manager.child("/project", { bootstrap: false })
+      expect(queries[0]?.().enabled).toBe(true)
+    } finally {
+      dispose()
+    }
+  })
 })

+ 31 - 6
packages/app/src/context/global-sync/child-store.ts

@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
   const disposers = new Map<string, () => void>()
   const mcpDirectories = new Set<string>()
   const mcpToggles = new Map<string, (enabled: boolean) => void>()
+  const activeDirectories = new Set<string>()
+  const activationToggles = new Map<string, (enabled: boolean) => void>()
 
   const markKey = (key: DirectoryKey) => {
     if (!key) return
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
     lifecycle.delete(key)
     mcpDirectories.delete(key)
     mcpToggles.delete(key)
+    activeDirectories.delete(key)
+    activationToggles.delete(key)
     const dispose = disposers.get(key)
     if (dispose) {
       dispose()
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
           const initialMeta = meta[0].value
           const initialIcon = icon[0].value
           const [mcpEnabled, setMcpEnabled] = createSignal(false)
+          const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
 
-          const pathQuery = useQuery(() => input.queryOptions.path(key))
+          const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
           const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
           const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
-          const lspQuery = useQuery(() => input.queryOptions.lsp(key))
-          const providerQuery = useQuery(() => input.queryOptions.providers(key))
-          const referenceQuery = useQuery(() => input.queryOptions.references(key))
+          const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
+          const providerQuery = useQuery(() => ({
+            ...input.queryOptions.providers(key),
+            enabled: instanceQueriesEnabled(),
+          }))
+          const referenceQuery = useQuery(() => ({
+            ...input.queryOptions.references(key),
+            enabled: instanceQueriesEnabled(),
+          }))
 
           const child = createStore<State>({
             project: "",
             projectMeta: initialMeta,
             icon: initialIcon,
             get provider_ready() {
-              return !providerQuery.isLoading
+              return instanceQueriesEnabled() && !providerQuery.isLoading
             },
             get provider() {
               const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -236,7 +247,7 @@ export function createChildStoreManager(input: {
               return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
             },
             get lsp_ready() {
-              return !lspQuery.isLoading
+              return instanceQueriesEnabled() && !lspQuery.isLoading
             },
             get lsp() {
               return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -250,6 +261,7 @@ export function createChildStoreManager(input: {
           children[key] = child
           disposers.set(key, dispose)
           mcpToggles.set(key, setMcpEnabled)
+          activationToggles.set(key, setInstanceQueriesEnabled)
 
           const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
             if (!(init instanceof Promise)) return
@@ -290,6 +302,7 @@ export function createChildStoreManager(input: {
     pinForOwner(key)
     if (options.mcp) enableMcp(directory, key, childStore)
     const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap) activate(key)
     if (shouldBootstrap && childStore[0].status === "loading") {
       input.onBootstrap(directory)
     }
@@ -301,6 +314,7 @@ export function createChildStoreManager(input: {
     const childStore = ensureChild(directory)
     if (options.mcp) enableMcp(directory, key, childStore)
     const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap) activate(key)
     if (shouldBootstrap && childStore[0].status === "loading") {
       input.onBootstrap(directory)
     }
@@ -314,6 +328,16 @@ export function createChildStoreManager(input: {
     if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
   }
 
+  // Passive Home/project metadata reads must not initialize the directory.
+  // A real directory access enables these queries once for the store lifetime.
+  // TODO(v2): After Home switches to v2.project.list and root-filtered,
+  // updated-time v2.session.list, remove any Home-only passive child creation.
+  function activate(key: DirectoryKey) {
+    if (activeDirectories.has(key)) return
+    activeDirectories.add(key)
+    activationToggles.get(key)?.(true)
+  }
+
   function disableMcp(directory: string) {
     const key = directoryKey(directory)
     if (!mcpDirectories.delete(key)) return
@@ -360,6 +384,7 @@ export function createChildStoreManager(input: {
     unpin,
     pinned,
     mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
+    active: (directory: string) => activeDirectories.has(directoryKey(directory)),
     disableMcp,
     disposeDirectory,
     runEviction,

+ 16 - 0
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -243,6 +243,22 @@ describe("applyDirectoryEvent", () => {
     expect(store.session_status.ses_1).toBeUndefined()
   })
 
+  test("ignores an archived session absent from a passive directory store", () => {
+    const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
+
+    applyDirectoryEvent({
+      event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
+      store,
+      setStore,
+      push() {},
+      directory: "/tmp",
+      loadLsp() {},
+    })
+
+    expect(store.session).toEqual([])
+    expect(store.sessionTotal).toBe(0)
+  })
+
   test("cleans session caches when deleted and decrements only root totals", () => {
     const cases = [
       { info: rootSession({ id: "ses_1" }), expectedTotal: 1 },

+ 7 - 8
packages/app/src/context/global-sync/event-reducer.ts

@@ -146,15 +146,14 @@ export function applyDirectoryEvent(input: {
       const info = (event.properties as { info: Session }).info
       const result = Binary.search(input.store.session, info.id, (s) => s.id)
       if (info.time.archived) {
+        if (!result.found) break
         if (input.store.session[result.index]!.time.archived === info.time.archived) break
-        if (result.found) {
-          input.setStore(
-            "session",
-            produce((draft) => {
-              draft.splice(result.index, 1)
-            }),
-          )
-        }
+        input.setStore(
+          "session",
+          produce((draft) => {
+            draft.splice(result.index, 1)
+          }),
+        )
         cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
         if (info.parentID) break
         input.setStore("sessionTotal", (value) => Math.max(0, value - 1))

+ 144 - 0
packages/app/src/context/global-sync/home-session-index.test.ts

@@ -0,0 +1,144 @@
+import { describe, expect, test } from "bun:test"
+import {
+  applyHomeSessionEvent,
+  appendHomeSessionEvent,
+  HOME_V2_SESSION_PAGE_LIMIT,
+  loadHomeSessionIndex,
+  homeSessionIndexSessions,
+  homeSessionIndexRefresh,
+  parseHomeSessionIndex,
+  retainHomeSessions,
+} from "./home-session-index"
+
+const session = (input: {
+  id: string
+  directory?: string
+  parentID?: string
+  archived?: number
+  updated?: number
+}) => ({
+  id: input.id,
+  parentID: input.parentID,
+  projectID: "project",
+  cost: 0,
+  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+  time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
+  title: input.id,
+  location: { directory: input.directory ?? "/project" },
+})
+
+describe("Home V2 session index", () => {
+  test("loads the Home index with one global V2 request", async () => {
+    const calls: unknown[] = []
+    const result = await loadHomeSessionIndex(async (input) => {
+      calls.push(input)
+      return { data: { data: [session({ id: "root" })], cursor: {} } }
+    })
+
+    expect(result.sessions).toHaveLength(1)
+    expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
+  })
+
+  test("loads subsequent pages until the session index is complete", async () => {
+    const calls: unknown[] = []
+    const controller = new AbortController()
+    const result = await loadHomeSessionIndex(
+      async (input, options) => {
+        calls.push({ input, signal: options.signal })
+        if (!("cursor" in input)) {
+          return {
+            data: {
+              data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
+                session({ id: `page-1-${index}` }),
+              ),
+              cursor: { next: "next-page" },
+            },
+          }
+        }
+        return { data: { data: [session({ id: "page-2" })], cursor: {} } }
+      },
+      0,
+      controller.signal,
+    )
+
+    expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
+    expect(calls).toEqual([
+      { input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
+      {
+        input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
+        signal: controller.signal,
+      },
+    ])
+  })
+
+  test("maps visible roots to Home session summaries", () => {
+    const result = parseHomeSessionIndex([
+      session({ id: "root", updated: 30 }),
+      session({ id: "child", parentID: "root", updated: 40 }),
+      session({ id: "archived", archived: 50, updated: 50 }),
+    ])
+
+    expect(result).toEqual([
+      expect.objectContaining({
+        id: "root",
+        slug: "root",
+        version: "",
+        directory: "/project",
+        projectID: "project",
+        title: "root",
+        time: { created: 1, updated: 30 },
+      }),
+    ])
+  })
+
+  test("preserves the per-directory Home retention limit", () => {
+    const now = 10 * 60 * 60 * 1000
+    const sessions = Array.from({ length: 80 }, (_, index) => ({
+      ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
+      directory: index % 2 === 0 ? "/one" : "/two",
+    }))
+
+    const retained = retainHomeSessions(sessions, 10, now)
+    expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
+    expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
+  })
+
+  test("replays session events over the loaded index", () => {
+    const initial = parseHomeSessionIndex([session({ id: "old" })])
+    const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
+
+    const afterCreate = applyHomeSessionEvent(initial, {
+      type: "session.created",
+      properties: { sessionID: created.id, info: created },
+    })
+    expect(
+      applyHomeSessionEvent(afterCreate, {
+        type: "session.deleted",
+        properties: { sessionID: initial[0]!.id, info: initial[0]! },
+      }),
+    ).toEqual([created])
+  })
+
+  test("applies only events newer than the index baseline", () => {
+    const initial = parseHomeSessionIndex([session({ id: "old" })])
+    const stale = { ...initial[0], title: "stale" }
+    const current = { ...initial[0], title: "current" }
+    const first = appendHomeSessionEvent(undefined, {
+      type: "session.updated",
+      properties: { sessionID: stale.id, info: stale },
+    })
+    const events = appendHomeSessionEvent(first, {
+      type: "session.updated",
+      properties: { sessionID: current.id, info: current },
+    })
+
+    expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
+  })
+
+  test("refetches after reconnect, disposal, and session moves", () => {
+    expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
+    expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
+    expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
+    expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
+  })
+})

+ 174 - 0
packages/app/src/context/global-sync/home-session-index.ts

@@ -0,0 +1,174 @@
+import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
+import type { QueryClient } from "@tanstack/solid-query"
+import { trimSessions } from "./session-trim"
+import { pathKey } from "@/utils/path-key"
+
+export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
+
+export type HomeSessionEvent = {
+  type: "session.created" | "session.updated" | "session.deleted"
+  properties: { sessionID: string; info: Session }
+}
+export type HomeSessionEvents = {
+  sequence: number
+  entries: Array<{ sequence: number; event: HomeSessionEvent }>
+}
+export type HomeSessionIndex = {
+  sessions: Session[]
+  eventSequence: number
+}
+
+export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
+export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
+
+type HomeSessionPage = { data?: V2SessionListResponse }
+
+export async function loadHomeSessionIndex(
+  list: (
+    input: { limit: number; order: "desc"; cursor?: string },
+    options: { signal?: AbortSignal },
+  ) => Promise<HomeSessionPage>,
+  eventSequence = 0,
+  signal?: AbortSignal,
+) {
+  const data: SessionV2Info[] = []
+  let cursor: string | undefined
+
+  for (;;) {
+    const response = await list(
+      {
+        limit: HOME_V2_SESSION_PAGE_LIMIT,
+        order: "desc",
+        ...(cursor ? { cursor } : {}),
+      },
+      { signal },
+    )
+    const page = response.data!
+    data.push(...page.data)
+    if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
+      return { sessions: parseHomeSessionIndex(data), eventSequence }
+    cursor = page.cursor.next
+  }
+}
+
+export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
+  const sequence = (current?.sequence ?? 0) + 1
+  return {
+    sequence,
+    entries: [...(current?.entries ?? []), { sequence, event }],
+  }
+}
+
+export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
+  return {
+    sequence: current?.sequence ?? sequence,
+    entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
+  }
+}
+
+export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
+  if (!index) return []
+  return (events?.entries ?? [])
+    .filter((entry) => entry.sequence > index.eventSequence)
+    .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
+}
+
+export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
+  if (event === "server.connected") return { connected: true, refetch: connected }
+  return {
+    connected,
+    refetch: event === "global.disposed" || event === "session.next.moved",
+  }
+}
+
+export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
+  const indexKey = homeSessionIndexKey(server)
+  const eventsKey = homeSessionEventsKey(server)
+  let connected = false
+
+  return {
+    indexKey,
+    eventsKey,
+    eventSequence() {
+      return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
+    },
+    complete(sequence: number) {
+      // Keep events received after the fetch began so its response cannot overwrite them.
+      queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
+    },
+    sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
+      return homeSessionIndexSessions(index, events)
+    },
+    apply(event: HomeSessionEvent) {
+      if (!queryClient.getQueryState(indexKey)) return
+      const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
+      if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
+        queryClient.setQueryData(eventsKey, next)
+        return
+      }
+
+      const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
+      if (index) {
+        queryClient.setQueryData<HomeSessionIndex>(indexKey, {
+          sessions: homeSessionIndexSessions(index, next),
+          eventSequence: next.sequence,
+        })
+      }
+      queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
+    },
+    refresh(event: Event["type"]) {
+      const result = homeSessionIndexRefresh(event, connected)
+      connected = result.connected
+      if (!result.refetch) return
+      void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
+    },
+  }
+}
+
+// TODO(v2): This deliberately dumb full-table scan is necessary because the
+// current V2 API orders by creation time and cannot filter roots, archives, or
+// multiple directories. A bounded page could omit an old session updated today.
+// Once released, use client.v2.project.list() and client.v2.session.list({
+// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
+export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
+  return sessions.flatMap((item) => {
+    if (item.parentID || item.time.archived !== undefined) return []
+    return [toLegacySummary(item)]
+  })
+}
+
+export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
+  const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
+  return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
+}
+
+export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
+  const info = event.properties.info
+  const index = sessions.findIndex((session) => session.id === info.id)
+  if (event.type === "session.deleted" || info.parentID || info.time.archived !== undefined) {
+    if (index === -1) return sessions
+    return sessions.toSpliced(index, 1)
+  }
+  if (event.type !== "session.created" && event.type !== "session.updated") return sessions
+  if (index === -1) return [...sessions, info]
+  return sessions.with(index, info)
+}
+
+function toLegacySummary(session: SessionV2Info): Session {
+  return {
+    id: session.id,
+    slug: session.id,
+    projectID: session.projectID,
+    workspaceID: session.location.workspaceID,
+    directory: session.location.directory,
+    path: session.subpath,
+    parentID: session.parentID,
+    cost: session.cost,
+    tokens: session.tokens,
+    title: session.title,
+    agent: session.agent,
+    model: session.model,
+    version: "",
+    time: session.time,
+  }
+}

+ 13 - 1
packages/app/src/context/server-sync.tsx

@@ -43,6 +43,7 @@ import { useGlobal } from "./global"
 import { ServerConnection, useServer } from "./server"
 import { retry } from "@opencode-ai/core/util/retry"
 import type { ServerScope } from "@/utils/server-scope"
+import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
 import { persisted } from "@/utils/persist"
 import { toggleMcp } from "./global-sync/mcp"
 import { createServerSession } from "./server-session"
@@ -153,6 +154,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
   })
 
   const queryClient = useQueryClient()
+  const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
 
   let bootedAt = 0
   let bootingRoot = false
@@ -376,6 +378,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     const recent = bootingRoot || Date.now() - bootedAt < 1500
 
     session.apply(event)
+    if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
+      homeSessions.apply(event)
+    }
+    homeSessions.refresh(event.type)
 
     if (directory === "global") {
       applyGlobalEvent({
@@ -390,6 +396,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       if (event.type === "server.connected" || event.type === "global.disposed") {
         if (recent) return
         for (const directory of Object.keys(children.children)) {
+          if (!children.active(directory)) continue
           queue.push(directory)
         }
       }
@@ -405,15 +412,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       directory,
       store,
       setStore,
-      push: queue.push,
+      push: (directory) => {
+        if (children.active(directory)) queue.push(directory)
+      },
       retainedLimit: sessionMeta.get(key)?.limit,
       sessionContent: false,
       permission: session.data.permission,
       vcsCache: children.vcsCache.get(key),
       loadLsp: () => {
+        if (!children.active(key)) return
         void queryClient.fetchQuery(queryOptionsApi.lsp(key))
       },
       loadReferences: () => {
+        if (!children.active(key)) return
         void queryClient.fetchQuery(queryOptionsApi.references(key))
       },
     })
@@ -486,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     updateConfig: updateConfigMutation.mutateAsync,
     project: projectApi,
     session,
+    homeSessions,
     mcp: {
       toggle: async (directory: string, name: string) => {
         const key = directoryKey(directory)

+ 58 - 23
packages/app/src/pages/home.tsx

@@ -40,7 +40,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
 import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
 import { ServerConnection, serverName, useServer } from "@/context/server"
 import { sessionHasOpenTab, useTabs } from "@/context/tabs"
-import { useServerSync, type ServerSync } from "@/context/server-sync"
+import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useNotification } from "@/context/notification"
 import {
@@ -50,7 +50,6 @@ import {
   getProjectAvatarSource,
   homeProjectDirectories,
   projectForSession,
-  sortedRootSessions,
   toggleHomeProjectSelection,
 } from "@/pages/layout/helpers"
 import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
@@ -69,6 +68,11 @@ import { archiveHomeSession } from "./home-session-archive"
 import { shouldOpenSessionInBackground } from "./home-session-open"
 import { showToast } from "@/utils/toast"
 import { fileManagerApp } from "@/utils/file-manager"
+import {
+  loadHomeSessionIndex,
+  retainHomeSessions,
+  type HomeSessionEvents,
+} from "@/context/global-sync/home-session-index"
 
 const HOME_SESSION_LIMIT = 64
 const HOME_SESSION_HEADER_STICKY_TOP = 12
@@ -106,22 +110,24 @@ const HOME_SEARCH_RESULT_META =
 let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
 
 function buildHomeSessionRecords(input: {
-  sync: Pick<ServerSync, "child">
+  sessions: () => Session[]
   projectDirectories: () => string[]
   projects: () => LocalProject[]
   projectByID: () => Map<string, LocalProject>
 }) {
-  return [
-    ...new Map(
-      input
-        .projectDirectories()
-        .flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now()))
-        .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
-    ).values(),
-  ]
+  const directories = new Set(input.projectDirectories().map(pathKey))
+  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
+  return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
     .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
     .flatMap((session) => {
-      const project = projectForSession(session, input.projects(), input.projectByID())
+      const directory = pathKey(session.directory)
+      const project =
+        input
+          .projects()
+          .find(
+            (item) =>
+              pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
+          ) ?? projectForSession(session, input.projects(), input.projectByID())
       if (!project) return []
       return {
         session,
@@ -286,6 +292,7 @@ export function NewHome() {
     return global.ensureServerCtx(conn)
   })
   const focusedSync = () => focusedServerCtx()?.sync ?? sync()
+  const homeSessions = () => focusedSync().homeSessions
   const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
   const recentlyClosed = createMemo(
     () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
@@ -318,24 +325,47 @@ export function NewHome() {
     }
     return language.t("home.sessions.search.placeholder")
   })
+  const sessionEventLoad = useQuery(() => ({
+    queryKey: homeSessions().eventsKey,
+    queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
+    initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
+    enabled: false,
+  }))
   const sessionLoad = useQuery(() => ({
-    queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
-    queryFn: async () => {
-      await Promise.all(
-        projectDirectories().map((directory) =>
-          focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
-        ),
+    queryKey: homeSessions().indexKey,
+    enabled: !!focusedServerCtx(),
+    queryFn: async ({ signal }) => {
+      const ctx = focusedServerCtx()
+      if (!ctx) return { sessions: [], eventSequence: 0 }
+      const cache = homeSessions()
+      const eventSequence = cache.eventSequence()
+      const index = await loadHomeSessionIndex(
+        (input, options) => ctx.sdk.client.v2.session.list(input, options),
+        eventSequence,
+        signal,
       )
-      return null
+      cache.complete(eventSequence)
+      return index
     },
+    retry: false,
+    staleTime: 30_000,
+    refetchOnMount: true,
+    refetchOnReconnect: true,
   }))
 
   const projectByID = createMemo(
     () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
   )
+  const indexedSessions = createMemo(() =>
+    retainHomeSessions(
+      homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
+      HOME_SESSION_LIMIT,
+      Date.now(),
+    ),
+  )
   const allRecords = createMemo(() =>
     buildHomeSessionRecords({
-      sync: focusedSync(),
+      sessions: indexedSessions,
       projectDirectories,
       projects,
       projectByID,
@@ -363,8 +393,7 @@ export function NewHome() {
         prefetched.add(key)
         createRoot((dispose) => {
           try {
-            const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
-            void directory.session
+            void ctx.sync.session
               .sync(record.session.id)
               .then(() => {
                 return Promise.all(
@@ -484,7 +513,13 @@ export function NewHome() {
   }
 
   function openSession(session: Session, options?: OpenSessionOptions) {
-    const project = projectForSession(session, projects(), projectByID())
+    const directoryKey = pathKey(session.directory)
+    const project =
+      projects().find(
+        (item) =>
+          pathKey(item.worktree) === directoryKey ||
+          item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
+      ) ?? projectForSession(session, projects(), projectByID())
     const conn = focusedServer()
     if (!conn) return
     const directory = project?.worktree ?? session.directory