import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, SessionStatus, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" import { ServerSDK } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, clearProviderRev, loadAgentsQuery, loadCommands, loadGlobalConfigQuery, loadPathQuery, loadProjectsQuery, loadProvidersQuery, loadReferencesQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" import { createDirSyncContext } from "./directory-sync" import { createSimpleContext } from "@opencode-ai/ui/context" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" import type { ServerApi } from "@/utils/server" import type { McpListInput, McpListOutput, McpResource, McpResourceCatalogInput, McpResourceCatalogOutput, McpServer, SessionActiveOutput, } from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" import { createServerSession, type ServerSession } from "./server-session" type GlobalStore = { ready: boolean error?: InitError path: Path project: Project[] provider: NormalizedProviderListResponse provider_auth: ProviderAuthResponse config: Config reload: undefined | "pending" | "complete" } type McpListApi = { readonly list: (input?: McpListInput) => Promise } type McpResourceApi = { readonly resource: { readonly catalog: (input?: McpResourceCatalogInput) => Promise } } type ApiQueryOptions = SolidQueryOptions & { initialData?: undefined queryKey: K } type SessionActiveApi = { readonly active: () => Promise } export const loadMcpQuery = ( scope: ServerScope, directory: string, api: McpListApi, legacy?: OpencodeClient, protocol?: Promise<"v1" | "v2">, ): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => queryOptions< Record, Error, Record, readonly [ServerScope, string, "mcp"] >({ queryKey: [scope, directory, "mcp"] as const, queryFn: async () => { if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {} return api .list({ location: { directory } }) .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) }, }) export const loadMcpResourcesQuery = ( scope: ServerScope, directory: string, api: McpResourceApi, legacy?: OpencodeClient, protocol?: Promise<"v1" | "v2">, ): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => queryOptions< Record, Error, Record, readonly [ServerScope, string, "mcpResources"] >({ queryKey: [scope, directory, "mcpResources"] as const, queryFn: async () => { if ((await protocol) === "v1" && legacy) { return Object.fromEntries( Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [ key, { ...resource, server: resource.client }, ]), ) } return api.resource .catalog({ location: { directory } }) .then((result) => Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), ) }, placeholderData: {}, }) export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "lsp"] as const, queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) export const loadActiveSessionsQuery = ( scope: ServerScope, api: SessionActiveApi, ): ApiQueryOptions => queryOptions({ queryKey: [scope, "activeSessions"] as const, queryFn: () => api.active(), enabled: true, staleTime: Number.POSITIVE_INFINITY, gcTime: Number.POSITIVE_INFINITY, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, }) export function seedActiveSessionStatuses( session: Pick, active: SessionActiveOutput | Record, ) { for (const sessionID of Object.keys(active)) { if (session.data.session_status[sessionID] !== undefined) continue const status = active[sessionID] session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) } } function makeQueryOptionsApi( scope: ServerScope, serverSDK: () => OpencodeClient, serverAPI: ServerApi, sdkFor: (dir: PathKey) => OpencodeClient, protocol: Promise<"v1" | "v2">, ) { return { globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol), projects: () => loadProjectsQuery(scope, serverAPI.project), providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), path: (directory: PathKey | null) => loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } } export type QueryOptionsApi = ReturnType export function createServerSyncContextInner(serverSDK: ServerSDK) { const language = useLanguage() const owner = getOwner() if (!owner) throw new Error("ServerSync must be created within owner") const sdkCache = new Map() const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() const sdkFor = (directory: string) => { const key = directoryKey(directory) const cached = sdkCache.get(key) if (cached) return cached const sdk = serverSDK.createClient({ directory, throwOnError: true, }) sdkCache.set(key, sdk) return sdk } const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { protocol: serverSDK.protocol, }) const queryOptionsApi = makeQueryOptionsApi( serverSDK.scope, () => serverSDK.client, serverSDK.api, sdkFor, serverSDK.protocol, ) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) const activeSessionsQuery = useQuery(() => loadActiveSessionsQuery(serverSDK.scope, { active: async () => { if ((await serverSDK.protocol) === "v1") { const statuses = (await serverSDK.client.session.status()).data ?? {} seedActiveSessionStatuses(session, statuses) for (const sessionID of Object.keys(statuses)) { void session.resolve(sessionID).catch(() => undefined) } return Object.fromEntries( Object.entries(statuses).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], ), ) } const active = await serverSDK.api.session.active() seedActiveSessionStatuses(session, active) for (const sessionID of Object.keys(active)) { void session.resolve(sessionID).catch(() => undefined) } return active }, }), ) const [globalStore, setGlobalStore] = createStore({ get ready() { return !bootstrap.isPending }, project: [], provider_auth: {}, get path() { const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } if (pathQuery.isLoading) return EMPTY return pathQuery.data ?? EMPTY }, get provider() { const EMPTY = { all: new Map(), connected: [], default: {} } if (providerQuery.isLoading) return EMPTY return providerQuery.data ?? EMPTY }, get config() { if (configQuery.isLoading) return {} return configQuery.data ?? {} }, get reload() { return updateConfigMutation.isPending ? "pending" : undefined }, }) const queryClient = useQueryClient() const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) const refreshProviders = () => queryClient.refetchQueries({ predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", }) let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { setGlobalStore("project", next) } const setBootStore = ((...input: unknown[]) => { if (input[0] === "project" && Array.isArray(input[1])) { setProjects(input[1] as Project[]) return input[1] } return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore const bootstrap = useQuery(() => ({ queryKey: [serverSDK.scope, "bootstrap"], queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, serverAPI: serverSDK.api, protocol: serverSDK.protocol, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), setGlobalStore: setBootStore, queryClient, }) bootedAt = Date.now() return bootedAt }, })) const set = ((...input: unknown[]) => { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) return input[1] } return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore const paused = () => untrack(() => globalStore.reload) !== undefined const queue = createRefreshQueue({ paused, key: directoryKey, bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }), bootstrapInstance, }) const children = createChildStoreManager({ owner, scope: serverSDK.scope, persist: persisted, isBooting: (directory) => booting.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory), onBootstrap: (directory) => { void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) .then((commands) => setStore("command", commands)) .catch((err) => { showToast({ variant: "error", title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), description: formatServerError(err, language.t), }) }) }, onDispose: (directory) => { const key = directoryKey(directory) queue.clear(key) sessionMeta.delete(key) sdkCache.delete(key) clearProviderRev(serverSDK.scope, key) }, translate: language.t, queryOptions: queryOptionsApi, global: { provider: globalStore.provider, }, }) async function loadSessions(directory: string, options?: { limit?: number }) { const key = directoryKey(directory) const pending = sessionLoads.get(key) if (pending) { await pending return loadSessions(directory, options) } children.pin(key) const [store, setStore] = children.child(directory, { bootstrap: false }) const meta = sessionMeta.get(key) const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0) if (meta && meta.limit >= retainedLimit) { const next = trimSessions(store.session, { limit: retainedLimit, permission: session.data.permission, }) if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) } children.unpin(key) return } const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => serverSDK.protocol .then((protocol) => protocol === "v1" ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) : loadRootSessions({ api: serverSDK.api.session, directory, limit }), ) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) const childSessions = store.session.filter((s) => !!s.parentID) const next = trimSessions([...nonArchived, ...childSessions], { limit, permission: session.data.permission, }) batch(() => { next.forEach(session.remember) setStore( "sessionTotal", estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited, }), ) setStore("session", reconcile(next, { key: "id" })) }) sessionMeta.set(key, { limit }) }) .catch((err) => { console.error("Failed to load sessions", err) const project = getFilename(directory) showToast({ variant: "error", title: language.t("toast.session.listFailed.title", { project }), description: formatServerError(err, language.t), }) }) .then(() => null), }) .then(() => {}) sessionLoads.set(key, promise) void promise.finally(() => { sessionLoads.delete(key) children.unpin(key) }) return promise } async function bootstrapInstance(directory: string) { const key = directoryKey(directory) if (!key) return const pending = booting.get(key) if (pending) return pending children.pin(key) const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) const cache = children.vcsCache.get(key) if (!cache) return const sdk = sdkFor(directory) await bootstrapDirectory({ directory, scope: serverSDK.scope, mcp: children.mcp(key), global: { config: globalStore.config, path: globalStore.path, project: globalStore.project, provider: globalStore.provider, }, sdk, api: serverSDK.api, store: child[0], setStore: child[1], vcsCache: cache, loadSessions, translate: language.t, queryClient, session, protocol: serverSDK.protocol, }) }) booting.set(key, promise) void promise.finally(() => { booting.delete(key) children.unpin(key) }) return promise } const indexSession = (info: Parameters[0]) => { const key = directoryKey(info.directory) const existing = children.children[key] if (!existing) return applyDirectoryEvent({ event: { type: "session.created", properties: { info } }, directory: key, store: existing[0], setStore: existing[1], push: queue.push, retainedLimit: sessionMeta.get(key)?.limit, sessionContent: false, permission: session.data.permission, loadLsp() {}, }) } const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 if (event.current) session.applyV2(event.current) session.apply(event) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) } homeSessions.refresh(event.type) if (eventType === "integration.connection.updated") void refreshProviders() if (directory === "global") { if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch() applyGlobalEvent({ event, project: globalStore.project, refresh: () => { if (recent) return bootstrap.refetch() }, setGlobalProject: setProjects, }) if ( eventType === "config.updated" || eventType === "catalog.updated" || eventType === "agent.updated" || eventType === "project.directories.updated" ) bootstrap.refetch() if (eventType === "server.connected" || eventType === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { if (!children.active(directory)) continue queue.push(directory) } } return } if (event.current?.type === "session.moved") { const info = session.get(event.current.data.sessionID) if (info) indexSession(info) } if (event.current?.type === "session.forked") void session .resolve(event.current.data.sessionID, { force: true }) .then(indexSession) .catch(() => {}) const existing = children.children[key] if (!existing) return children.mark(key) if ( event.current?.type === "session.moved" || // event.current?.type === "session.archived" || event.current?.type === "session.forked" || eventType === "command.updated" || eventType === "config.updated" || eventType === "agent.updated" ) queue.push(key) if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing applyDirectoryEvent({ event, directory, store, setStore, push: (directory) => { if (children.active(directory)) queue.push(directory) }, retainedLimit: sessionMeta.get(key)?.limit, sessionContent: false, permission: session.data.permission, vcsCache: children.vcsCache.get(key), loadLsp: () => { if (!children.active(key)) return void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, loadReferences: () => { if (!children.active(key)) return void queryClient.fetchQuery(queryOptionsApi.references(key)) }, }) }) onCleanup(unsub) onCleanup(() => { queue.dispose() }) onCleanup(() => { for (const directory of Object.keys(children.children)) { children.disposeDirectory(directoryKey(directory)) } }) onMount(() => { if (typeof requestAnimationFrame === "function") { eventFrame = requestAnimationFrame(() => { eventFrame = undefined eventTimer = setTimeout(() => { eventTimer = undefined void serverSDK.event.start() }, 0) }) } else { eventTimer = setTimeout(() => { eventTimer = undefined void serverSDK.event.start() }, 0) } }) const projectApi = { loadSessions, meta(directory: string, patch: ProjectMeta) { children.projectMeta(directory, patch) }, icon(directory: string, value: string | undefined) { children.projectIcon(directory, value) }, } const updateConfigMutation = useMutation(() => ({ mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), onSuccess: () => { bootstrap.refetch() // Invalidate all provider queries so newly configured custom providers // appear immediately in the available provider list across all directories. queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", }) }, })) return { data: globalStore, set, get ready() { return globalStore.ready }, get error() { return globalStore.error }, child: children.child, peek: children.peek, disableMcp: children.disableMcp, queryOptions: queryOptionsApi, refreshProviders, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, session, homeSessions, mcp: { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) const sdk = sdkFor(key) const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status if (!status) return await toggleMcp({ status, connect: async () => { if ((await serverSDK.protocol) === "v1") { await sdk.mcp.connect({ name }) return } await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) }, disconnect: async () => { if ((await serverSDK.protocol) === "v1") { await sdk.mcp.disconnect({ name }) return } await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) }, authenticate: async () => { await sdk.mcp.auth.authenticate({ name }) }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) }, }) }, }, } } export function createServerSyncContext(serverSDK: ServerSDK) { const inner = createServerSyncContextInner(serverSDK) return Object.assign(inner, { ensureDirSyncContext: createRefCountMap( (dir) => createDirSyncContext(dir, inner, serverSDK), (dir) => inner.disableMcp(dir), directoryKey, ), }) } export type ServerSync = ReturnType export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ name: "ServerSync", // Returns an accessor so the resolved server can change reactively without // re-instantiating the subtree (mirrors useServerSDK). init: (props: { server?: Accessor }) => { const global = useGlobal() const language = useLanguage() const server = useServer() return createMemo(() => { const conn = props.server?.() ?? server.current if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) return global.ensureServerCtx(conn).sync }) }, }) export function useQueryOptions() { const sync = useServerSync() return createMemo(() => sync().queryOptions) }