simplify mcp loading

This commit is contained in:
Brendan Allan 2026-04-22 14:25:27 +08:00
commit e8f56bace1
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
6 changed files with 70 additions and 140 deletions

View file

@ -1,13 +1,12 @@
import { useMutation } from "@tanstack/solid-query" import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { Component, createEffect, createMemo, on, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { loadMcpQuery } from "@/context/global-sync"
const statusLabels = { const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
@ -20,48 +19,7 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const [state, setState] = createStore({ const queryClient = useQueryClient()
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 items = createMemo(() => const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
@ -71,16 +29,10 @@ export const DialogSelectMcp: Component = () => {
const toggle = useMutation(() => ({ const toggle = useMutation(() => ({
mutationFn: async (name: string) => { mutationFn: async (name: string) => {
const status = sync.data.mcp[name] if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
if (status?.status === "connected") { else await sdk.client.mcp.connect({ name })
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)
}, },
onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }),
})) }))
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)

View file

@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" 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 { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" 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 { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { loadMcpQuery } from "@/context/global-sync"
const pollMs = 10_000 const pollMs = 10_000
@ -137,14 +138,14 @@ const useMcpToggleMutation = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const queryClient = useQueryClient()
return useMutation(() => ({ return useMutation(() => ({
mutationFn: async (name: string) => { mutationFn: async (name: string) => {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ 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) => { onError: (err) => {
showToast({ showToast({
variant: "error", variant: "error",

View file

@ -10,9 +10,8 @@ import type {
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path" import { getFilename } from "@opencode-ai/shared/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" 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 { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { import {
@ -31,9 +30,9 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } 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 { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@ -61,7 +60,7 @@ export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) =>
export const loadLspQuery = (directory: string, sdk?: OpencodeClient) => export const loadLspQuery = (directory: string, sdk?: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [directory, "lsp"], 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() { function createGlobalSync() {
@ -75,11 +74,6 @@ function createGlobalSync() {
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()], queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()],
})) }))
@ -88,7 +82,7 @@ function createGlobalSync() {
get ready() { get ready() {
return bootstrap.isPending return bootstrap.isPending
}, },
project: projectCache.value, project: [],
session_todo: {}, session_todo: {},
provider_auth: {}, provider_auth: {},
get path() { get path() {
@ -111,32 +105,18 @@ function createGlobalSync() {
}) })
const queryClient = useQueryClient() const queryClient = useQueryClient()
let active = true
let projectWritten = false
let bootedAt = 0 let bootedAt = 0
let bootingRoot = false let bootingRoot = false
let eventFrame: number | undefined let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => { onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer) if (eventTimer !== undefined) clearTimeout(eventTimer)
}) })
const cacheProjects = () => {
setProjectCache(
"value",
untrack(() => globalStore.project.map(sanitizeProject)),
)
}
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
projectWritten = true
setGlobalStore("project", next) setGlobalStore("project", next)
cacheProjects()
} }
const setBootStore = ((...input: unknown[]) => { const setBootStore = ((...input: unknown[]) => {
@ -171,16 +151,6 @@ function createGlobalSync() {
return (setGlobalStore as (...args: unknown[]) => unknown)(...input) return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore }) 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) => { const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return if (!sessionID) return
if (!todos) { if (!todos) {
@ -197,27 +167,10 @@ function createGlobalSync() {
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
// const queue = createRefreshQueue({ const queue = createRefreshQueue({
// paused, paused,
// bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
// bootstrapInstance, bootstrapInstance,
// })
const children = createChildStoreManager({
owner,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
onDispose: (directory) => {
// queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearProviderRev(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
}) })
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
@ -231,6 +184,24 @@ function createGlobalSync() {
return sdk return sdk
} }
const children = createChildStoreManager({
owner,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
onDispose: (directory) => {
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearProviderRev(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
getSdk: sdkFor,
})
async function loadSessions(directory: string) { async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory) const pending = sessionLoads.get(directory)
if (pending) return pending if (pending) return pending
@ -362,7 +333,7 @@ function createGlobalSync() {
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return if (recent) return
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
// queue.push(directory) queue.push(directory)
} }
} }
return return
@ -377,21 +348,19 @@ function createGlobalSync() {
directory, directory,
store, store,
setStore, setStore,
push: () => {}, // queue.push, push: queue.push,
setSessionTodo, setSessionTodo,
vcsCache: children.vcsCache.get(directory), vcsCache: children.vcsCache.get(directory),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))).then((data) => { void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
setStore("lsp", data ?? [])
})
}, },
}) })
}) })
onCleanup(unsub) onCleanup(unsub)
// onCleanup(() => { onCleanup(() => {
// queue.dispose() queue.dispose()
// }) })
onCleanup(() => { onCleanup(() => {
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directory) children.disposeDirectory(directory)
@ -427,7 +396,7 @@ function createGlobalSync() {
const updateConfigMutation = useMutation(() => ({ const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }), mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
// onSuccess: () => bootstrap.refetch(), onSuccess: () => bootstrap.refetch(),
})) }))
return { return {

View file

@ -265,8 +265,6 @@ export async function bootstrapDirectory(input: {
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config) input.setStore("config", input.global.config)
} }
input.setStore("mcp", {})
input.setStore("lsp", [])
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const rev = (providerRev.get(input.directory) ?? 0) + 1 const rev = (providerRev.get(input.directory) ?? 0) + 1
@ -354,18 +352,14 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)), () => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
() => () =>
input.queryClient.ensureQueryData( input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
loadProvidersQuery(input.directory, input.sdk), const project = getFilename(input.directory)
// .catch((err) => { showToast({
// if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) variant: "error",
// const project = getFilename(input.directory) title: input.translate("toast.project.reloadFailed.title", { project }),
// showToast({ description: formatServerError(err, input.translate),
// variant: "error", })
// title: input.translate("toast.project.reloadFailed.title", { project }), }),
// description: formatServerError(err, input.translate),
// })
// })
),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()

