From d0f1768b74d7790f9a2b60811a963669881386c3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 27 Jul 2026 16:32:40 +0800 Subject: [PATCH 1/5] refactor(app): extract provider connection controller --- .../components/dialog-connect-provider.tsx | 376 +++++------------- .../provider-connection-controller.test.ts | 129 ++++++ .../provider-connection-controller.ts | 254 ++++++++++++ 3 files changed, 489 insertions(+), 270 deletions(-) create mode 100644 packages/app/src/components/provider-connection-controller.test.ts create mode 100644 packages/app/src/components/provider-connection-controller.ts diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 8291bfc9474d..975d98c8f91e 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,3 @@ -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" @@ -13,21 +12,9 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" -import { - type Accessor, - type Component, - createEffect, - createMemo, - createResource, - createUniqueId, - For, - Match, - onCleanup, - onMount, - Show, - Switch, -} from "solid-js" -import { createStore, produce } from "solid-js/store" +import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" +import { createStore } 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" @@ -37,10 +24,10 @@ 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" +import { createProviderConnectionController } from "./provider-connection-controller" const CUSTOM_ID = "_custom" -type ConnectMethod = Extract - export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) const reset = () => setStore("selected", undefined) @@ -390,112 +377,84 @@ function ProviderConnection(props: { const newLayout = settings.general.newLayoutDesigns const providers = useProviders(() => props.directory?.()) const directory = () => props.directory?.() ?? decode64(params.dir) - const location = () => { - const value = directory() - return value ? { directory: value } : undefined - } - - const alive = { value: true } - const timer = { current: undefined as ReturnType | undefined } - - onCleanup(() => { - alive.value = false - if (timer.current === undefined) return - clearTimeout(timer.current) - timer.current = undefined - }) const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ - { - type: "key" as const, - label: language.t("provider.connect.method.apiKey"), + const controller = createProviderConnectionController({ + provider: props.provider, + directory, + fallbackKeyLabel: () => language.t("provider.connect.method.apiKey"), + requestFailed: () => language.t("common.requestFailed"), + invalidCode: () => language.t("provider.connect.oauth.code.invalid"), + services: { + integration: { + load: (integrationID, value) => + serverSDK() + .api.integration.get({ + integrationID, + location: value ? { directory: value } : undefined, + }) + .then((result) => result.data), + }, + connection: { + key: (integrationID, value, key) => + serverSDK().api.integration.connect.key({ + integrationID, + location: value ? { directory: value } : undefined, + key, + }), + oauth: (integrationID, value, methodID, inputs) => + serverSDK() + .api.integration.oauth.connect({ + integrationID, + methodID, + inputs, + location: value ? { directory: value } : undefined, + }) + .then((result) => result.data), + status: (integrationID, value, attemptID) => + serverSDK() + .api.integration.oauth.status({ + integrationID, + attemptID, + location: value ? { directory: value } : undefined, + }) + .then((result) => result.data), + complete: (integrationID, value, attemptID, code) => + serverSDK().api.integration.oauth.complete({ + integrationID, + attemptID, + location: value ? { directory: value } : undefined, + code, + }), + }, + provider: { + refresh: () => + queryClient.refetchQueries(serverSync().queryOptions.providers(directory() ? pathKey(directory()!) : null)), + }, + completion: { + finish: () => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), + description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + }) + }, + }, }, - ]) - 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(() => integration.loading) - const methods = createMemo(() => { - const values = integration.latest?.methods.filter( - (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", - ) - return values?.length ? values : fallback() }) - const [store, setStore] = createStore({ - methodIndex: undefined as undefined | number, - authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], - promptInputs: undefined as undefined | Record, - state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", - error: undefined as string | undefined, - }) - - type Action = - | { type: "method.select"; index: number } - | { type: "method.reset" } - | { type: "auth.prompt" } - | { type: "auth.inputs"; inputs: Record } - | { type: "auth.pending" } - | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } - | { type: "auth.error"; error: string } - - function dispatch(action: Action) { - setStore( - produce((draft) => { - if (action.type === "method.select") { - draft.methodIndex = action.index - draft.authorization = undefined - draft.promptInputs = undefined - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "method.reset") { - draft.methodIndex = undefined - draft.authorization = undefined - draft.promptInputs = undefined - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "auth.prompt") { - draft.state = "prompt" - draft.error = undefined - return - } - if (action.type === "auth.inputs") { - draft.promptInputs = action.inputs - draft.state = undefined - draft.error = undefined - return - } - if (action.type === "auth.pending") { - draft.state = "pending" - draft.error = undefined - return - } - if (action.type === "auth.complete") { - draft.state = "complete" - draft.authorization = action.authorization - draft.error = undefined - return - } - draft.state = "error" - draft.error = action.error - }), - ) - } - - const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) + const loading = controller.data.loading + const methods = controller.data.methods + const method = controller.data.method + const methodIndex = controller.data.methodIndex + const authorization = controller.data.authorization + const state = controller.auth.state + const error = controller.auth.error + const connectKey = controller.auth.connectKey + const completeCode = controller.auth.completeCode const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" @@ -513,56 +472,7 @@ function ProviderConnection(props: { } } - function formatError(value: unknown, fallback: string): string { - if (value && typeof value === "object" && "data" in value) { - const data = (value as { data?: { message?: unknown } }).data - if (typeof data?.message === "string" && data.message) return data.message - } - if (value && typeof value === "object" && "error" in value) { - const nested = formatError((value as { error?: unknown }).error, "") - if (nested) return nested - } - if (value && typeof value === "object" && "message" in value) { - const message = (value as { message?: unknown }).message - if (typeof message === "string" && message) return message - } - if (value instanceof Error && value.message) return value.message - if (typeof value === "string" && value) return value - return fallback - } - - async function selectMethod(index: number, inputs?: Record) { - if (timer.current !== undefined) { - clearTimeout(timer.current) - timer.current = undefined - } - - const method = methods()[index] - dispatch({ type: "method.select", index }) - - if (method.type === "oauth") { - if (method.prompts?.length && !inputs) { - dispatch({ type: "auth.prompt" }) - return - } - dispatch({ type: "auth.pending" }) - await serverSDK() - .api.integration.oauth.connect({ - integrationID: props.provider, - methodID: method.id, - inputs: inputs ?? {}, - location: location(), - }) - .then((x) => { - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data }) - }) - .catch((e) => { - if (!alive.value) return - dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) - }) - } - } + const selectMethod = controller.auth.select function AuthPromptsView() { const [formStore, setFormStore] = createStore({ @@ -583,7 +493,7 @@ function ProviderConnection(props: { const current = createMemo(() => { const all = prompts() const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) - if (index === -1) return + if (index === -1) return undefined return { index, prompt: all[index], @@ -597,13 +507,14 @@ function ProviderConnection(props: { }) async function next(index: number, value: Record) { - if (store.methodIndex === undefined) return + const selected = methodIndex() + if (selected === undefined) return const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) if (next !== -1) { setFormStore("index", next) return } - await selectMethod(store.methodIndex, value) + await selectMethod(selected, value) } async function handleSubmit(e: SubmitEvent) { @@ -617,12 +528,12 @@ function ProviderConnection(props: { const item = () => current() const text = createMemo(() => { const prompt = item()?.prompt - if (!prompt || prompt.type !== "text") return + if (!prompt || prompt.type !== "text") return undefined return prompt }) const select = createMemo(() => { const prompt = item()?.prompt - if (!prompt || prompt.type !== "select") return + if (!prompt || prompt.type !== "select") return undefined return prompt }) @@ -693,32 +604,9 @@ function ProviderConnection(props: { listRef?.onKeyDown(e) } - let auto = false - createEffect(() => { - if (auto) return - if (loading()) return - if (methods().length === 1) { - auto = true - void selectMethod(0) - } - }) - - async function complete() { - await serverSync() - .refreshProviders() - .catch(() => undefined) - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), - description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), - }) - } - function goBack() { - if (methods().length > 1 && store.methodIndex !== undefined) { - dispatch({ type: "method.reset" }) + if (methods().length > 1 && methodIndex() !== undefined) { + controller.auth.reset() return } props.onBack() @@ -806,9 +694,9 @@ function ProviderConnection(props: { async function handleSubmit(e: SubmitEvent) { e.preventDefault() - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string + if (!(e.currentTarget instanceof HTMLFormElement)) return + const value = new FormData(e.currentTarget).get("apiKey") + const apiKey = typeof value === "string" ? value : "" if (!apiKey?.trim()) { setFormStore("error", language.t("provider.connect.apiKey.required")) @@ -816,12 +704,7 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().api.integration.connect.key({ - integrationID: props.provider, - location: location(), - key: apiKey, - }) - await complete() + await connectKey(apiKey) } if (newLayout()) @@ -936,9 +819,9 @@ function ProviderConnection(props: { async function handleSubmit(e: SubmitEvent) { e.preventDefault() - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string + if (!(e.currentTarget instanceof HTMLFormElement)) return + const value = new FormData(e.currentTarget).get("code") + const code = typeof value === "string" ? value : "" if (!code?.trim()) { setFormStore("error", language.t("provider.connect.oauth.code.required")) @@ -946,20 +829,7 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - const result = await serverSDK() - .api.integration.oauth.complete({ - integrationID: props.provider, - attemptID: store.authorization!.attemptID, - location: location(), - code, - }) - .then(() => ({ ok: true as const })) - .catch((error) => ({ ok: false as const, error })) - if (result.ok) { - await complete() - return - } - setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) + setFormStore("error", await completeCode(code)) } if (newLayout()) @@ -967,7 +837,7 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - + {language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} @@ -1006,7 +876,7 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1032,52 +902,18 @@ function ProviderConnection(props: { function OAuthAutoView() { const code = createMemo(() => { - const instructions = store.authorization?.instructions + const instructions = authorization()?.instructions if (instructions?.includes(":")) { return instructions.split(":").pop()?.trim() } return instructions }) - onMount(() => { - const poll = async () => { - const authorization = store.authorization - if (!authorization || !alive.value) return - const result = await serverSDK() - .api.integration.oauth.status({ - integrationID: props.provider, - attemptID: authorization.attemptID, - location: location(), - }) - .then((value) => ({ ok: true as const, status: value.data })) - .catch((error) => ({ ok: false as const, error })) - if (!alive.value) return - if (!result.ok) { - dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) - return - } - 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 (
{language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.link")} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
@@ -1132,10 +968,10 @@ function ProviderConnection(props: {
- + - +
@@ -1143,14 +979,14 @@ function ProviderConnection(props: {
- + - +
- {language.t("provider.connect.status.failed", { error: store.error ?? "" })} + {language.t("provider.connect.status.failed", { error: error() ?? "" })}
@@ -1159,10 +995,10 @@ function ProviderConnection(props: {
- + - + diff --git a/packages/app/src/components/provider-connection-controller.test.ts b/packages/app/src/components/provider-connection-controller.test.ts new file mode 100644 index 000000000000..4bf49da2f060 --- /dev/null +++ b/packages/app/src/components/provider-connection-controller.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test, vi } from "bun:test" +import { createRoot } from "solid-js" +import { + createProviderConnectionWorkflowController, + type ProviderConnectMethod, +} from "./provider-connection-controller" + +const authorization = { + attemptID: "attempt-1", + url: "https://example.com/auth", + instructions: "Code: ABCD", + mode: "auto" as const, + time: { created: 0, expires: 1 }, +} + +function services(options: { + methods: readonly ProviderConnectMethod[] + status?: () => Promise<{ status: "pending" | "complete" }> + refresh?: () => void + finish?: () => void +}) { + return { + connection: { + key: async () => undefined, + oauth: async () => authorization, + status: options.status ?? (async () => ({ status: "pending" as const })), + complete: async () => undefined, + }, + provider: { refresh: async () => options.refresh?.() }, + completion: { finish: options.finish ?? (() => undefined) }, + } +} + +function create(options: Parameters[0]) { + return createRoot((dispose) => ({ + dispose, + controller: createProviderConnectionWorkflowController({ + provider: "example", + directory: () => "/project", + requestFailed: () => "Request failed", + invalidCode: () => "Invalid code", + loading: () => false, + methods: () => [...options.methods], + services: services(options), + pollInterval: 100, + }), + })) +} + +async function settle() { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe("provider connection controller", () => { + test("moves prompted OAuth methods through prompt and authorization states", async () => { + const owned = create({ + methods: [ + { + id: "oauth", + type: "oauth", + label: "OAuth", + prompts: [{ type: "text", key: "account", message: "Account" }], + }, + ], + }) + + await owned.controller.auth.select(0) + expect(owned.controller.auth.state()).toBe("prompt") + + await owned.controller.auth.select(0, { account: "team" }) + expect(owned.controller.auth.state()).toBe("complete") + expect(owned.controller.data.authorization()).toEqual(authorization) + owned.dispose() + }) + + test("polls auto OAuth independently and completes after provider refresh", async () => { + vi.useFakeTimers() + try { + const calls: string[] = [] + const statuses = [{ status: "pending" as const }, { status: "complete" as const }] + const owned = create({ + methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], + status: async () => statuses.shift() ?? { status: "complete" as const }, + refresh: () => calls.push("refresh"), + finish: () => calls.push("finish"), + }) + + await owned.controller.auth.select(0) + await settle() + expect(owned.controller.data.authorization()).toEqual(authorization) + expect(calls).toEqual([]) + + vi.advanceTimersByTime(100) + await settle() + expect(calls).toEqual(["refresh", "finish"]) + owned.dispose() + } finally { + vi.useRealTimers() + } + }) + + test("cancels an owned poll timer on reset and disposal", async () => { + vi.useFakeTimers() + try { + const status = vi.fn(async () => ({ status: "pending" as const })) + const owned = create({ methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], status }) + + await owned.controller.auth.select(0) + expect(status).toHaveBeenCalledTimes(1) + + owned.controller.auth.reset() + vi.advanceTimersByTime(1_000) + await settle() + expect(status).toHaveBeenCalledTimes(1) + + await owned.controller.auth.select(0) + expect(status).toHaveBeenCalledTimes(2) + owned.dispose() + vi.advanceTimersByTime(1_000) + await settle() + expect(status).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/app/src/components/provider-connection-controller.ts b/packages/app/src/components/provider-connection-controller.ts new file mode 100644 index 000000000000..b6778087e1f0 --- /dev/null +++ b/packages/app/src/components/provider-connection-controller.ts @@ -0,0 +1,254 @@ +import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" +import { createEffect, createMemo, createResource, onCleanup } from "solid-js" +import { createStore, produce } from "solid-js/store" + +export type ProviderConnectMethod = Extract +type Authorization = IntegrationOauthConnectOutput["data"] +type OAuthStatus = { status: "pending" | "complete" | "expired" } | { status: "failed"; message: string } + +type ProviderConnectionServices = { + integration: { + load: (provider: string, directory?: string) => Promise<{ methods: readonly IntegrationMethod[] } | null> + } + connection: { + key: (provider: string, directory: string | undefined, key: string) => Promise + oauth: ( + provider: string, + directory: string | undefined, + method: string, + inputs: Record, + ) => Promise + status: (provider: string, directory: string | undefined, attempt: string) => Promise + complete: (provider: string, directory: string | undefined, attempt: string, code: string) => Promise + } + provider: { refresh: () => Promise } + completion: { finish: () => void } +} + +export function createProviderConnectionController(options: { + provider: string + directory: () => string | undefined + fallbackKeyLabel: () => string + requestFailed: () => string + invalidCode: () => string + services: ProviderConnectionServices + pollInterval?: number +}) { + const [integration] = createResource( + () => ({ provider: options.provider, directory: options.directory() }), + (input) => options.services.integration.load(input.provider, input.directory), + ) + const methods = createMemo(() => { + const values = integration.latest?.methods.filter( + (method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth", + ) + if (values?.length) return [...values] + return [{ type: "key", label: options.fallbackKeyLabel() }] + }) + return createProviderConnectionWorkflowController({ + ...options, + loading: () => integration.loading, + methods, + }) +} + +export function createProviderConnectionWorkflowController(options: { + provider: string + directory: () => string | undefined + requestFailed: () => string + invalidCode: () => string + loading: () => boolean + methods: () => ProviderConnectMethod[] + services: Pick + pollInterval?: number +}) { + const [store, setStore] = createStore({ + methodIndex: undefined as number | undefined, + authorization: undefined as Authorization | undefined, + state: "pending" as "pending" | "complete" | "error" | "prompt" | undefined, + error: undefined as string | undefined, + }) + const polling = { + generation: 0, + timer: undefined as ReturnType | undefined, + disposed: false, + } + const methods = options.methods + const method = createMemo(() => (store.methodIndex === undefined ? undefined : methods().at(store.methodIndex))) + + type Action = + | { type: "method.select"; index: number } + | { type: "method.reset" } + | { type: "auth.prompt" } + | { type: "auth.pending" } + | { type: "auth.complete"; authorization: Authorization } + | { type: "auth.error"; error: string } + + const dispatch = (action: Action) => { + setStore( + produce((draft) => { + if (action.type === "method.select") { + draft.methodIndex = action.index + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "method.reset") { + draft.methodIndex = undefined + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.prompt") { + draft.state = "prompt" + draft.error = undefined + return + } + if (action.type === "auth.pending") { + draft.state = "pending" + draft.error = undefined + return + } + if (action.type === "auth.complete") { + draft.state = "complete" + draft.authorization = action.authorization + draft.error = undefined + return + } + draft.state = "error" + draft.error = action.error + }), + ) + } + + const cancelPolling = () => { + polling.generation++ + if (polling.timer === undefined) return + clearTimeout(polling.timer) + polling.timer = undefined + } + const finish = async () => { + cancelPolling() + await options.services.provider.refresh().catch(() => undefined) + if (polling.disposed) return + options.services.completion.finish() + } + const poll = async (authorization: Authorization, generation: number) => { + const result = await options.services.connection + .status(options.provider, options.directory(), authorization.attemptID) + .then((status) => ({ ok: true as const, status })) + .catch((error) => ({ ok: false as const, error })) + if (polling.disposed || generation !== polling.generation) return + if (!result.ok) { + dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) }) + return + } + if (result.status.status === "complete") { + await finish() + 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: options.requestFailed() }) + return + } + polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000) + } + const select = async (index: number, inputs?: Record) => { + cancelPolling() + const generation = polling.generation + const selected = methods()[index] + dispatch({ type: "method.select", index }) + if (selected.type !== "oauth") return + if (selected.prompts?.length && !inputs) { + dispatch({ type: "auth.prompt" }) + return + } + dispatch({ type: "auth.pending" }) + const result = await options.services.connection + .oauth(options.provider, options.directory(), selected.id, inputs ?? {}) + .then((authorization) => ({ ok: true as const, authorization })) + .catch((error) => ({ ok: false as const, error })) + if (polling.disposed || generation !== polling.generation) return + if (!result.ok) { + dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) }) + return + } + dispatch({ type: "auth.complete", authorization: result.authorization }) + if (result.authorization.mode === "auto") void poll(result.authorization, generation) + } + const reset = () => { + cancelPolling() + dispatch({ type: "method.reset" }) + } + const connectKey = async (key: string) => { + await options.services.connection.key(options.provider, options.directory(), key) + await finish() + } + const completeCode = async (code: string) => { + const authorization = store.authorization + if (!authorization) return options.invalidCode() + const result = await options.services.connection + .complete(options.provider, options.directory(), authorization.attemptID, code) + .then(() => ({ ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (!result.ok) return formatProviderConnectionError(result.error, options.invalidCode()) + await finish() + return undefined + } + + let auto = false + createEffect(() => { + if (auto || options.loading() || methods().length !== 1) return + auto = true + void select(0) + }) + onCleanup(() => { + polling.disposed = true + cancelPolling() + }) + + return { + data: { + loading: options.loading, + methods, + method, + methodIndex: () => store.methodIndex, + authorization: () => store.authorization, + }, + auth: { + state: () => store.state, + error: () => store.error, + select, + reset, + connectKey, + completeCode, + }, + } +} + +export type ProviderConnectionController = ReturnType + +export function formatProviderConnectionError(value: unknown, fallback: string): string { + if (value && typeof value === "object" && "data" in value) { + const data = value.data + if (data && typeof data === "object" && "message" in data && typeof data.message === "string" && data.message) + return data.message + } + if (value && typeof value === "object" && "error" in value) { + const nested = formatProviderConnectionError(value.error, "") + if (nested) return nested + } + if (value && typeof value === "object" && "message" in value) { + const message = value.message + if (typeof message === "string" && message) return message + } + if (value instanceof Error && value.message) return value.message + if (typeof value === "string" && value) return value + return fallback +} From 65c96f914b39ea51f36ae8690af1105371ccdc4b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 28 Jul 2026 09:49:41 +0800 Subject: [PATCH 2/5] fix(app): bind provider query client --- packages/app/src/components/dialog-connect-provider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 975d98c8f91e..ecc71fe54612 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -371,6 +371,7 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const queryClient = useQueryClient() const params = useParams() const language = useLanguage() const settings = useSettings() From 2a4cee42aa69ceaf6ea1fb1bf7de0327389d180a Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 10 Aug 2026 13:47:53 +0800 Subject: [PATCH 3/5] remove tests --- .../provider-connection-controller.test.ts | 129 ------------------ 1 file changed, 129 deletions(-) delete mode 100644 packages/app/src/components/provider-connection-controller.test.ts diff --git a/packages/app/src/components/provider-connection-controller.test.ts b/packages/app/src/components/provider-connection-controller.test.ts deleted file mode 100644 index 4bf49da2f060..000000000000 --- a/packages/app/src/components/provider-connection-controller.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, test, vi } from "bun:test" -import { createRoot } from "solid-js" -import { - createProviderConnectionWorkflowController, - type ProviderConnectMethod, -} from "./provider-connection-controller" - -const authorization = { - attemptID: "attempt-1", - url: "https://example.com/auth", - instructions: "Code: ABCD", - mode: "auto" as const, - time: { created: 0, expires: 1 }, -} - -function services(options: { - methods: readonly ProviderConnectMethod[] - status?: () => Promise<{ status: "pending" | "complete" }> - refresh?: () => void - finish?: () => void -}) { - return { - connection: { - key: async () => undefined, - oauth: async () => authorization, - status: options.status ?? (async () => ({ status: "pending" as const })), - complete: async () => undefined, - }, - provider: { refresh: async () => options.refresh?.() }, - completion: { finish: options.finish ?? (() => undefined) }, - } -} - -function create(options: Parameters[0]) { - return createRoot((dispose) => ({ - dispose, - controller: createProviderConnectionWorkflowController({ - provider: "example", - directory: () => "/project", - requestFailed: () => "Request failed", - invalidCode: () => "Invalid code", - loading: () => false, - methods: () => [...options.methods], - services: services(options), - pollInterval: 100, - }), - })) -} - -async function settle() { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -describe("provider connection controller", () => { - test("moves prompted OAuth methods through prompt and authorization states", async () => { - const owned = create({ - methods: [ - { - id: "oauth", - type: "oauth", - label: "OAuth", - prompts: [{ type: "text", key: "account", message: "Account" }], - }, - ], - }) - - await owned.controller.auth.select(0) - expect(owned.controller.auth.state()).toBe("prompt") - - await owned.controller.auth.select(0, { account: "team" }) - expect(owned.controller.auth.state()).toBe("complete") - expect(owned.controller.data.authorization()).toEqual(authorization) - owned.dispose() - }) - - test("polls auto OAuth independently and completes after provider refresh", async () => { - vi.useFakeTimers() - try { - const calls: string[] = [] - const statuses = [{ status: "pending" as const }, { status: "complete" as const }] - const owned = create({ - methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], - status: async () => statuses.shift() ?? { status: "complete" as const }, - refresh: () => calls.push("refresh"), - finish: () => calls.push("finish"), - }) - - await owned.controller.auth.select(0) - await settle() - expect(owned.controller.data.authorization()).toEqual(authorization) - expect(calls).toEqual([]) - - vi.advanceTimersByTime(100) - await settle() - expect(calls).toEqual(["refresh", "finish"]) - owned.dispose() - } finally { - vi.useRealTimers() - } - }) - - test("cancels an owned poll timer on reset and disposal", async () => { - vi.useFakeTimers() - try { - const status = vi.fn(async () => ({ status: "pending" as const })) - const owned = create({ methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], status }) - - await owned.controller.auth.select(0) - expect(status).toHaveBeenCalledTimes(1) - - owned.controller.auth.reset() - vi.advanceTimersByTime(1_000) - await settle() - expect(status).toHaveBeenCalledTimes(1) - - await owned.controller.auth.select(0) - expect(status).toHaveBeenCalledTimes(2) - owned.dispose() - vi.advanceTimersByTime(1_000) - await settle() - expect(status).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) -}) From 0a5df7ab01c87075ca1b554bf198346f8eda62a7 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 10 Aug 2026 14:05:38 +0800 Subject: [PATCH 4/5] improvements --- .../components/dialog-connect-provider.tsx | 147 +++++------------- .../provider-connection-controller.ts | 141 +++++++++-------- 2 files changed, 111 insertions(+), 177 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index ecc71fe54612..b58d2e41a62e 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -14,17 +14,14 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" import { createStore } 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" 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" import { createProviderConnectionController } from "./provider-connection-controller" const CUSTOM_ID = "_custom" @@ -370,8 +367,6 @@ function ProviderConnection(props: { }) { const dialog = useDialog() const serverSync = useServerSync() - const serverSDK = useServerSDK() - const queryClient = useQueryClient() const params = useParams() const language = useLanguage() const settings = useSettings() @@ -383,80 +378,18 @@ function ProviderConnection(props: { () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) const controller = createProviderConnectionController({ - provider: props.provider, + provider: () => props.provider, directory, - fallbackKeyLabel: () => language.t("provider.connect.method.apiKey"), - requestFailed: () => language.t("common.requestFailed"), - invalidCode: () => language.t("provider.connect.oauth.code.invalid"), - services: { - integration: { - load: (integrationID, value) => - serverSDK() - .api.integration.get({ - integrationID, - location: value ? { directory: value } : undefined, - }) - .then((result) => result.data), - }, - connection: { - key: (integrationID, value, key) => - serverSDK().api.integration.connect.key({ - integrationID, - location: value ? { directory: value } : undefined, - key, - }), - oauth: (integrationID, value, methodID, inputs) => - serverSDK() - .api.integration.oauth.connect({ - integrationID, - methodID, - inputs, - location: value ? { directory: value } : undefined, - }) - .then((result) => result.data), - status: (integrationID, value, attemptID) => - serverSDK() - .api.integration.oauth.status({ - integrationID, - attemptID, - location: value ? { directory: value } : undefined, - }) - .then((result) => result.data), - complete: (integrationID, value, attemptID, code) => - serverSDK().api.integration.oauth.complete({ - integrationID, - attemptID, - location: value ? { directory: value } : undefined, - code, - }), - }, - provider: { - refresh: () => - queryClient.refetchQueries(serverSync().queryOptions.providers(directory() ? pathKey(directory()!) : null)), - }, - completion: { - finish: () => { - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), - description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), - }) - }, - }, + onComplete: () => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), + description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + }) }, }) - const loading = controller.data.loading - const methods = controller.data.methods - const method = controller.data.method - const methodIndex = controller.data.methodIndex - const authorization = controller.data.authorization - const state = controller.auth.state - const error = controller.auth.error - const connectKey = controller.auth.connectKey - const completeCode = controller.auth.completeCode - const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" if (value.type === "key") return language.t("provider.connect.method.apiKey") @@ -473,8 +406,6 @@ function ProviderConnection(props: { } } - const selectMethod = controller.auth.select - function AuthPromptsView() { const [formStore, setFormStore] = createStore({ value: {} as Record, @@ -482,7 +413,7 @@ function ProviderConnection(props: { }) const prompts = createMemo(() => { - const value = method() + const value = controller.currentMethod() return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { @@ -508,14 +439,14 @@ function ProviderConnection(props: { }) async function next(index: number, value: Record) { - const selected = methodIndex() + const selected = controller.methodIndex() if (selected === undefined) return const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) if (next !== -1) { setFormStore("index", next) return } - await selectMethod(selected, value) + await controller.auth.select(selected, value) } async function handleSubmit(e: SubmitEvent) { @@ -606,7 +537,7 @@ function ProviderConnection(props: { } function goBack() { - if (methods().length > 1 && methodIndex() !== undefined) { + if (controller.methods().length > 1 && controller.methodIndex() !== undefined) { controller.auth.reset() return } @@ -623,14 +554,14 @@ function ProviderConnection(props: { {language.t("provider.connect.selectMethod", { provider: provider().name })}
- + {(item, index) => { const details = () => methodDetails(item) return (