diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 5779c3a423f..f7757374124 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -146,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on server: ServerConnection.key(serverSDK.server), opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index e996fd0be77..dbf966cf52a 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: { server: ServerConnection.key(props.server), opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 8291bfc9474..615640089fc 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -418,7 +418,7 @@ function ProviderConnection(props: { () => ({ provider: props.provider, directory: directory() }), (input) => serverSDK() - .api.integration.get({ + .currentApi.integration.get({ integrationID: input.provider, location: input.directory ? { directory: input.directory } : undefined, }) @@ -547,7 +547,7 @@ function ProviderConnection(props: { } dispatch({ type: "auth.pending" }) await serverSDK() - .api.integration.oauth.connect({ + .currentApi.integration.oauth.connect({ integrationID: props.provider, methodID: method.id, inputs: inputs ?? {}, @@ -816,7 +816,7 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().api.integration.connect.key({ + await serverSDK().currentApi.integration.connect.key({ integrationID: props.provider, location: location(), key: apiKey, @@ -947,7 +947,7 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .api.integration.oauth.complete({ + .currentApi.integration.oauth.complete({ integrationID: props.provider, attemptID: store.authorization!.attemptID, location: location(), @@ -1044,7 +1044,7 @@ function ProviderConnection(props: { const authorization = store.authorization if (!authorization || !alive.value) return const result = await serverSDK() - .api.integration.oauth.status({ + .currentApi.integration.oauth.status({ integrationID: props.provider, attemptID: authorization.attemptID, location: location(), diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 6fe0c445601..86c3a21e2c8 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,7 +69,7 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) + .currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .then((forked) => { dialog.close() prompt.set(restored, undefined, { dir, id: forked.id }) diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index d3adb4e1530..e799f638d8d 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -70,10 +70,20 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async (): Promise => { - if ((await sdk.protocol) !== "v1") return - return sdk.client.path + if ((await sdk.protocol) === "v1") + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + return sdk.currentApi.location .get() - .then((result) => result.data) + .then((location) => ({ + state: "", + config: "", + worktree: location.project.directory, + directory: location.directory, + home: "", + })) .catch(() => undefined) }, { initialValue: undefined }, @@ -97,7 +107,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } const base = pickerRoot(cleaned) || root() || start() if (!base) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.api.file + const files = await sdk.currentApi.file .find({ location: { directory: base }, query: pickerFileSearchQuery(base, value, home()), @@ -127,7 +137,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.api.file + return sdk.currentApi.file .list({ location: { directory: absolute } }) .then((result) => result.data.map((entry) => ({ diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 35737412d36..dfce1571ed9 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -61,10 +61,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async (): Promise => { - if ((await sdk.protocol) !== "v1") return - return sdk.client.path + if ((await sdk.protocol) === "v1") + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + return sdk.currentApi.location .get() - .then((result) => result.data) + .then((location) => ({ + state: "", + config: "", + worktree: location.project.directory, + directory: location.directory, + home: "", + })) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 1bc9af08334..26fb9f2ffef 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - api: { + currentApi: { file: { find: (input: { location?: { directory?: string } }) => { directories.push(input.location?.directory ?? "") @@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () = test("searches from an absolute root without a default base", async () => { const directories: string[] = [] const sdk = { - api: { + currentApi: { file: { list: (input: { location?: { directory?: string } }) => { directories.push(input.location?.directory ?? "") diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9539ae1d01d..075b16a3144 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.api.file + const request = args.sdk.currentApi.file .list({ location: { directory: key } }) .then((result) => result.data) .catch(() => []) @@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.api.file + const results = await args.sdk.currentApi.file .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b3201b3ef68..e79b6617e07 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -199,6 +199,7 @@ beforeAll(async () => { directory: "/repo/main", client: rootClient, api: rootClient.api, + currentApi: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -332,7 +333,7 @@ describe("prompt submit worktree selection", () => { selected = "/repo/worktree-b" await submit.handleSubmit(event) - expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(createdClients).toEqual([]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sessionCreateInputs).toEqual([ { @@ -489,9 +490,6 @@ describe("prompt submit worktree selection", () => { agents: [], }) expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") - expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([ - { id: expect.stringMatching(/^prt_/), type: "text", text: "ls" }, - ]) }) test("submits slash commands through the current session API", async () => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 1c6d802e0ae..e001a63987f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" +import { getDirectory } from "@opencode-ai/core/util/path" type PendingPrompt = { abort: AbortController @@ -41,7 +42,7 @@ export type FollowupDraft = { } type FollowupSendInput = { - api: DirectorySDK["api"]["session"] + api: DirectorySDK["currentApi"]["session"] serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -159,10 +160,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) { await input.api.prompt({ sessionID: input.draft.sessionID, id: messageID, - agent: input.draft.agent, - model: input.draft.model, - variant: input.draft.variant, - legacyParts: requestParts, text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), files: requestParts.flatMap((part) => { if (part.type !== "file") return [] @@ -264,7 +261,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .api.session.interrupt({ sessionID }) + .currentApi.session.interrupt({ sessionID }) .catch(() => {}) } @@ -348,13 +345,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory - let client = sdk().client if (isNewSession) { if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) + const createdWorktree = await sdk() + .currentApi.projectCopy.create({ + projectID: sync().data.project, + strategy: "git_worktree", + directory: getDirectory(projectDirectory), + location: { directory: projectDirectory }, + }) .catch((err) => { showToast({ title: language.t("prompt.toast.worktreeCreateFailed.title"), @@ -363,13 +363,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return undefined }) - if (!createdWorktree?.directory) { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: language.t("common.requestFailed"), - }) - return - } + if (!createdWorktree) return WorktreeState.pending(sdk().scope, createdWorktree.directory) sessionDirectory = createdWorktree.directory } @@ -379,10 +373,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (sessionDirectory !== projectDirectory) { - client = sdk().createClient({ - directory: sessionDirectory, - throwOnError: true, - }) serverSync().child(sessionDirectory) } @@ -392,7 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { const created = await sdk() - .api.session.create({ + .currentApi.session.create({ agent: currentAgent.name, model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, location: { directory: sessionDirectory }, @@ -483,12 +473,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { clearInput() const eventID = Event.ID.create() sdk() - .api.session.shell({ + .currentApi.session.shell({ sessionID: session.id, id: eventID, command: text, - agent, - model, }) .catch((err) => { showToast({ @@ -509,7 +497,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const messageID = Identifier.ascending("message") serverSync().session.set("session_status", session.id, { type: "busy" }) sdk() - .api.session.command({ + .currentApi.session.command({ sessionID: session.id, id: messageID, command: commandName, @@ -606,7 +594,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - api: sdk().api.session, + api: sdk().currentApi.session, sync: sync(), serverSync: serverSync(), draft, diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 080e6c517bf..ca167a19bb8 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -129,9 +129,13 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => return } await serverSDK() - .client.auth.remove({ providerID }) - .then(async () => { - await serverSDK().client.global.dispose() + .currentApi.integration.get({ integrationID: providerID }) + .then(async (integration) => { + const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] + if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) + await Promise.all( + credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })), + ) showToast({ variant: "success", icon: "circle-check", diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index acd73ddbe54..4e260989490 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -125,10 +125,17 @@ export const SettingsProvidersV2: Component<{ await disableProvider(providerID, name) return } + const location = props.directory() ? { directory: props.directory() } : undefined await serverSdk() - .client.auth.remove({ providerID }) - .then(async () => { - await serverSdk().client.global.dispose() + .currentApi.integration.get({ integrationID: providerID, location }) + .then(async (integration) => { + const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] + if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) + await Promise.all( + credentials.map((credential) => + serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }), + ), + ) showToast({ variant: "success", icon: "circle-check", diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 65035034760..6ada6eb4b7b 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -17,6 +17,7 @@ import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" +import { authTokenFromCredentials } from "@/utils/server" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" @@ -182,8 +183,6 @@ export const Terminal = (props: TerminalProps) => { const auth = connection.http const username = auth?.username ?? "opencode" const password = auth?.password ?? "" - const authToken = connection.type === "http" ? connection.authToken : false - const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement const [local, others] = splitProps(props, [ "pty", @@ -241,18 +240,8 @@ export const Terminal = (props: TerminalProps) => { } const pushSize = async (cols: number, rows: number) => { - if ((await sdk().protocol) === "v1") { - return sdk() - .client.pty.update({ - ptyID: id, - size: { cols, rows }, - }) - .catch((err) => { - debugTerminal("failed to sync terminal size", err) - }) - } return sdk() - .api.pty.update({ + .currentApi.pty.update({ ptyID: id, location: { directory }, size: { cols, rows }, @@ -533,17 +522,8 @@ export const Terminal = (props: TerminalProps) => { } const gone = async () => { - if ((await sdk().protocol) === "v1") { - return sdk() - .client.pty.get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) - .catch((err) => { - debugTerminal("failed to inspect terminal session", err) - return false - }) - } return sdk() - .api.pty.get({ ptyID: id, location: { directory } }) + .currentApi.pty.get({ ptyID: id, location: { directory } }) .then((result) => result.data.status === "exited") .catch((err) => { if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true @@ -553,33 +533,23 @@ export const Terminal = (props: TerminalProps) => { } const connectToken = async () => { - if ((await sdk().protocol) === "v1") { - const result = await sdk() - .client.pty.connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) - .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return - throw err - }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) - } - // return sdk() - // .api.pty.connectToken({ - // ptyID: id, - // location: { directory }, - // "x-opencode-ticket": "1", - // }) - // .then((result) => result.data.ticket) + const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url) + endpoint.searchParams.set("location[directory]", directory) + const response = await (platform.fetch ?? globalThis.fetch)(endpoint, { + method: "POST", + headers: { + "x-opencode-ticket": "1", + ...(password + ? { Authorization: `Basic ${authTokenFromCredentials({ username, password })}` } + : undefined), + }, + }) + if (response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + if (!response.ok) throw new Error(`PTY connect ticket failed with ${response.status}`) + const result = (await response.json()) as { data?: { ticket?: string } } + if (!result.data?.ticket) throw new Error("PTY connect ticket response did not include a ticket") + return result.data.ticket } const retry = (err: unknown) => { @@ -609,23 +579,16 @@ export const Terminal = (props: TerminalProps) => { fail(err) return undefined }) - const protocol = await sdk().protocol - // if (protocol === "v2" && !ticket) return if (once.value) return if (disposed) return const socket = new WebSocket( terminalWebSocketURL({ - protocol, url, id, directory, cursor: seek, ticket, - sameOrigin, - username, - password, - authToken, }), ) socket.binaryType = "arraybuffer" diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 242de395c40..43359d7ccf5 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -105,7 +105,7 @@ function SessionTabEntry(props: { ctx.sync.session.remember({ ...value, title }) try { - await ctx.sdk.api.session.rename({ sessionID: value.id, title }) + await ctx.sdk.currentApi.session.rename({ sessionID: value.id, title }) } catch (err) { const current = session() const currentCtx = props.serverCtx() diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 649a8f57773..a7fffa52a3c 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -192,7 +192,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.api.session + sdk.currentApi.session .get({ sessionID: route.sessionId }) .then(normalizeSessionInfo) .catch(() => {}), diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 359074da25c..1daa1e286dd 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -124,7 +124,7 @@ export const createDirSyncContext = ( fetch: async (count = 10) => { const [store, setStore] = current() setStore("limit", (value) => value + count) - const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) + const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" }) const sessions = response.data .map(normalizeSessionInfo) .sort((a, b) => cmp(a.id, b.id)) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index fbbef3a2a8e..4c0740ea3f0 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -81,8 +81,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ normalizeDir: path.normalizeDir, list: (dir) => sdk() - .client.file.list({ path: dir }) - .then((x) => x.data ?? []), + .currentApi.file.list({ path: dir, location: { directory: scope() } }) + .then((x) => + x.data.map((entry) => ({ + ...entry, + name: entry.path.split("/").at(-1) ?? entry.path, + absolute: `${scope()}/${entry.path}`, + ignored: false, + })), + ), onError: (message) => { showToast({ variant: "error", @@ -181,10 +188,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setLoading(file) const promise = sdk() - .client.file.read({ path: file }) - .then((x) => { + .currentApi.file.read({ path: file, location: { directory } }) + .then((data) => { if (scope() !== directory) return - const content = x.data + const content = { type: "text" as const, content: new TextDecoder().decode(data) } setLoaded(file, content) if (!content) return @@ -205,7 +212,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => serverSDK() - .api.file.find( + .currentApi.file.find( { location: { directory: sdk().directory }, query, diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index fd4044a0fa7..09b85d70fa5 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -76,9 +76,30 @@ function directoryState() { } describe("bootstrapDirectory", () => { - test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { + test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => { const mcpReads: string[] = [] const [store, setStore] = directoryState() + const currentApi = { + ...api, + command: { + list: async () => { + mcpReads.push("command") + return { location: {}, data: [] } + }, + }, + mcp: { + list: async () => { + mcpReads.push("status") + return { location: {}, data: [] } + }, + resource: { + catalog: async () => { + mcpReads.push("resource") + return { location: {}, data: { resources: [], templates: [] } } + }, + }, + }, + } as unknown as ServerApi await bootstrapDirectory({ directory: "/project", @@ -120,7 +141,7 @@ describe("bootstrapDirectory", () => { }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, - api, + api: currentApi, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, @@ -141,12 +162,21 @@ describe("bootstrapDirectory", () => { describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as Parameters[2] + const location = {} as Parameters[2] + const client = {} as Parameters[3] const api = {} as CatalogApi const remote = "https://debian.example" as typeof ServerScope.local - expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) - expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) + expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([ + "local", + "/repo", + "path", + ]) + expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([ + "https://debian.example", + "/repo", + "path", + ]) expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 238a53b6230..34f4a406608 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -16,6 +16,8 @@ import type { CommandInfo, CommandListInput, CommandListOutput, + LocationGetInput, + LocationGetOutput, ProjectCurrentInput, ProjectCurrentOutput, ProjectListOutput, @@ -115,6 +117,7 @@ type ProjectApi = { readonly list: () => Promise readonly current: (input?: ProjectCurrentInput) => Promise } +type LocationApi = { readonly get: (input?: LocationGetInput) => Promise } type McpApi = ServerApi["mcp"] type PermissionApi = ServerApi["permission"] @@ -139,7 +142,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient - serverAPI: CatalogApi & { readonly project: ProjectApi } + serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi } protocol?: Promise scope: ServerScope requestFailedTitle: string @@ -152,9 +155,12 @@ export async function bootstrapGlobal(input: { () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), () => input.queryClient.fetchQuery( - loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), + loadProvidersQuery(input.scope, null, input.serverAPI), + ), + () => + input.queryClient.fetchQuery( + loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol), ), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), () => input.queryClient .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) @@ -219,17 +225,11 @@ export const loadProvidersQuery = ( scope: ServerScope, directory: string | null, sdk: CatalogApi, - legacy?: OpencodeClient, - protocol?: Promise, ) => queryOptions({ queryKey: [scope, directory, "providers"], 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), @@ -256,54 +256,38 @@ export const loadAgentsQuery = ( scope: ServerScope, directory: string, sdk: AgentListApi, - legacy?: OpencodeClient, - protocol?: Promise, ) => queryOptions({ queryKey: [scope, directory, "agents"], 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)) - }), + retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), }) export const loadCommands = ( directory: string, api: CommandListApi, - legacy?: OpencodeClient, - protocol?: Promise, ): Promise => - 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) - }) + retry(() => api.list({ location: { directory } }).then((result) => result.data)) export const loadPathQuery = ( scope: ServerScope, directory: string | null, + api: LocationApi, sdk: OpencodeClient, protocol?: Promise, ) => queryOptions({ queryKey: [scope, directory, "path"], 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!)) + if ((await protocol) === "v1") + return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({ + state: "", + config: "", + worktree: location.project.directory, + directory: location.directory, + home: "", + })) }, }) @@ -311,16 +295,11 @@ export const loadReferencesQuery = ( scope: ServerScope, directory: string, api: ReferenceListApi, - legacy?: OpencodeClient, - protocol?: Promise, ) => queryOptions({ queryKey: [scope, directory, "references"] as const, 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(() => []), + retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []), placeholderData: [], }) @@ -339,6 +318,7 @@ export async function bootstrapDirectory(input: { readonly reference: ReferenceListApi readonly session: SessionApi readonly vcs: VcsApi + readonly location: LocationApi } store: Store setStore: SetStoreFunction @@ -373,7 +353,7 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent)) .then((data) => input.setStore("agent", data)), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), @@ -412,7 +392,9 @@ export async function bootstrapDirectory(input: { !seededPath && (() => input.queryClient - .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) + .ensureQueryData( + loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol), + ) .then((data) => { const next = projectID(data.directory ?? input.directory, input.global.project) if (next) input.setStore("project", next) @@ -428,12 +410,12 @@ export async function bootstrapDirectory(input: { }), input.mcp && (() => - loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => + loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands), )), () => input.queryClient.fetchQuery( - loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), + loadReferencesQuery(input.scope, input.directory, input.api.reference), ), () => retry(() => @@ -511,16 +493,16 @@ export async function bootstrapDirectory(input: { input.mcp && (() => input.queryClient.fetchQuery( - loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + loadMcpQuery(input.scope, input.directory, input.api.mcp), )), input.mcp && (() => input.queryClient.fetchQuery( - loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp), )), () => input.queryClient - .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) + .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)) .catch((err) => { const project = getFilename(input.directory) showToast({ diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index c5ee9bd7c74..bb2bef4ba8d 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -1,8 +1,8 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { type Accessor, createMemo } from "solid-js" -import { type ServerSDK, useServerSDK } from "./server-sdk" +import { type DirectorySDK, useServerSDK } from "./server-sdk" -export type DirectorySDK = ReturnType +export type { DirectorySDK } export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index af19038e57f..58e080b5271 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -13,6 +13,7 @@ import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" @@ -192,11 +193,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS })() const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) - const eventSdk = createSdkForServer({ - signal: abort.signal, - fetch: eventFetch, - server: server.http, - }) const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) const [protocolKind] = createResource( () => protocol, @@ -264,18 +260,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } abort.signal.addEventListener("abort", onAbort) try { - const kind = await protocol - const events = - kind === "v1" - ? (await eventSdk.global.event({ signal: attempt.signal })).stream - : eventApi.event.subscribe({ signal: attempt.signal }) + const events = eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() for await (const event of events) { streamErrorLogged = false - const legacy = "payload" in event - if (legacy && event.payload.type === "sync") continue - const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") - const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + const directory = event.location?.directory ?? "global" + const payload = adaptServerEvent(event) if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue @@ -364,8 +354,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } +type SDKEventMap = { + [key in Event["type"]]: Extract +} + +export type DirectorySDK = { + scope: ServerScope + protocol: Promise + directory: string + client: OpencodeClient + currentApi: ServerApi + api: CompatibleApi + event: ReturnType> + readonly url: string + createClient: ServerSDKBase["createClient"] +} + export type ServerSDK = ServerSDKBase & { - ensureDirSdkContext: (directory: string) => ReturnType + ensureDirSdkContext: (directory: string) => DirectorySDK } export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { @@ -397,11 +403,7 @@ export function useServerProtocol() { return createMemo(() => serverSDK().protocolKind()) } -type SDKEventMap = { - [key in Event["type"]]: Extract -} - -function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { +function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK { const client = serverSDK.createClient({ directory, throwOnError: true, @@ -419,6 +421,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { protocol: serverSDK.protocol, directory, client, + currentApi: serverSDK.currentApi, api: createCompatibleApi({ protocol: serverSDK.protocol, current: serverSDK.currentApi, diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 7f0ab7654b9..83f9f23a577 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -27,7 +27,7 @@ import { } 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 { estimateRootSessionTotal, loadRootSessions } 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" @@ -59,6 +59,7 @@ import type { } from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" import { createServerSession, type ServerSession } from "./server-session" +import { usePlatform } from "./platform" type GlobalStore = { ready: boolean @@ -94,8 +95,6 @@ export const loadMcpQuery = ( scope: ServerScope, directory: string, api: McpListApi, - legacy?: OpencodeClient, - protocol?: Promise<"v1" | "v2">, ): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => queryOptions< Record, @@ -105,7 +104,6 @@ export const loadMcpQuery = ( >({ 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]))) @@ -116,8 +114,6 @@ export const loadMcpResourcesQuery = ( scope: ServerScope, directory: string, api: McpResourceApi, - legacy?: OpencodeClient, - protocol?: Promise<"v1" | "v2">, ): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => queryOptions< Record, @@ -127,14 +123,6 @@ export const loadMcpResourcesQuery = ( >({ 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) => @@ -186,16 +174,13 @@ function makeQueryOptionsApi( return { globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), projects: () => loadProjectsQuery(scope, serverAPI.project), - providers: (directory: PathKey | null) => - loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), + providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), 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), + loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), + references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp), + mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -204,6 +189,7 @@ export type QueryOptionsApi = ReturnType export function createServerSyncContextInner(serverSDK: ServerSDK) { const language = useLanguage() + const platform = usePlatform() const owner = getOwner() if (!owner) throw new Error("ServerSync must be created within owner") @@ -224,13 +210,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return sdk } - const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { - protocol: serverSDK.protocol, - }) + const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message) const queryOptionsApi = makeQueryOptionsApi( serverSDK.scope, () => serverSDK.client, - serverSDK.api, + serverSDK.currentApi, sdkFor, serverSDK.protocol, ) @@ -241,19 +225,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { 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() + const active = await serverSDK.currentApi.session.active() seedActiveSessionStatuses(session, active) for (const sessionID of Object.keys(active)) { void session.resolve(sessionID).catch(() => undefined) @@ -322,7 +294,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, - serverAPI: serverSDK.api, + serverAPI: serverSDK.currentApi, protocol: serverSDK.protocol, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), @@ -363,7 +335,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { - void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) + void loadCommands(directory, serverSDK.currentApi.command) .then((commands) => setStore("command", commands)) .catch((err) => { showToast({ @@ -416,12 +388,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => - serverSDK.protocol - .then((protocol) => - protocol === "v1" - ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) - : loadRootSessions({ api: serverSDK.api.session, directory, limit }), - ) + loadRootSessions({ api: serverSDK.currentApi.session, directory, limit }) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -491,7 +458,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { provider: globalStore.provider, }, sdk, - api: serverSDK.api, + api: serverSDK.currentApi, store: child[0], setStore: child[1], vcsCache: cache, @@ -692,27 +659,35 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { 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 } }) + await serverSDK.currentApi.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 } }) + await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } }) }, authenticate: async () => { - await sdk.mcp.auth.authenticate({ name }) + const server = (await serverSDK.currentApi.mcp.list({ location: { directory: key } })).data.find( + (item) => item.name === name, + ) + if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`) + const integration = await serverSDK.currentApi.integration.get({ + integrationID: server.integrationID, + location: { directory: key }, + }) + const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length) + if (!method || method.type !== "oauth") + throw new Error(`MCP server ${name} requires an interactive authentication form`) + const attempt = await serverSDK.currentApi.integration.oauth.connect({ + integrationID: server.integrationID, + methodID: method.id, + inputs: {}, + location: { directory: key }, + }) + platform.openLink(attempt.data.url) }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index df575b71f7c..8172ef78676 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -248,20 +248,12 @@ function createWorkspaceTerminalSession( setStore("all", index, (item) => ({ ...item, ...pty })) } const doUpdate = async () => { - if ((await sdk.protocol) === "v1") { - await sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - } else { - await sdk.api.pty.update({ - ptyID: pty.id, - location, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - } + await sdk.currentApi.pty.update({ + ptyID: pty.id, + location, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) } doUpdate().catch((error: unknown) => { if (previous) { @@ -276,20 +268,13 @@ function createWorkspaceTerminalSession( const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const data = await (async () => { - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.create({ title: pty.title })).data - } - return ( - await sdk.api.pty.create({ - location, - title: pty.title, - }) - ).data - })().catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) + const data = await sdk.currentApi.pty + .create({ location, title: pty.title }) + .then((result) => result.data) + .catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) if (!data?.id) return const active = store.active === pty.id @@ -326,10 +311,9 @@ function createWorkspaceTerminalSession( const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined const doCreate = async () => { - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data - } - return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data + return sdk.currentApi.pty + .create({ location, title: defaultTitle(nextNumber) }) + .then((result) => result.data) } doCreate() .then((data) => { @@ -433,11 +417,7 @@ function createWorkspaceTerminalSession( }) } - const removePromise = - (await sdk.protocol) === "v1" - ? sdk.client.pty.remove({ ptyID: id }) - : sdk.api.pty.remove({ ptyID: id, location }) - await removePromise.catch((error: unknown) => { + await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => { console.error("Failed to close terminal", error) }) }, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1dde8b59cb6..478e4fe2cc8 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -25,7 +25,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/core/util/path" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import type { Session } from "@/types" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -1189,10 +1189,13 @@ export default function LegacyLayout(props: ParentProps) { const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) const listed = await Promise.resolve( - project?.id ?? serverSDK().api.project.current({ location: { directory: root } }), + project?.id ?? serverSDK().currentApi.project.current({ location: { directory: root } }), ) .then((value) => (typeof value === "string" ? value : value.id)) - .then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } })) + .then(async (projectID) => { + await serverSDK().currentApi.projectCopy.refresh({ projectID, location: { directory: root } }) + return serverSDK().currentApi.project.directories({ projectID, location: { directory: root } }) + }) .then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root))) .catch(() => [] as string[]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) @@ -1237,7 +1240,7 @@ export default function LegacyLayout(props: ParentProps) { await Promise.all( dirs.map(async (item) => ({ path: { directory: item }, - session: await listAllSessions(serverSDK().api.session, { + session: await listAllSessions(serverSDK().currentApi.session, { directory: item, parentID: null, order: "desc", @@ -1403,16 +1406,19 @@ export default function LegacyLayout(props: ParentProps) { setBusy(directory, true) - const result = await serverSDK() - .client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("workspace.delete.failed.title"), - description: errorMessage(err, language.t("common.requestFailed")), - }) - return false - }) + const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id + const result = projectID + ? await serverSDK() + .currentApi.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } }) + .then(() => true) + .catch((err) => { + showToast({ + title: language.t("workspace.delete.failed.title"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + return false + }) + : false setBusy(directory, false) @@ -1461,7 +1467,9 @@ export default function LegacyLayout(props: ParentProps) { }) const dismiss = () => toaster.dismiss(progress) - const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) + const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch( + () => [], + ) clearWorkspaceTerminals( directory, @@ -1595,7 +1603,7 @@ export default function LegacyLayout(props: ParentProps) { }) const refresh = async () => { - const sessions = await listAllSessions(serverSDK().api.session, { + const sessions = await listAllSessions(serverSDK().currentApi.session, { directory: props.directory, order: "desc", }).catch(() => []) @@ -1835,20 +1843,26 @@ export default function LegacyLayout(props: ParentProps) { const createWorkspace = async (project: LocalProject) => { clearSidebarHoverState() - const created = await serverSDK() - .client.worktree.create({ directory: project.worktree }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("workspace.create.failed.title"), - description: errorMessage(err, language.t("common.requestFailed")), - }) - return undefined - }) + const created = project.id + ? await serverSDK() + .currentApi.projectCopy.create({ + projectID: project.id, + strategy: "git_worktree", + directory: getDirectory(project.worktree), + location: { directory: project.worktree }, + }) + .catch((err) => { + showToast({ + title: language.t("workspace.create.failed.title"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + return undefined + }) + : undefined if (!created?.directory) return - setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch) + setWorkspaceName(created.directory, getFilename(created.directory), project.id) const local = project.worktree const key = pathKey(created.directory) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3c2263feb4d..0a327afb01f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1724,7 +1724,7 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - api: sdk().api.session, + api: sdk().currentApi.session, sync: sync(), serverSync: serverSync(), draft: item, @@ -1820,13 +1820,13 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .api.session.interrupt({ sessionID }) + .currentApi.session.interrupt({ sessionID }) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const session = sdk().api.session + const session = sdk().currentApi.session const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1849,7 +1849,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const session = sdk().api.session + const session = sdk().currentApi.session const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 880433016fc..8601a725935 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -55,8 +55,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) { const readFile = async (path: string) => { return sdk() - .client.file.read({ path }) - .then((x) => x.data) + .currentApi.file.read({ path, location: { directory: sdk().directory } }) + .then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) })) .catch((error) => { console.debug("[session-review] failed to read file", { path, error }) return undefined diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 23c85514764..dd203aa59bf 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -680,7 +680,7 @@ export function MessageTimeline(props: { const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => - sdk().api.session.rename({ sessionID: input.id, title: input.title }), + sdk().currentApi.session.rename({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -854,7 +854,7 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .api.session.remove({ sessionID }) + .currentApi.session.remove({ sessionID }) .then(() => true) .catch((err) => { showToast({ diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 04638d53bfc..70d5a262a41 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const session = sdk().api.session + const session = sdk().currentApi.session const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const session = sdk().api.session + const session = sdk().currentApi.session const messages = userMessages() const promptSession = prompt.capture() @@ -366,19 +366,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return - const model = local.model.current() - if (!model) { - showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), - }) - return - } - - await sdk().api.session.compact({ - sessionID, - model: { providerID: model.provider.id, modelID: model.id }, - }) + await sdk().currentApi.session.compact({ sessionID }) } const fork = () => { diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index df3f22960df..6c0be85fd15 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -102,8 +102,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { const readFile = async (path: string) => sdk() - .client.file.read({ path }) - .then((x) => x.data) + .currentApi.file.read({ path, location: { directory: sdk().directory } }) + .then((content) => ({ type: "text" as const, content: new TextDecoder().decode(content) })) .catch((error) => { console.debug("[session-review-v2] failed to read file", { path, error }) return undefined