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

This commit is contained in:
Brendan Allan
2026-07-24 14:38:39 +08:00
committed by GitHub
parent e96a8939a7
commit 23c7d63c8c
24 changed files with 353 additions and 222 deletions
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" 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 tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) 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() await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) 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 current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) 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 === "/global/health") return json(route, {}, 404)
if (url.pathname === "/session") return json(route, [current]) 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 (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) 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)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) 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, {}) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
@@ -116,7 +121,17 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory, directory: current.directory,
home: 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 === "/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, {}) return json(route, {})
}) })
} }
@@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([ .toEqual([
{ {
origin: serverA, origin: serverA,
directory: undefined, directory: directoryA,
sessionID: sessionA.id, sessionID: sessionA.id,
permissionID: "permission-background-a", permissionID: "permission-background-a",
body: { response: "once" }, body: { response: "once" },
@@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([ .toEqual([
{ {
origin: serverA, origin: serverA,
directory: undefined, directory: directoryA,
sessionID: sessionA.id, sessionID: sessionA.id,
permissionID: "permission-background-a", permissionID: "permission-background-a",
body: { response: "once" }, body: { response: "once" },
}, },
{ {
origin: serverA, origin: serverA,
directory: undefined, directory: directoryA,
sessionID: childSessionA.id, sessionID: childSessionA.id,
permissionID: "permission-background-a-child", permissionID: "permission-background-a-child",
body: { response: "once" }, body: { response: "once" },
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" 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 current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) 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")
if (url.pathname === "/global/health") return json(route, { healthy: true }) return sse(route, url.pathname === "/api/event")
if (url.pathname === "/session/status") if (url.pathname === "/global/health") return json(route, {}, 404)
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) if (url.pathname === "/api/health") return json(route, { pid: 1 })
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 (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
@@ -90,7 +95,17 @@ async function mockServers(page: Page) {
directory: current.directory, directory: current.directory,
home: 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 === "/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, {}) 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({ return route.fulfill({
status: 200, status: 200,
contentType: "text/event-stream", 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",
}) })
} }
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => { const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve releasePath = resolve
}) })
await page.route("**/path?*", async (route) => { await page.route("**/api/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
await pathBlocked await pathBlocked
return route.fallback() return route.fallback()
}) })
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = [] const rejectRequests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "POST") return 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() 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() await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest( 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() await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) 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") const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click() await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ response: "once" }) expect(request.postDataJSON()).toEqual({ reply: "once" })
}) })
test("restores the draft caret before typing after a request dock closes", async ({ page }) => { test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -170,6 +173,7 @@ async function mockServer(
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096" const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session") const sessionA = session("ses_tab_a", "Tab A session")
@@ -56,9 +57,14 @@ async function mockServer(page: Page) {
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback() 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 === "/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}`) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId) if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) 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 (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) 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, {}) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
@@ -89,7 +95,20 @@ async function mockServer(page: Page) {
directory: sessionA.directory, directory: sessionA.directory,
home: 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 === "/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, {}) return json(route, {})
}) })
} }
@@ -1,5 +1,6 @@
import { getFilename } from "@opencode-ai/core/util/path" 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" 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 { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => 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"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -219,7 +221,7 @@ export function createServerSessionEntries(props: {
server: ServerConnection.Key server: ServerConnection.Key
opened: () => LocalProject[] opened: () => LocalProject[]
stored: () => Project[] stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
@@ -255,7 +257,8 @@ export function createServerSessionEntries(props: {
return props return props
.load(search, current.signal) .load(search, current.signal)
.then((result) => .then((result) =>
(result.data ?? []) result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived) .filter((session) => !session.time.archived)
.map((session) => { .map((session) => {
const project = const project =
@@ -264,7 +267,7 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`, id: `session:${props.server}:${session.id}`,
type: "session" as const, type: "session" as const,
title: session.title || props.untitled(), 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(), category: props.category(),
directory: session.directory, directory: session.directory,
sessionID: session.id, sessionID: session.id,
@@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: {
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => 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"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -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 { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -28,6 +31,8 @@ import {
Switch, Switch,
} from "solid-js" } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
@@ -35,8 +40,11 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) { export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -228,8 +236,6 @@ function ProviderPickerV2(props: {
}) { }) {
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const language = useLanguage() const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
filter: "", filter: "",
active: undefined as string | undefined, active: undefined as string | undefined,
@@ -266,19 +272,7 @@ function ProviderPickerV2(props: {
const connect = (provider: string) => { const connect = (provider: string) => {
props.onPrepare?.() props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { props.onSelect(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))
} }
const move = (event: KeyboardEvent, direction: number) => { const move = (event: KeyboardEvent, direction: number) => {
@@ -395,10 +389,17 @@ function ProviderConnection(props: {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory) 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 alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined } const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -413,38 +414,34 @@ function ProviderConnection(props: {
const provider = createMemo( const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, () => 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"), label: language.t("provider.connect.method.apiKey"),
}, },
]) ])
const [auth] = createResource( const [integration] = createResource(
() => props.provider, () => ({ provider: props.provider, directory: directory() }),
async () => { (input) =>
const cached = serverSync().data.provider_auth[props.provider] serverSDK()
if (cached) return cached .api.integration.get({
const res = await serverSDK().client.provider.auth() integrationID: input.provider,
if (!alive.value) return fallback() location: input.directory ? { directory: input.directory } : undefined,
serverSync().set("provider_auth", res.data ?? {}) })
return res.data?.[props.provider] ?? fallback() .then((result) => result.data),
},
) )
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const loading = createMemo(() => integration.loading)
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const methods = createMemo<ConnectMethod[]>(() => {
const cachedMethods = serverSync().data.provider_auth[props.provider] const values = integration.latest?.methods.filter(
const directMethod = (method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined )
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: directMethod as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>, promptInputs: undefined as undefined | Record<string, string>,
state: (directMethod === undefined ? "pending" : undefined) as state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -454,7 +451,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> } | { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
function dispatch(action: Action) { function dispatch(action: Action) {
@@ -508,7 +505,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" 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 ?? "" return value.label ?? ""
} }
@@ -518,7 +515,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1] const hint = suffix?.[1]
return { return {
label: suffix ? label.slice(0, -suffix[0].length) : label, 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] const method = methods()[index]
dispatch({ type: "method.select", 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.type === "oauth") {
if (method.prompts?.length && !inputs) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" }) dispatch({ type: "auth.prompt" })
return return
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK() await serverSDK()
.client.provider.oauth.authorize( .api.integration.oauth.connect({
{ integrationID: props.provider,
providerID: props.provider, methodID: method.id,
method: index, inputs: inputs ?? {},
inputs, location: location(),
}, })
{ throwOnError: true },
)
.then((x) => { .then((x) => {
if (!alive.value) return if (!alive.value) return
const elapsed = Date.now() - start dispatch({ type: "auth.complete", authorization: x.data })
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! })
}) })
.catch((e) => { .catch((e) => {
if (!alive.value) return if (!alive.value) return
@@ -603,9 +576,9 @@ function ProviderConnection(props: {
index: 0, index: 0,
}) })
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => { const prompts = createMemo(() => {
const value = method() const value = method()
return value?.prompts ?? [] return value?.type === "oauth" ? (value.prompts ?? []) : []
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
@@ -636,10 +609,6 @@ function ProviderConnection(props: {
setFormStore("index", next) setFormStore("index", next)
return return
} }
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value) await selectMethod(store.methodIndex, value)
} }
@@ -741,7 +710,10 @@ function ProviderConnection(props: {
}) })
async function complete() { 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() dialog.close()
showToast({ showToast({
variant: "success", variant: "success",
@@ -805,7 +777,7 @@ function ProviderConnection(props: {
listRef = ref listRef = ref
}} }}
items={methods} items={methods}
key={(m) => m?.label} key={(m) => m?.label ?? m?.type}
onSelect={async (selected, index) => { onSelect={async (selected, index) => {
if (!selected) return if (!selected) return
void selectMethod(index) void selectMethod(index)
@@ -851,13 +823,10 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().client.auth.set({ await serverSDK().api.integration.connect.key({
providerID: props.provider, integrationID: props.provider,
auth: { location: location(),
type: "api", key: apiKey,
key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
}) })
await complete() await complete()
} }
@@ -984,12 +953,13 @@ function ProviderConnection(props: {
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() const result = await serverSDK()
.client.provider.oauth.callback({ .api.integration.oauth.complete({
providerID: props.provider, integrationID: props.provider,
method: store.methodIndex, attemptID: store.authorization!.attemptID,
location: location(),
code, 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 })) .catch((error) => ({ ok: false as const, error }))
if (result.ok) { if (result.ok) {
await complete() await complete()
@@ -1076,25 +1046,37 @@ function ProviderConnection(props: {
}) })
onMount(() => { onMount(() => {
void (async () => { const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK() const result = await serverSDK()
.client.provider.oauth.callback({ .api.integration.oauth.status({
providerID: props.provider, integrationID: props.provider,
method: store.methodIndex, 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 })) .catch((error) => ({ ok: false as const, error }))
if (!alive.value) return if (!alive.value) return
if (!result.ok) { if (!result.ok) {
const message = formatError(result.error, language.t("common.requestFailed")) dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
dispatch({ type: "auth.error", error: message })
return return
} }
if (result.status.status === "complete") {
await 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 ( return (
@@ -1178,15 +1160,15 @@ function ProviderConnection(props: {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={method()?.type === "api"}> <Match when={method()?.type === "key"}>
<ApiAuthView /> <ApiAuthView />
</Match> </Match>
<Match when={method()?.type === "oauth"}> <Match when={method()?.type === "oauth"}>
<Switch> <Switch>
<Match when={store.authorization?.method === "code"}> <Match when={store.authorization?.mode === "code"}>
<OAuthCodeView /> <OAuthCodeView />
</Match> </Match>
<Match when={store.authorization?.method === "auto"}> <Match when={store.authorization?.mode === "auto"}>
<OAuthAutoView /> <OAuthAutoView />
</Match> </Match>
</Switch> </Switch>
@@ -28,6 +28,7 @@ import {
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
() => () =>
sdk.client.path sdk.api.path
.get() .get()
.then((result) => result.data)
.catch(() => undefined), .catch(() => undefined),
{ initialValue: undefined }, { initialValue: undefined },
) )
@@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
) )
const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => { 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(/\/+$/, "") 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 })) const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find const base = pickerRoot(cleaned) || root() || start()
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) if (!base) return { query: value, items: directories.slice(0, 5) }
.then((result) => result.data ?? []) const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
.then((result) => result.data)
.catch(() => []) .catch(() => [])
const results = [ const results = [
...directories, ...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 { return {
query: value, query: value,
@@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ?? existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file return sdk.api.file
.list({ directory: absolute, path: "" }) .list({ location: { directory: absolute } })
.then((result) => result.data ?? []) .then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined) .catch(() => undefined)
}) })
listings.set(key, request) listings.set(key, request)
@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
async () => { async () => {
return sdk.client.path return sdk.api.path
.get() .get()
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
}, },
{ initialValue: undefined }, { initialValue: undefined },
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (!x || toggle.isPending) return if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name) toggle.mutate(x.name)
}} }}
> >
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name} disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => { onChange={() => {
if (toggle.isPending) return if (toggle.isPending) return
toggle.mutate(i.name) toggle.mutate(i.name)
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => { test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
client: { api: {
find: { file: {
files: (input: { directory: string }) => { find: (input: { location?: { directory?: string } }) => {
directories.push(input.directory) directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
}, },
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"]) 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", () => { test("identifies the next directory level to preload", () => {
expect( expect(
preloadTreeDirectories("src/", [ preloadTreeDirectories("src/", [
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
let current = 0 let current = 0
const scoped = (value: string) => { 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() const base = args.base()
if (!base) return if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" } if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home() const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } 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 } return { directory: trimPickerPath(base), path: raw }
} }
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory) const key = trimPickerPath(directory)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = args.sdk.client.file const request = args.sdk.api.file
.list({ directory: key, path: "" }) .list({ location: { directory: key } })
.then((result) => result.data ?? []) .then((result) => result.data)
.catch(() => []) .catch(() => [])
.then((nodes) => .then((nodes) =>
nodes nodes
.filter((node) => node.type === "directory") .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) cache.set(key, request)
return request return request
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path) const query = normalizePickerDrive(input.path)
if (!pathInput) { if (!pathInput) {
const results = await args.sdk.client.find const results = await args.sdk.api.file
.files({ directory: input.directory, query, type: "directory", limit: 50 }) .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data ?? []) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
+5 -2
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { 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, projectID: props.project.id,
directory: props.project.worktree,
name, name,
icon: { color: store.color || "", override: store.iconOverride || "" }, icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start }, 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) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close() dialog.close()
return return
@@ -120,8 +120,7 @@ export function TabNavItem(props: {
const ctx = serverCtx() const ctx = serverCtx()
const session = props.session() const session = props.session()
if (!ctx || !session) return if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) await ctx.sdk.api.session.rename({ sessionID: session.id, title })
await client.session.update({ sessionID: session.id, title })
} }
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {
+3 -2
View File
@@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => ({ route, sdk }) =>
sdk.client.session sdk.api.session
.get({ sessionID: route.sessionId }) .get({ sessionID: route.sessionId })
.then((x) => x.data) .then(normalizeSessionInfo)
.catch(() => {}), .catch(() => {}),
) )
+11 -3
View File
@@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
} }
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk() serverSDK()
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) .api.file.find(
{
location: { directory: sdk().directory },
query,
type: dirs === "true" ? "directory" : "file",
limit: options?.limit,
},
{ signal: options?.signal },
)
.then( .then(
(x) => (x.data ?? []).map(path.normalize), (x) => x.data.map((entry) => path.normalize(entry.path)),
(error) => { (error) => {
if (options?.signal?.aborted) throw error if (options?.signal?.aborted) throw error
return [] return []
+7 -1
View File
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
@@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
} }
void serverSdk() 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(() => { .catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
}) })
+6 -1
View File
@@ -245,7 +245,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
const respond: PermissionRespondFn = (request) => { const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return if (meta.disposed) return
input.sdk.api.permission 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(() => { .catch(() => {
responded.delete(request.permissionID) responded.delete(request.permissionID)
}) })
+30 -33
View File
@@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast" import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal" import { clearWorkspaceTerminals } from "@/context/terminal"
import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
@@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { listAllSessions } from "@/utils/session"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" 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 index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1] const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({ await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
directory: session.directory,
sessionID: session.id,
time: { archived: Date.now() },
})
setStore( setStore(
produce((draft) => { produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id) 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) => { const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target) if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK() const listed = await Promise.resolve(
.client.worktree.list({ directory: root }) project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
.then((x) => x.data ?? []) )
.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[]) .catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target) return canOpen(target)
@@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) {
await Promise.all( await Promise.all(
dirs.map(async (item) => ({ dirs.map(async (item) => ({
path: { directory: item }, path: { directory: item },
session: await serverSDK() session: await listAllSessions(serverSDK().api.session, {
.client.session.list({ directory: item }) directory: item,
.then((x) => x.data ?? []) parentID: null,
.catch(() => []), order: "desc",
}).catch(() => []),
})), })),
), ),
Date.now(), Date.now(),
@@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") { 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 return
} }
@@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) {
}) })
const dismiss = () => toaster.dismiss(progress) const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await serverSDK() const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
.client.session.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
clearWorkspaceTerminals( clearWorkspaceTerminals(
directory, directory,
@@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) {
return return
} }
const archivedAt = Date.now()
await Promise.all( await Promise.all(
sessions sessions
.filter((session) => session.time.archived === undefined) .filter((session) => session.time.archived === undefined)
.map((session) => .map((session) =>
serverSDK() serverSDK()
.client.session.update({ .api.session.archive({ sessionID: session.id, directory: session.directory })
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
})
.catch(() => undefined), .catch(() => undefined),
), ),
) )
@@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => { onMount(() => {
serverSDK() serverSDK()
.client.vcs.status({ directory: props.directory }) .api.vcs.status({ location: { directory: props.directory } })
.then((x) => { .then((result) => {
const files = x.data ?? [] const files = result.data
const dirty = files.length > 0 const dirty = files.length > 0
setData({ status: "ready", dirty }) setData({ status: "ready", dirty })
}) })
@@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) {
}) })
const refresh = async () => { const refresh = async () => {
const sessions = await serverSDK() const sessions = await listAllSessions(serverSDK().api.session, {
.client.session.list({ directory: props.directory }) directory: props.directory,
.then((x) => x.data ?? []) order: "desc",
.catch(() => []) }).catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined) const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active }) setState({ sessions: active })
} }
onMount(() => { onMount(() => {
serverSDK() serverSDK()
.client.vcs.status({ directory: props.directory }) .api.vcs.status({ location: { directory: props.directory } })
.then((x) => { .then((result) => {
const files = x.data ?? [] const files = result.data
const dirty = files.length > 0 const dirty = files.length > 0
setState({ status: "ready", dirty }) setState({ status: "ready", dirty })
void refresh() void refresh()
@@ -123,4 +123,27 @@ describe("createCompatibleApi", () => {
data: { branch: "feature", defaultBranch: "dev" }, 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
View File
@@ -30,7 +30,15 @@ type CompatibleSessionApi = Omit<
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]> archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]> 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 = { type LegacyPrompt = {
agent?: string agent?: string
model?: { providerID: string; modelID: string } model?: { providerID: string; modelID: string }
@@ -350,7 +358,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async find(value: Parameters<ServerApi["file"]["find"]>[0]) { async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
const result = await legacy(value.location).find.files({ const result = await legacy(value.location).find.files({
query: value.query, query: value.query,
type: value.type, dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false",
limit: value.limit, limit: value.limit,
}) })
return located( return located(
@@ -471,11 +479,14 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
}, },
permission: { permission: {
...input.current.permission, ...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) { async reply(
await legacy().permission.respond({ value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } },
) {
await legacy(value.location).permission.respond({
sessionID: value.sessionID, sessionID: value.sessionID,
permissionID: value.requestID, permissionID: value.requestID,
response: value.reply, response: value.reply,
directory: directory(value.location),
}) })
}, },
}, },
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" 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 { createRoot } from "solid-js"
import { createServerSessionEntries } from "@/components/command-palette" import { createServerSessionEntries } from "@/components/command-palette"
import type { LocalProject } from "@/context/layout" import type { LocalProject } from "@/context/layout"
@@ -14,15 +15,16 @@ const stored: Project = {
time: { created: 1, updated: 1 }, time: { created: 1, updated: 1 },
} }
const session: GlobalSession = { const session: SessionInfo = {
id: "session-1", id: "session-1",
slug: "session-1",
projectID: stored.id, 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", title: "Palette session",
version: "1",
time: { created: 1, updated: 2 }, time: { created: 1, updated: 2 },
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
} }
describe("command palette sessions", () => { describe("command palette sessions", () => {