From 539cd7bb4c50c1c991d5a95ed6102efa2a1fe2a6 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 23 Jul 2026 14:30:24 +0800 Subject: [PATCH 1/6] feat(app): migrate session interactions --- .../app/e2e/regression/session-request-docks.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 714d6ca96f15..6c253f462ac6 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) - expect(request.postDataJSON()).toEqual({ response: "once" }) + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { From f92e436590bc8f43026f30556a9822f0f14c0bf0 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 10:41:16 +0800 Subject: [PATCH 2/6] test(app): fix current session request fixture --- packages/app/src/components/prompt-input/submit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index ac0691646451..cf23059b1bd3 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -197,8 +197,8 @@ beforeAll(async () => { const sdk = { scope: "local", directory: "/repo/main", - client: rootClient, api: rootClient.api, + client: rootClient, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) From da742f7928270242cf1a1413eeafb4dfbe2e7f26 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 10:42:42 +0800 Subject: [PATCH 3/6] test(app): use current request client fixture --- packages/app/src/components/prompt-input/submit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index cf23059b1bd3..ac0691646451 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -197,8 +197,8 @@ beforeAll(async () => { const sdk = { scope: "local", directory: "/repo/main", - api: rootClient.api, client: rootClient, + api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) From 31fa2575a23b139075e99daf649e08f2b9f176af Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 23 Jul 2026 14:30:37 +0800 Subject: [PATCH 4/6] feat(app): migrate discovery workflows --- .../regression/cross-server-tab-close.spec.ts | 22 +- .../e2e/regression/remote-tab-busy.spec.ts | 26 ++- .../session-list-path-loading.spec.ts | 4 +- .../regression/tab-navigate-mousedown.spec.ts | 25 ++- .../app/src/components/command-palette.ts | 13 +- .../components/dialog-command-palette-v2.tsx | 2 +- .../components/dialog-connect-provider.tsx | 200 ++++++++---------- .../components/dialog-select-directory-v2.tsx | 35 ++- .../components/dialog-select-directory.tsx | 3 +- .../app/src/components/dialog-select-mcp.tsx | 4 +- .../directory-picker-domain.test.ts | 31 ++- .../src/components/directory-picker-domain.ts | 23 +- packages/app/src/components/edit-project.ts | 7 +- .../app/src/components/titlebar-tab-nav.tsx | 3 +- packages/app/src/components/titlebar.tsx | 5 +- packages/app/src/context/file.tsx | 14 +- packages/app/src/context/layout.tsx | 8 +- packages/app/src/pages/layout.tsx | 63 +++--- .../app/test-browser/command-palette.test.ts | 14 +- 19 files changed, 294 insertions(+), 208 deletions(-) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 159b5a506767..7a43089d7fd7 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn await tabA.locator('[data-slot="tab-close"] button').click() await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) await expect(page.getByText(sessionB.title).first()).toBeVisible() const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) @@ -84,16 +85,19 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) @@ -116,7 +120,17 @@ async function mockServers(page: Page, requests: string[]) { directory: current.directory, home: current.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 7692928f9db8..e7cd11badeef 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -57,11 +58,14 @@ async function mockServers(page: Page) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route, url.pathname === "/api/event") if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") - return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) - if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === "/api/session/active") + return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) @@ -90,7 +94,17 @@ async function mockServers(page: Page) { directory: current.directory, home: current.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) return json(route, {}) }) } @@ -104,10 +118,10 @@ function json(route: Route, body: unknown, status = 200) { }) } -function sse(route: Route) { +function sse(route: Route, current: boolean) { return route.fulfill({ status: 200, contentType: "text/event-stream", - body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`, + body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", }) } diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 4a3855122a40..3319514df648 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() + await page.route("**/api/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 94afbc9a9d66..4136c16d0129 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const server = "http://127.0.0.1:4096" const sessionA = session("ses_tab_a", "Tab A session") @@ -56,9 +57,14 @@ async function mockServer(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, sessions) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) + return json(route, { data: [], cursor: {} }) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) if (byId) return json(route, byId) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -66,7 +72,7 @@ async function mockServer(page: Page) { if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) @@ -89,7 +95,20 @@ async function mockServer(page: Page) { directory: sessionA.directory, home: sessionA.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: sessionA.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) return json(route, {}) }) } diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 59d3cbd1da72..487d8235509e 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,5 +1,6 @@ import { getFilename } from "@opencode-ai/core/util/path" -import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createMemo, onCleanup } from "solid-js" import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" @@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs" import { displayName, projectForSession } from "@/pages/layout/helpers" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" +import { normalizeSessionInfo } from "@/utils/session" export type CommandPaletteEntry = { id: string @@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, load: (search, signal) => - serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -219,7 +221,7 @@ export function createServerSessionEntries(props: { server: ServerConnection.Key opened: () => LocalProject[] stored: () => Project[] - load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> + load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> untitled: () => string category: () => string }) { @@ -255,7 +257,8 @@ export function createServerSessionEntries(props: { return props .load(search, current.signal) .then((result) => - (result.data ?? []) + result.data + .map(normalizeSessionInfo) .filter((session) => !session.time.archived) .map((session) => { const project = @@ -264,7 +267,7 @@ export function createServerSessionEntries(props: { id: `session:${props.server}:${session.id}`, type: "session" as const, title: session.title || props.untitled(), - description: project ? displayName(project) : session.project?.name || getFilename(session.directory), + description: project ? displayName(project) : getFilename(session.directory), category: props.category(), directory: session.directory, sessionID: session.id, diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index 85ca44ae69db..c23b703e5255 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: { opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, load: (search, signal) => - serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4c58857249dd..93a62acb61d3 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,7 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { + IntegrationMethod, + IntegrationOauthConnectOutput, +} from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -28,6 +31,8 @@ import { Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" +import { useQueryClient } from "@tanstack/solid-query" +import { useParams } from "@solidjs/router" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -35,8 +40,11 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" +import { decode64 } from "@/utils/base64" +import { pathKey } from "@/utils/path-key" const CUSTOM_ID = "_custom" +type ConnectMethod = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) @@ -228,8 +236,6 @@ function ProviderPickerV2(props: { }) { const providers = useProviders(props.directory) const language = useLanguage() - const serverSync = useServerSync() - const serverSDK = useServerSDK() const [store, setStore] = createStore({ filter: "", active: undefined as string | undefined, @@ -266,19 +272,7 @@ function ProviderPickerV2(props: { const connect = (provider: string) => { props.onPrepare?.() - if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { - props.onSelect(provider) - return - } - if (store.connecting) return - setStore("connecting", provider) - void serverSDK() - .client.provider.auth() - .then((response) => { - serverSync().set("provider_auth", response.data ?? {}) - props.onSelect(provider) - }) - .catch(() => props.onSelect(provider)) + props.onSelect(provider) } const move = (event: KeyboardEvent, direction: number) => { @@ -395,10 +389,17 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const queryClient = useQueryClient() + const params = useParams() const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const providers = useProviders(props.directory) + const directory = () => props.directory?.() ?? decode64(params.dir) + const location = () => { + const value = directory() + return value ? { directory: value } : undefined + } const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -413,38 +414,34 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "api" as const, + type: "key" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - const [auth] = createResource( - () => props.provider, - async () => { - const cached = serverSync().data.provider_auth[props.provider] - if (cached) return cached - const res = await serverSDK().client.provider.auth() - if (!alive.value) return fallback() - serverSync().set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() - }, + const [integration] = createResource( + () => ({ provider: props.provider, directory: directory() }), + (input) => + serverSDK() + .api.integration.get({ + integrationID: input.provider, + location: input.directory ? { directory: input.directory } : undefined, + }) + .then((result) => result.data), ) - const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) - const cachedMethods = serverSync().data.provider_auth[props.provider] - const directMethod = - cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined + const loading = createMemo(() => integration.loading) + const methods = createMemo(() => { + const values = integration.latest?.methods.filter( + (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", + ) + return values?.length ? values : fallback() + }) const [store, setStore] = createStore({ - methodIndex: directMethod as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, + methodIndex: undefined as undefined | number, + authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], promptInputs: undefined as undefined | Record, - state: (directMethod === undefined ? "pending" : undefined) as - | undefined - | "pending" - | "complete" - | "error" - | "prompt", + state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) @@ -454,7 +451,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -508,7 +505,7 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "api") return language.t("provider.connect.method.apiKey") + if (value.type === "key") return language.t("provider.connect.method.apiKey") return value.label ?? "" } @@ -518,7 +515,7 @@ function ProviderConnection(props: { const hint = suffix?.[1] return { label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, + hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, } } @@ -549,46 +546,22 @@ function ProviderConnection(props: { const method = methods()[index] dispatch({ type: "method.select", index }) - if (method.type === "api" && method.prompts?.length) { - if (!inputs) { - dispatch({ type: "auth.prompt" }) - return - } - dispatch({ type: "auth.inputs", inputs }) - return - } - if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) return } dispatch({ type: "auth.pending" }) - const start = Date.now() await serverSDK() - .client.provider.oauth.authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) + .api.integration.oauth.connect({ + integrationID: props.provider, + methodID: method.id, + inputs: inputs ?? {}, + location: location(), + }) .then((x) => { if (!alive.value) return - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - if (timer.current !== undefined) clearTimeout(timer.current) - timer.current = setTimeout(() => { - timer.current = undefined - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data! }) - }, delay) - return - } - dispatch({ type: "auth.complete", authorization: x.data! }) + dispatch({ type: "auth.complete", authorization: x.data }) }) .catch((e) => { if (!alive.value) return @@ -603,9 +576,9 @@ function ProviderConnection(props: { index: 0, }) - const prompts = createMemo>(() => { + const prompts = createMemo(() => { const value = method() - return value?.prompts ?? [] + return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -636,10 +609,6 @@ function ProviderConnection(props: { setFormStore("index", next) return } - if (method()?.type === "api") { - dispatch({ type: "auth.inputs", inputs: value }) - return - } await selectMethod(store.methodIndex, value) } @@ -741,7 +710,10 @@ function ProviderConnection(props: { }) async function complete() { - await serverSDK().client.global.dispose() + const value = directory() + await queryClient + .refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null)) + .catch(() => undefined) dialog.close() showToast({ variant: "success", @@ -805,7 +777,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label} + key={(m) => m?.label ?? m?.type} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -851,13 +823,10 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - ...(store.promptInputs ? { metadata: store.promptInputs } : {}), - }, + await serverSDK().api.integration.connect.key({ + integrationID: props.provider, + location: location(), + key: apiKey, }) await complete() } @@ -984,12 +953,13 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.complete({ + integrationID: props.provider, + attemptID: store.authorization!.attemptID, + location: location(), code, }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -1076,25 +1046,37 @@ function ProviderConnection(props: { }) onMount(() => { - void (async () => { + const poll = async () => { + const authorization = store.authorization + if (!authorization || !alive.value) return const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.status({ + integrationID: props.provider, + attemptID: authorization.attemptID, + location: location(), }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then((value) => ({ ok: true as const, status: value.data })) .catch((error) => ({ ok: false as const, error })) - if (!alive.value) return - if (!result.ok) { - const message = formatError(result.error, language.t("common.requestFailed")) - dispatch({ type: "auth.error", error: message }) + dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) return } - - await complete() - })() + if (result.status.status === "complete") { + await complete() + return + } + if (result.status.status === "failed") { + dispatch({ type: "auth.error", error: result.status.message }) + return + } + if (result.status.status === "expired") { + dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + return + } + timer.current = setTimeout(poll, 1_000) + } + void poll() }) return ( @@ -1178,15 +1160,15 @@ function ProviderConnection(props: { - + - + - + diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 69d46ddcb62a..a457c9a2f5e5 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -28,6 +28,7 @@ import { } from "./directory-picker-domain" import "./dialog-select-directory-v2.css" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { getFilename } from "@opencode-ai/core/util/path" interface DialogSelectDirectoryV2Props { title?: string @@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), () => - sdk.client.path + sdk.api.path .get() - .then((result) => result.data) .catch(() => undefined), { initialValue: undefined }, ) @@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const typed = cleanPickerInput(value).replace(/\/+$/, "") + const cleaned = cleanPickerInput(value) + const typed = cleaned.replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!typed || typed === current) return { query: value, items: [] } + if (!cleaned || (root() && typed === current)) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.client.find - .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) - .then((result) => result.data ?? []) + const base = pickerRoot(cleaned) || root() || start() + if (!base) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.api.file + .find({ + location: { directory: base }, + query: pickerFileSearchQuery(base, value, home()), + type: "file", + limit: 20, + }) + .then((result) => result.data) .catch(() => []) const results = [ ...directories, - ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), + ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), ] return { query: value, @@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.client.file - .list({ directory: absolute, path: "" }) - .then((result) => result.data ?? []) + return sdk.api.file + .list({ location: { directory: absolute } }) + .then((result) => + result.data.map((entry) => ({ + name: getFilename(entry.path.replace(/[\\/]+$/, "")), + type: entry.type, + })), + ) .catch(() => undefined) }) listings.set(key, request) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 8ba09a9f9046..80ac070750da 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async () => { - return sdk.client.path + return sdk.api.path .get() - .then((x) => x.data) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 05253381f0c9..4f1a3cd2392c 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (!x || toggle.isPending) return + if (!x || x.status === "pending" || toggle.isPending) return toggle.mutate(x.name) }} > @@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> { if (toggle.isPending) return toggle.mutate(i.name) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 57464106106c..1bc9af08334c 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - client: { - find: { - files: (input: { directory: string }) => { - directories.push(input.directory) + api: { + file: { + find: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") return Promise.resolve({ data: [] }) }, }, @@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) +test("searches from an absolute root without a default base", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + list: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "Users/", type: "directory" }, + { path: "tmp/", type: "directory" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) + + expect(await search("/")).toEqual(["/Users", "/tmp"]) + expect(directories).toEqual(["/"]) +}) + test("identifies the next directory level to preload", () => { expect( preloadTreeDirectories("src/", [ diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9900265962ea..9539ae1d01dc 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string let current = 0 const scoped = (value: string) => { + const raw = normalizePickerDrive(value) + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } const base = args.base() if (!base) return - const raw = normalizePickerDrive(value) if (!raw) return { directory: trimPickerPath(base), path: "" } const home = args.home() if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } - const root = pickerRoot(raw) - if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } return { directory: trimPickerPath(base), path: raw } } @@ -342,14 +342,17 @@ 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.client.file - .list({ directory: key, path: "" }) - .then((result) => result.data ?? []) + const request = args.sdk.api.file + .list({ location: { directory: key } }) + .then((result) => result.data) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), + .map((node) => { + const relative = trimPickerPath(normalizePickerDrive(node.path)) + return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } + }), ) cache.set(key, request) return request @@ -371,9 +374,9 @@ 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.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) - .then((result) => result.data ?? []) + const results = await args.sdk.api.file + .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) + .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) if (!active()) return [] return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts index 3ec999da0639..42053f6eff81 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -1,6 +1,7 @@ import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useGlobal } from "@/context/global" @@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverCtx().sdk.client.project.update({ + const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, - directory: props.project.worktree, 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)), + ) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index a397046f9b3e..3058e6881af7 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -120,8 +120,7 @@ export function TabNavItem(props: { const ctx = serverCtx() const session = props.session() if (!ctx || !session) return - const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) - await client.session.update({ sessionID: session.id, title }) + await ctx.sdk.api.session.rename({ sessionID: session.id, title }) } const closeRename = async (save: boolean) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 45786561077a..aa2f220e4949 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" +import { normalizeSessionInfo } from "@/utils/session" type TauriDesktopWindow = { startDragging?: () => Promise @@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.client.session + sdk.api.session .get({ sessionID: route.sessionId }) - .then((x) => x.data) + .then(normalizeSessionInfo) .catch(() => {}), ) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 6032b81dde70..fbbef3a2a8e7 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => - sdk() - .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) + serverSDK() + .api.file.find( + { + location: { directory: sdk().directory }, + query, + type: dirs === "true" ? "directory" : "file", + limit: options?.limit, + }, + { signal: options?.signal }, + ) .then( - (x) => (x.data ?? []).map(path.normalize), + (x) => x.data.map((entry) => path.normalize(entry.path)), (error) => { if (options?.signal?.aborted) throw error return [] diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 7ac248dc1d1b..c039b3d48277 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" +import { normalizeProjectInfo } from "./global-sync/utils" import { Persist, persisted, removePersisted } from "@/utils/persist" import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" @@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } void serverSdk() - .client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) + .api.project.update({ projectID: project.id, icon: { color } }) + .then((result) => + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ), + ) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 812a479b200f..59474423184a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers" import { toaster } from "@opencode-ai/ui/toast" import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" @@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { listAllSessions } from "@/utils/session" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" @@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) { const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] - await serverSDK().client.session.update({ - directory: session.directory, - sessionID: session.id, - time: { archived: Date.now() }, - }) + await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory }) setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) @@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) { } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) - const listed = await serverSDK() - .client.worktree.list({ directory: root }) - .then((x) => x.data ?? []) + const listed = await Promise.resolve( + project?.id ?? serverSDK().api.project.current({ location: { directory: root } }), + ) + .then((value) => (typeof value === "string" ? value : value.id)) + .then((projectID) => serverSDK().api.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]) return canOpen(target) @@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) { await Promise.all( dirs.map(async (item) => ({ path: { directory: item }, - session: await serverSDK() - .client.session.list({ directory: item }) - .then((x) => x.data ?? []) - .catch(() => []), + session: await listAllSessions(serverSDK().api.session, { + directory: item, + parentID: null, + order: "desc", + }).catch(() => []), })), ), Date.now(), @@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) + 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)), + ) return } @@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) { }) const dismiss = () => toaster.dismiss(progress) - const sessions: Session[] = await serverSDK() - .client.session.list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) clearWorkspaceTerminals( directory, @@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) { return } - const archivedAt = Date.now() await Promise.all( sessions .filter((session) => session.time.archived === undefined) .map((session) => serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: archivedAt }, - }) + .api.session.archive({ sessionID: session.id, directory: session.directory }) .catch(() => undefined), ), ) @@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setData({ status: "ready", dirty }) }) @@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) { }) const refresh = async () => { - const sessions = await serverSDK() - .client.session.list({ directory: props.directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { + directory: props.directory, + order: "desc", + }).catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) } onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts index 421a2e71fd32..6a74834fd076 100644 --- a/packages/app/test-browser/command-palette.test.ts +++ b/packages/app/test-browser/command-palette.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" import { createRoot } from "solid-js" import { createServerSessionEntries } from "@/components/command-palette" import type { LocalProject } from "@/context/layout" @@ -14,15 +15,16 @@ const stored: Project = { time: { created: 1, updated: 1 }, } -const session: GlobalSession = { +const session: SessionInfo = { id: "session-1", - slug: "session-1", projectID: stored.id, - directory: stored.worktree, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: stored.worktree }, title: "Palette session", - version: "1", time: { created: 1, updated: 2 }, - project: { id: stored.id, name: stored.name, worktree: stored.worktree }, } describe("command palette sessions", () => { From e6cab020f0e115efe13d188b1ceaf1e28133bab6 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 13:43:02 +0800 Subject: [PATCH 5/6] fix e2e --- .../regression/cross-server-tab-close.spec.ts | 3 ++- .../e2e/regression/remote-tab-busy.spec.ts | 3 ++- .../regression/session-request-docks.spec.ts | 1 + packages/app/src/context/permission.tsx | 7 +++++- packages/app/src/utils/server-compat.test.ts | 23 +++++++++++++++++++ packages/app/src/utils/server-compat.ts | 19 +++++++++++---- 6 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 7a43089d7fd7..a8fc81b17c24 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -86,7 +86,8 @@ async function mockServers(page: Page, requests: string[]) { const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index e7cd11badeef..faf591e3a19d 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -60,7 +60,8 @@ async function mockServers(page: Page) { if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route, url.pathname === "/api/event") - if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session/active") return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 6c253f462ac6..5ea9d4f7613b 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -173,6 +173,7 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 388e4534a11a..3ed91e60bfea 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -245,7 +245,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } const respond: PermissionRespondFn = (request) => { if (meta.disposed) return input.sdk.api.permission - .reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response }) + .reply({ + sessionID: request.sessionID, + requestID: request.permissionID, + reply: request.response, + location: request.directory ? { directory: request.directory } : undefined, + }) .catch(() => { responded.delete(request.permissionID) }) diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 908664cdec4b..f46c5f86e0cf 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -123,4 +123,27 @@ describe("createCompatibleApi", () => { data: { branch: "feature", defaultBranch: "dev" }, }) }) + + test("translates current file searches to the V1 dirs parameter", async () => { + const { api, requests } = setup("v1") + await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/find/file") + expect(url.searchParams.get("dirs")).toBe("false") + expect(url.searchParams.get("limit")).toBe("20") + }) + + test("routes V1 permission replies through the requested directory", async () => { + const { api, requests } = setup("v1") + await api.permission.reply({ + sessionID: "ses_1", + requestID: "permission_1", + reply: "once", + location: { directory: "/other" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1") + expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other") + }) }) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 88282742b96f..7070c5222aa4 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -30,7 +30,15 @@ type CompatibleSessionApi = Omit< archive: (input: Parameters[0] & LegacyLocation) => ReturnType remove: (input: Parameters[0] & LegacyLocation) => ReturnType } -export type CompatibleApi = Omit & { readonly session: CompatibleSessionApi } +type CompatiblePermissionApi = Omit & { + reply: ( + input: Parameters[0] & { location?: { directory?: string } }, + ) => ReturnType +} +export type CompatibleApi = Omit & { + readonly session: CompatibleSessionApi + readonly permission: CompatiblePermissionApi +} type LegacyPrompt = { agent?: string model?: { providerID: string; modelID: string } @@ -350,7 +358,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { async find(value: Parameters[0]) { const result = await legacy(value.location).find.files({ query: value.query, - type: value.type, + dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false", limit: value.limit, }) return located( @@ -471,11 +479,14 @@ function createV1Api(input: CompatibleInput): CompatibleApi { }, permission: { ...input.current.permission, - async reply(value: Parameters[0]) { - await legacy().permission.respond({ + async reply( + value: Parameters[0] & { location?: { directory?: string } }, + ) { + await legacy(value.location).permission.respond({ sessionID: value.sessionID, permissionID: value.requestID, response: value.reply, + directory: directory(value.location), }) }, }, From a37bc25f641e70e960a3bcddbdcfbb6fab727ab4 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 14:09:13 +0800 Subject: [PATCH 6/6] test(app): expect directory in remote session permission reply --- packages/app/e2e/regression/remote-session-settings.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 40491c867ea1..c17ae5c1c66e 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: undefined, + directory: directoryA, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, @@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: undefined, + directory: directoryA, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, }, { origin: serverA, - directory: undefined, + directory: directoryA, sessionID: childSessionA.id, permissionID: "permission-background-a-child", body: { response: "once" },