From 80312a5e9affae06a25c6a5d1f148b8d7ba63ac6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:46:54 +1000 Subject: [PATCH 1/6] fix(app): improve Home cold loading --- .../context/global-sync/child-store.test.ts | 52 +++++++++ .../src/context/global-sync/child-store.ts | 31 +++++- packages/app/src/context/server-sync.tsx | 7 +- .../src/pages/home-session-snapshot.test.ts | 96 ++++++++++++++++ .../app/src/pages/home-session-snapshot.ts | 105 ++++++++++++++++++ packages/app/src/pages/home.tsx | 82 +++++++++++--- 6 files changed, 353 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/pages/home-session-snapshot.test.ts create mode 100644 packages/app/src/pages/home-session-snapshot.ts 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..cadc446d450 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 queryDirectories = new Set() + const queryToggles = 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) + queryDirectories.delete(key) + queryToggles.delete(key) const dispose = disposers.get(key) if (dispose) { dispose() @@ -182,20 +186,21 @@ export function createChildStoreManager(input: { const initialMeta = meta[0].value const initialIcon = icon[0].value const [mcpEnabled, setMcpEnabled] = createSignal(false) + const [queriesEnabled, setQueriesEnabled] = createSignal(false) - const pathQuery = useQuery(() => input.queryOptions.path(key)) + const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: queriesEnabled() })) 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: queriesEnabled() })) + const providerQuery = useQuery(() => ({ ...input.queryOptions.providers(key), enabled: queriesEnabled() })) + const referenceQuery = useQuery(() => ({ ...input.queryOptions.references(key), enabled: queriesEnabled() })) const child = createStore({ project: "", projectMeta: initialMeta, icon: initialIcon, get provider_ready() { - return !providerQuery.isLoading + return queriesEnabled() && !providerQuery.isLoading }, get provider() { const EMPTY = { all: new Map(), connected: [], default: {} } @@ -236,7 +241,7 @@ export function createChildStoreManager(input: { return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) }, get lsp_ready() { - return !lspQuery.isLoading + return queriesEnabled() && !lspQuery.isLoading }, get lsp() { return lspQuery.isLoading ? [] : (lspQuery.data ?? []) @@ -250,6 +255,7 @@ export function createChildStoreManager(input: { children[key] = child disposers.set(key, dispose) mcpToggles.set(key, setMcpEnabled) + queryToggles.set(key, setQueriesEnabled) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return @@ -290,6 +296,7 @@ export function createChildStoreManager(input: { pinForOwner(key) if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap) enableQueries(key) if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) } @@ -301,6 +308,7 @@ export function createChildStoreManager(input: { const childStore = ensureChild(directory) if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap) enableQueries(key) if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) } @@ -314,6 +322,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): When Home uses released v2.project.list and root-filtered + // v2.session.list, remove its legacy child creation instead of relying on this guard. + function enableQueries(key: DirectoryKey) { + if (queryDirectories.has(key)) return + queryDirectories.add(key) + queryToggles.get(key)?.(true) + } + function disableMcp(directory: string) { const key = directoryKey(directory) if (!mcpDirectories.delete(key)) return @@ -360,6 +378,7 @@ export function createChildStoreManager(input: { unpin, pinned, mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), + queries: (directory: string) => queryDirectories.has(directoryKey(directory)), disableMcp, disposeDirectory, runEviction, diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa0765..7eca7d42f4b 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -390,6 +390,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.queries(directory)) continue queue.push(directory) } } @@ -405,15 +406,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { directory, store, setStore, - push: queue.push, + push: (directory) => { + if (children.queries(directory)) queue.push(directory) + }, retainedLimit: sessionMeta.get(key)?.limit, sessionContent: false, permission: session.data.permission, vcsCache: children.vcsCache.get(key), loadLsp: () => { + if (!children.queries(key)) return void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, loadReferences: () => { + if (!children.queries(key)) return void queryClient.fetchQuery(queryOptionsApi.references(key)) }, }) diff --git a/packages/app/src/pages/home-session-snapshot.test.ts b/packages/app/src/pages/home-session-snapshot.test.ts new file mode 100644 index 00000000000..5803bd62e0e --- /dev/null +++ b/packages/app/src/pages/home-session-snapshot.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { + applyHomeSessionEvent, + HOME_V2_SESSION_LIMIT, + HomeSessionSnapshotUnsupported, + loadHomeSessionSnapshot, + parseHomeSessionSnapshot, + retainHomeSessions, +} from "./home-session-snapshot" + +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 snapshot", () => { + test("loads the Home snapshot with one global V2 request", async () => { + const calls: unknown[] = [] + const result = await loadHomeSessionSnapshot(async (input) => { + calls.push(input) + return { data: { data: [session({ id: "root" })], cursor: {} } } + }) + + expect(result).toHaveLength(1) + expect(calls).toEqual([{ limit: HOME_V2_SESSION_LIMIT, order: "desc" }]) + }) + + test("maps visible roots without creating incomplete legacy cache entries", () => { + const result = parseHomeSessionSnapshot({ + data: [ + session({ id: "root", updated: 30 }), + session({ id: "child", parentID: "root", updated: 40 }), + session({ id: "archived", archived: 50, updated: 50 }), + ], + cursor: {}, + }) + + expect(result).toEqual([ + expect.objectContaining({ + id: "root", + slug: "root", + version: "", + directory: "/project", + projectID: "project", + title: "root", + time: { created: 1, updated: 30 }, + }), + ]) + }) + + test("rejects malformed and potentially truncated snapshots", () => { + expect(() => parseHomeSessionSnapshot({ data: "bad", cursor: {} })).toThrow(HomeSessionSnapshotUnsupported) + expect(() => + parseHomeSessionSnapshot({ + data: Array.from({ length: HOME_V2_SESSION_LIMIT }, (_, index) => session({ id: `session-${index}` })), + cursor: { next: "more" }, + }), + ).toThrow(HomeSessionSnapshotUnsupported) + }) + + test("preserves the per-directory Home retention limit", () => { + const now = 10 * 60 * 60 * 1000 + const sessions = Array.from({ length: 80 }, (_, index) => ({ + ...parseHomeSessionSnapshot({ data: [session({ id: `session-${index}`, updated: index + 1 })], cursor: {} })[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 an in-flight snapshot", () => { + const initial = parseHomeSessionSnapshot({ data: [session({ id: "old" })], cursor: {} }) + const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } } + + expect( + [ + { type: "session.created", properties: { info: created } }, + { type: "session.deleted", properties: { info: initial[0] } }, + ].reduce(applyHomeSessionEvent, initial), + ).toEqual([created]) + }) +}) diff --git a/packages/app/src/pages/home-session-snapshot.ts b/packages/app/src/pages/home-session-snapshot.ts new file mode 100644 index 00000000000..a8885983c56 --- /dev/null +++ b/packages/app/src/pages/home-session-snapshot.ts @@ -0,0 +1,105 @@ +import type { Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" +import { trimSessions } from "@/context/global-sync/session-trim" +import { pathKey } from "@/utils/path-key" + +export const HOME_V2_SESSION_LIMIT = 5_000 + +export class HomeSessionSnapshotUnsupported extends Error { + constructor(message: string) { + super(message) + this.name = "HomeSessionSnapshotUnsupported" + } +} + +export async function loadHomeSessionSnapshot( + list: (input: { limit: number; order: "desc" }) => Promise<{ data?: unknown }>, +) { + const response = await list({ limit: HOME_V2_SESSION_LIMIT, order: "desc" }) + return parseHomeSessionSnapshot(response.data) +} + +// TODO(v2): Once the released V2 server supports project.list plus root-only, +// updated-time session listing, replace this full-table compatibility adapter +// and remove its synthetic legacy fields, client filtering, and 5,000-row guard. +export function parseHomeSessionSnapshot(value: unknown): Session[] { + if (!isRecord(value) || !Array.isArray(value.data)) throw new HomeSessionSnapshotUnsupported("Invalid V2 response") + if (value.data.length >= HOME_V2_SESSION_LIMIT) + throw new HomeSessionSnapshotUnsupported("V2 session snapshot exceeded the safe single-page limit") + + const seen = new Map() + return value.data.flatMap((item) => { + if (!isV2Session(item)) throw new HomeSessionSnapshotUnsupported("Invalid V2 session") + const directory = item.location.directory + const previous = seen.get(item.id) + if (previous !== undefined && pathKey(previous) !== pathKey(directory)) + throw new HomeSessionSnapshotUnsupported("Conflicting V2 session directories") + seen.set(item.id, directory) + 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: { type: string; properties?: unknown }) { + if (!isRecord(event.properties) || !isLegacySummary(event.properties.info)) return sessions + 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, + } +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function isV2Session(value: unknown): value is SessionV2Info { + if (!isRecord(value) || !isRecord(value.location) || !isRecord(value.time)) return false + return ( + typeof value.id === "string" && + typeof value.projectID === "string" && + typeof value.title === "string" && + typeof value.location.directory === "string" && + typeof value.time.created === "number" && + typeof value.time.updated === "number" + ) +} + +function isLegacySummary(value: unknown): value is Session { + if (!isRecord(value) || !isRecord(value.time)) return false + return ( + typeof value.id === "string" && + typeof value.directory === "string" && + typeof value.projectID === "string" && + typeof value.title === "string" && + typeof value.time.created === "number" && + typeof value.time.updated === "number" + ) +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672dfc..aa7b782b57e 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -69,6 +69,7 @@ import { archiveHomeSession } from "./home-session-archive" import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" import { fileManagerApp } from "@/utils/file-manager" +import { applyHomeSessionEvent, loadHomeSessionSnapshot, retainHomeSessions } from "./home-session-snapshot" const HOME_SESSION_LIMIT = 64 const HOME_SESSION_HEADER_STICKY_TOP = 12 @@ -106,22 +107,30 @@ const HOME_SEARCH_RESULT_META = let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined function buildHomeSessionRecords(input: { + sessions?: () => Session[] | undefined sync: Pick projectDirectories: () => string[] projects: () => LocalProject[] projectByID: () => Map }) { - return [ - ...new Map( - input + const directories = new Set(input.projectDirectories().map(pathKey)) + const snapshot = input.sessions?.() + const sessions = snapshot + ? snapshot.filter((session) => directories.has(pathKey(session.directory))) + : 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(), - ] + 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, @@ -274,6 +283,9 @@ export function NewHome() { search: "", searchFocused: false, }) + const [sessionEvents, setSessionEvents] = createStore>>( + {}, + ) const selection = layout.home.selection const focusedServer = createMemo( @@ -318,7 +330,20 @@ export function NewHome() { return language.t("home.sessions.search.placeholder") }) const sessionLoad = useQuery(() => ({ - queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const, + queryKey: ["home", "sessions-v2", selection().server] as const, + enabled: !!focusedServerCtx(), + queryFn: async () => { + const ctx = focusedServerCtx() + if (!ctx) return [] + return loadHomeSessionSnapshot((input) => ctx.sdk.client.v2.session.list(input)) + }, + retry: false, + refetchOnMount: "always", + refetchOnReconnect: true, + })) + const fallbackSessionLoad = useQuery(() => ({ + queryKey: ["home", "sessions-v1-fallback", selection().server, ...projectDirectories()] as const, + enabled: sessionLoad.isError, queryFn: async () => { await Promise.all( projectDirectories().map((directory) => @@ -329,11 +354,37 @@ export function NewHome() { }, })) + createEffect(() => { + const ctx = focusedServerCtx() + const conn = focusedServer() + if (!ctx || !conn) return + const key = ServerConnection.key(conn) + const unsubscribe = ctx.sdk.event.listen((item) => { + const event = item.details + if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") + return + setSessionEvents( + produce((draft) => { + const list = (draft[key] ??= []) + list.push(event) + }), + ) + }) + onCleanup(unsubscribe) + }) + const projectByID = createMemo( () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) + const snapshotSessions = createMemo(() => { + const sessions = sessionLoad.data + if (!sessions) return + const events = sessionEvents[selection().server] ?? [] + return retainHomeSessions(events.reduce(applyHomeSessionEvent, sessions), HOME_SESSION_LIMIT, Date.now()) + }) const allRecords = createMemo(() => buildHomeSessionRecords({ + sessions: snapshotSessions, sync: focusedSync(), projectDirectories, projects, @@ -362,8 +413,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( @@ -483,7 +533,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 @@ -577,7 +633,7 @@ export function NewHome() { value={state.search} placeholder={searchPlaceholder()} open={searchOpen()} - loading={sessionLoad.isLoading} + loading={sessionLoad.isLoading || (sessionLoad.isError && fallbackSessionLoad.isLoading)} results={searchResults()} showProjectName={!selectedProject()} server={selection().server} @@ -610,7 +666,7 @@ export function NewHome() { From db83888f86c562965658b1453f63f83c217cef1f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:57:37 +1000 Subject: [PATCH 2/6] refactor(app): make V2 Home sessions authoritative --- ...hot.test.ts => home-session-index.test.ts} | 60 ++++++++++++------- ...sion-snapshot.ts => home-session-index.ts} | 48 ++++++++++----- packages/app/src/pages/home.tsx | 37 +++--------- 3 files changed, 78 insertions(+), 67 deletions(-) rename packages/app/src/pages/{home-session-snapshot.test.ts => home-session-index.test.ts} (54%) rename packages/app/src/pages/{home-session-snapshot.ts => home-session-index.ts} (66%) diff --git a/packages/app/src/pages/home-session-snapshot.test.ts b/packages/app/src/pages/home-session-index.test.ts similarity index 54% rename from packages/app/src/pages/home-session-snapshot.test.ts rename to packages/app/src/pages/home-session-index.test.ts index 5803bd62e0e..64bb5d2d5a1 100644 --- a/packages/app/src/pages/home-session-snapshot.test.ts +++ b/packages/app/src/pages/home-session-index.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" import { applyHomeSessionEvent, - HOME_V2_SESSION_LIMIT, - HomeSessionSnapshotUnsupported, - loadHomeSessionSnapshot, - parseHomeSessionSnapshot, + HOME_V2_SESSION_PAGE_LIMIT, + HomeSessionIndexInvalid, + loadHomeSessionIndex, + parseHomeSessionIndex, retainHomeSessions, -} from "./home-session-snapshot" +} from "./home-session-index" const session = (input: { id: string @@ -25,20 +25,42 @@ const session = (input: { location: { directory: input.directory ?? "/project" }, }) -describe("Home V2 session snapshot", () => { - test("loads the Home snapshot with one global V2 request", async () => { +describe("Home V2 session index", () => { + test("loads the Home index with one global V2 request", async () => { const calls: unknown[] = [] - const result = await loadHomeSessionSnapshot(async (input) => { + const result = await loadHomeSessionIndex(async (input) => { calls.push(input) return { data: { data: [session({ id: "root" })], cursor: {} } } }) expect(result).toHaveLength(1) - expect(calls).toEqual([{ limit: HOME_V2_SESSION_LIMIT, order: "desc" }]) + expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }]) }) - test("maps visible roots without creating incomplete legacy cache entries", () => { - const result = parseHomeSessionSnapshot({ + test("loads subsequent pages until the session index is complete", async () => { + const calls: unknown[] = [] + const result = await loadHomeSessionIndex(async (input) => { + calls.push(input) + 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: {} } } + }) + + expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) + expect(calls).toEqual([ + { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, + { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" }, + ]) + }) + + test("maps visible roots to Home session summaries", () => { + const result = parseHomeSessionIndex({ data: [ session({ id: "root", updated: 30 }), session({ id: "child", parentID: "root", updated: 40 }), @@ -60,20 +82,14 @@ describe("Home V2 session snapshot", () => { ]) }) - test("rejects malformed and potentially truncated snapshots", () => { - expect(() => parseHomeSessionSnapshot({ data: "bad", cursor: {} })).toThrow(HomeSessionSnapshotUnsupported) - expect(() => - parseHomeSessionSnapshot({ - data: Array.from({ length: HOME_V2_SESSION_LIMIT }, (_, index) => session({ id: `session-${index}` })), - cursor: { next: "more" }, - }), - ).toThrow(HomeSessionSnapshotUnsupported) + test("rejects malformed indexes", () => { + expect(() => parseHomeSessionIndex({ data: "bad", cursor: {} })).toThrow(HomeSessionIndexInvalid) }) test("preserves the per-directory Home retention limit", () => { const now = 10 * 60 * 60 * 1000 const sessions = Array.from({ length: 80 }, (_, index) => ({ - ...parseHomeSessionSnapshot({ data: [session({ id: `session-${index}`, updated: index + 1 })], cursor: {} })[0], + ...parseHomeSessionIndex({ data: [session({ id: `session-${index}`, updated: index + 1 })], cursor: {} })[0], directory: index % 2 === 0 ? "/one" : "/two", })) @@ -82,8 +98,8 @@ describe("Home V2 session snapshot", () => { expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10) }) - test("replays session events over an in-flight snapshot", () => { - const initial = parseHomeSessionSnapshot({ data: [session({ id: "old" })], cursor: {} }) + test("replays session events over the loaded index", () => { + const initial = parseHomeSessionIndex({ data: [session({ id: "old" })], cursor: {} }) const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } } expect( diff --git a/packages/app/src/pages/home-session-snapshot.ts b/packages/app/src/pages/home-session-index.ts similarity index 66% rename from packages/app/src/pages/home-session-snapshot.ts rename to packages/app/src/pages/home-session-index.ts index a8885983c56..44790f6f513 100644 --- a/packages/app/src/pages/home-session-snapshot.ts +++ b/packages/app/src/pages/home-session-index.ts @@ -2,37 +2,53 @@ import type { Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" import { trimSessions } from "@/context/global-sync/session-trim" import { pathKey } from "@/utils/path-key" -export const HOME_V2_SESSION_LIMIT = 5_000 +export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 -export class HomeSessionSnapshotUnsupported extends Error { +export class HomeSessionIndexInvalid extends Error { constructor(message: string) { super(message) - this.name = "HomeSessionSnapshotUnsupported" + this.name = "HomeSessionIndexInvalid" } } -export async function loadHomeSessionSnapshot( - list: (input: { limit: number; order: "desc" }) => Promise<{ data?: unknown }>, +export async function loadHomeSessionIndex( + list: (input: { limit: number; order: "desc"; cursor?: string }) => Promise<{ data?: unknown }>, ) { - const response = await list({ limit: HOME_V2_SESSION_LIMIT, order: "desc" }) - return parseHomeSessionSnapshot(response.data) + const data: unknown[] = [] + const cursors = new Set() + let cursor: string | undefined + + for (;;) { + const response = await list({ + limit: HOME_V2_SESSION_PAGE_LIMIT, + order: "desc", + ...(cursor ? { cursor } : {}), + }) + if (!isRecord(response.data) || !Array.isArray(response.data.data)) + throw new HomeSessionIndexInvalid("Invalid V2 response") + data.push(...response.data.data) + if (response.data.data.length < HOME_V2_SESSION_PAGE_LIMIT) return parseHomeSessionIndex({ data }) + + const next = isRecord(response.data.cursor) ? response.data.cursor.next : undefined + if (typeof next !== "string" || cursors.has(next)) throw new HomeSessionIndexInvalid("Invalid V2 pagination cursor") + cursors.add(next) + cursor = next + } } -// TODO(v2): Once the released V2 server supports project.list plus root-only, -// updated-time session listing, replace this full-table compatibility adapter -// and remove its synthetic legacy fields, client filtering, and 5,000-row guard. -export function parseHomeSessionSnapshot(value: unknown): Session[] { - if (!isRecord(value) || !Array.isArray(value.data)) throw new HomeSessionSnapshotUnsupported("Invalid V2 response") - if (value.data.length >= HOME_V2_SESSION_LIMIT) - throw new HomeSessionSnapshotUnsupported("V2 session snapshot exceeded the safe single-page limit") +// TODO(v2): Once released, load projects with client.v2.project.list() and use +// client.v2.session.list({ parentID: null, order: "desc" }). Then remove this +// full-table adapter, synthetic V1 fields, and client-side child filtering. +export function parseHomeSessionIndex(value: unknown): Session[] { + if (!isRecord(value) || !Array.isArray(value.data)) throw new HomeSessionIndexInvalid("Invalid V2 response") const seen = new Map() return value.data.flatMap((item) => { - if (!isV2Session(item)) throw new HomeSessionSnapshotUnsupported("Invalid V2 session") + if (!isV2Session(item)) throw new HomeSessionIndexInvalid("Invalid V2 session") const directory = item.location.directory const previous = seen.get(item.id) if (previous !== undefined && pathKey(previous) !== pathKey(directory)) - throw new HomeSessionSnapshotUnsupported("Conflicting V2 session directories") + throw new HomeSessionIndexInvalid("Conflicting V2 session directories") seen.set(item.id, directory) if (item.parentID || item.time.archived !== undefined) return [] return [toLegacySummary(item)] diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index aa7b782b57e..0095c647571 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,7 +68,7 @@ import { archiveHomeSession } from "./home-session-archive" import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" import { fileManagerApp } from "@/utils/file-manager" -import { applyHomeSessionEvent, loadHomeSessionSnapshot, retainHomeSessions } from "./home-session-snapshot" +import { applyHomeSessionEvent, loadHomeSessionIndex, retainHomeSessions } from "./home-session-index" const HOME_SESSION_LIMIT = 64 const HOME_SESSION_HEADER_STICKY_TOP = 12 @@ -107,19 +106,13 @@ const HOME_SEARCH_RESULT_META = let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined function buildHomeSessionRecords(input: { - sessions?: () => Session[] | undefined - sync: Pick + sessions: () => Session[] projectDirectories: () => string[] projects: () => LocalProject[] projectByID: () => Map }) { const directories = new Set(input.projectDirectories().map(pathKey)) - const snapshot = input.sessions?.() - const sessions = snapshot - ? snapshot.filter((session) => directories.has(pathKey(session.directory))) - : input - .projectDirectories() - .flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now())) + 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) => { @@ -335,25 +328,12 @@ export function NewHome() { queryFn: async () => { const ctx = focusedServerCtx() if (!ctx) return [] - return loadHomeSessionSnapshot((input) => ctx.sdk.client.v2.session.list(input)) + return loadHomeSessionIndex((input) => ctx.sdk.client.v2.session.list(input)) }, retry: false, refetchOnMount: "always", refetchOnReconnect: true, })) - const fallbackSessionLoad = useQuery(() => ({ - queryKey: ["home", "sessions-v1-fallback", selection().server, ...projectDirectories()] as const, - enabled: sessionLoad.isError, - queryFn: async () => { - await Promise.all( - projectDirectories().map((directory) => - focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }), - ), - ) - return null - }, - })) - createEffect(() => { const ctx = focusedServerCtx() const conn = focusedServer() @@ -378,14 +358,13 @@ export function NewHome() { ) const snapshotSessions = createMemo(() => { const sessions = sessionLoad.data - if (!sessions) return + if (!sessions) return [] const events = sessionEvents[selection().server] ?? [] return retainHomeSessions(events.reduce(applyHomeSessionEvent, sessions), HOME_SESSION_LIMIT, Date.now()) }) const allRecords = createMemo(() => buildHomeSessionRecords({ sessions: snapshotSessions, - sync: focusedSync(), projectDirectories, projects, projectByID, @@ -633,7 +612,7 @@ export function NewHome() { value={state.search} placeholder={searchPlaceholder()} open={searchOpen()} - loading={sessionLoad.isLoading || (sessionLoad.isError && fallbackSessionLoad.isLoading)} + loading={sessionLoad.isLoading} results={searchResults()} showProjectName={!selectedProject()} server={selection().server} @@ -666,7 +645,7 @@ export function NewHome() { From 547b0ede5773a8799f1be1d46a66a6c52b8a4d79 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:51:19 +1000 Subject: [PATCH 3/6] fix(app): reconcile Home session index events --- packages/app/e2e/utils/mock-server.ts | 29 ++++++++ .../src/context/global-sync/child-store.ts | 4 +- .../context/global-sync/event-reducer.test.ts | 16 ++++ .../src/context/global-sync/event-reducer.ts | 15 ++-- .../global-sync}/home-session-index.test.ts | 60 +++++++++++---- .../global-sync}/home-session-index.ts | 70 +++++++++++++++--- packages/app/src/context/server-sync.tsx | 40 ++++++++++ packages/app/src/pages/home.tsx | 74 ++++++++++--------- 8 files changed, 239 insertions(+), 69 deletions(-) rename packages/app/src/{pages => context/global-sync}/home-session-index.test.ts (59%) rename packages/app/src/{pages => context/global-sync}/home-session-index.ts (66%) 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.ts b/packages/app/src/context/global-sync/child-store.ts index cadc446d450..25381a37acf 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -324,8 +324,8 @@ export function createChildStoreManager(input: { // Passive Home/project metadata reads must not initialize the directory. // A real directory access enables these queries once for the store lifetime. - // TODO(v2): When Home uses released v2.project.list and root-filtered - // v2.session.list, remove its legacy child creation instead of relying on this guard. + // 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 enableQueries(key: DirectoryKey) { if (queryDirectories.has(key)) return queryDirectories.add(key) 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/pages/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts similarity index 59% rename from packages/app/src/pages/home-session-index.test.ts rename to packages/app/src/context/global-sync/home-session-index.test.ts index 64bb5d2d5a1..81ba1869917 100644 --- a/packages/app/src/pages/home-session-index.test.ts +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test" import { applyHomeSessionEvent, + appendHomeSessionEvent, HOME_V2_SESSION_PAGE_LIMIT, HomeSessionIndexInvalid, loadHomeSessionIndex, + homeSessionIndexSessions, + homeSessionIndexRefresh, parseHomeSessionIndex, retainHomeSessions, } from "./home-session-index" @@ -33,29 +36,39 @@ describe("Home V2 session index", () => { return { data: { data: [session({ id: "root" })], cursor: {} } } }) - expect(result).toHaveLength(1) + 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 result = await loadHomeSessionIndex(async (input) => { - calls.push(input) - 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" }, - }, + 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: {} } } - }) + return { data: { data: [session({ id: "page-2" })], cursor: {} } } + }, + 0, + controller.signal, + ) - expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) + expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) expect(calls).toEqual([ - { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, - { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" }, + { 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, + }, ]) }) @@ -109,4 +122,21 @@ describe("Home V2 session index", () => { ].reduce(applyHomeSessionEvent, initial), ).toEqual([created]) }) + + test("applies only events newer than the index baseline", () => { + const initial = parseHomeSessionIndex({ data: [session({ id: "old" })], cursor: {} }) + const stale = { ...initial[0], title: "stale" } + const current = { ...initial[0], title: "current" } + const first = appendHomeSessionEvent(undefined, { type: "session.updated", properties: { info: stale } }) + const events = appendHomeSessionEvent(first, { type: "session.updated", properties: { 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/pages/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts similarity index 66% rename from packages/app/src/pages/home-session-index.ts rename to packages/app/src/context/global-sync/home-session-index.ts index 44790f6f513..81debbfbea8 100644 --- a/packages/app/src/pages/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -1,9 +1,22 @@ import type { Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" -import { trimSessions } from "@/context/global-sync/session-trim" +import { trimSessions } from "./session-trim" import { pathKey } from "@/utils/path-key" export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 +export type HomeSessionEvent = { type: string; properties?: unknown } +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 + export class HomeSessionIndexInvalid extends Error { constructor(message: string) { super(message) @@ -12,22 +25,31 @@ export class HomeSessionIndexInvalid extends Error { } export async function loadHomeSessionIndex( - list: (input: { limit: number; order: "desc"; cursor?: string }) => Promise<{ data?: unknown }>, + list: ( + input: { limit: number; order: "desc"; cursor?: string }, + options: { signal?: AbortSignal }, + ) => Promise<{ data?: unknown }>, + eventSequence = 0, + signal?: AbortSignal, ) { const data: unknown[] = [] const cursors = new Set() let cursor: string | undefined for (;;) { - const response = await list({ - limit: HOME_V2_SESSION_PAGE_LIMIT, - order: "desc", - ...(cursor ? { cursor } : {}), - }) + const response = await list( + { + limit: HOME_V2_SESSION_PAGE_LIMIT, + order: "desc", + ...(cursor ? { cursor } : {}), + }, + { signal }, + ) if (!isRecord(response.data) || !Array.isArray(response.data.data)) throw new HomeSessionIndexInvalid("Invalid V2 response") data.push(...response.data.data) - if (response.data.data.length < HOME_V2_SESSION_PAGE_LIMIT) return parseHomeSessionIndex({ data }) + if (response.data.data.length < HOME_V2_SESSION_PAGE_LIMIT) + return { sessions: parseHomeSessionIndex({ data }), eventSequence } const next = isRecord(response.data.cursor) ? response.data.cursor.next : undefined if (typeof next !== "string" || cursors.has(next)) throw new HomeSessionIndexInvalid("Invalid V2 pagination cursor") @@ -36,6 +58,36 @@ export async function loadHomeSessionIndex( } } +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: string, connected: boolean) { + if (event === "server.connected") return { connected: true, refetch: connected } + return { + connected, + refetch: event === "global.disposed" || event === "session.next.moved", + } +} + // TODO(v2): Once released, load projects with client.v2.project.list() and use // client.v2.session.list({ parentID: null, order: "desc" }). Then remove this // full-table adapter, synthetic V1 fields, and client-side child filtering. @@ -60,7 +112,7 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) } -export function applyHomeSessionEvent(sessions: Session[], event: { type: string; properties?: unknown }) { +export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { if (!isRecord(event.properties) || !isLegacySummary(event.properties.info)) return sessions const info = event.properties.info const index = sessions.findIndex((session) => session.id === info.id) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 7eca7d42f4b..1ba324db41a 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -43,6 +43,15 @@ 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 { + appendHomeSessionEvent, + homeSessionEventsKey, + homeSessionIndexKey, + homeSessionIndexRefresh, + homeSessionIndexSessions, + type HomeSessionEvents, + type HomeSessionIndex, +} from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" import { toggleMcp } from "./global-sync/mcp" import { createServerSession } from "./server-session" @@ -153,9 +162,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) const queryClient = useQueryClient() + const homeServerKey = ServerConnection.key(serverSDK.server) + const homeIndexKey = homeSessionIndexKey(homeServerKey) + const homeEventsKey = homeSessionEventsKey(homeServerKey) let bootedAt = 0 let bootingRoot = false + let connected = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined @@ -212,6 +225,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const session = createServerSession(serverSDK.client) + const applyHomeEvent = (event: { type: string; properties?: unknown }) => { + if (!queryClient.getQueryState(homeIndexKey)) return + const next = appendHomeSessionEvent(queryClient.getQueryData(homeEventsKey), event) + if (queryClient.isFetching({ queryKey: homeIndexKey, exact: true }) > 0) { + queryClient.setQueryData(homeEventsKey, next) + return + } + + const index = queryClient.getQueryData(homeIndexKey) + if (index) { + queryClient.setQueryData(homeIndexKey, { + sessions: homeSessionIndexSessions(index, next), + eventSequence: next.sequence, + }) + } + queryClient.setQueryData(homeEventsKey, { sequence: next.sequence, entries: [] }) + } + + const refetchHomeIndex = () => + queryClient.refetchQueries({ queryKey: homeIndexKey, exact: true, type: "active" }).then(() => undefined) + const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -376,6 +410,12 @@ 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") { + applyHomeEvent(event) + } + const homeRefresh = homeSessionIndexRefresh(event.type, connected) + connected = homeRefresh.connected + if (homeRefresh.refetch) void refetchHomeIndex() if (directory === "global") { applyGlobalEvent({ diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 0095c647571..115f4669b6a 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -16,7 +16,7 @@ import { } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createStore, produce } from "solid-js/store" -import { useQuery } from "@tanstack/solid-query" +import { useQuery, useQueryClient } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { Spinner } from "@opencode-ai/ui/spinner" @@ -68,7 +68,15 @@ import { archiveHomeSession } from "./home-session-archive" import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" import { fileManagerApp } from "@/utils/file-manager" -import { applyHomeSessionEvent, loadHomeSessionIndex, retainHomeSessions } from "./home-session-index" +import { + homeSessionEventsKey, + homeSessionIndexKey, + homeSessionIndexSessions, + loadHomeSessionIndex, + retainHomeSessions, + trimHomeSessionEvents, + type HomeSessionEvents, +} from "@/context/global-sync/home-session-index" const HOME_SESSION_LIMIT = 64 const HOME_SESSION_HEADER_STICKY_TOP = 12 @@ -276,9 +284,7 @@ export function NewHome() { search: "", searchFocused: false, }) - const [sessionEvents, setSessionEvents] = createStore>>( - {}, - ) + const queryClient = useQueryClient() const selection = layout.home.selection const focusedServer = createMemo( @@ -322,49 +328,47 @@ export function NewHome() { } return language.t("home.sessions.search.placeholder") }) + const sessionEventLoad = useQuery(() => ({ + queryKey: homeSessionEventsKey(selection().server), + queryFn: async (): Promise => ({ sequence: 0, entries: [] }), + initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, + enabled: false, + })) const sessionLoad = useQuery(() => ({ - queryKey: ["home", "sessions-v2", selection().server] as const, + queryKey: homeSessionIndexKey(selection().server), enabled: !!focusedServerCtx(), - queryFn: async () => { + queryFn: async ({ signal }) => { const ctx = focusedServerCtx() - if (!ctx) return [] - return loadHomeSessionIndex((input) => ctx.sdk.client.v2.session.list(input)) + if (!ctx) return { sessions: [], eventSequence: 0 } + const eventsKey = homeSessionEventsKey(selection().server) + const eventSequence = queryClient.getQueryData(eventsKey)?.sequence ?? 0 + const index = await loadHomeSessionIndex( + (input, options) => ctx.sdk.client.v2.session.list(input, options), + eventSequence, + signal, + ) + queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, eventSequence)) + return index }, retry: false, - refetchOnMount: "always", + staleTime: 30_000, + refetchOnMount: true, refetchOnReconnect: true, })) - createEffect(() => { - const ctx = focusedServerCtx() - const conn = focusedServer() - if (!ctx || !conn) return - const key = ServerConnection.key(conn) - const unsubscribe = ctx.sdk.event.listen((item) => { - const event = item.details - if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") - return - setSessionEvents( - produce((draft) => { - const list = (draft[key] ??= []) - list.push(event) - }), - ) - }) - onCleanup(unsubscribe) - }) const projectByID = createMemo( () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) - const snapshotSessions = createMemo(() => { - const sessions = sessionLoad.data - if (!sessions) return [] - const events = sessionEvents[selection().server] ?? [] - return retainHomeSessions(events.reduce(applyHomeSessionEvent, sessions), HOME_SESSION_LIMIT, Date.now()) - }) + const indexedSessions = createMemo(() => + retainHomeSessions( + homeSessionIndexSessions(sessionLoad.data, sessionEventLoad.data), + HOME_SESSION_LIMIT, + Date.now(), + ), + ) const allRecords = createMemo(() => buildHomeSessionRecords({ - sessions: snapshotSessions, + sessions: indexedSessions, projectDirectories, projects, projectByID, From ae54a190ed1928973faeaf20b92d30480c497b67 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:01:04 +1000 Subject: [PATCH 4/6] refactor(app): simplify Home session index --- .../global-sync/home-session-index.test.ts | 46 +++++------ .../context/global-sync/home-session-index.ts | 78 ++++--------------- packages/app/src/context/server-sync.tsx | 3 +- 3 files changed, 42 insertions(+), 85 deletions(-) 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 index 81ba1869917..f17127527a9 100644 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -3,7 +3,6 @@ import { applyHomeSessionEvent, appendHomeSessionEvent, HOME_V2_SESSION_PAGE_LIMIT, - HomeSessionIndexInvalid, loadHomeSessionIndex, homeSessionIndexSessions, homeSessionIndexRefresh, @@ -73,14 +72,11 @@ describe("Home V2 session index", () => { }) test("maps visible roots to Home session summaries", () => { - const result = parseHomeSessionIndex({ - data: [ - session({ id: "root", updated: 30 }), - session({ id: "child", parentID: "root", updated: 40 }), - session({ id: "archived", archived: 50, updated: 50 }), - ], - cursor: {}, - }) + 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({ @@ -95,14 +91,10 @@ describe("Home V2 session index", () => { ]) }) - test("rejects malformed indexes", () => { - expect(() => parseHomeSessionIndex({ data: "bad", cursor: {} })).toThrow(HomeSessionIndexInvalid) - }) - test("preserves the per-directory Home retention limit", () => { const now = 10 * 60 * 60 * 1000 const sessions = Array.from({ length: 80 }, (_, index) => ({ - ...parseHomeSessionIndex({ data: [session({ id: `session-${index}`, updated: index + 1 })], cursor: {} })[0], + ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], directory: index % 2 === 0 ? "/one" : "/two", })) @@ -112,23 +104,33 @@ describe("Home V2 session index", () => { }) test("replays session events over the loaded index", () => { - const initial = parseHomeSessionIndex({ data: [session({ id: "old" })], cursor: {} }) + 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( - [ - { type: "session.created", properties: { info: created } }, - { type: "session.deleted", properties: { info: initial[0] } }, - ].reduce(applyHomeSessionEvent, initial), + 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({ data: [session({ id: "old" })], cursor: {} }) + 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: { info: stale } }) - const events = appendHomeSessionEvent(first, { type: "session.updated", properties: { info: 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") }) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 81debbfbea8..660c64f184b 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -1,10 +1,13 @@ -import type { Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" +import type { Event, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" import { trimSessions } from "./session-trim" import { pathKey } from "@/utils/path-key" export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 -export type HomeSessionEvent = { type: string; properties?: unknown } +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 }> @@ -17,23 +20,17 @@ export type HomeSessionIndex = { export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const -export class HomeSessionIndexInvalid extends Error { - constructor(message: string) { - super(message) - this.name = "HomeSessionIndexInvalid" - } -} +type HomeSessionPage = { data?: { data: SessionV2Info[]; cursor: { next?: string } } } export async function loadHomeSessionIndex( list: ( input: { limit: number; order: "desc"; cursor?: string }, options: { signal?: AbortSignal }, - ) => Promise<{ data?: unknown }>, + ) => Promise, eventSequence = 0, signal?: AbortSignal, ) { - const data: unknown[] = [] - const cursors = new Set() + const data: SessionV2Info[] = [] let cursor: string | undefined for (;;) { @@ -45,16 +42,11 @@ export async function loadHomeSessionIndex( }, { signal }, ) - if (!isRecord(response.data) || !Array.isArray(response.data.data)) - throw new HomeSessionIndexInvalid("Invalid V2 response") - data.push(...response.data.data) - if (response.data.data.length < HOME_V2_SESSION_PAGE_LIMIT) - return { sessions: parseHomeSessionIndex({ data }), eventSequence } - - const next = isRecord(response.data.cursor) ? response.data.cursor.next : undefined - if (typeof next !== "string" || cursors.has(next)) throw new HomeSessionIndexInvalid("Invalid V2 pagination cursor") - cursors.add(next) - cursor = next + 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 } } @@ -80,7 +72,7 @@ export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, ev .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions) } -export function homeSessionIndexRefresh(event: string, connected: boolean) { +export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { if (event === "server.connected") return { connected: true, refetch: connected } return { connected, @@ -91,17 +83,8 @@ export function homeSessionIndexRefresh(event: string, connected: boolean) { // TODO(v2): Once released, load projects with client.v2.project.list() and use // client.v2.session.list({ parentID: null, order: "desc" }). Then remove this // full-table adapter, synthetic V1 fields, and client-side child filtering. -export function parseHomeSessionIndex(value: unknown): Session[] { - if (!isRecord(value) || !Array.isArray(value.data)) throw new HomeSessionIndexInvalid("Invalid V2 response") - - const seen = new Map() - return value.data.flatMap((item) => { - if (!isV2Session(item)) throw new HomeSessionIndexInvalid("Invalid V2 session") - const directory = item.location.directory - const previous = seen.get(item.id) - if (previous !== undefined && pathKey(previous) !== pathKey(directory)) - throw new HomeSessionIndexInvalid("Conflicting V2 session directories") - seen.set(item.id, directory) +export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] { + return sessions.flatMap((item) => { if (item.parentID || item.time.archived !== undefined) return [] return [toLegacySummary(item)] }) @@ -113,7 +96,6 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb } export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { - if (!isRecord(event.properties) || !isLegacySummary(event.properties.info)) return sessions 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) { @@ -143,31 +125,3 @@ function toLegacySummary(session: SessionV2Info): Session { time: session.time, } } - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function isV2Session(value: unknown): value is SessionV2Info { - if (!isRecord(value) || !isRecord(value.location) || !isRecord(value.time)) return false - return ( - typeof value.id === "string" && - typeof value.projectID === "string" && - typeof value.title === "string" && - typeof value.location.directory === "string" && - typeof value.time.created === "number" && - typeof value.time.updated === "number" - ) -} - -function isLegacySummary(value: unknown): value is Session { - if (!isRecord(value) || !isRecord(value.time)) return false - return ( - typeof value.id === "string" && - typeof value.directory === "string" && - typeof value.projectID === "string" && - typeof value.title === "string" && - typeof value.time.created === "number" && - typeof value.time.updated === "number" - ) -} diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 1ba324db41a..f559b8599f9 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -50,6 +50,7 @@ import { homeSessionIndexRefresh, homeSessionIndexSessions, type HomeSessionEvents, + type HomeSessionEvent, type HomeSessionIndex, } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" @@ -225,7 +226,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const session = createServerSession(serverSDK.client) - const applyHomeEvent = (event: { type: string; properties?: unknown }) => { + const applyHomeEvent = (event: HomeSessionEvent) => { if (!queryClient.getQueryState(homeIndexKey)) return const next = appendHomeSessionEvent(queryClient.getQueryData(homeEventsKey), event) if (queryClient.isFetching({ queryKey: homeIndexKey, exact: true }) > 0) { From 555c3aa581c7475a6823b63cf5d0350c12130751 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:13:05 +1000 Subject: [PATCH 5/6] refactor(app): encapsulate Home session cache --- .../src/context/global-sync/child-store.ts | 44 +++++++++------- .../context/global-sync/home-session-index.ts | 49 ++++++++++++++++- packages/app/src/context/server-sync.tsx | 52 ++++--------------- packages/app/src/pages/home.tsx | 20 +++---- 4 files changed, 89 insertions(+), 76 deletions(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 25381a37acf..b36973e7f02 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -45,8 +45,8 @@ export function createChildStoreManager(input: { const disposers = new Map void>() const mcpDirectories = new Set() const mcpToggles = new Map void>() - const queryDirectories = new Set() - const queryToggles = new Map void>() + const activeDirectories = new Set() + const activationToggles = new Map void>() const markKey = (key: DirectoryKey) => { if (!key) return @@ -120,8 +120,8 @@ export function createChildStoreManager(input: { lifecycle.delete(key) mcpDirectories.delete(key) mcpToggles.delete(key) - queryDirectories.delete(key) - queryToggles.delete(key) + activeDirectories.delete(key) + activationToggles.delete(key) const dispose = disposers.get(key) if (dispose) { dispose() @@ -186,21 +186,27 @@ export function createChildStoreManager(input: { const initialMeta = meta[0].value const initialIcon = icon[0].value const [mcpEnabled, setMcpEnabled] = createSignal(false) - const [queriesEnabled, setQueriesEnabled] = createSignal(false) + const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) - const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: queriesEnabled() })) + 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), enabled: queriesEnabled() })) - const providerQuery = useQuery(() => ({ ...input.queryOptions.providers(key), enabled: queriesEnabled() })) - const referenceQuery = useQuery(() => ({ ...input.queryOptions.references(key), enabled: queriesEnabled() })) + 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 queriesEnabled() && !providerQuery.isLoading + return instanceQueriesEnabled() && !providerQuery.isLoading }, get provider() { const EMPTY = { all: new Map(), connected: [], default: {} } @@ -241,7 +247,7 @@ export function createChildStoreManager(input: { return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) }, get lsp_ready() { - return queriesEnabled() && !lspQuery.isLoading + return instanceQueriesEnabled() && !lspQuery.isLoading }, get lsp() { return lspQuery.isLoading ? [] : (lspQuery.data ?? []) @@ -255,7 +261,7 @@ export function createChildStoreManager(input: { children[key] = child disposers.set(key, dispose) mcpToggles.set(key, setMcpEnabled) - queryToggles.set(key, setQueriesEnabled) + activationToggles.set(key, setInstanceQueriesEnabled) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return @@ -296,7 +302,7 @@ export function createChildStoreManager(input: { pinForOwner(key) if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap) enableQueries(key) + if (shouldBootstrap) activate(key) if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) } @@ -308,7 +314,7 @@ export function createChildStoreManager(input: { const childStore = ensureChild(directory) if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap) enableQueries(key) + if (shouldBootstrap) activate(key) if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) } @@ -326,10 +332,10 @@ export function createChildStoreManager(input: { // 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 enableQueries(key: DirectoryKey) { - if (queryDirectories.has(key)) return - queryDirectories.add(key) - queryToggles.get(key)?.(true) + function activate(key: DirectoryKey) { + if (activeDirectories.has(key)) return + activeDirectories.add(key) + activationToggles.get(key)?.(true) } function disableMcp(directory: string) { @@ -378,7 +384,7 @@ export function createChildStoreManager(input: { unpin, pinned, mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), - queries: (directory: string) => queryDirectories.has(directoryKey(directory)), + active: (directory: string) => activeDirectories.has(directoryKey(directory)), disableMcp, disposeDirectory, runEviction, diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 660c64f184b..e5cf01d27dc 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -1,4 +1,5 @@ -import type { Event, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" +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" @@ -20,7 +21,7 @@ export type HomeSessionIndex = { 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?: { data: SessionV2Info[]; cursor: { next?: string } } } +type HomeSessionPage = { data?: V2SessionListResponse } export async function loadHomeSessionIndex( list: ( @@ -80,6 +81,50 @@ export function homeSessionIndexRefresh(event: Event["type"], connected: boolean } } +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): Once released, load projects with client.v2.project.list() and use // client.v2.session.list({ parentID: null, order: "desc" }). Then remove this // full-table adapter, synthetic V1 fields, and client-side child filtering. diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index f559b8599f9..05806fba547 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -43,16 +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 { - appendHomeSessionEvent, - homeSessionEventsKey, - homeSessionIndexKey, - homeSessionIndexRefresh, - homeSessionIndexSessions, - type HomeSessionEvents, - type HomeSessionEvent, - type HomeSessionIndex, -} from "./global-sync/home-session-index" +import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" import { toggleMcp } from "./global-sync/mcp" import { createServerSession } from "./server-session" @@ -163,13 +154,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) const queryClient = useQueryClient() - const homeServerKey = ServerConnection.key(serverSDK.server) - const homeIndexKey = homeSessionIndexKey(homeServerKey) - const homeEventsKey = homeSessionEventsKey(homeServerKey) + const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) let bootedAt = 0 let bootingRoot = false - let connected = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined @@ -226,27 +214,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const session = createServerSession(serverSDK.client) - const applyHomeEvent = (event: HomeSessionEvent) => { - if (!queryClient.getQueryState(homeIndexKey)) return - const next = appendHomeSessionEvent(queryClient.getQueryData(homeEventsKey), event) - if (queryClient.isFetching({ queryKey: homeIndexKey, exact: true }) > 0) { - queryClient.setQueryData(homeEventsKey, next) - return - } - - const index = queryClient.getQueryData(homeIndexKey) - if (index) { - queryClient.setQueryData(homeIndexKey, { - sessions: homeSessionIndexSessions(index, next), - eventSequence: next.sequence, - }) - } - queryClient.setQueryData(homeEventsKey, { sequence: next.sequence, entries: [] }) - } - - const refetchHomeIndex = () => - queryClient.refetchQueries({ queryKey: homeIndexKey, exact: true, type: "active" }).then(() => undefined) - const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -412,11 +379,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { session.apply(event) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { - applyHomeEvent(event) + homeSessions.apply(event) } - const homeRefresh = homeSessionIndexRefresh(event.type, connected) - connected = homeRefresh.connected - if (homeRefresh.refetch) void refetchHomeIndex() + homeSessions.refresh(event.type) if (directory === "global") { applyGlobalEvent({ @@ -431,7 +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.queries(directory)) continue + if (!children.active(directory)) continue queue.push(directory) } } @@ -448,18 +413,18 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { store, setStore, push: (directory) => { - if (children.queries(directory)) queue.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.queries(key)) return + if (!children.active(key)) return void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, loadReferences: () => { - if (!children.queries(key)) return + if (!children.active(key)) return void queryClient.fetchQuery(queryOptionsApi.references(key)) }, }) @@ -532,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 115f4669b6a..2373136b274 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -16,7 +16,7 @@ import { } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createStore, produce } from "solid-js/store" -import { useQuery, useQueryClient } from "@tanstack/solid-query" +import { useQuery } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { Spinner } from "@opencode-ai/ui/spinner" @@ -69,12 +69,8 @@ import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" import { fileManagerApp } from "@/utils/file-manager" import { - homeSessionEventsKey, - homeSessionIndexKey, - homeSessionIndexSessions, loadHomeSessionIndex, retainHomeSessions, - trimHomeSessionEvents, type HomeSessionEvents, } from "@/context/global-sync/home-session-index" @@ -284,7 +280,6 @@ export function NewHome() { search: "", searchFocused: false, }) - const queryClient = useQueryClient() const selection = layout.home.selection const focusedServer = createMemo( @@ -296,6 +291,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(), @@ -329,25 +325,25 @@ export function NewHome() { return language.t("home.sessions.search.placeholder") }) const sessionEventLoad = useQuery(() => ({ - queryKey: homeSessionEventsKey(selection().server), + queryKey: homeSessions().eventsKey, queryFn: async (): Promise => ({ sequence: 0, entries: [] }), initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, enabled: false, })) const sessionLoad = useQuery(() => ({ - queryKey: homeSessionIndexKey(selection().server), + queryKey: homeSessions().indexKey, enabled: !!focusedServerCtx(), queryFn: async ({ signal }) => { const ctx = focusedServerCtx() if (!ctx) return { sessions: [], eventSequence: 0 } - const eventsKey = homeSessionEventsKey(selection().server) - const eventSequence = queryClient.getQueryData(eventsKey)?.sequence ?? 0 + const cache = homeSessions() + const eventSequence = cache.eventSequence() const index = await loadHomeSessionIndex( (input, options) => ctx.sdk.client.v2.session.list(input, options), eventSequence, signal, ) - queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, eventSequence)) + cache.complete(eventSequence) return index }, retry: false, @@ -361,7 +357,7 @@ export function NewHome() { ) const indexedSessions = createMemo(() => retainHomeSessions( - homeSessionIndexSessions(sessionLoad.data, sessionEventLoad.data), + homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), HOME_SESSION_LIMIT, Date.now(), ), From 312a7d14e9352f0f79b2f78e9b1aa5a470a0552c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:29:28 +1000 Subject: [PATCH 6/6] docs(app): explain Home session overfetch --- .../app/src/context/global-sync/home-session-index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index e5cf01d27dc..fcb98bb8aff 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -125,9 +125,11 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st } } -// TODO(v2): Once released, load projects with client.v2.project.list() and use -// client.v2.session.list({ parentID: null, order: "desc" }). Then remove this -// full-table adapter, synthetic V1 fields, and client-side child filtering. +// 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 []