From 3300bd30ad306490795c6c607d05ec3d45eb598f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 15:09:59 +0800 Subject: [PATCH 1/4] refactor(app): extract provider connect auth views --- .../dialog-connect-provider-auth-views.tsx | 156 ++++++++ .../dialog-connect-provider-auto-view.tsx | 75 ++++ .../dialog-connect-provider-error.ts | 17 + .../dialog-connect-provider-prompt-view.tsx | 133 +++++++ .../dialog-connect-provider-source.test.ts | 51 +++ .../components/dialog-connect-provider.tsx | 362 ++---------------- 6 files changed, 462 insertions(+), 332 deletions(-) create mode 100644 packages/app/src/components/dialog-connect-provider-auth-views.tsx create mode 100644 packages/app/src/components/dialog-connect-provider-auto-view.tsx create mode 100644 packages/app/src/components/dialog-connect-provider-error.ts create mode 100644 packages/app/src/components/dialog-connect-provider-prompt-view.tsx create mode 100644 packages/app/src/components/dialog-connect-provider-source.test.ts diff --git a/packages/app/src/components/dialog-connect-provider-auth-views.tsx b/packages/app/src/components/dialog-connect-provider-auth-views.tsx new file mode 100644 index 000000000..9508ab3c4 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-auth-views.tsx @@ -0,0 +1,156 @@ +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import { Button } from "@opencode-ai/ui/button" +import { TextField } from "@opencode-ai/ui/text-field" +import { Match, Switch } from "solid-js" +import { createStore } from "solid-js/store" +import { Link } from "@/components/link" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { formatProviderConnectError } from "./dialog-connect-provider-error" + +type ProviderConnectInfo = { + id: string + name: string +} + +export function ProviderApiAuthView(props: { provider: () => ProviderConnectInfo; onComplete: () => Promise }) { + const globalSDK = useGlobalSDK() + const language = useLanguage() + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + 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 (!apiKey?.trim()) { + setFormStore("error", language.t("provider.connect.apiKey.required")) + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider().id, + auth: { + type: "api", + key: apiKey, + }, + }) + await props.onComplete() + } + + return ( +
+ + +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+ +
+ {language.t("provider.connect.apiKey.description", { provider: props.provider().name })} +
+
+
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) +} + +export function ProviderOAuthCodeView(props: { + provider: () => ProviderConnectInfo + authorization: () => ProviderAuthAuthorization | undefined + method: () => ProviderAuthMethod | undefined + methodIndex: () => number | undefined + onComplete: () => Promise +}) { + const globalSDK = useGlobalSDK() + const language = useLanguage() + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + 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 (!code?.trim()) { + setFormStore("error", language.t("provider.connect.oauth.code.required")) + return + } + + setFormStore("error", undefined) + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider().id, + method: props.methodIndex(), + code, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await props.onComplete() + return + } + setFormStore("error", formatProviderConnectError(result.error, language.t("provider.connect.oauth.code.invalid"))) + } + + return ( +
+
+ {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: props.provider().name })} +
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) +} diff --git a/packages/app/src/components/dialog-connect-provider-auto-view.tsx b/packages/app/src/components/dialog-connect-provider-auto-view.tsx new file mode 100644 index 000000000..7b02967d1 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-auto-view.tsx @@ -0,0 +1,75 @@ +import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { createMemo, onMount } from "solid-js" +import { Link } from "@/components/link" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { formatProviderConnectError } from "./dialog-connect-provider-error" + +type ProviderConnectInfo = { + id: string + name: string +} + +export function ProviderOAuthAutoView(props: { + provider: () => ProviderConnectInfo + authorization: () => ProviderAuthAuthorization | undefined + methodIndex: () => number | undefined + alive: () => boolean + onComplete: () => Promise + onError: (error: string) => void +}) { + const globalSDK = useGlobalSDK() + const language = useLanguage() + const code = createMemo(() => { + const instructions = props.authorization()?.instructions + if (instructions?.includes(":")) { + return instructions.split(":")[1]?.trim() + } + return instructions + }) + + onMount(() => { + void (async () => { + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider().id, + method: props.methodIndex(), + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + + if (!props.alive()) return + + if (!result.ok) { + const message = formatProviderConnectError(result.error, language.t("common.requestFailed")) + props.onError(message) + return + } + + await props.onComplete() + })() + }) + + return ( +
+
+ {language.t("provider.connect.oauth.auto.visit.prefix")} + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: props.provider().name })} +
+ +
+ + {language.t("provider.connect.status.waiting")} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-connect-provider-error.ts b/packages/app/src/components/dialog-connect-provider-error.ts new file mode 100644 index 000000000..e2984e9cf --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-error.ts @@ -0,0 +1,17 @@ +export function formatProviderConnectError(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 = formatProviderConnectError((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 +} diff --git a/packages/app/src/components/dialog-connect-provider-prompt-view.tsx b/packages/app/src/components/dialog-connect-provider-prompt-view.tsx new file mode 100644 index 000000000..ec390016c --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-prompt-view.tsx @@ -0,0 +1,133 @@ +import type { ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import { Button } from "@opencode-ai/ui/button" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { createMemo, Match, Switch } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" + +export function ProviderOAuthPromptsView(props: { + method: () => ProviderAuthMethod | undefined + methodIndex: () => number | undefined + onSubmit: (index: number, inputs?: Record) => Promise +}) { + const language = useLanguage() + const [formStore, setFormStore] = createStore({ + value: {} as Record, + index: 0, + }) + + const prompts = createMemo>(() => { + const value = props.method() + if (value?.type !== "oauth") return [] + return value.prompts ?? [] + }) + const matches = (prompt: NonNullable[number]>, value: Record) => { + if (!prompt.when) return true + const actual = value[prompt.when.key] + if (actual === undefined) return false + return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value + } + const current = createMemo(() => { + const all = prompts() + const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) + if (index === -1) return + return { + index, + prompt: all[index], + } + }) + const valid = createMemo(() => { + const item = current() + if (!item || item.prompt.type !== "text") return false + const value = formStore.value[item.prompt.key] ?? "" + return value.trim().length > 0 + }) + + async function next(index: number, value: Record) { + const methodIndex = props.methodIndex() + if (methodIndex === undefined) return + const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) + if (next !== -1) { + setFormStore("index", next) + return + } + await props.onSubmit(methodIndex, value) + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const item = current() + if (!item || item.prompt.type !== "text") return + if (!valid()) return + await next(item.index, formStore.value) + } + + const item = () => current() + const text = createMemo(() => { + const prompt = item()?.prompt + if (!prompt || prompt.type !== "text") return + return prompt + }) + const select = createMemo(() => { + const prompt = item()?.prompt + if (!prompt || prompt.type !== "select") return + return prompt + }) + + return ( +
+ + + { + const prompt = text() + if (!prompt) return + setFormStore("value", prompt.key, value) + }} + /> + + + +
+
{select()?.message}
+
+ x.value} + current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} + onSelect={(value) => { + if (!value) return + const prompt = select() + if (!prompt) return + const nextValue = { + ...formStore.value, + [prompt.key]: value.value, + } + setFormStore("value", prompt.key, value.value) + void next(item()!.index, nextValue) + }} + > + {(option) => ( +
+
+ + {option.label} + {option.hint} +
+ )} + +
+
+ + + + ) +} diff --git a/packages/app/src/components/dialog-connect-provider-source.test.ts b/packages/app/src/components/dialog-connect-provider-source.test.ts new file mode 100644 index 000000000..534dcb224 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-source.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { formatProviderConnectError } from "./dialog-connect-provider-error" + +const dialogSource = readFileSync(new URL("./dialog-connect-provider.tsx", import.meta.url), "utf8") +const authViewsSource = readFileSync(new URL("./dialog-connect-provider-auth-views.tsx", import.meta.url), "utf8") +const autoViewSource = readFileSync(new URL("./dialog-connect-provider-auto-view.tsx", import.meta.url), "utf8") +const promptViewSource = readFileSync(new URL("./dialog-connect-provider-prompt-view.tsx", import.meta.url), "utf8") +const errorSource = readFileSync(new URL("./dialog-connect-provider-error.ts", import.meta.url), "utf8") + +describe("dialog-connect-provider source boundary", () => { + test("keeps auth views and error formatting in owner files", () => { + expect(dialogSource).toContain("ProviderApiAuthView") + expect(dialogSource).toContain("ProviderOAuthCodeView") + expect(dialogSource).toContain("ProviderOAuthAutoView") + expect(dialogSource).toContain("ProviderOAuthPromptsView") + expect(authViewsSource).toContain("export function ProviderApiAuthView") + expect(authViewsSource).toContain("export function ProviderOAuthCodeView") + expect(autoViewSource).toContain("export function ProviderOAuthAutoView") + expect(promptViewSource).toContain("export function ProviderOAuthPromptsView") + expect(errorSource).toContain("export function formatProviderConnectError") + }) + + test("preserves provider auth copy and action wiring after extraction", () => { + for (const key of [ + "provider.connect.apiKey.required", + "provider.connect.oauth.code.required", + ]) { + expect(authViewsSource).toContain(key) + } + + for (const key of [ + "provider.connect.oauth.auto.confirmationCode", + "provider.connect.status.waiting", + ]) { + expect(autoViewSource).toContain(key) + } + + expect(promptViewSource).toContain("prompt.when") + expect(dialogSource).toContain("globalSDK.client.provider.oauth") + expect(dialogSource).toContain(".authorize(") + expect(dialogSource).toContain("globalSDK.client.global.dispose") + }) + + test("formats nested provider auth errors without losing fallback behavior", () => { + expect(formatProviderConnectError({ data: { message: "data failed" } }, "fallback")).toBe("data failed") + expect(formatProviderConnectError({ error: { message: "nested failed" } }, "fallback")).toBe("nested failed") + expect(formatProviderConnectError(new Error("error failed"), "fallback")).toBe("error failed") + expect(formatProviderConnectError("", "fallback")).toBe("fallback") + }) +}) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index eacf52cfc..1bf31edbc 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,5 +1,4 @@ import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" -import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { Icon } from "@opencode-ai/ui/icon" @@ -7,15 +6,20 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" -import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" +import { + ProviderApiAuthView, + ProviderOAuthCodeView, +} from "./dialog-connect-provider-auth-views" +import { ProviderOAuthAutoView } from "./dialog-connect-provider-auto-view" +import { formatProviderConnectError } from "./dialog-connect-provider-error" +import { ProviderOAuthPromptsView } from "./dialog-connect-provider-prompt-view" export function DialogConnectProvider(props: { provider: string }) { const dialog = useDialog() @@ -126,24 +130,6 @@ export function DialogConnectProvider(props: { provider: string }) { return value.label ?? "" } - 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) @@ -187,131 +173,11 @@ export function DialogConnectProvider(props: { provider: string }) { }) .catch((e) => { if (!alive.value) return - dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) + dispatch({ type: "auth.error", error: formatProviderConnectError(e, language.t("common.requestFailed")) }) }) } } - function OAuthPromptsView() { - const [formStore, setFormStore] = createStore({ - value: {} as Record, - index: 0, - }) - - const prompts = createMemo>(() => { - const value = method() - if (value?.type !== "oauth") return [] - return value.prompts ?? [] - }) - const matches = (prompt: NonNullable[number]>, value: Record) => { - if (!prompt.when) return true - const actual = value[prompt.when.key] - if (actual === undefined) return false - return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value - } - const current = createMemo(() => { - const all = prompts() - const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) - if (index === -1) return - return { - index, - prompt: all[index], - } - }) - const valid = createMemo(() => { - const item = current() - if (!item || item.prompt.type !== "text") return false - const value = formStore.value[item.prompt.key] ?? "" - return value.trim().length > 0 - }) - - async function next(index: number, value: Record) { - if (store.methodIndex === 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) - } - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - const item = current() - if (!item || item.prompt.type !== "text") return - if (!valid()) return - await next(item.index, formStore.value) - } - - const item = () => current() - const text = createMemo(() => { - const prompt = item()?.prompt - if (!prompt || prompt.type !== "text") return - return prompt - }) - const select = createMemo(() => { - const prompt = item()?.prompt - if (!prompt || prompt.type !== "select") return - return prompt - }) - - return ( -
- - - { - const prompt = text() - if (!prompt) return - setFormStore("value", prompt.key, value) - }} - /> - - - -
-
{select()?.message}
-
- x.value} - current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} - onSelect={(value) => { - if (!value) return - const prompt = select() - if (!prompt) return - const nextValue = { - ...formStore.value, - [prompt.key]: value.value, - } - setFormStore("value", prompt.key, value.value) - void next(item()!.index, nextValue) - }} - > - {(option) => ( -
-
- - {option.label} - {option.hint} -
- )} - -
-
- - - - ) - } - let listRef: ListRef | undefined function handleKey(e: KeyboardEvent) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) { @@ -390,191 +256,6 @@ export function DialogConnectProvider(props: { provider: string }) { ) } - function ApiAuthView() { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - 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 (!apiKey?.trim()) { - setFormStore("error", language.t("provider.connect.apiKey.required")) - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
{language.t("provider.connect.opencodeZen.line1")}
-
{language.t("provider.connect.opencodeZen.line2")}
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
-
-
- -
- {language.t("provider.connect.apiKey.description", { provider: provider().name })} -
-
-
-
- setFormStore("value", v)} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - - -
- ) - } - - function OAuthCodeView() { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - 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 (!code?.trim()) { - setFormStore("error", language.t("provider.connect.oauth.code.required")) - return - } - - setFormStore("error", undefined) - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - code, - }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { 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"))) - } - - return ( -
-
- {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 })} -
-
- setFormStore("value", v)} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - - -
- ) - } - - function OAuthAutoView() { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions.split(":")[1]?.trim() - } - return instructions - }) - - onMount(() => { - void (async () => { - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) - .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 }) - return - } - - await complete() - })() - }) - - return ( -
-
- {language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} - {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} -
- -
- - {language.t("provider.connect.status.waiting")} -
-
- ) - } - return ( - + store.methodIndex} + onSubmit={selectMethod} + />
@@ -633,15 +318,28 @@ export function DialogConnectProvider(props: { provider: string }) {
- + - + store.authorization} + method={method} + methodIndex={() => store.methodIndex} + onComplete={complete} + /> - + store.authorization} + methodIndex={() => store.methodIndex} + alive={() => alive.value} + onComplete={complete} + onError={(error) => dispatch({ type: "auth.error", error })} + /> From 771b34046490b0f390b5f431688e5316de8f3845 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 19:22:22 +0800 Subject: [PATCH 2/4] fix(app): stabilize provider prompt transitions --- .../src/components/dialog-connect-provider-prompt-view.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider-prompt-view.tsx b/packages/app/src/components/dialog-connect-provider-prompt-view.tsx index ec390016c..399ff60a8 100644 --- a/packages/app/src/components/dialog-connect-provider-prompt-view.tsx +++ b/packages/app/src/components/dialog-connect-provider-prompt-view.tsx @@ -104,14 +104,15 @@ export function ProviderOAuthPromptsView(props: { current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} onSelect={(value) => { if (!value) return - const prompt = select() - if (!prompt) return + const currentItem = item() + if (!currentItem || currentItem.prompt.type !== "select") return + const prompt = currentItem.prompt const nextValue = { ...formStore.value, [prompt.key]: value.value, } setFormStore("value", prompt.key, value.value) - void next(item()!.index, nextValue) + void next(currentItem.index, nextValue) }} > {(option) => ( From dc563b2698438caa7666153cd10e2e9d55cabbb0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 19:34:41 +0800 Subject: [PATCH 3/4] test(app): cover provider prompt select transitions --- .../dialog-connect-provider-prompt-state.ts | 22 +++++++++ ...ialog-connect-provider-prompt-view.test.ts | 45 +++++++++++++++++++ .../dialog-connect-provider-prompt-view.tsx | 16 +++---- 3 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/components/dialog-connect-provider-prompt-state.ts create mode 100644 packages/app/src/components/dialog-connect-provider-prompt-view.test.ts diff --git a/packages/app/src/components/dialog-connect-provider-prompt-state.ts b/packages/app/src/components/dialog-connect-provider-prompt-state.ts new file mode 100644 index 000000000..423f9df58 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-prompt-state.ts @@ -0,0 +1,22 @@ +import type { ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" + +export type ProviderOAuthPrompt = NonNullable[number] +export type ProviderOAuthPromptItem = { + index: number + prompt: ProviderOAuthPrompt +} + +export function getProviderOAuthSelectPromptState( + item: ProviderOAuthPromptItem, + value: { value: string }, + currentValue: Record, +) { + if (item.prompt.type !== "select") return + return { + index: item.index, + value: { + ...currentValue, + [item.prompt.key]: value.value, + }, + } +} diff --git a/packages/app/src/components/dialog-connect-provider-prompt-view.test.ts b/packages/app/src/components/dialog-connect-provider-prompt-view.test.ts new file mode 100644 index 000000000..3d4dd2178 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-prompt-view.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { getProviderOAuthSelectPromptState } from "./dialog-connect-provider-prompt-state" + +describe("getProviderOAuthSelectPromptState", () => { + test("preserves the active prompt index when adding a selected value", () => { + const state = getProviderOAuthSelectPromptState( + { + index: 2, + prompt: { + type: "select", + key: "region", + message: "Region", + options: [{ label: "US", value: "us" }], + }, + }, + { value: "us" }, + { account: "work" }, + ) + + expect(state).toEqual({ + index: 2, + value: { + account: "work", + region: "us", + }, + }) + }) + + test("does not continue non-select prompts through the select path", () => { + const state = getProviderOAuthSelectPromptState( + { + index: 1, + prompt: { + type: "text", + key: "token", + message: "Token", + }, + }, + { value: "us" }, + {}, + ) + + expect(state).toBeUndefined() + }) +}) diff --git a/packages/app/src/components/dialog-connect-provider-prompt-view.tsx b/packages/app/src/components/dialog-connect-provider-prompt-view.tsx index 399ff60a8..169668fec 100644 --- a/packages/app/src/components/dialog-connect-provider-prompt-view.tsx +++ b/packages/app/src/components/dialog-connect-provider-prompt-view.tsx @@ -5,6 +5,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import { createMemo, Match, Switch } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" +import { getProviderOAuthSelectPromptState, type ProviderOAuthPrompt } from "./dialog-connect-provider-prompt-state" export function ProviderOAuthPromptsView(props: { method: () => ProviderAuthMethod | undefined @@ -22,7 +23,7 @@ export function ProviderOAuthPromptsView(props: { if (value?.type !== "oauth") return [] return value.prompts ?? [] }) - const matches = (prompt: NonNullable[number]>, value: Record) => { + const matches = (prompt: ProviderOAuthPrompt, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] if (actual === undefined) return false @@ -105,14 +106,11 @@ export function ProviderOAuthPromptsView(props: { onSelect={(value) => { if (!value) return const currentItem = item() - if (!currentItem || currentItem.prompt.type !== "select") return - const prompt = currentItem.prompt - const nextValue = { - ...formStore.value, - [prompt.key]: value.value, - } - setFormStore("value", prompt.key, value.value) - void next(currentItem.index, nextValue) + if (!currentItem) return + const nextState = getProviderOAuthSelectPromptState(currentItem, value, formStore.value) + if (!nextState) return + setFormStore("value", currentItem.prompt.key, value.value) + void next(nextState.index, nextState.value) }} > {(option) => ( From db0c8957ceb5975fab45c7a5a0f2f2c18a26b9ae Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 16 May 2026 19:41:14 +0800 Subject: [PATCH 4/4] fix(app): surface provider api auth errors --- .../dialog-connect-provider-api-auth.test.ts | 37 +++++++++++++++++++ .../dialog-connect-provider-api-auth.ts | 15 ++++++++ .../dialog-connect-provider-auth-views.tsx | 20 ++++++---- 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/components/dialog-connect-provider-api-auth.test.ts create mode 100644 packages/app/src/components/dialog-connect-provider-api-auth.ts diff --git a/packages/app/src/components/dialog-connect-provider-api-auth.test.ts b/packages/app/src/components/dialog-connect-provider-api-auth.test.ts new file mode 100644 index 000000000..ed129b97d --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-api-auth.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { submitProviderApiAuth } from "./dialog-connect-provider-api-auth" + +describe("submitProviderApiAuth", () => { + test("completes after a successful api auth write", async () => { + const calls: string[] = [] + const error = await submitProviderApiAuth({ + setAuth: async () => { + calls.push("setAuth") + }, + onComplete: async () => { + calls.push("onComplete") + }, + formatError: () => "failed", + }) + + expect(error).toBeUndefined() + expect(calls).toEqual(["setAuth", "onComplete"]) + }) + + test("returns a form error without completing when api auth write fails", async () => { + const calls: string[] = [] + const error = await submitProviderApiAuth({ + setAuth: async () => { + calls.push("setAuth") + throw new Error("bad key") + }, + onComplete: async () => { + calls.push("onComplete") + }, + formatError: (value) => (value instanceof Error ? value.message : "failed"), + }) + + expect(error).toBe("bad key") + expect(calls).toEqual(["setAuth"]) + }) +}) diff --git a/packages/app/src/components/dialog-connect-provider-api-auth.ts b/packages/app/src/components/dialog-connect-provider-api-auth.ts new file mode 100644 index 000000000..06d0012e4 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-api-auth.ts @@ -0,0 +1,15 @@ +export async function submitProviderApiAuth(input: { + setAuth: () => Promise + onComplete: () => Promise + formatError: (error: unknown) => string +}) { + const result = await input + .setAuth() + .then(() => ({ ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await input.onComplete() + return + } + return input.formatError(result.error) +} diff --git a/packages/app/src/components/dialog-connect-provider-auth-views.tsx b/packages/app/src/components/dialog-connect-provider-auth-views.tsx index 9508ab3c4..8ceeb3af3 100644 --- a/packages/app/src/components/dialog-connect-provider-auth-views.tsx +++ b/packages/app/src/components/dialog-connect-provider-auth-views.tsx @@ -6,6 +6,7 @@ import { createStore } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" +import { submitProviderApiAuth } from "./dialog-connect-provider-api-auth" import { formatProviderConnectError } from "./dialog-connect-provider-error" type ProviderConnectInfo = { @@ -34,14 +35,19 @@ export function ProviderApiAuthView(props: { provider: () => ProviderConnectInfo } setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider().id, - auth: { - type: "api", - key: apiKey, - }, + const error = await submitProviderApiAuth({ + setAuth: () => + globalSDK.client.auth.set({ + providerID: props.provider().id, + auth: { + type: "api", + key: apiKey, + }, + }), + onComplete: props.onComplete, + formatError: (error) => formatProviderConnectError(error, language.t("common.requestFailed")), }) - await props.onComplete() + if (error) setFormStore("error", error) } return (