View file

@ -22,6 +22,7 @@ describe("createChildStoreManager", () => {
onBootstrap() {}, onBootstrap() {},
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
getSdk: () => null!,
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {

View file

@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" 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 { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@ -25,6 +25,7 @@ export function createChildStoreManager(input: {
onBootstrap: (directory: string) => void onBootstrap: (directory: string) => void
onDispose: (directory: string) => void onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
getSdk: (directory: string) => OpencodeClient
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>() const vcsCache = new Map<string, VcsCache>()
@ -157,20 +158,23 @@ export function createChildStoreManager(input: {
const init = () => const init = () =>
createRoot((dispose) => { createRoot((dispose) => {
const sdk = input.getSdk(directory)
const initialMeta = meta[0].value
const initialIcon = icon[0].value const initialIcon = icon[0].value
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [ queries: [
loadPathQuery(directory), loadPathQuery(directory, sdk),
loadMcpQuery(directory), loadMcpQuery(directory, sdk),
loadLspQuery(directory), loadLspQuery(directory, sdk),
loadProvidersQuery(directory), loadProvidersQuery(directory, sdk),
], ],
})) }))
const child = createStore<State>({ const child = createStore<State>({
project: "", project: "",
projectMeta: undefined, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return providerQuery.isLoading return providerQuery.isLoading
@ -195,11 +199,15 @@ export function createChildStoreManager(input: {
get mcp_ready() { get mcp_ready() {
return mcpQuery.isLoading return mcpQuery.isLoading
}, },
mcp: {}, get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get lsp_ready() { get lsp_ready() {
return lspQuery.isLoading return lspQuery.isLoading
}, },
lsp: [], get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
},
vcs: vcsStore.value, vcs: vcsStore.value,
limit: 5, limit: 5,
message: {}, message: {},
@ -222,6 +230,11 @@ export function createChildStoreManager(input: {
child[1]("vcs", (value) => value ?? cached) child[1]("vcs", (value) => value ?? cached)
}) })
onPersistedInit(meta[2], () => {
if (child[0].projectMeta !== initialMeta) return
child[1]("projectMeta", meta[0].value)
})
onPersistedInit(icon[2], () => { onPersistedInit(icon[2], () => {
if (child[0].icon !== initialIcon) return if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value) child[1]("icon", icon[0].value)