|
|
@@ -9,6 +9,20 @@ import type {
|
|
|
ReferenceInfo,
|
|
|
Session,
|
|
|
} from "@opencode-ai/sdk/v2/client"
|
|
|
+import type {
|
|
|
+ AgentListInput,
|
|
|
+ AgentListOutput,
|
|
|
+ CatalogApi,
|
|
|
+ CommandInfo,
|
|
|
+ CommandListInput,
|
|
|
+ CommandListOutput,
|
|
|
+ ProjectCurrentInput,
|
|
|
+ ProjectCurrentOutput,
|
|
|
+ ProjectListOutput,
|
|
|
+ ReferenceListInput,
|
|
|
+ ReferenceListOutput,
|
|
|
+ SessionApi,
|
|
|
+} from "@opencode-ai/client/promise"
|
|
|
import { showToast } from "@/utils/toast"
|
|
|
import { getFilename } from "@opencode-ai/core/util/path"
|
|
|
import { retry } from "@opencode-ai/core/util/retry"
|
|
|
@@ -16,12 +30,21 @@ import { batch } from "solid-js"
|
|
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
|
|
import type { State, VcsCache } from "./types"
|
|
|
import type { ServerSession } from "../server-session"
|
|
|
-import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
|
|
+import {
|
|
|
+ cmp,
|
|
|
+ normalizeAgentList,
|
|
|
+ normalizePermissionRequest,
|
|
|
+ normalizeProjectInfo,
|
|
|
+ normalizeProviderList,
|
|
|
+} from "./utils"
|
|
|
import { formatServerError } from "@/utils/server-errors"
|
|
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
|
|
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
|
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
|
|
+import { normalizeSessionInfo } from "@/utils/session"
|
|
|
+import type { ServerProtocol } from "@/utils/server-protocol"
|
|
|
+import type { ServerApi } from "@/utils/server"
|
|
|
|
|
|
type GlobalStore = {
|
|
|
ready: boolean
|
|
|
@@ -88,15 +111,26 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
|
|
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
|
|
})
|
|
|
|
|
|
-export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
|
|
+type ProjectApi = {
|
|
|
+ readonly list: () => Promise<ProjectListOutput>
|
|
|
+ readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
|
|
+}
|
|
|
+
|
|
|
+type McpApi = ServerApi["mcp"]
|
|
|
+type PermissionApi = ServerApi["permission"]
|
|
|
+type QuestionApi = ServerApi["question"]
|
|
|
+type VcsApi = ServerApi["vcs"]
|
|
|
+
|
|
|
+export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|
|
queryOptions({
|
|
|
queryKey: [scope, "project"],
|
|
|
queryFn: () =>
|
|
|
retry(() =>
|
|
|
- sdk.project.list().then((x) => {
|
|
|
- return (x.data ?? [])
|
|
|
+ api.list().then((projects) => {
|
|
|
+ return projects
|
|
|
.filter((p) => !!p?.id)
|
|
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
|
+ .map(normalizeProjectInfo)
|
|
|
.slice()
|
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
|
}),
|
|
|
@@ -105,6 +139,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
|
|
|
|
|
export async function bootstrapGlobal(input: {
|
|
|
serverSDK: OpencodeClient
|
|
|
+ serverAPI: CatalogApi & { readonly project: ProjectApi }
|
|
|
+ protocol?: Promise<ServerProtocol>
|
|
|
scope: ServerScope
|
|
|
requestFailedTitle: string
|
|
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
|
|
@@ -114,11 +150,14 @@ export async function bootstrapGlobal(input: {
|
|
|
}) {
|
|
|
const slow = [
|
|
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
|
|
- () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
|
|
- () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
|
|
+ () =>
|
|
|
+ input.queryClient.fetchQuery(
|
|
|
+ loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
|
|
+ ),
|
|
|
+ () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
|
|
() =>
|
|
|
input.queryClient
|
|
|
- .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
|
|
+ .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
|
|
.then((data) => input.setGlobalStore("project", data)),
|
|
|
]
|
|
|
await runAll(slow)
|
|
|
@@ -162,44 +201,126 @@ function warmSessions(input: {
|
|
|
ids: string[]
|
|
|
store: Store<State>
|
|
|
setStore: SetStoreFunction<State>
|
|
|
- sdk: OpencodeClient
|
|
|
+ api: SessionApi
|
|
|
}) {
|
|
|
const known = new Set(input.store.session.map((item) => item.id))
|
|
|
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
|
|
if (ids.length === 0) return Promise.resolve()
|
|
|
return Promise.all(
|
|
|
ids.map((sessionID) =>
|
|
|
- retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
|
|
- const session = x.data
|
|
|
- if (!session?.id) return
|
|
|
- mergeSession(input.setStore, session)
|
|
|
- }),
|
|
|
+ retry(() => input.api.get({ sessionID })).then((session) =>
|
|
|
+ mergeSession(input.setStore, normalizeSessionInfo(session)),
|
|
|
+ ),
|
|
|
),
|
|
|
).then(() => undefined)
|
|
|
}
|
|
|
|
|
|
-export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
|
|
+export const loadProvidersQuery = (
|
|
|
+ scope: ServerScope,
|
|
|
+ directory: string | null,
|
|
|
+ sdk: CatalogApi,
|
|
|
+ legacy?: OpencodeClient,
|
|
|
+ protocol?: Promise<ServerProtocol>,
|
|
|
+) =>
|
|
|
queryOptions({
|
|
|
queryKey: [scope, directory, "providers"],
|
|
|
- queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
|
|
+ queryFn: () =>
|
|
|
+ retry(async () => {
|
|
|
+ if ((await protocol) === "v1" && legacy) {
|
|
|
+ const result = await legacy.provider.list()
|
|
|
+ return normalizeProviderList(result.data!)
|
|
|
+ }
|
|
|
+ const location = directory ? { location: { directory } } : undefined
|
|
|
+ const [providers, models, defaultModel] = await Promise.all([
|
|
|
+ sdk.provider.list(location),
|
|
|
+ sdk.model.list(location),
|
|
|
+ sdk.model.default(location),
|
|
|
+ ])
|
|
|
+ return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
|
|
+ }),
|
|
|
})
|
|
|
|
|
|
-export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
|
|
+type AgentListApi = {
|
|
|
+ readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
|
|
+}
|
|
|
+
|
|
|
+type CommandListApi = {
|
|
|
+ readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
|
|
+}
|
|
|
+
|
|
|
+type ReferenceListApi = {
|
|
|
+ readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
|
|
+}
|
|
|
+
|
|
|
+export const loadAgentsQuery = (
|
|
|
+ scope: ServerScope,
|
|
|
+ directory: string,
|
|
|
+ sdk: AgentListApi,
|
|
|
+ legacy?: OpencodeClient,
|
|
|
+ protocol?: Promise<ServerProtocol>,
|
|
|
+) =>
|
|
|
queryOptions({
|
|
|
queryKey: [scope, directory, "agents"],
|
|
|
- queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
|
|
+ queryFn: () =>
|
|
|
+ retry(async () => {
|
|
|
+ if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
|
|
+ return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
|
|
+ }),
|
|
|
})
|
|
|
|
|
|
-export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
|
|
+export const loadCommands = (
|
|
|
+ directory: string,
|
|
|
+ api: CommandListApi,
|
|
|
+ legacy?: OpencodeClient,
|
|
|
+ protocol?: Promise<ServerProtocol>,
|
|
|
+): Promise<CommandInfo[]> =>
|
|
|
+ retry(async () => {
|
|
|
+ if ((await protocol) === "v1" && legacy) {
|
|
|
+ return ((await legacy.command.list()).data ?? []).map((command) => {
|
|
|
+ const [providerID, id] = command.model?.split("/") ?? []
|
|
|
+ return {
|
|
|
+ name: command.name,
|
|
|
+ template: command.template,
|
|
|
+ description: command.description,
|
|
|
+ agent: command.agent,
|
|
|
+ model: providerID && id ? { providerID, id } : undefined,
|
|
|
+ subtask: command.subtask,
|
|
|
+ // source: command.source === "skill" ? undefined : command.source,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return api.list({ location: { directory } }).then((result) => result.data)
|
|
|
+ })
|
|
|
+
|
|
|
+export const loadPathQuery = (
|
|
|
+ scope: ServerScope,
|
|
|
+ directory: string | null,
|
|
|
+ sdk: OpencodeClient,
|
|
|
+ protocol?: Promise<ServerProtocol>,
|
|
|
+) =>
|
|
|
queryOptions<Path>({
|
|
|
queryKey: [scope, directory, "path"],
|
|
|
- queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
|
|
+ queryFn: async () => {
|
|
|
+ if ((await protocol) !== "v1")
|
|
|
+ return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
|
|
|
+ return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
|
|
+ },
|
|
|
})
|
|
|
|
|
|
-export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
|
|
+export const loadReferencesQuery = (
|
|
|
+ scope: ServerScope,
|
|
|
+ directory: string,
|
|
|
+ api: ReferenceListApi,
|
|
|
+ legacy?: OpencodeClient,
|
|
|
+ protocol?: Promise<ServerProtocol>,
|
|
|
+) =>
|
|
|
queryOptions<ReferenceInfo[]>({
|
|
|
queryKey: [scope, directory, "references"] as const,
|
|
|
- queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
|
|
+ queryFn: () =>
|
|
|
+ retry(async () => {
|
|
|
+ if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
|
|
|
+ return api.list({ location: { directory } }).then((result) => result.data)
|
|
|
+ }).catch(() => []),
|
|
|
placeholderData: [],
|
|
|
})
|
|
|
|
|
|
@@ -208,6 +329,17 @@ export async function bootstrapDirectory(input: {
|
|
|
scope: ServerScope
|
|
|
mcp: boolean
|
|
|
sdk: OpencodeClient
|
|
|
+ api: CatalogApi & {
|
|
|
+ readonly agent: AgentListApi
|
|
|
+ readonly command: CommandListApi
|
|
|
+ readonly mcp: McpApi
|
|
|
+ readonly permission: PermissionApi
|
|
|
+ readonly project: ProjectApi
|
|
|
+ readonly question: QuestionApi
|
|
|
+ readonly reference: ReferenceListApi
|
|
|
+ readonly session: SessionApi
|
|
|
+ readonly vcs: VcsApi
|
|
|
+ }
|
|
|
store: Store<State>
|
|
|
setStore: SetStoreFunction<State>
|
|
|
vcsCache: VcsCache
|
|
|
@@ -221,6 +353,7 @@ export async function bootstrapDirectory(input: {
|
|
|
}
|
|
|
queryClient: QueryClient
|
|
|
session?: ServerSession
|
|
|
+ protocol?: Promise<ServerProtocol>
|
|
|
}) {
|
|
|
const loading = input.store.status !== "complete"
|
|
|
const seededProject = projectID(input.directory, input.global.project)
|
|
|
@@ -240,13 +373,15 @@ export async function bootstrapDirectory(input: {
|
|
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
|
|
() =>
|
|
|
input.queryClient
|
|
|
- .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
|
|
+ .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
|
|
.then((data) => input.setStore("agent", data)),
|
|
|
() =>
|
|
|
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
|
|
() =>
|
|
|
retry(() =>
|
|
|
- input.sdk.session.status().then(async (x) => {
|
|
|
+ (async () => {
|
|
|
+ if ((await input.protocol) !== "v1") return
|
|
|
+ const x = await input.sdk.session.status()
|
|
|
if (!input.session) {
|
|
|
input.setStore("session_status", x.data!)
|
|
|
return
|
|
|
@@ -264,42 +399,57 @@ export async function bootstrapDirectory(input: {
|
|
|
for (const [sessionID, status] of Object.entries(statuses)) {
|
|
|
input.session.set("session_status", sessionID, reconcile(status))
|
|
|
}
|
|
|
- // Warm session info only after seeding statuses so a stalled session
|
|
|
- // fetch cannot park busy indicators behind it, mirroring how live
|
|
|
- // session.status events apply first and resolve info in the background.
|
|
|
await Promise.all(
|
|
|
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
|
|
)
|
|
|
- }),
|
|
|
+ })(),
|
|
|
),
|
|
|
!seededProject &&
|
|
|
- (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
|
|
+ (() =>
|
|
|
+ retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
|
|
+ input.setStore("project", project.id),
|
|
|
+ )),
|
|
|
!seededPath &&
|
|
|
(() =>
|
|
|
- input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
|
|
- const next = projectID(data.directory ?? input.directory, input.global.project)
|
|
|
- if (next) input.setStore("project", next)
|
|
|
- })),
|
|
|
+ input.queryClient
|
|
|
+ .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
|
|
|
+ .then((data) => {
|
|
|
+ const next = projectID(data.directory ?? input.directory, input.global.project)
|
|
|
+ if (next) input.setStore("project", next)
|
|
|
+ })),
|
|
|
() =>
|
|
|
- retry(() =>
|
|
|
- input.sdk.vcs.get().then((x) => {
|
|
|
- const next = x.data ?? input.store.vcs
|
|
|
+ retry(async () => {
|
|
|
+ if ((await input.protocol) !== "v1") return
|
|
|
+ return input.sdk.vcs.get().then((result) => {
|
|
|
+ const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
|
|
input.setStore("vcs", next)
|
|
|
if (next) input.vcsCache.setStore("value", next)
|
|
|
- }),
|
|
|
+ })
|
|
|
+ }),
|
|
|
+ input.mcp &&
|
|
|
+ (() =>
|
|
|
+ loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
|
|
|
+ input.setStore("command", commands),
|
|
|
+ )),
|
|
|
+ () =>
|
|
|
+ input.queryClient.fetchQuery(
|
|
|
+ loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
|
|
|
),
|
|
|
- input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
|
|
- () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
|
|
() =>
|
|
|
retry(() =>
|
|
|
- input.sdk.permission.list().then((x) => {
|
|
|
- const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
|
|
+ (async () => {
|
|
|
+ if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
|
|
+ return input.api.permission.request
|
|
|
+ .list({ location: { directory: input.directory } })
|
|
|
+ .then((result) => result.data.map(normalizePermissionRequest))
|
|
|
+ })().then((permissions) => {
|
|
|
+ const ids = permissions.map((permission) => permission.sessionID)
|
|
|
const grouped = groupBySession(
|
|
|
- (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
|
|
+ permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
|
|
)
|
|
|
const warm = input.session
|
|
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
|
|
- : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
|
|
+ : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
|
|
return warm.then(() =>
|
|
|
batch(() => {
|
|
|
const current = input.session?.data.permission ?? input.store.permission
|
|
|
@@ -323,12 +473,19 @@ export async function bootstrapDirectory(input: {
|
|
|
),
|
|
|
() =>
|
|
|
retry(() =>
|
|
|
- input.sdk.question.list().then((x) => {
|
|
|
- const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
|
|
- const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
|
|
+ (async () => {
|
|
|
+ if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
|
|
+ return input.api.question.request
|
|
|
+ .list({ location: { directory: input.directory } })
|
|
|
+ .then((result) => result.data)
|
|
|
+ })().then((questions) => {
|
|
|
+ const ids = questions.map((question) => question.sessionID)
|
|
|
+ const grouped = groupBySession(
|
|
|
+ questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
|
|
+ )
|
|
|
const warm = input.session
|
|
|
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
|
|
- : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
|
|
+ : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
|
|
return warm.then(() =>
|
|
|
batch(() => {
|
|
|
const current = input.session?.data.question ?? input.store.question
|
|
|
@@ -351,17 +508,27 @@ export async function bootstrapDirectory(input: {
|
|
|
}),
|
|
|
),
|
|
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
|
|
- input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
|
|
- input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
|
|
+ input.mcp &&
|
|
|
+ (() =>
|
|
|
+ input.queryClient.fetchQuery(
|
|
|
+ loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
|
|
+ )),
|
|
|
+ input.mcp &&
|
|
|
+ (() =>
|
|
|
+ input.queryClient.fetchQuery(
|
|
|
+ loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
|
|
+ )),
|
|
|
() =>
|
|
|
- input.queryClient.fetchQuery(loadProvidersQuery(input.scope, 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),
|
|
|
- })
|
|
|
- }),
|
|
|
+ input.queryClient
|
|
|
+ .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
|
|
+ .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<any>)[]
|
|
|
|
|
|
await waitForPaint()
|