Compare commits

...

10 Commits

Author SHA1 Message Date
Brendan Allan be9d49c3f6 test(app): retain gated legacy projection coverage 2026-08-04 15:30:53 +08:00
Brendan Allan 38df0b0c74 Merge branch 'app-v2-apis' into app-v2-gating 2026-08-04 15:28:51 +08:00
Brendan Allan 84787ec55f test(app): select v2 smoke transport 2026-08-04 15:28:38 +08:00
Brendan Allan 65ba003cbc Merge branch 'app-v2-apis' into app-v2-gating 2026-08-04 15:15:45 +08:00
Brendan Allan 5deafd93a1 test(app): align migrated session projections 2026-08-04 15:15:31 +08:00
Brendan Allan 0be919b6e5 Merge branch 'app-v2-apis' into app-v2-gating 2026-08-04 15:03:34 +08:00
Brendan Allan e0285d3f83 test(app): cover migrated v2 operations 2026-08-04 15:03:22 +08:00
Brendan Allan 251d26adf3 Merge branch 'app-v2-sessions' into app-v2-apis 2026-08-04 15:02:05 +08:00
Brendan Allan 8bd5407019 refactor(app): gate unsupported v2 capabilities 2026-08-04 14:13:13 +08:00
Brendan Allan ca00e39d4a refactor(app): migrate supported v2 APIs 2026-08-04 13:59:02 +08:00
55 changed files with 690 additions and 686 deletions
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
await writeProbe(page)
await switchTab(page, titleB)
@@ -66,7 +66,6 @@ async function readProbe(page: Page) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -21,7 +21,7 @@ const words = [
"vector",
]
const serverKey = "http://127.0.0.1:4096"
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
@@ -134,7 +134,7 @@ function toolPart(
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
callID: id("call", index * 100 + partIndex),
tool,
state: {
status: "completed",
@@ -235,8 +235,17 @@ function renderable(part: MessagePart) {
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 }
return message.parts
.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
.sort()
}
export const fixture = {
@@ -290,12 +299,10 @@ export const fixture = {
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages
.flatMap((message) => message.parts)
.find((part) => part.tool === "bash")!.callID,
},
}
@@ -33,6 +33,7 @@ test.describe("smoke: session timeline", () => {
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -91,6 +92,7 @@ test.describe("smoke: session timeline", () => {
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -117,6 +119,7 @@ test.describe("smoke: session timeline", () => {
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -125,20 +128,19 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
({ server, sourceID, targetID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
server,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
@@ -243,6 +245,7 @@ test.describe("smoke: session timeline", () => {
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -251,20 +254,19 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
({ server, sourceID, targetID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
server,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -322,6 +324,7 @@ test.describe("smoke: session timeline", () => {
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -6,7 +6,7 @@ const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project and selects its model", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v1",
protocol: "v2",
directory,
project: {
id: "proj_model_selection_flow",
+1 -1
View File
@@ -80,7 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
@@ -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"),
})
@@ -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"),
})
@@ -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(),
@@ -136,7 +136,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK().client.auth.set({
await serverSDK().legacy.auth.set({
providerID: result.providerID,
auth: {
type: "api",
+1 -1
View File
@@ -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 })
@@ -70,10 +70,17 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then((result) => result.data)
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -97,7 +104,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 +134,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) => ({
@@ -61,10 +61,17 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
if ((await sdk.protocol) === "v1")
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then((result) => result.data)
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -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 ?? "")
@@ -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(() => [])
+1 -7
View File
@@ -73,7 +73,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
.sdk.legacy.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
@@ -82,12 +82,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
@@ -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 () => {
@@ -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,
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,16 +125,11 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -325,10 +320,11 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
@@ -345,7 +341,8 @@ export const SettingsGeneral: Component = () => {
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -122,16 +122,18 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
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",
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -122,14 +123,8 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -284,10 +279,11 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
@@ -303,7 +299,8 @@ export const SettingsGeneralV2: Component<{
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -119,16 +119,21 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
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",
@@ -318,10 +318,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<Show when={protocol() === "v1"}>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
@@ -485,7 +488,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins">
+20 -57
View File
@@ -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"
@@ -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()
+1 -1
View File
@@ -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(() => {}),
+2 -3
View File
@@ -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))
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
await serverSDK.legacy.session.archive(sessionID, directory)
current()[1](
"session",
produce((draft) => {
+13 -6
View File
@@ -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,
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
@@ -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",
@@ -90,37 +111,8 @@ describe("bootstrapDirectory", () => {
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api,
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
api: currentApi,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -141,12 +133,20 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const client = {} as Parameters<typeof loadPathQuery>[2]
const location = {} as Parameters<typeof loadPathQuery>[2]
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).queryKey]).toEqual([
"local",
"/repo",
"path",
])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
"https://debian.example",
"/repo",
"path",
])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
+55 -111
View File
@@ -8,7 +8,7 @@ import type {
ReferenceInfo,
Session,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type {
AgentListInput,
AgentListOutput,
@@ -16,6 +16,8 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
LocationGetInput,
LocationGetOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
@@ -105,16 +107,18 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
queryFn: () => retry(() => legacy.config.global()),
enabled,
})
type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
@@ -138,8 +142,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
})
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly project: ProjectApi }
legacy: LegacyCapabilities
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
@@ -148,18 +152,22 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
const protocol = await input.protocol
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
() =>
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.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)),
]
].filter(Boolean) as Array<() => Promise<unknown>>
await runAll(slow)
// showErrors({
// errors: errors(),
@@ -219,17 +227,11 @@ export const loadProvidersQuery = (
scope: ServerScope,
directory: string | null,
sdk: CatalogApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
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,71 +258,45 @@ export const loadAgentsQuery = (
scope: ServerScope,
directory: string,
sdk: AgentListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
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<ServerProtocol>,
): Promise<CommandInfo[]> =>
retry(async () => {
if ((await protocol) === "v1" && legacy) {
return ((await legacy.command.list()).data ?? []).map((command) => {
const [providerID, id] = command.model?.split("/") ?? []
return {
name: command.name,
template: command.template,
description: command.description,
agent: command.agent,
model: providerID && id ? { providerID, id } : undefined,
subtask: command.subtask,
// source: command.source === "skill" ? undefined : command.source,
}
})
}
return api.list({ location: { directory } }).then((result) => result.data)
})
retry(() => api.list({ location: { directory } }).then((result) => result.data))
export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
api: LocationApi,
) =>
queryOptions<Path>({
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!))
},
queryFn: () =>
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
})),
})
export const loadReferencesQuery = (
scope: ServerScope,
directory: string,
api: ReferenceListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<ReferenceInfo[]>({
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: [],
})
@@ -328,7 +304,7 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
legacy: LegacyCapabilities
api: CatalogApi & {
readonly agent: AgentListApi
readonly command: CommandListApi
@@ -339,6 +315,7 @@ export async function bootstrapDirectory(input: {
readonly reference: ReferenceListApi
readonly session: SessionApi
readonly vcs: VcsApi
readonly location: LocationApi
}
store: Store<State>
setStore: SetStoreFunction<State>
@@ -373,37 +350,15 @@ 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 })))),
() =>
retry(() =>
(async () => {
if ((await input.protocol) !== "v1") return
const x = await input.sdk.session.status()
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
})(),
),
(await input.protocol) === "v1" &&
(() =>
retry(() =>
input.legacy.config
.directory(input.directory)
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -412,37 +367,28 @@ 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),
)
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
})
}),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
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(() =>
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
return input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
})().then((permissions) => {
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
@@ -473,12 +419,10 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
return input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
})().then((questions) => {
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
@@ -511,16 +455,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({
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const lspQuery = useQuery(() => {
const options = input.queryOptions.lsp(key)
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
})
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
} as const
}
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
try {
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
return { data: result.data, limit: input.limit, limited: true } as const
} catch {
const result = await input.client.session.list({ directory: input.directory, roots: true })
return { data: result.data, limit: input.limit, limited: false } as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count
if (input.count < input.limit) return input.count
+1 -1
View File
@@ -574,7 +574,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
void (async () => {
const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return
return sdk.client.project
return sdk.legacy.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
-3
View File
@@ -258,9 +258,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}
const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
+2 -2
View File
@@ -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<ServerSDK["ensureDirSdkContext"]>
export type { DirectorySDK }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
+39 -21
View File
@@ -12,7 +12,13 @@ import { createRefCountMap } from "@/utils/refcount"
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 {
createCompatibleApi,
createLegacyCapabilities,
type CompatibleApi,
type LegacyCapabilities,
} 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"
@@ -165,6 +171,7 @@ type ServerSDKBase = {
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
legacy: LegacyCapabilities
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
@@ -192,11 +199,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 +266,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
@@ -339,6 +335,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
return {
server,
@@ -348,6 +345,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
url: server.http.url,
client: sdk,
api,
legacy: capabilities,
currentApi,
event: {
on: emitter.on.bind(emitter),
@@ -364,8 +362,25 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type DirectorySDK = {
scope: ServerScope
protocol: Promise<ServerProtocol>
directory: string
client: OpencodeClient
currentApi: ServerApi
api: CompatibleApi
legacy: LegacyCapabilities
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
createClient: ServerSDKBase["createClient"]
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
ensureDirSdkContext: (directory: string) => DirectorySDK
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
@@ -397,11 +412,7 @@ export function useServerProtocol() {
return createMemo(() => serverSDK().protocolKind())
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const client = serverSDK.createClient({
directory,
throwOnError: true,
@@ -419,12 +430,19 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
protocol: serverSDK.protocol,
directory,
client,
currentApi: serverSDK.currentApi,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
legacy: createLegacyCapabilities({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
+11 -5
View File
@@ -10,7 +10,7 @@ import type {
SessionStatus,
Todo,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -183,10 +183,14 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
type ServerSessionOptions = {
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
legacy?: LegacyCapabilities
}
export function createServerSession(
client: OpencodeClient,
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions,
@@ -1389,14 +1393,16 @@ export function createServerSession(
touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return
if ((await options?.protocol) === "v2") {
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
if (!options?.legacy) return Promise.resolve()
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
setData("todo", sessionID, reconcile(result, { key: "id" }))
})
})
},
+58 -91
View File
@@ -5,7 +5,6 @@ import type {
ProviderAuthResponse,
SessionStatus,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -27,7 +26,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 +58,8 @@ import type {
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
import type { LegacyCapabilities } from "@/utils/server-compat"
type GlobalStore = {
ready: boolean
@@ -94,8 +95,6 @@ export const loadMcpQuery = (
scope: ServerScope,
directory: string,
api: McpListApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
queryOptions<
Record<string, McpServer["status"]>,
@@ -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<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
queryOptions<
Record<string, McpResource>,
@@ -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) =>
@@ -144,10 +132,11 @@ export const loadMcpResourcesQuery = (
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
queryFn: () => legacy.lsp.status(directory),
enabled,
})
export const loadActiveSessionsQuery = (
@@ -178,25 +167,20 @@ export function seedActiveSessionStatuses(
function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
serverAPI: ServerApi,
sdkFor: (dir: PathKey) => OpencodeClient,
protocol: Promise<"v1" | "v2">,
protocolKind: Accessor<"v1" | "v2" | undefined>,
legacy: LegacyCapabilities,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
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),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
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, legacy, protocolKind() === "v1"),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
@@ -204,35 +188,28 @@ export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
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")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sdkFor = (directory: string) => {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol,
})
const session = createServerSession(
{ session: serverSDK.legacy.session },
serverSDK.currentApi.session,
serverSDK.currentApi.message,
{
protocol: serverSDK.protocol,
legacy: serverSDK.legacy,
},
)
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
() => serverSDK.client,
serverSDK.api,
sdkFor,
serverSDK.protocol,
serverSDK.currentApi,
serverSDK.protocolKind,
serverSDK.legacy,
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
@@ -241,19 +218,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)
@@ -321,8 +286,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
serverAPI: serverSDK.api,
legacy: serverSDK.legacy,
serverAPI: serverSDK.currentApi,
protocol: serverSDK.protocol,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
@@ -363,7 +328,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({
@@ -377,7 +342,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory)
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key)
},
translate: language.t,
@@ -416,12 +380,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)
@@ -479,7 +438,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key)
if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
@@ -490,8 +448,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
api: serverSDK.api,
legacy: serverSDK.legacy,
api: serverSDK.currentApi,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -610,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (serverSDK.protocolKind() !== "v1") return
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
@@ -658,7 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -692,27 +651,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))
+17 -37
View File
@@ -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)
})
},
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
home.server.context(conn).projects.move(worktree, index)
},
canReveal: canRevealProject,
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean
canEditProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
@@ -548,9 +549,11 @@ function HomeProjectRow(
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.canEditProject(props.server)}>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
</Show>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t(
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal}
canEditProject={props.projects.project.canEdit}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
@@ -1,4 +1,4 @@
import type { Session } from "@/types"
import type { Session, V2SessionListResponse } from "@/types"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
(input, options) =>
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
data: data as unknown as V2SessionListResponse,
})),
eventSequence,
signal,
)
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
create: home.project.openNewSession,
open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.directory)
@@ -211,16 +215,10 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
archive: (sessionID) =>
ctx.sdk.client.session.update({
sessionID,
directory: session.directory,
time: { archived: Date.now() },
}),
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
remove: () =>
setStore(
produce((draft) => {
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
canArchiveSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
group-hover/session:opacity-100 focus-within:opacity-100
`}
>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<Show when={props.canArchiveSession()}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</TooltipV2>
</Show>
</div>
</Show>
</div>
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
canArchiveSession={props.sessions.session.canArchive}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
+68 -64
View File
@@ -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"
@@ -872,17 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
await serverSDK().legacy.session.archive(session.id, session.directory)
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -980,6 +975,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"),
category: language.t("command.category.session"),
keybind: "mod+shift+backspace",
hidden: serverSDK().protocolKind() !== "v1",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
@@ -1189,10 +1185,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 +1236,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",
@@ -1301,13 +1300,10 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
const sdk = serverSDK()
if ((await sdk.protocol) !== "v1") return
const result = await sdk.client.project
const result = await serverSDK().legacy.project
.update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data)
if (!result) return
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
@@ -1403,16 +1399,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 +1460,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,
@@ -1469,12 +1470,8 @@ export default function LegacyLayout(props: ParentProps) {
platform,
serverSDK().scope,
)
await serverSDK()
.client.instance.dispose({ directory })
.catch(() => undefined)
const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
.legacy.workspace.reset(root, directory)
.then((x) => x.data)
.catch((err) => {
showToast({
@@ -1496,11 +1493,7 @@ export default function LegacyLayout(props: ParentProps) {
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
.legacy.session.archive(session.id, session.directory)
.catch(() => undefined),
),
)
@@ -1595,7 +1588,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 +1828,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)
@@ -1881,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
workspaceName,
renameWorkspace,
editorOpen,
@@ -1917,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
openSidebar: () => layout.sidebar.open(),
closeProject,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
canEditProject: () => serverSDK().protocolKind() === "v1",
toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds,
@@ -1927,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
},
}
@@ -2017,16 +2020,19 @@ export default function LegacyLayout(props: ParentProps) {
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
void renameProject(project, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Show
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
>
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => void renameProject(project, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
</Show>
<Tooltip
placement="bottom"
@@ -2061,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
showEditProjectDialog(server.current!, project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={serverSDK().protocolKind() === "v1"}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
@@ -87,6 +87,7 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
}
const SessionRow = (props: {
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
<Show when={!props.level}>
<Show when={!props.level && props.canArchive()}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
openSidebar: () => void
closeProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
onProjectFocus: (worktree: string) => void
navigateToProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
closeProject: (directory: string) => void
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={props.canEditProject()}>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
onProjectFocus={props.ctx.onProjectFocus}
navigateToProject={props.ctx.navigateToProject}
showEditProjectDialog={props.ctx.showEditProjectDialog}
canEditProject={props.ctx.canEditProject}
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
canResetWorkspace: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
workspaceValue: Accessor<string>
openEditor: WorkspaceSidebarContext["openEditor"]
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
@@ -199,12 +202,14 @@ const WorkspaceActions = (props: {
>
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.canResetWorkspace()}>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
@@ -272,6 +277,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/>
)}
</For>
@@ -416,6 +422,7 @@ export const SortableWorkspace = (props: {
workspaceValue={workspaceValue}
openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
canResetWorkspace={props.ctx.canResetWorkspace}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+19 -13
View File
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
@@ -361,6 +361,7 @@ export default function Page() {
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
@@ -847,7 +848,7 @@ export default function Page() {
}
const gitMutation = useMutation(() => ({
mutationFn: () => sdk().client.project.initGit(),
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
@@ -896,17 +897,19 @@ export default function Page() {
() => {
const id = params.id
return [
protocol(),
sdk().directory,
id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
([serverProtocol, dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (serverProtocol !== "v1") return
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined)
@@ -1217,11 +1220,13 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
<Show when={protocol() === "v1"}>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</Show>
</div>
)
@@ -1254,7 +1259,8 @@ export default function Page() {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
return empty(language.t("session.review.noVcs.createGit.description"))
}
return <SessionReviewEmptyChangesV2 />
}
@@ -1724,7 +1730,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 +1826,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 +1855,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
@@ -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
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -302,7 +302,8 @@ export function MessageTimeline(props: {
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -665,14 +666,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -680,7 +681,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) => {
@@ -818,14 +819,12 @@ export function MessageTimeline(props: {
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.legacy.session.archive(sessionID, sdk().directory)
.then(() => {
sync().set(
produce((draft) => {
@@ -854,7 +853,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({
@@ -1574,9 +1573,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={protocol() === "v1"}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1645,9 +1646,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<Show when={protocol() === "v1"}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import { useServerProtocol } from "@/context/server-sdk"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
@@ -194,7 +196,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const url = await sdk()
.client.session.share({ sessionID })
.legacy.session.share(sessionID)
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
@@ -214,7 +216,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!sessionID) return
await sdk()
.client.session.unshare({ sessionID })
.legacy.session.unshare(sessionID)
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
@@ -306,7 +308,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 +336,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 +368,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 = () => {
@@ -389,6 +379,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({
@@ -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
+134 -34
View File
@@ -3,7 +3,6 @@ import type { ServerProtocol } from "./server-protocol"
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
SessionApi,
SessionCommandInput,
@@ -28,7 +27,6 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -54,6 +52,99 @@ type CompatibleInput = {
directory?: string
}
export function createLegacyCapabilities(input: CompatibleInput) {
const directory = (value?: string) => value ?? input.directory
const client = (value?: string) => input.legacy(directory(value))
const requireV1 = async () => {
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
}
return {
config: {
global: async () => {
await requireV1()
return (await client().global.config.get()).data ?? {}
},
directory: async (value?: string) => {
await requireV1()
return (await client(value).config.get()).data ?? {}
},
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
await requireV1()
return client().global.config.update({ config })
},
},
auth: {
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
await requireV1()
return client().auth.set(value)
},
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
await requireV1()
return client().auth.remove(value)
},
},
session: {
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
share: async (sessionID: string) => {
await requireV1()
return client().session.share({ sessionID })
},
unshare: async (sessionID: string) => {
await requireV1()
return client().session.unshare({ sessionID })
},
archive: async (sessionID: string, value?: string) => {
await requireV1()
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
},
todo: async (sessionID: string, value?: string) => {
await requireV1()
return (await client(value).session.todo({ sessionID })).data ?? []
},
},
project: {
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
await requireV1()
return client(value.directory).project.update(value)
},
initGit: async (value?: string) => {
await requireV1()
return client(value).project.initGit()
},
},
workspace: {
reset: async (root: string, value: string) => {
await requireV1()
await client(value).instance.dispose().catch(() => undefined)
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
},
},
pty: {
shells: async () => {
await requireV1()
return (await client().pty.shells()).data ?? []
},
},
path: {
get: async (value?: string) => {
await requireV1()
return (await client(value).path.get()).data
},
},
lsp: {
status: async (value: string) => {
await requireV1()
return (await client(value).lsp.status()).data ?? []
},
},
}
}
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream"
@@ -184,9 +275,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
@@ -313,34 +401,28 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
canonical: result.data.worktree,
} satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
// path: {
// ...input.current.path,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable")
// return result.data
// },
// },
location: {
...input.current.location,
async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Location unavailable")
return {
directory: result.data.directory,
project: {
id: "",
directory: result.data.worktree,
canonical: result.data.worktree,
},
}
},
},
vcs: {
...input.current.vcs,
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
@@ -456,9 +538,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
pty: {
...input.current.pty,
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
@@ -490,14 +569,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
},
permission: {
...input.current.permission,
request: {
...input.current.permission.request,
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
const result = await legacy(value?.location).permission.list()
return located(
(result.data ?? []).map((item) => ({
id: item.id,
sessionID: item.sessionID,
action: item.permission,
resources: item.patterns,
metadata: item.metadata,
save: item.always,
source: item.tool
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
: undefined,
})),
value?.location,
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
},
},
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
await legacy(value.location).permission.respond({
sessionID: value.sessionID,
@@ -509,6 +603,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
question: {
...input.current.question,
request: {
...input.current.question.request,
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
return located((await legacy(value?.location).question.list()).data ?? [], value?.location)
},
},
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
await legacy().question.reply({
requestID: value.requestID,