From 449c64928b0dc5ddb47f0e85d9b65323f79a267e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:03:51 +1000 Subject: [PATCH] fix(app): 78x faster Home cold loading (#36214) --- packages/app/e2e/utils/mock-server.ts | 29 +++ .../context/global-sync/child-store.test.ts | 52 ++++++ .../src/context/global-sync/child-store.ts | 37 +++- .../context/global-sync/event-reducer.test.ts | 16 ++ .../src/context/global-sync/event-reducer.ts | 15 +- .../global-sync/home-session-index.test.ts | 144 +++++++++++++++ .../context/global-sync/home-session-index.ts | 174 ++++++++++++++++++ packages/app/src/context/server-sync.tsx | 14 +- packages/app/src/pages/home.tsx | 81 +++++--- 9 files changed, 524 insertions(+), 38 deletions(-) create mode 100644 packages/app/src/context/global-sync/home-session-index.test.ts create mode 100644 packages/app/src/context/global-sync/home-session-index.ts diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e1946b8fba0..2bfba5871ab 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/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, 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, status = 200) { return route.fulfill({ status, diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index ddbfffe1adc..e05b7c39a96 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/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 | 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() + } + }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 53b9536182e..b36973e7f02 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -45,6 +45,8 @@ export function createChildStoreManager(input: { const disposers = new Map void>() const mcpDirectories = new Set() const mcpToggles = new Map void>() + const activeDirectories = new Set() + const activationToggles = new Map 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({ 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 | 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, diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 2811e6e3c6c..fb58fc48322 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/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 }, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 15d125df391..b12df5eb559 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/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)) diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts new file mode 100644 index 00000000000..f17127527a9 --- /dev/null +++ b/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) + }) +}) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts new file mode 100644 index 00000000000..fcb98bb8aff --- /dev/null +++ b/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, + 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(eventsKey)?.sequence ?? 0 + }, + complete(sequence: number) { + // Keep events received after the fetch began so its response cannot overwrite them. + queryClient.setQueryData(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(eventsKey), event) + if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) { + queryClient.setQueryData(eventsKey, next) + return + } + + const index = queryClient.getQueryData(indexKey) + if (index) { + queryClient.setQueryData(indexKey, { + sessions: homeSessionIndexSessions(index, next), + eventSequence: next.sequence, + }) + } + queryClient.setQueryData(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, + } +} diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa0765..05806fba547 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/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) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index d697e53ae71..da309ab0f32 100644 --- a/packages/app/src/pages/home.tsx +++ b/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 + sessions: () => Session[] projectDirectories: () => string[] projects: () => LocalProject[] projectByID: () => Map }) { - 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 => ({ 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