Migrate UI to v2 SDK client

Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
This commit is contained in:
Shantur Rathore
2026-01-04 22:01:49 +00:00
parent fcb5998474
commit 1377bc6b91
15 changed files with 186 additions and 117 deletions

7
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -90,9 +90,9 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (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) {

View File

@@ -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<SessionViewProps> = (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) {

View File

@@ -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")

View File

@@ -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)

View File

@@ -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<T> =
| {
data: T
error?: undefined
}
| {
data?: undefined
error: unknown
}
export async function requestData<T>(
promise: Promise<RequestResultLike<T> | undefined>,
label: string,
): Promise<T> {
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 }

View File

@@ -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<string, OpencodeClient>()
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)

View File

@@ -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<Map<string, SDKCommand[]>>(new Map())
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
const response = await client.command.list()
const commands = response.data ?? []
const commands = await requestData<SDKCommand[]>(client.command.list(), "command.list").catch(() => [])
setCommandMap((prev) => {
const next = new Map(prev)
next.set(instanceId, commands)

View File

@@ -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<void> {
const instance = instances().get(instanceId)
if (!instance?.client) return
try {
const remote = await requestData<PermissionRequestLike[]>(
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<LspStatus[] | undefin
return undefined
}
log.info("lsp.status", { instanceId })
const response = await lsp.status()
return response.data ?? []
return await requestData<LspStatus[]>(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)

View File

@@ -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<void> {
@@ -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<void> {
@@ -270,9 +271,12 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
try {
log.info("session.abort", { instanceId, sessionId })
await instance.client.session.abort({
path: { id: sessionId },
})
await requestData(
instance.client.session.abort({
sessionID: sessionId,
}),
"session.abort",
)
log.info("abortSession complete", { instanceId, sessionId })
} catch (error) {
log.error("Failed to abort session", error)
@@ -350,10 +354,13 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
throw new Error("Session title is required")
}
await instance.client.session.update({
path: { id: sessionId },
body: { title: trimmedTitle },
})
await requestData(
instance.client.session.update({
sessionID: sessionId,
title: trimmedTitle,
}),
"session.update",
)
withSession(instanceId, sessionId, (current) => {
current.title = trimmedTitle

View File

@@ -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<SessionForkResponse>(
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<voi
try {
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await instance.client.session.delete({ path: { id: sessionId } })
await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete")
setSessions((prev) => {
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<any[]>(
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<string, any>()
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") {

View File

@@ -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<any>(
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)

View File

@@ -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<any[]>(
instance.client.session.messages({ sessionID: session.id }),
"session.messages",
)
} catch (error) {
log.error(`Failed to fetch messages for session ${session.id}`, error)
return isFreshSession

View File

@@ -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