summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context/global-sync
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/context/global-sync
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/context/global-sync')
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts195
-rw-r--r--packages/app/src/context/global-sync/child-store.ts263
-rw-r--r--packages/app/src/context/global-sync/event-reducer.test.ts201
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts319
-rw-r--r--packages/app/src/context/global-sync/eviction.ts28
-rw-r--r--packages/app/src/context/global-sync/queue.ts83
-rw-r--r--packages/app/src/context/global-sync/session-load.ts26
-rw-r--r--packages/app/src/context/global-sync/session-trim.test.ts59
-rw-r--r--packages/app/src/context/global-sync/session-trim.ts56
-rw-r--r--packages/app/src/context/global-sync/types.ts134
-rw-r--r--packages/app/src/context/global-sync/utils.ts25
11 files changed, 1389 insertions, 0 deletions
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
new file mode 100644
index 000000000..2137a19a8
--- /dev/null
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -0,0 +1,195 @@
+import {
+ type Config,
+ type Path,
+ type PermissionRequest,
+ type Project,
+ type ProviderAuthResponse,
+ type ProviderListResponse,
+ type QuestionRequest,
+ createOpencodeClient,
+} from "@opencode-ai/sdk/v2/client"
+import { batch } from "solid-js"
+import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import { retry } from "@opencode-ai/util/retry"
+import { getFilename } from "@opencode-ai/util/path"
+import { showToast } from "@opencode-ai/ui/toast"
+import { cmp, normalizeProviderList } from "./utils"
+import type { State, VcsCache } from "./types"
+
+type GlobalStore = {
+ ready: boolean
+ path: Path
+ project: Project[]
+ provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
+ config: Config
+ reload: undefined | "pending" | "complete"
+}
+
+export async function bootstrapGlobal(input: {
+ globalSDK: ReturnType<typeof createOpencodeClient>
+ connectErrorTitle: string
+ connectErrorDescription: string
+ requestFailedTitle: string
+ setGlobalStore: SetStoreFunction<GlobalStore>
+}) {
+ const health = await input.globalSDK.global
+ .health()
+ .then((x) => x.data)
+ .catch(() => undefined)
+ if (!health?.healthy) {
+ showToast({
+ variant: "error",
+ title: input.connectErrorTitle,
+ description: input.connectErrorDescription,
+ })
+ input.setGlobalStore("ready", true)
+ return
+ }
+
+ const tasks = [
+ retry(() =>
+ input.globalSDK.path.get().then((x) => {
+ input.setGlobalStore("path", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.global.config.get().then((x) => {
+ input.setGlobalStore("config", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.project.list().then((x) => {
+ const projects = (x.data ?? [])
+ .filter((p) => !!p?.id)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+ .slice()
+ .sort((a, b) => cmp(a.id, b.id))
+ input.setGlobalStore("project", projects)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.list().then((x) => {
+ input.setGlobalStore("provider", normalizeProviderList(x.data!))
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.auth().then((x) => {
+ input.setGlobalStore("provider_auth", x.data ?? {})
+ }),
+ ),
+ ]
+
+ const results = await Promise.allSettled(tasks)
+ const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
+ if (errors.length) {
+ const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
+ const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
+ showToast({
+ variant: "error",
+ title: input.requestFailedTitle,
+ description: message + more,
+ })
+ }
+ input.setGlobalStore("ready", true)
+}
+
+function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
+ return input.reduce<Record<string, T[]>>((acc, item) => {
+ if (!item?.id || !item.sessionID) return acc
+ const list = acc[item.sessionID]
+ if (list) list.push(item)
+ if (!list) acc[item.sessionID] = [item]
+ return acc
+ }, {})
+}
+
+export async function bootstrapDirectory(input: {
+ directory: string
+ sdk: ReturnType<typeof createOpencodeClient>
+ store: Store<State>
+ setStore: SetStoreFunction<State>
+ vcsCache: VcsCache
+ loadSessions: (directory: string) => Promise<void> | void
+}) {
+ input.setStore("status", "loading")
+
+ const blockingRequests = {
+ project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
+ provider: () =>
+ input.sdk.provider.list().then((x) => {
+ input.setStore("provider", normalizeProviderList(x.data!))
+ }),
+ agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
+ config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+ }
+
+ try {
+ await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+ } catch (err) {
+ console.error("Failed to bootstrap instance", err)
+ const project = getFilename(input.directory)
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: `Failed to reload ${project}`, description: message })
+ input.setStore("status", "partial")
+ return
+ }
+
+ if (input.store.status !== "complete") input.setStore("status", "partial")
+
+ Promise.all([
+ input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
+ input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
+ input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
+ input.loadSessions(input.directory),
+ input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
+ input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
+ input.sdk.vcs.get().then((x) => {
+ const next = x.data ?? input.store.vcs
+ input.setStore("vcs", next)
+ if (next?.branch) input.vcsCache.setStore("value", next)
+ }),
+ input.sdk.permission.list().then((x) => {
+ const grouped = groupBySession(
+ (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+ )
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.permission)) {
+ if (grouped[sessionID]) continue
+ input.setStore("permission", sessionID, [])
+ }
+ for (const [sessionID, permissions] of Object.entries(grouped)) {
+ input.setStore(
+ "permission",
+ sessionID,
+ reconcile(
+ permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ input.sdk.question.list().then((x) => {
+ const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.question)) {
+ if (grouped[sessionID]) continue
+ input.setStore("question", sessionID, [])
+ }
+ for (const [sessionID, questions] of Object.entries(grouped)) {
+ input.setStore(
+ "question",
+ sessionID,
+ reconcile(
+ questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ ]).then(() => {
+ input.setStore("status", "complete")
+ })
+}
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
new file mode 100644
index 000000000..2feb7fe08
--- /dev/null
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -0,0 +1,263 @@
+import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
+import {
+ DIR_IDLE_TTL_MS,
+ MAX_DIR_STORES,
+ type ChildOptions,
+ type DirState,
+ type IconCache,
+ type MetaCache,
+ type ProjectMeta,
+ type State,
+ type VcsCache,
+} from "./types"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+
+export function createChildStoreManager(input: {
+ owner: Owner
+ markStats: (activeDirectoryStores: number) => void
+ incrementEvictions: () => void
+ isBooting: (directory: string) => boolean
+ isLoadingSessions: (directory: string) => boolean
+ onBootstrap: (directory: string) => void
+ onDispose: (directory: string) => void
+}) {
+ const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
+ const vcsCache = new Map<string, VcsCache>()
+ const metaCache = new Map<string, MetaCache>()
+ const iconCache = new Map<string, IconCache>()
+ const lifecycle = new Map<string, DirState>()
+ const pins = new Map<string, number>()
+ const ownerPins = new WeakMap<object, Set<string>>()
+ const disposers = new Map<string, () => void>()
+
+ const mark = (directory: string) => {
+ if (!directory) return
+ lifecycle.set(directory, { lastAccessAt: Date.now() })
+ runEviction()
+ }
+
+ const pin = (directory: string) => {
+ if (!directory) return
+ pins.set(directory, (pins.get(directory) ?? 0) + 1)
+ mark(directory)
+ }
+
+ const unpin = (directory: string) => {
+ if (!directory) return
+ const next = (pins.get(directory) ?? 0) - 1
+ if (next > 0) {
+ pins.set(directory, next)
+ return
+ }
+ pins.delete(directory)
+ runEviction()
+ }
+
+ const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+ const pinForOwner = (directory: string) => {
+ const current = getOwner()
+ if (!current) return
+ if (current === input.owner) return
+ const key = current as object
+ const set = ownerPins.get(key)
+ if (set?.has(directory)) return
+ if (set) set.add(directory)
+ if (!set) ownerPins.set(key, new Set([directory]))
+ pin(directory)
+ onCleanup(() => {
+ const set = ownerPins.get(key)
+ if (set) {
+ set.delete(directory)
+ if (set.size === 0) ownerPins.delete(key)
+ }
+ unpin(directory)
+ })
+ }
+
+ function disposeDirectory(directory: string) {
+ if (
+ !canDisposeDirectory({
+ directory,
+ hasStore: !!children[directory],
+ pinned: pinned(directory),
+ booting: input.isBooting(directory),
+ loadingSessions: input.isLoadingSessions(directory),
+ })
+ ) {
+ return false
+ }
+
+ vcsCache.delete(directory)
+ metaCache.delete(directory)
+ iconCache.delete(directory)
+ lifecycle.delete(directory)
+ const dispose = disposers.get(directory)
+ if (dispose) {
+ dispose()
+ disposers.delete(directory)
+ }
+ delete children[directory]
+ input.onDispose(directory)
+ input.markStats(Object.keys(children).length)
+ return true
+ }
+
+ function runEviction() {
+ const stores = Object.keys(children)
+ if (stores.length === 0) return
+ const list = pickDirectoriesToEvict({
+ stores,
+ state: lifecycle,
+ pins: new Set(stores.filter(pinned)),
+ max: MAX_DIR_STORES,
+ ttl: DIR_IDLE_TTL_MS,
+ now: Date.now(),
+ })
+ if (list.length === 0) return
+ for (const directory of list) {
+ if (!disposeDirectory(directory)) continue
+ input.incrementEvictions()
+ }
+ }
+
+ function ensureChild(directory: string) {
+ if (!directory) console.error("No directory provided")
+ if (!children[directory]) {
+ const vcs = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "vcs", ["vcs.v1"]),
+ createStore({ value: undefined as VcsInfo | undefined }),
+ ),
+ )
+ if (!vcs) throw new Error("Failed to create persisted cache")
+ const vcsStore = vcs[0]
+ const vcsReady = vcs[3]
+ vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+
+ const meta = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "project", ["project.v1"]),
+ createStore({ value: undefined as ProjectMeta | undefined }),
+ ),
+ )
+ if (!meta) throw new Error("Failed to create persisted project metadata")
+ metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
+
+ const icon = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "icon", ["icon.v1"]),
+ createStore({ value: undefined as string | undefined }),
+ ),
+ )
+ if (!icon) throw new Error("Failed to create persisted project icon")
+ iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
+
+ const init = () =>
+ createRoot((dispose) => {
+ const child = createStore<State>({
+ project: "",
+ projectMeta: meta[0].value,
+ icon: icon[0].value,
+ provider: { all: [], connected: [], default: {} },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ status: "loading" as const,
+ agent: [],
+ command: [],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: vcsStore.value,
+ limit: 5,
+ message: {},
+ part: {},
+ })
+ children[directory] = child
+ disposers.set(directory, dispose)
+
+ createEffect(() => {
+ if (!vcsReady()) return
+ const cached = vcsStore.value
+ if (!cached?.branch) return
+ child[1]("vcs", (value) => value ?? cached)
+ })
+ createEffect(() => {
+ child[1]("projectMeta", meta[0].value)
+ })
+ createEffect(() => {
+ child[1]("icon", icon[0].value)
+ })
+ })
+
+ runWithOwner(input.owner, init)
+ input.markStats(Object.keys(children).length)
+ }
+ mark(directory)
+ const childStore = children[directory]
+ if (!childStore) throw new Error("Failed to create store")
+ return childStore
+ }
+
+ function child(directory: string, options: ChildOptions = {}) {
+ const childStore = ensureChild(directory)
+ pinForOwner(directory)
+ const shouldBootstrap = options.bootstrap ?? true
+ if (shouldBootstrap && childStore[0].status === "loading") {
+ input.onBootstrap(directory)
+ }
+ return childStore
+ }
+
+ function projectMeta(directory: string, patch: ProjectMeta) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = metaCache.get(directory)
+ if (!cached) return
+ const previous = store.projectMeta ?? {}
+ const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
+ const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
+ const next = {
+ ...previous,
+ ...patch,
+ icon,
+ commands,
+ }
+ cached.setStore("value", next)
+ setStore("projectMeta", next)
+ }
+
+ function projectIcon(directory: string, value: string | undefined) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = iconCache.get(directory)
+ if (!cached) return
+ if (store.icon === value) return
+ cached.setStore("value", value)
+ setStore("icon", value)
+ }
+
+ return {
+ children,
+ ensureChild,
+ child,
+ projectMeta,
+ projectIcon,
+ mark,
+ pin,
+ unpin,
+ pinned,
+ disposeDirectory,
+ runEviction,
+ vcsCache,
+ metaCache,
+ iconCache,
+ }
+}
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
new file mode 100644
index 000000000..f79b9fc95
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
+import { createStore } from "solid-js/store"
+import type { State } from "./types"
+import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
+
+const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: 1,
+ updated: 1,
+ archived: input.archived,
+ },
+ }) as Session
+
+const userMessage = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+ }) as Message
+
+const textPart = (id: string, sessionID: string, messageID: string) =>
+ ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ text: id,
+ }) as Part
+
+const baseState = (input: Partial<State> = {}) =>
+ ({
+ status: "complete",
+ agent: [],
+ command: [],
+ project: "",
+ projectMeta: undefined,
+ icon: undefined,
+ provider: {} as State["provider"],
+ config: {} as State["config"],
+ path: { directory: "/tmp" } as State["path"],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: undefined,
+ limit: 10,
+ message: {},
+ part: {},
+ ...input,
+ }) as State
+
+describe("applyGlobalEvent", () => {
+ test("upserts project.updated in sorted position", () => {
+ const project = [{ id: "a" }, { id: "c" }] as Project[]
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "project.updated", properties: { id: "b" } },
+ project,
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject(next) {
+ if (typeof next === "function") next(project)
+ },
+ })
+
+ expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
+ expect(refreshCount).toBe(0)
+ })
+
+ test("handles global.disposed by triggering refresh", () => {
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "global.disposed" },
+ project: [],
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject() {},
+ })
+
+ expect(refreshCount).toBe(1)
+ })
+})
+
+describe("applyDirectoryEvent", () => {
+ test("inserts root sessions in sorted order and updates sessionTotal", () => {
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "b" })],
+ sessionTotal: 1,
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
+ expect(store.sessionTotal).toBe(2)
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.sessionTotal).toBe(2)
+ })
+
+ test("cleans session caches when archived", () => {
+ const message = userMessage("msg_1", "ses_1")
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
+ sessionTotal: 2,
+ message: { ses_1: [message] },
+ part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
+ session_diff: { ses_1: [] },
+ todo: { ses_1: [] },
+ permission: { ses_1: [] },
+ question: { ses_1: [] },
+ session_status: { ses_1: { type: "busy" } },
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
+ expect(store.sessionTotal).toBe(1)
+ expect(store.message.ses_1).toBeUndefined()
+ expect(store.part[message.id]).toBeUndefined()
+ expect(store.session_diff.ses_1).toBeUndefined()
+ expect(store.todo.ses_1).toBeUndefined()
+ expect(store.permission.ses_1).toBeUndefined()
+ expect(store.question.ses_1).toBeUndefined()
+ expect(store.session_status.ses_1).toBeUndefined()
+ })
+
+ test("routes disposal and lsp events to side-effect handlers", () => {
+ const [store, setStore] = createStore(baseState())
+ const pushes: string[] = []
+ let lspLoads = 0
+
+ applyDirectoryEvent({
+ event: { type: "server.instance.disposed" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ applyDirectoryEvent({
+ event: { type: "lsp.updated" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ expect(pushes).toEqual(["/tmp"])
+ expect(lspLoads).toBe(1)
+ })
+})
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
new file mode 100644
index 000000000..c658d82c8
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -0,0 +1,319 @@
+import { Binary } from "@opencode-ai/util/binary"
+import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import type {
+ FileDiff,
+ Message,
+ Part,
+ PermissionRequest,
+ Project,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+} from "@opencode-ai/sdk/v2/client"
+import type { State, VcsCache } from "./types"
+import { trimSessions } from "./session-trim"
+
+export function applyGlobalEvent(input: {
+ event: { type: string; properties?: unknown }
+ project: Project[]
+ setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+ refresh: () => void
+}) {
+ if (input.event.type === "global.disposed") {
+ input.refresh()
+ return
+ }
+
+ if (input.event.type !== "project.updated") return
+ const properties = input.event.properties as Project
+ const result = Binary.search(input.project, properties.id, (s) => s.id)
+ if (result.found) {
+ input.setGlobalProject((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ })
+ return
+ }
+ input.setGlobalProject((draft) => {
+ draft.splice(result.index, 0, properties)
+ })
+}
+
+function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
+ if (!sessionID) return
+ const hasAny =
+ store.message[sessionID] !== undefined ||
+ store.session_diff[sessionID] !== undefined ||
+ store.todo[sessionID] !== undefined ||
+ store.permission[sessionID] !== undefined ||
+ store.question[sessionID] !== undefined ||
+ store.session_status[sessionID] !== undefined
+ if (!hasAny) return
+ setStore(
+ produce((draft) => {
+ const messages = draft.message[sessionID]
+ if (messages) {
+ for (const message of messages) {
+ const id = message?.id
+ if (!id) continue
+ delete draft.part[id]
+ }
+ }
+ delete draft.message[sessionID]
+ delete draft.session_diff[sessionID]
+ delete draft.todo[sessionID]
+ delete draft.permission[sessionID]
+ delete draft.question[sessionID]
+ delete draft.session_status[sessionID]
+ }),
+ )
+}
+
+export function applyDirectoryEvent(input: {
+ event: { type: string; properties?: unknown }
+ store: Store<State>
+ setStore: SetStoreFunction<State>
+ push: (directory: string) => void
+ directory: string
+ loadLsp: () => void
+ vcsCache?: VcsCache
+}) {
+ const event = input.event
+ switch (event.type) {
+ case "server.instance.disposed": {
+ input.push(input.directory)
+ return
+ }
+ case "session.created": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
+ break
+ }
+ case "session.updated": {
+ 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) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ break
+ }
+ case "session.deleted": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ case "session.diff": {
+ const props = event.properties as { sessionID: string; diff: FileDiff[] }
+ input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+ break
+ }
+ case "todo.updated": {
+ const props = event.properties as { sessionID: string; todos: Todo[] }
+ input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+ break
+ }
+ case "session.status": {
+ const props = event.properties as { sessionID: string; status: SessionStatus }
+ input.setStore("session_status", props.sessionID, reconcile(props.status))
+ break
+ }
+ case "message.updated": {
+ const info = (event.properties as { info: Message }).info
+ const messages = input.store.message[info.sessionID]
+ if (!messages) {
+ input.setStore("message", info.sessionID, [info])
+ break
+ }
+ const result = Binary.search(messages, info.id, (m) => m.id)
+ if (result.found) {
+ input.setStore("message", info.sessionID, result.index, reconcile(info))
+ break
+ }
+ input.setStore(
+ "message",
+ info.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, info)
+ }),
+ )
+ break
+ }
+ case "message.removed": {
+ const props = event.properties as { sessionID: string; messageID: string }
+ input.setStore(
+ produce((draft) => {
+ const messages = draft.message[props.sessionID]
+ if (messages) {
+ const result = Binary.search(messages, props.messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[props.messageID]
+ }),
+ )
+ break
+ }
+ case "message.part.updated": {
+ const part = (event.properties as { part: Part }).part
+ const parts = input.store.part[part.messageID]
+ if (!parts) {
+ input.setStore("part", part.messageID, [part])
+ break
+ }
+ const result = Binary.search(parts, part.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("part", part.messageID, result.index, reconcile(part))
+ break
+ }
+ input.setStore(
+ "part",
+ part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, part)
+ }),
+ )
+ break
+ }
+ case "message.part.removed": {
+ const props = event.properties as { messageID: string; partID: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (result.found) {
+ input.setStore(
+ produce((draft) => {
+ const list = draft.part[props.messageID]
+ if (!list) return
+ const next = Binary.search(list, props.partID, (p) => p.id)
+ if (!next.found) return
+ list.splice(next.index, 1)
+ if (list.length === 0) delete draft.part[props.messageID]
+ }),
+ )
+ }
+ break
+ }
+ case "vcs.branch.updated": {
+ const props = event.properties as { branch: string }
+ const next = { branch: props.branch }
+ input.setStore("vcs", next)
+ if (input.vcsCache) input.vcsCache.setStore("value", next)
+ break
+ }
+ case "permission.asked": {
+ const permission = event.properties as PermissionRequest
+ const permissions = input.store.permission[permission.sessionID]
+ if (!permissions) {
+ input.setStore("permission", permission.sessionID, [permission])
+ break
+ }
+ const result = Binary.search(permissions, permission.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
+ break
+ }
+ input.setStore(
+ "permission",
+ permission.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, permission)
+ }),
+ )
+ break
+ }
+ case "permission.replied": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const permissions = input.store.permission[props.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, props.requestID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "permission",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "question.asked": {
+ const question = event.properties as QuestionRequest
+ const questions = input.store.question[question.sessionID]
+ if (!questions) {
+ input.setStore("question", question.sessionID, [question])
+ break
+ }
+ const result = Binary.search(questions, question.id, (q) => q.id)
+ if (result.found) {
+ input.setStore("question", question.sessionID, result.index, reconcile(question))
+ break
+ }
+ input.setStore(
+ "question",
+ question.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, question)
+ }),
+ )
+ break
+ }
+ case "question.replied":
+ case "question.rejected": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const questions = input.store.question[props.sessionID]
+ if (!questions) break
+ const result = Binary.search(questions, props.requestID, (q) => q.id)
+ if (!result.found) break
+ input.setStore(
+ "question",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "lsp.updated": {
+ input.loadLsp()
+ break
+ }
+ }
+}
diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts
new file mode 100644
index 000000000..676a6ee17
--- /dev/null
+++ b/packages/app/src/context/global-sync/eviction.ts
@@ -0,0 +1,28 @@
+import type { DisposeCheck, EvictPlan } from "./types"
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+ const overflow = Math.max(0, input.stores.length - input.max)
+ let pendingOverflow = overflow
+ const sorted = input.stores
+ .filter((dir) => !input.pins.has(dir))
+ .slice()
+ .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+ const output: string[] = []
+ for (const dir of sorted) {
+ const last = input.state.get(dir)?.lastAccessAt ?? 0
+ const idle = input.now - last >= input.ttl
+ if (!idle && pendingOverflow <= 0) continue
+ output.push(dir)
+ if (pendingOverflow > 0) pendingOverflow -= 1
+ }
+ return output
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+ if (!input.directory) return false
+ if (!input.hasStore) return false
+ if (input.pinned) return false
+ if (input.booting) return false
+ if (input.loadingSessions) return false
+ return true
+}
diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts
new file mode 100644
index 000000000..c3468583b
--- /dev/null
+++ b/packages/app/src/context/global-sync/queue.ts
@@ -0,0 +1,83 @@
+type QueueInput = {
+ paused: () => boolean
+ bootstrap: () => Promise<void>
+ bootstrapInstance: (directory: string) => Promise<void> | void
+}
+
+export function createRefreshQueue(input: QueueInput) {
+ const queued = new Set<string>()
+ let root = false
+ let running = false
+ let timer: ReturnType<typeof setTimeout> | undefined
+
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
+
+ const take = (count: number) => {
+ if (queued.size === 0) return [] as string[]
+ const items: string[] = []
+ for (const item of queued) {
+ queued.delete(item)
+ items.push(item)
+ if (items.length >= count) break
+ }
+ return items
+ }
+
+ const schedule = () => {
+ if (timer) return
+ timer = setTimeout(() => {
+ timer = undefined
+ void drain()
+ }, 0)
+ }
+
+ const push = (directory: string) => {
+ if (!directory) return
+ queued.add(directory)
+ if (input.paused()) return
+ schedule()
+ }
+
+ const refresh = () => {
+ root = true
+ if (input.paused()) return
+ schedule()
+ }
+
+ async function drain() {
+ if (running) return
+ running = true
+ try {
+ while (true) {
+ if (input.paused()) return
+ if (root) {
+ root = false
+ await input.bootstrap()
+ await tick()
+ continue
+ }
+ const dirs = take(2)
+ if (dirs.length === 0) return
+ await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
+ await tick()
+ }
+ } finally {
+ running = false
+ if (input.paused()) return
+ if (root || queued.size) schedule()
+ }
+ }
+
+ return {
+ push,
+ refresh,
+ clear(directory: string) {
+ queued.delete(directory)
+ },
+ dispose() {
+ if (!timer) return
+ clearTimeout(timer)
+ timer = undefined
+ },
+ }
+}
diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts
new file mode 100644
index 000000000..443aa8450
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-load.ts
@@ -0,0 +1,26 @@
+import type { RootLoadArgs } from "./types"
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+ try {
+ const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: true,
+ } as const
+ } catch {
+ input.onFallback()
+ const result = await input.list({ directory: input.directory, roots: true })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: false,
+ } as const
+ }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+ if (!input.limited) return input.count
+ if (input.count < input.limit) return input.count
+ return input.count + 1
+}
diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts
new file mode 100644
index 000000000..be12c074b
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { trimSessions } from "./session-trim"
+
+const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: input.created,
+ updated: input.updated,
+ archived: input.archived,
+ },
+ }) as Session
+
+describe("trimSessions", () => {
+ test("keeps base roots and recent roots beyond the limit", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "a", created: now - 100_000 }),
+ session({ id: "b", created: now - 90_000 }),
+ session({ id: "c", created: now - 80_000 }),
+ session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
+ session({ id: "e", created: now - 60_000, archived: now - 10 }),
+ ]
+
+ const result = trimSessions(list, { limit: 2, permission: {}, now })
+ expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
+ })
+
+ test("keeps children when root is kept, permission exists, or child is recent", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "root-1", created: now - 1000 }),
+ session({ id: "root-2", created: now - 2000 }),
+ session({ id: "z-root", created: now - 30_000_000 }),
+ session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
+ session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
+ ]
+
+ const result = trimSessions(list, {
+ limit: 2,
+ permission: {
+ "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
+ },
+ now,
+ })
+
+ expect(result.map((x) => x.id)).toEqual([
+ "child-kept-by-permission",
+ "child-kept-by-recency",
+ "child-kept-by-root",
+ "root-1",
+ "root-2",
+ ])
+ })
+})
diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts
new file mode 100644
index 000000000..800ba74a6
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.ts
@@ -0,0 +1,56 @@
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { cmp } from "./utils"
+import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
+
+export function sessionUpdatedAt(session: Session) {
+ return session.time.updated ?? session.time.created
+}
+
+export function compareSessionRecent(a: Session, b: Session) {
+ const aUpdated = sessionUpdatedAt(a)
+ const bUpdated = sessionUpdatedAt(b)
+ if (aUpdated !== bUpdated) return bUpdated - aUpdated
+ return cmp(a.id, b.id)
+}
+
+export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
+ if (limit <= 0) return [] as Session[]
+ const selected: Session[] = []
+ const seen = new Set<string>()
+ for (const session of sessions) {
+ if (!session?.id) continue
+ if (seen.has(session.id)) continue
+ seen.add(session.id)
+ if (sessionUpdatedAt(session) <= cutoff) continue
+ const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
+ if (index === -1) selected.push(session)
+ if (index !== -1) selected.splice(index, 0, session)
+ if (selected.length > limit) selected.pop()
+ }
+ return selected
+}
+
+export function trimSessions(
+ input: Session[],
+ options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
+) {
+ const limit = Math.max(0, options.limit)
+ const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+ const all = input
+ .filter((s) => !!s?.id)
+ .filter((s) => !s.time?.archived)
+ .sort((a, b) => cmp(a.id, b.id))
+ const roots = all.filter((s) => !s.parentID)
+ const children = all.filter((s) => !!s.parentID)
+ const base = roots.slice(0, limit)
+ const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+ const keepRoots = [...base, ...recent]
+ const keepRootIds = new Set(keepRoots.map((s) => s.id))
+ const keepChildren = children.filter((s) => {
+ if (s.parentID && keepRootIds.has(s.parentID)) return true
+ const perms = options.permission[s.id] ?? []
+ if (perms.length > 0) return true
+ return sessionUpdatedAt(s) > cutoff
+ })
+ return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
+}
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
new file mode 100644
index 000000000..ade0b973a
--- /dev/null
+++ b/packages/app/src/context/global-sync/types.ts
@@ -0,0 +1,134 @@
+import type {
+ Agent,
+ Command,
+ Config,
+ FileDiff,
+ LspStatus,
+ McpStatus,
+ Message,
+ Part,
+ Path,
+ PermissionRequest,
+ Project,
+ ProviderListResponse,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+ VcsInfo,
+} from "@opencode-ai/sdk/v2/client"
+import type { Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+export type ProjectMeta = {
+ name?: string
+ icon?: {
+ override?: string
+ color?: string
+ }
+ commands?: {
+ start?: string
+ }
+}
+
+export type State = {
+ status: "loading" | "partial" | "complete"
+ agent: Agent[]
+ command: Command[]
+ project: string
+ projectMeta: ProjectMeta | undefined
+ icon: string | undefined
+ provider: ProviderListResponse
+ config: Config
+ path: Path
+ session: Session[]
+ sessionTotal: number
+ session_status: {
+ [sessionID: string]: SessionStatus
+ }
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ permission: {
+ [sessionID: string]: PermissionRequest[]
+ }
+ question: {
+ [sessionID: string]: QuestionRequest[]
+ }
+ mcp: {
+ [name: string]: McpStatus
+ }
+ lsp: LspStatus[]
+ vcs: VcsInfo | undefined
+ limit: number
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+}
+
+export type VcsCache = {
+ store: Store<{ value: VcsInfo | undefined }>
+ setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type MetaCache = {
+ store: Store<{ value: ProjectMeta | undefined }>
+ setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type IconCache = {
+ store: Store<{ value: string | undefined }>
+ setStore: SetStoreFunction<{ value: string | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type ChildOptions = {
+ bootstrap?: boolean
+}
+
+export type DirState = {
+ lastAccessAt: number
+}
+
+export type EvictPlan = {
+ stores: string[]
+ state: Map<string, DirState>
+ pins: Set<string>
+ max: number
+ ttl: number
+ now: number
+}
+
+export type DisposeCheck = {
+ directory: string
+ hasStore: boolean
+ pinned: boolean
+ booting: boolean
+ loadingSessions: boolean
+}
+
+export type RootLoadArgs = {
+ directory: string
+ limit: number
+ list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+ onFallback: () => void
+}
+
+export type RootLoadResult = {
+ data?: Session[]
+ limit: number
+ limited: boolean
+}
+
+export const MAX_DIR_STORES = 30
+export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
+export const SESSION_RECENT_LIMIT = 50
diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts
new file mode 100644
index 000000000..6b78134a6
--- /dev/null
+++ b/packages/app/src/context/global-sync/utils.ts
@@ -0,0 +1,25 @@
+import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+
+export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
+export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
+ return {
+ ...input,
+ all: input.all.map((provider) => ({
+ ...provider,
+ models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
+ })),
+ }
+}
+
+export function sanitizeProject(project: Project) {
+ if (!project.icon?.url && !project.icon?.override) return project
+ return {
+ ...project,
+ icon: {
+ ...project.icon,
+ url: undefined,
+ override: undefined,
+ },
+ }
+}