Просмотр исходного кода

feat(app): migrate discovery workflows (#38465)

Brendan Allan 1 месяц назад
Родитель
Сommit
d07323ef59
24 измененных файлов с 353 добавлено и 222 удалено
  1. 20 5
      packages/app/e2e/regression/cross-server-tab-close.spec.ts
  2. 3 3
      packages/app/e2e/regression/remote-session-settings.spec.ts
  3. 22 7
      packages/app/e2e/regression/remote-tab-busy.spec.ts
  4. 2 2
      packages/app/e2e/regression/session-list-path-loading.spec.ts
  5. 8 4
      packages/app/e2e/regression/session-request-docks.spec.ts
  6. 22 3
      packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
  7. 8 5
      packages/app/src/components/command-palette.ts
  8. 1 1
      packages/app/src/components/dialog-command-palette-v2.tsx
  9. 91 109
      packages/app/src/components/dialog-connect-provider.tsx
  10. 24 11
      packages/app/src/components/dialog-select-directory-v2.tsx
  11. 1 2
      packages/app/src/components/dialog-select-directory.tsx
  12. 2 2
      packages/app/src/components/dialog-select-mcp.tsx
  13. 27 4
      packages/app/src/components/directory-picker-domain.test.ts
  14. 13 10
      packages/app/src/components/directory-picker-domain.ts
  15. 5 2
      packages/app/src/components/edit-project.ts
  16. 1 2
      packages/app/src/components/titlebar-tab-nav.tsx
  17. 3 2
      packages/app/src/components/titlebar.tsx
  18. 11 3
      packages/app/src/context/file.tsx
  19. 7 1
      packages/app/src/context/layout.tsx
  20. 6 1
      packages/app/src/context/permission.tsx
  21. 30 33
      packages/app/src/pages/layout.tsx
  22. 23 0
      packages/app/src/utils/server-compat.test.ts
  23. 15 4
      packages/app/src/utils/server-compat.ts
  24. 8 6
      packages/app/test-browser/command-palette.test.ts

+ 20 - 5
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,20 @@ 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/health") return json(route, { healthy: true })
-    if (url.pathname === "/session") return json(route, [current])
+    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
+    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) })
+    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 +121,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, {})
   })
 }

+ 3 - 3
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" },

+ 22 - 7
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,15 @@ 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/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 === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+      return sse(route, url.pathname === "/api/event")
+    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: {} })
+    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 +95,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 +119,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",
   })
 }

+ 2 - 2
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<void>((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()
   })

+ 8 - 4
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 }) => {
@@ -170,6 +173,7 @@ async function mockServer(
   },
 ) {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,

+ 22 - 3
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, {})
   })
 }

+ 8 - 5
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,

+ 1 - 1
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"),
   })

+ 91 - 109
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<IntegrationMethod, { type: "key" | "oauth" }>
 
 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<typeof setTimeout> | 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<ProviderAuthMethod[]>(() => [
+  const fallback = createMemo<ConnectMethod[]>(() => [
     {
-      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<ConnectMethod[]>(() => {
+    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<string, string>,
-    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<string, string> }
     | { 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<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
+    const prompts = createMemo(() => {
       const value = method()
-      return value?.prompts ?? []
+      return value?.type === "oauth" ? (value.prompts ?? []) : []
     })
     const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
       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: {
                 </div>
               </div>
             </Match>
-            <Match when={method()?.type === "api"}>
+            <Match when={method()?.type === "key"}>
               <ApiAuthView />
             </Match>
             <Match when={method()?.type === "oauth"}>
               <Switch>
-                <Match when={store.authorization?.method === "code"}>
+                <Match when={store.authorization?.mode === "code"}>
                   <OAuthCodeView />
                 </Match>
-                <Match when={store.authorization?.method === "auto"}>
+                <Match when={store.authorization?.mode === "auto"}>
                   <OAuthAutoView />
                 </Match>
               </Switch>

+ 24 - 11
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)

+ 1 - 2
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 },

+ 2 - 2
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 = () => {
               <div onClick={(e) => e.stopPropagation()}>
                 <Switch
                   checked={enabled()}
-                  disabled={toggle.isPending && toggle.variables === i.name}
+                  disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
                   onChange={() => {
                     if (toggle.isPending) return
                     toggle.mutate(i.name)

+ 27 - 4
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<typeof createDirectorySearch>[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/", [

+ 13 - 10
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)

+ 5 - 2
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

+ 1 - 2
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) => {

+ 3 - 2
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<void>
@@ -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(() => {}),
             )
 

+ 11 - 3
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 []

+ 7 - 1
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)
           })

+ 6 - 1
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)
       })

+ 30 - 33
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()

+ 23 - 0
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")
+  })
 })

+ 15 - 4
packages/app/src/utils/server-compat.ts

@@ -30,7 +30,15 @@ type CompatibleSessionApi = Omit<
   archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
   remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
 }
-export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
+type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
+  reply: (
+    input: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
+  ) => ReturnType<ServerApi["permission"]["reply"]>
+}
+export type CompatibleApi = Omit<ServerApi, "session" | "permission"> & {
+  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<ServerApi["file"]["find"]>[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<ServerApi["permission"]["reply"]>[0]) {
-        await legacy().permission.respond({
+      async reply(
+        value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
+      ) {
+        await legacy(value.location).permission.respond({
           sessionID: value.sessionID,
           permissionID: value.requestID,
           response: value.reply,
+          directory: directory(value.location),
         })
       },
     },

+ 8 - 6
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", () => {