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

This commit is contained in:
Brendan Allan 2026-07-24 14:38:39 +08:00 committed by GitHub
commit d07323ef59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 353 additions and 222 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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