From 1377bc6b91e7ceed3fd39bb691b003f999042d68 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 4 Jan 2026 22:01:49 +0000 Subject: [PATCH] Migrate UI to v2 SDK client Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration. --- package-lock.json | 7 +- packages/ui/package.json | 2 +- .../components/instance-service-status.tsx | 4 +- .../src/components/session/session-view.tsx | 12 ++- packages/ui/src/components/unified-picker.tsx | 2 +- packages/ui/src/lib/hooks/use-commands.ts | 23 +++--- packages/ui/src/lib/opencode-api.ts | 37 +++++++++ packages/ui/src/lib/sdk-manager.ts | 10 ++- packages/ui/src/stores/commands.ts | 8 +- packages/ui/src/stores/instances.ts | 78 ++++++++++--------- packages/ui/src/stores/session-actions.ts | 61 ++++++++------- packages/ui/src/stores/session-api.ts | 39 ++++------ packages/ui/src/stores/session-events.ts | 9 ++- packages/ui/src/stores/session-state.ts | 7 +- packages/ui/src/types/instance.ts | 4 +- 15 files changed, 186 insertions(+), 117 deletions(-) create mode 100644 packages/ui/src/lib/opencode-api.ts diff --git a/package-lock.json b/package-lock.json index 44e8ed40..36328d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1090,7 +1090,10 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.0.166" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz", + "integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==", + "license": "MIT" }, "node_modules/@pinojs/redact": { "version": "0.4.0", @@ -7476,7 +7479,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.0.166", + "@opencode-ai/sdk": "1.1.1", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 98093ea6..8edabf7f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.0.166", + "@opencode-ai/sdk": "1.1.1", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index 9cc1a639..6368e207 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -90,9 +90,9 @@ const InstanceServiceStatus: Component = (props) => setPendingMcpAction(serverName, action) try { if (shouldEnable) { - await client.mcp.connect({ path: { name: serverName } }) + await client.mcp.connect({ name: serverName }) } else { - await client.mcp.disconnect({ path: { name: serverName } }) + await client.mcp.disconnect({ name: serverName }) } await refreshMetadata() } catch (error) { diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 19d21a00..ac19183f 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -10,6 +10,7 @@ import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setAc import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" +import { requestData } from "../../lib/opencode-api" const log = getLogger("session") @@ -122,10 +123,13 @@ export const SessionView: Component = (props) => { if (!instance || !instance.client) return try { - await instance.client.session.revert({ - path: { id: props.sessionId }, - body: { messageID: messageId }, - }) + await requestData( + instance.client.session.revert({ + sessionID: props.sessionId, + messageID: messageId, + }), + "session.revert", + ) const restoredText = getUserMessageText(messageId) if (restoredText) { diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 3f9e9ebc..d12e9899 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -1,6 +1,6 @@ import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import type { Agent } from "../types/session" -import type { OpencodeClient } from "@opencode-ai/sdk/client" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { serverApi } from "../lib/api-client" import { getLogger } from "../lib/logger" const log = getLogger("actions") diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index d6155f55..96c2d2c5 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -18,6 +18,7 @@ import type { MessageRecord } from "../../stores/message-v2/types" import { messageStoreBus } from "../../stores/message-v2/bus" import { cleanupBlankSessions } from "../../stores/session-state" import { getLogger } from "../logger" +import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" const log = getLogger("actions") @@ -241,13 +242,14 @@ export function useCommands(options: UseCommandsOptions) { try { setSessionCompactionState(instance.id, sessionId, true) - await instance.client.session.summarize({ - path: { id: sessionId }, - body: { + await requestData( + instance.client.session.summarize({ + sessionID: sessionId, providerID: session.model.providerId, modelID: session.model.modelId, - }, - }) + }), + "session.summarize", + ) } catch (error) { setSessionCompactionState(instance.id, sessionId, false) log.error("Failed to compact session", error) @@ -332,10 +334,13 @@ export function useCommands(options: UseCommandsOptions) { } try { - await instance.client.session.revert({ - path: { id: sessionId }, - body: { messageID }, - }) + await requestData( + instance.client.session.revert({ + sessionID: sessionId, + messageID, + }), + "session.revert", + ) if (!restoredText) { const fallbackRecord = store.getMessage(messageID) diff --git a/packages/ui/src/lib/opencode-api.ts b/packages/ui/src/lib/opencode-api.ts new file mode 100644 index 00000000..58c4f24a --- /dev/null +++ b/packages/ui/src/lib/opencode-api.ts @@ -0,0 +1,37 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +export class OpencodeApiError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message) + this.name = "OpencodeApiError" + if (options && "cause" in options) { + ;(this as any).cause = options.cause + } + } +} + +type RequestResultLike = + | { + data: T + error?: undefined + } + | { + data?: undefined + error: unknown + } + +export async function requestData( + promise: Promise | undefined>, + label: string, +): Promise { + const result = await promise + if (!result) { + throw new OpencodeApiError(`${label} returned no result`) + } + if ((result as any).error) { + throw new OpencodeApiError(`${label} failed`, { cause: (result as any).error }) + } + return (result as any).data as T +} + +export type { OpencodeClient } diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index c394b49c..c0302e52 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -1,18 +1,20 @@ -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client" +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client" import { CODENOMAD_API_BASE } from "./api-client" class SDKManager { private clients = new Map() createClient(instanceId: string, proxyPath: string): OpencodeClient { - if (this.clients.has(instanceId)) { - return this.clients.get(instanceId)! + const existing = this.clients.get(instanceId) + if (existing) { + return existing } const baseUrl = buildInstanceBaseUrl(proxyPath) const client = createOpencodeClient({ baseUrl }) this.clients.set(instanceId, client) + return client } @@ -29,6 +31,8 @@ class SDKManager { } } +export type { OpencodeClient } + function buildInstanceBaseUrl(proxyPath: string): string { const normalized = normalizeProxyPath(proxyPath) const base = stripTrailingSlashes(CODENOMAD_API_BASE) diff --git a/packages/ui/src/stores/commands.ts b/packages/ui/src/stores/commands.ts index a975990d..ac60efca 100644 --- a/packages/ui/src/stores/commands.ts +++ b/packages/ui/src/stores/commands.ts @@ -1,12 +1,12 @@ import { createSignal } from "solid-js" -import type { Command as SDKCommand } from "@opencode-ai/sdk" -import type { OpencodeClient } from "@opencode-ai/sdk/client" +import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { requestData } from "../lib/opencode-api" const [commandMap, setCommandMap] = createSignal>(new Map()) export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise { - const response = await client.command.list() - const commands = response.data ?? [] + const commands = await requestData(client.command.list(), "command.list").catch(() => []) setCommandMap((prev) => { const next = new Map(prev) next.set(instanceId, commands) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index fac1cead..282b945b 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -1,8 +1,9 @@ import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" -import type { LspStatus } from "@opencode-ai/sdk" +import type { LspStatus } from "@opencode-ai/sdk/v2" import type { PermissionReply, PermissionRequestLike } from "../types/permission" import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission" +import { requestData } from "../lib/opencode-api" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" @@ -20,6 +21,7 @@ import { preferences } from "./preferences" import { setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" +import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge" import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" @@ -123,6 +125,37 @@ function releaseInstanceResources(instanceId: string) { sseManager.seedStatus(instanceId, "disconnected") } +async function syncPendingPermissions(instanceId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) return + + try { + const remote = await requestData( + instance.client.permission.list(), + "permission.list", + ) + + const remoteIds = new Set(remote.map((item) => item.id)) + const local = getPermissionQueue(instanceId) + + // Remove any stale local permissions missing from server. + for (const entry of local) { + if (!remoteIds.has(entry.id)) { + removePermissionFromQueue(instanceId, entry.id) + removePermissionV2(instanceId, entry.id) + } + } + + // Upsert all server-side pending permissions. + for (const permission of remote) { + addPermissionToQueue(instanceId, permission) + upsertPermissionV2(instanceId, permission) + } + } catch (error) { + log.warn("Failed to sync pending permissions", { instanceId, error }) + } +} + async function hydrateInstanceData(instanceId: string) { try { await fetchSessions(instanceId) @@ -132,6 +165,7 @@ async function hydrateInstanceData(instanceId: string) { const instance = instances().get(instanceId) if (!instance?.client) return await fetchCommands(instanceId, instance.client) + await syncPendingPermissions(instanceId) } catch (error) { log.error("Failed to fetch initial data", error) } @@ -349,8 +383,7 @@ async function fetchLspStatus(instanceId: string): Promise(lsp.status(), "lsp.status") } function getActiveInstance(): Instance | null { @@ -540,39 +573,14 @@ async function sendPermissionResponse( throw new Error("Instance not ready") } - const client: any = instance.client - try { - // New API (preferred): POST /permission/:requestID/reply - if (typeof client.postPermissionRequestIdReply === "function") { - await client.postPermissionRequestIdReply({ - path: { requestID: requestId }, - body: { reply }, - }) - } else if (typeof client.postPermissionRequestIDReply === "function") { - await client.postPermissionRequestIDReply({ - path: { requestID: requestId }, - body: { reply }, - }) - } else if (typeof client.postPermissionRequestIdReply2 === "function") { - await client.postPermissionRequestIdReply2({ - path: { requestID: requestId }, - body: { reply }, - }) - } else if (client.permission && typeof client.permission.reply === "function") { - await client.permission.reply({ - path: { requestID: requestId }, - body: { reply }, - }) - } else if (typeof client.postSessionIdPermissionsPermissionId === "function") { - // Legacy API fallback: POST /session/:sessionID/permissions/:permissionID - await client.postSessionIdPermissionsPermissionId({ - path: { id: sessionId, permissionID: requestId }, - body: { response: reply }, - }) - } else { - throw new Error("Unsupported permissions API in client") - } + await requestData( + instance.client.permission.reply({ + requestID: requestId, + reply, + }), + "permission.reply", + ) // Remove from queue after successful response removePermissionFromQueue(instanceId, requestId) diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 22444fc1..5f06a16b 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -7,6 +7,7 @@ import { getDefaultModel, isModelValid } from "./session-models" import { updateSessionInfo } from "./message-v2/session-info" import { messageStoreBus } from "./message-v2/bus" import { getLogger } from "../lib/logger" +import { requestData } from "../lib/opencode-api" const log = getLogger("actions") @@ -179,17 +180,13 @@ async function sendMessage( try { log.info("session.promptAsync", { instanceId, sessionId, requestBody }) - const response = await instance.client.session.promptAsync({ - path: { id: sessionId }, - body: requestBody, - }) - - log.info("sendMessage response", response) - - if (response.error) { - log.error("sendMessage server error", response.error) - throw new Error(JSON.stringify(response.error) || "Failed to send message") - } + await requestData( + instance.client.session.promptAsync({ + sessionID: sessionId, + ...(requestBody as any), + }), + "session.promptAsync", + ) } catch (error) { log.error("Failed to send prompt", error) throw error @@ -232,10 +229,13 @@ async function executeCustomCommand( body.model = `${session.model.providerId}/${session.model.modelId}` } - await instance.client.session.command({ - path: { id: sessionId }, - body, - }) + await requestData( + instance.client.session.command({ + sessionID: sessionId, + ...(body as any), + }), + "session.command", + ) } async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise { @@ -251,13 +251,14 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s const agent = session.agent || "build" - await instance.client.session.shell({ - path: { id: sessionId }, - body: { + await requestData( + instance.client.session.shell({ + sessionID: sessionId, agent, command, - }, - }) + }), + "session.shell", + ) } async function abortSession(instanceId: string, sessionId: string): Promise { @@ -270,9 +271,12 @@ async function abortSession(instanceId: string, sessionId: string): Promise { current.title = trimmedTitle diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index fd4406bf..47611470 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -33,6 +33,7 @@ import { seedSessionMessagesV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" +import { requestData } from "../lib/opencode-api" const log = getLogger("api") @@ -269,25 +270,16 @@ async function forkSession( throw new Error("Instance not ready") } - const request: { - path: { id: string } - body?: { messageID: string } - } = { - path: { id: sourceSessionId }, - } - - if (options?.messageId) { - request.body = { messageID: options.messageId } + const request: { sessionID: string; messageID?: string } = { + sessionID: sourceSessionId, + messageID: options?.messageId, } log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) - const response = await instance.client.session.fork(request) - - if (!response.data) { - throw new Error("Failed to fork session: No data returned") - } - - const info = response.data as SessionForkResponse + const info = await requestData( + instance.client.session.fork(request), + "session.fork", + ) const forkedSession = { id: info.id, instanceId, @@ -365,7 +357,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const next = new Map(prev) @@ -528,14 +520,17 @@ async function loadMessages(instanceId: string, sessionId: string, force = false try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const response = await instance.client.session["messages"]({ path: { id: sessionId } }) + const apiMessages = await requestData( + instance.client.session.messages({ sessionID: sessionId }), + "session.messages", + ) - if (!response.data || !Array.isArray(response.data)) { + if (!Array.isArray(apiMessages) || apiMessages.length === 0) { return } const messagesInfo = new Map() - const messages: Message[] = response.data.map((apiMessage: any) => { + const messages: Message[] = apiMessages.map((apiMessage: any) => { const info = apiMessage.info || apiMessage const role = info.role || "assistant" const messageId = info.id || String(Date.now()) @@ -561,8 +556,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false let providerID = "" let modelID = "" - for (let i = response.data.length - 1; i >= 0; i--) { - const apiMessage = response.data[i] + for (let i = apiMessages.length - 1; i >= 0; i--) { + const apiMessage = apiMessages[i] const info = apiMessage.info || apiMessage if (info.role === "assistant") { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 51eb5b4e..f5427cfe 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -15,6 +15,7 @@ import type { import type { MessageStatus } from "./message-v2/types" import { getLogger } from "../lib/logger" +import { requestData } from "../lib/opencode-api" import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission" import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission" import { showToastNotification, ToastVariant } from "../lib/notifications" @@ -79,10 +80,12 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise< if (!instance?.client) return null try { - const response = await instance.client.session.get({ path: { id: sessionId } }) - if (!response.data) return null + const info = await requestData( + instance.client.session.get({ sessionID: sessionId }), + "session.get", + ) - const fetched = createClientSession(response.data, instanceId) + const fetched = createClientSession(info, instanceId) setSessions((prev) => { const next = new Map(prev) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index bcb01f7a..0150e58b 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -7,6 +7,7 @@ import { messageStoreBus } from "./message-v2/bus" import { instances } from "./instances" import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" +import { requestData } from "../lib/opencode-api" const log = getLogger("session") @@ -283,8 +284,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede } let messages: any[] = [] try { - const response = await instance.client.session.messages({ path: { id: session.id } }) - messages = response.data || [] + messages = await requestData( + instance.client.session.messages({ sessionID: session.id }), + "session.messages", + ) } catch (error) { log.error(`Failed to fetch messages for session ${session.id}`, error) return isFreshSession diff --git a/packages/ui/src/types/instance.ts b/packages/ui/src/types/instance.ts index b2ba792f..71486150 100644 --- a/packages/ui/src/types/instance.ts +++ b/packages/ui/src/types/instance.ts @@ -1,5 +1,5 @@ -import type { OpencodeClient } from "@opencode-ai/sdk/client" -import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk/v2" export interface LogEntry { timestamp: number