From 5e76ab2094ae90e13d90730690e8332298642e1f Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 22 Apr 2026 13:49:37 +0800 Subject: [PATCH 1/3] feat(app): configure TanStack Query client with default options Add defaultOptions to QueryClient to disable automatic refetching: - refetchOnReconnect: false - refetchOnMount: false - refetchOnWindowFocus: false --- packages/app/src/app.tsx | 10 +- .../app/src/components/dialog-select-mcp.tsx | 2 +- .../src/components/status-popover-body.tsx | 42 ---- packages/app/src/context/global-sync.tsx | 192 +++++++++------- .../app/src/context/global-sync/bootstrap.ts | 209 +++++++++--------- .../src/context/global-sync/child-store.ts | 27 ++- 6 files changed, 251 insertions(+), 231 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 18c6fef30a9e..bf8138fcdeae 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -82,7 +82,15 @@ declare global { } function QueryProvider(props: ParentProps) { - const client = new QueryClient() + const client = new QueryClient({ + defaultOptions: { + queries: { + refetchOnReconnect: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + }, + }) return {props.children} } diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 98f262ce5a32..0f5aebc6d158 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -47,7 +47,7 @@ export const DialogSelectMcp: Component = () => { .status() .then((result) => { sync.set("mcp", result.data ?? {}) - sync.set("mcp_ready", true) + // sync.set("mcp_ready", true) setState("done", true) }) .catch((err) => { diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 0f6a1c1355f0..f2cdd1a6a427 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -162,14 +162,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() - const sdk = useSDK() - - const [load, setLoad] = createStore({ - lspDone: false, - lspLoading: false, - mcpDone: false, - mcpLoading: false, - }) const fail = (err: unknown) => { showToast({ @@ -181,40 +173,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { createEffect(() => { if (!props.shown()) return - - if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) { - setLoad("mcpLoading", true) - void sdk.client.mcp - .status() - .then((result) => { - sync.set("mcp", result.data ?? {}) - sync.set("mcp_ready", true) - }) - .catch((err) => { - setLoad("mcpDone", true) - fail(err) - }) - .finally(() => { - setLoad("mcpLoading", false) - }) - } - - if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) { - setLoad("lspLoading", true) - void sdk.client.lsp - .status() - .then((result) => { - sync.set("lsp", result.data ?? []) - sync.set("lsp_ready", true) - }) - .catch((err) => { - setLoad("lspDone", true) - fail(err) - }) - .finally(() => { - setLoad("lspLoading", false) - }) - } }) let dialogRun = 0 diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 86496bad50c2..5e2f1864164d 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -10,21 +10,30 @@ import type { import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/core/util/path" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" +import { createStore, produce, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" +import { Persist, persisted } from "@/utils/persist" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" -import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" +import { + bootstrapDirectory, + bootstrapGlobal, + clearProviderRev, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, +} from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" -import { createRefreshQueue } from "./global-sync/queue" import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } 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 { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" -import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" +import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" type GlobalStore = { ready: boolean @@ -43,6 +52,18 @@ type GlobalStore = { export const loadSessionsQuery = (directory: string) => queryOptions({ queryKey: [directory, "loadSessions"], queryFn: skipToken }) +export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "mcp"], + queryFn: sdk ? () => sdk.mcp.status().then((r) => r.data ?? {}) : skipToken, + }) + +export const loadLspQuery = (directory: string, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "lsp"], + queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? {}) : skipToken, + }) + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -54,30 +75,68 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const [projectCache, setProjectCache, projectInit] = persisted( + Persist.global("globalSync.project", ["globalSync.project.v1"]), + createStore({ value: [] as Project[] }), + ) + + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ + queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()], + })) + const [globalStore, setGlobalStore] = createStore({ - ready: false, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, - project: [], + get ready() { + return bootstrap.isPending + }, + project: projectCache.value, session_todo: {}, - provider: { all: [], connected: [], default: {} }, provider_auth: {}, - config: {}, - reload: undefined, + get path() { + const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } + if (pathQuery.isLoading) return EMPTY + return pathQuery.data ?? EMPTY + }, + get provider() { + const EMPTY = { all: [], 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() + let active = true + let projectWritten = false let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined + onCleanup(() => { + active = false + }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) + const cacheProjects = () => { + setProjectCache( + "value", + untrack(() => globalStore.project.map(sanitizeProject)), + ) + } + const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { + projectWritten = true setGlobalStore("project", next) + cacheProjects() } const setBootStore = ((...input: unknown[]) => { @@ -88,6 +147,22 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore + const bootstrap = useQuery(() => ({ + queryKey: ["bootstrap"], + queryFn: async () => { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + 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[])) @@ -96,6 +171,16 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore + if (projectInit instanceof Promise) { + void projectInit.then(() => { + if (!active) return + if (projectWritten) return + const cached = projectCache.value + if (cached.length === 0) return + setGlobalStore("project", cached) + }) + } + const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return if (!todos) { @@ -112,11 +197,11 @@ function createGlobalSync() { const paused = () => untrack(() => globalStore.reload) !== undefined - const queue = createRefreshQueue({ - paused, - bootstrap, - bootstrapInstance, - }) + // const queue = createRefreshQueue({ + // paused, + // bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), + // bootstrapInstance, + // }) const children = createChildStoreManager({ owner, @@ -126,7 +211,7 @@ function createGlobalSync() { void bootstrapInstance(directory) }, onDispose: (directory) => { - queue.clear(directory) + // queue.clear(directory) sessionMeta.delete(directory) sdkCache.delete(directory) clearProviderRev(directory) @@ -264,33 +349,20 @@ function createGlobalSync() { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 - if (event.type === "session.error") { - const error = event.properties.error - if (error?.name !== "MessageAbortedError") { - console.error("[global-sync] session error", { - scope: directory === "global" ? "global" : "workspace", - directory: directory === "global" ? undefined : directory, - project: directory === "global" ? undefined : getFilename(directory), - sessionID: event.properties.sessionID, - error, - }) - } - } - if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, refresh: () => { if (recent) return - queue.refresh() + bootstrap.refetch() }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { - queue.push(directory) + // queue.push(directory) } } return @@ -305,47 +377,27 @@ function createGlobalSync() { directory, store, setStore, - push: queue.push, + push: () => {}, // queue.push, setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - void sdkFor(directory) - .lsp.status() - .then((x) => { - setStore("lsp", x.data ?? []) - setStore("lsp_ready", true) - }) + void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))).then((data) => { + setStore("lsp", data ?? []) + }) }, }) }) onCleanup(unsub) - onCleanup(() => { - queue.dispose() - }) + // onCleanup(() => { + // queue.dispose() + // }) onCleanup(() => { for (const directory of Object.keys(children.children)) { children.disposeDirectory(directory) } }) - async function bootstrap() { - bootingRoot = true - try { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - queryClient, - }) - bootedAt = Date.now() - } finally { - bootingRoot = false - } - } - onMount(() => { if (typeof requestAnimationFrame === "function") { eventFrame = requestAnimationFrame(() => { @@ -361,7 +413,6 @@ function createGlobalSync() { void globalSDK.event.start() }, 0) } - void bootstrap() }) const projectApi = { @@ -374,21 +425,10 @@ function createGlobalSync() { }, } - const updateConfig = async (config: Config) => { - setGlobalStore("reload", "pending") - return globalSDK.client.global.config - .update({ config }) - .then(bootstrap) - .then(() => { - queue.refresh() - setGlobalStore("reload", undefined) - queue.refresh() - }) - .catch((error) => { - setGlobalStore("reload", undefined) - throw error - }) - } + const updateConfigMutation = useMutation(() => ({ + mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), + // onSuccess: () => bootstrap.refetch(), + })) return { data: globalStore, @@ -401,8 +441,8 @@ function createGlobalSync() { }, child: children.child, peek: children.peek, - bootstrap, - updateConfig, + // bootstrap, + updateConfig: updateConfigMutation.mutateAsync, project: projectApi, todo: { set: setSessionTodo, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index a83030fad25f..e496984d653a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,6 +19,7 @@ import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" +import { loadMcpQuery } from "../global-sync" type GlobalStore = { ready: boolean @@ -66,6 +67,62 @@ function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + +export const loadGlobalConfigQuery = ( + sdk?: OpencodeClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: ["config"], + queryFn: sdk + ? () => + retry(() => + sdk.global.config.get().then((x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, + }) + +export const loadProjectsQuery = ( + sdk?: OpencodeClient, + transform?: (x: Awaited>["data"]) => void, +) => + queryOptions({ + queryKey: ["project"], + queryFn: sdk + ? () => + retry(() => + sdk.project + .list() + .then((x) => { + return (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + }) + .then(transform), + ) + : skipToken, + }) + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -74,61 +131,21 @@ export async function bootstrapGlobal(input: { setGlobalStore: SetStoreFunction queryClient: QueryClient }) { - const fast = [ - () => - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", reconcile(x.data!, { merge: false })) - }), - ), - ] - const slow = [ + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)), + () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)), + () => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)), () => - input.queryClient.fetchQuery({ - ...loadProvidersQuery(null), - queryFn: () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - return null - }), - ), - }), - () => - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", 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) - }), + input.queryClient.fetchQuery( + loadProjectsQuery(input.globalSDK, (data) => input.setGlobalStore("project", data ?? [])), ), ] - await runAll(fast) - // showErrors({ - // errors: errors(await runAll(fast)), - // title: input.requestFailedTitle, - // translate: input.translate, - // formatMoreCount: input.formatMoreCount, - // }) - await waitForPaint() - await runAll(slow) - // showErrors({ - // errors: errors(), - // title: input.requestFailedTitle, - // translate: input.translate, - // formatMoreCount: input.formatMoreCount, - // }) - input.setGlobalStore("ready", true) + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) } function groupBySession(input: T[]) { @@ -179,26 +196,28 @@ function warmSessions(input: { ).then(() => undefined) } -export const loadProvidersQuery = (directory: string | null) => - queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken }) +export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) => + queryOptions({ + queryKey: [directory, "providers"], + queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken, + }) export const loadAgentsQuery = ( directory: string | null, sdk?: OpencodeClient, transform?: (x: Awaited>) => void, ) => - queryOptions({ + queryOptions({ queryKey: [directory, "agents"], - queryFn: - sdk && transform - ? () => - retry(() => - sdk.app - .agents() - .then(transform) - .then(() => null), - ) - : skipToken, + queryFn: sdk + ? () => + retry(() => + sdk.app.agents().then((x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, }) export const loadPathQuery = ( @@ -208,16 +227,15 @@ export const loadPathQuery = ( ) => queryOptions({ queryKey: [directory, "path"], - queryFn: - sdk && transform - ? () => - retry(() => - sdk.path.get().then(async (x) => { - transform(x) - return x.data! - }), - ) - : skipToken, + queryFn: sdk + ? () => + retry(() => + sdk.path.get().then(async (x) => { + transform?.(x) + return x.data! + }), + ) + : skipToken, }) export async function bootstrapDirectory(input: { @@ -247,12 +265,7 @@ export async function bootstrapDirectory(input: { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } - if (loading || input.store.provider.all.length === 0) { - input.setStore("provider_ready", false) - } - input.setStore("mcp_ready", false) input.setStore("mcp", {}) - input.setStore("lsp_ready", false) input.setStore("lsp", []) if (loading) input.setStore("status", "partial") @@ -340,34 +353,20 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), + () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), () => - retry(() => - input.sdk.mcp.status().then((x) => { - input.setStore("mcp", x.data!) - input.setStore("mcp_ready", true) - }), + input.queryClient.ensureQueryData( + loadProvidersQuery(input.directory, input.sdk), + // .catch((err) => { + // if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) + // const project = getFilename(input.directory) + // showToast({ + // variant: "error", + // title: input.translate("toast.project.reloadFailed.title", { project }), + // description: formatServerError(err, input.translate), + // }) + // }) ), - () => - input.queryClient.ensureQueryData({ - ...loadProvidersQuery(input.directory), - queryFn: () => - retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }) - .then(() => null), - }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index f3b613a7f248..10704f35ab79 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,8 +14,9 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" -import { useQuery } from "@tanstack/solid-query" -import { loadPathQuery } from "./bootstrap" +import { useQueries } from "@tanstack/solid-query" +import { loadPathQuery, loadProvidersQuery } from "./bootstrap" +import { loadLspQuery, loadMcpQuery } from "../global-sync" export function createChildStoreManager(input: { owner: Owner @@ -158,12 +159,22 @@ export function createChildStoreManager(input: { createRoot((dispose) => { const initialIcon = icon[0].value - const pathQuery = useQuery(() => loadPathQuery(directory)) + const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ + queries: [ + loadPathQuery(directory), + loadMcpQuery(directory), + loadLspQuery(directory), + loadProvidersQuery(directory), + ], + })) + const child = createStore({ project: "", projectMeta: undefined, icon: initialIcon, - provider_ready: false, + get provider_ready() { + return providerQuery.isLoading + }, provider: { all: [], connected: [], default: {} }, config: {}, get path() { @@ -181,9 +192,13 @@ export function createChildStoreManager(input: { todo: {}, permission: {}, question: {}, - mcp_ready: false, + get mcp_ready() { + return mcpQuery.isLoading + }, mcp: {}, - lsp_ready: false, + get lsp_ready() { + return lspQuery.isLoading + }, lsp: [], vcs: vcsStore.value, limit: 5, From 3d25d7126465668d65e69aa35609c1e4d2a09b8d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 22 Apr 2026 14:25:27 +0800 Subject: [PATCH 2/3] simplify mcp loading --- .../app/src/components/dialog-select-mcp.tsx | 62 ++----------- .../src/components/status-popover-body.tsx | 7 +- packages/app/src/context/global-sync.tsx | 89 ++++++------------- .../app/src/context/global-sync/bootstrap.ts | 22 ++--- .../context/global-sync/child-store.test.ts | 1 + .../src/context/global-sync/child-store.ts | 29 ++++-- 6 files changed, 70 insertions(+), 140 deletions(-) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 0f5aebc6d158..9bb36d32d838 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,13 +1,12 @@ -import { useMutation } from "@tanstack/solid-query" -import { Component, createEffect, createMemo, on, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { useMutation, useQueryClient } from "@tanstack/solid-query" +import { Component, createMemo, Show } from "solid-js" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" -import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" +import { loadMcpQuery } from "@/context/global-sync" const statusLabels = { connected: "mcp.status.connected", @@ -20,48 +19,7 @@ export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() - const [state, setState] = createStore({ - done: false, - loading: false, - }) - - createEffect( - on( - () => sync.data.mcp_ready, - (ready, prev) => { - if (!ready && prev) setState("done", false) - }, - { defer: true }, - ), - ) - - createEffect(() => { - if (state.done || state.loading) return - if (sync.data.mcp_ready) { - setState("done", true) - return - } - - setState("loading", true) - void sdk.client.mcp - .status() - .then((result) => { - sync.set("mcp", result.data ?? {}) - // sync.set("mcp_ready", true) - setState("done", true) - }) - .catch((err) => { - setState("done", true) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - }) - .finally(() => { - setState("loading", false) - }) - }) + const queryClient = useQueryClient() const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -71,16 +29,10 @@ export const DialogSelectMcp: Component = () => { const toggle = useMutation(() => ({ mutationFn: async (name: string) => { - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) - } - - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) + if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) + else await sdk.client.mcp.connect({ name }) }, + onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index f2cdd1a6a427..952e3eac64a0 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Icon } from "@opencode-ai/ui/icon" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" -import { useMutation } from "@tanstack/solid-query" +import { useMutation, useQueryClient } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" @@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { loadMcpQuery } from "@/context/global-sync" const pollMs = 10_000 @@ -137,14 +138,14 @@ const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() + const queryClient = useQueryClient() return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) }, + onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }), onError: (err) => { showToast({ variant: "error", diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 5e2f1864164d..2c80f31b19ba 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -10,9 +10,8 @@ import type { import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/core/util/path" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" -import { createStore, produce, reconcile, unwrap } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" -import { Persist, persisted } from "@/utils/persist" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" import { @@ -31,9 +30,9 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" -import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createRefreshQueue } from "./global-sync/queue" type GlobalStore = { ready: boolean @@ -61,7 +60,7 @@ export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) => export const loadLspQuery = (directory: string, sdk?: OpencodeClient) => queryOptions({ queryKey: [directory, "lsp"], - queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? {}) : skipToken, + queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? []) : skipToken, }) function createGlobalSync() { @@ -75,11 +74,6 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() - const [projectCache, setProjectCache, projectInit] = persisted( - Persist.global("globalSync.project", ["globalSync.project.v1"]), - createStore({ value: [] as Project[] }), - ) - const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()], })) @@ -88,7 +82,7 @@ function createGlobalSync() { get ready() { return bootstrap.isPending }, - project: projectCache.value, + project: [], session_todo: {}, provider_auth: {}, get path() { @@ -111,32 +105,18 @@ function createGlobalSync() { }) const queryClient = useQueryClient() - let active = true - let projectWritten = false let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined - onCleanup(() => { - active = false - }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) - const cacheProjects = () => { - setProjectCache( - "value", - untrack(() => globalStore.project.map(sanitizeProject)), - ) - } - const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { - projectWritten = true setGlobalStore("project", next) - cacheProjects() } const setBootStore = ((...input: unknown[]) => { @@ -171,16 +151,6 @@ function createGlobalSync() { return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore - if (projectInit instanceof Promise) { - void projectInit.then(() => { - if (!active) return - if (projectWritten) return - const cached = projectCache.value - if (cached.length === 0) return - setGlobalStore("project", cached) - }) - } - const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return if (!todos) { @@ -197,11 +167,22 @@ function createGlobalSync() { const paused = () => untrack(() => globalStore.reload) !== undefined - // const queue = createRefreshQueue({ - // paused, - // bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), - // bootstrapInstance, - // }) + const queue = createRefreshQueue({ + paused, + bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), + bootstrapInstance, + }) + + const sdkFor = (directory: string) => { + const cached = sdkCache.get(directory) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(directory, sdk) + return sdk + } const children = createChildStoreManager({ owner, @@ -211,26 +192,16 @@ function createGlobalSync() { void bootstrapInstance(directory) }, onDispose: (directory) => { - // queue.clear(directory) + queue.clear(directory) sessionMeta.delete(directory) sdkCache.delete(directory) clearProviderRev(directory) clearSessionPrefetchDirectory(directory) }, translate: language.t, + getSdk: sdkFor, }) - const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) - if (cached) return cached - const sdk = globalSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(directory, sdk) - return sdk - } - async function loadSessions(directory: string) { const pending = sessionLoads.get(directory) if (pending) return pending @@ -362,7 +333,7 @@ function createGlobalSync() { if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { - // queue.push(directory) + queue.push(directory) } } return @@ -377,21 +348,19 @@ function createGlobalSync() { directory, store, setStore, - push: () => {}, // queue.push, + push: queue.push, setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))).then((data) => { - setStore("lsp", data ?? []) - }) + void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))) }, }) }) onCleanup(unsub) - // onCleanup(() => { - // queue.dispose() - // }) + onCleanup(() => { + queue.dispose() + }) onCleanup(() => { for (const directory of Object.keys(children.children)) { children.disposeDirectory(directory) @@ -427,7 +396,7 @@ function createGlobalSync() { const updateConfigMutation = useMutation(() => ({ mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), - // onSuccess: () => bootstrap.refetch(), + onSuccess: () => bootstrap.refetch(), })) return { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index e496984d653a..af77ffe4327f 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -265,8 +265,6 @@ export async function bootstrapDirectory(input: { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } - input.setStore("mcp", {}) - input.setStore("lsp", []) if (loading) input.setStore("status", "partial") const rev = (providerRev.get(input.directory) ?? 0) + 1 @@ -355,18 +353,14 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), () => - input.queryClient.ensureQueryData( - loadProvidersQuery(input.directory, input.sdk), - // .catch((err) => { - // if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) - // const project = getFilename(input.directory) - // showToast({ - // variant: "error", - // title: input.translate("toast.project.reloadFailed.title", { project }), - // description: formatServerError(err, input.translate), - // }) - // }) - ), + input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() 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 eee763f16dee..24b4a465002d 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -22,6 +22,7 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, + getSdk: () => null!, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 10704f35ab79..d3b82894a46c 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, 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 type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -25,6 +25,7 @@ export function createChildStoreManager(input: { onBootstrap: (directory: string) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string + getSdk: (directory: string) => OpencodeClient }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -157,20 +158,23 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { + const sdk = input.getSdk(directory) + + const initialMeta = meta[0].value const initialIcon = icon[0].value const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(directory), - loadMcpQuery(directory), - loadLspQuery(directory), - loadProvidersQuery(directory), + loadPathQuery(directory, sdk), + loadMcpQuery(directory, sdk), + loadLspQuery(directory, sdk), + loadProvidersQuery(directory, sdk), ], })) const child = createStore({ project: "", - projectMeta: undefined, + projectMeta: initialMeta, icon: initialIcon, get provider_ready() { return providerQuery.isLoading @@ -195,11 +199,15 @@ export function createChildStoreManager(input: { get mcp_ready() { return mcpQuery.isLoading }, - mcp: {}, + get mcp() { + return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) + }, get lsp_ready() { return lspQuery.isLoading }, - lsp: [], + get lsp() { + return lspQuery.isLoading ? [] : (lspQuery.data ?? []) + }, vcs: vcsStore.value, limit: 5, message: {}, @@ -222,6 +230,11 @@ export function createChildStoreManager(input: { child[1]("vcs", (value) => value ?? cached) }) + onPersistedInit(meta[2], () => { + if (child[0].projectMeta !== initialMeta) return + child[1]("projectMeta", meta[0].value) + }) + onPersistedInit(icon[2], () => { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) From c3418c9fb315db6f55ed3745b5b71df03359e2ee Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Apr 2026 12:16:27 +0800 Subject: [PATCH 3/3] supress bootstrap errors --- packages/app/src/context/global-sync/bootstrap.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index af77ffe4327f..451531835dec 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -140,12 +140,13 @@ export async function bootstrapGlobal(input: { loadProjectsQuery(input.globalSDK, (data) => input.setGlobalStore("project", data ?? [])), ), ] - showErrors({ - errors: errors(await runAll(slow)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) + await runAll(slow) + // showErrors({ + // errors: errors(), + // title: input.requestFailedTitle, + // translate: input.translate, + // formatMoreCount: input.formatMoreCount, + // }) } function groupBySession(input: T[]) {