From d69e15e3ff643ee4089172341a40d832742df57b Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:38:04 +0200 Subject: [PATCH 1/6] feat(desktop): add Console onboarding flow --- packages/app/src/new-session/view.tsx | 26 +- .../app/src/providers/catalog/providers.ts | 19 +- .../app/src/providers/connect/console.tsx | 68 ++ .../app/src/providers/connect/controller.ts | 172 +++- packages/app/src/providers/connect/dialog.tsx | 789 ++++++++++++++---- packages/app/src/providers/connect/models.css | 17 + .../app/src/providers/connect/remote.test.ts | 30 + packages/app/src/providers/connect/remote.tsx | 30 + packages/app/src/providers/connect/setup.tsx | 51 ++ packages/app/src/providers/opencode-logo.tsx | 17 + packages/app/src/runtime/i18n/en.ts | 49 ++ .../app/src/runtime/platform/platform.tsx | 3 + .../desktop/src/main/ipc-handlers/files.ts | 17 +- packages/desktop/src/renderer/api-types.ts | 1 + packages/desktop/src/renderer/api.ts | 1 + .../src/renderer/platform/files.test.ts | 5 + .../desktop/src/renderer/platform/files.ts | 2 + packages/desktop/src/shared/ipc-rpc/files.ts | 5 + packages/ui/src/overlays/dialog/dialog.tsx | 13 +- 19 files changed, 1126 insertions(+), 189 deletions(-) create mode 100644 packages/app/src/providers/connect/console.tsx create mode 100644 packages/app/src/providers/connect/models.css create mode 100644 packages/app/src/providers/connect/remote.test.ts create mode 100644 packages/app/src/providers/connect/remote.tsx create mode 100644 packages/app/src/providers/connect/setup.tsx create mode 100644 packages/app/src/providers/opencode-logo.tsx diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index b5df344e53ce..379e33b7c4ca 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -17,6 +17,7 @@ import { import { useLanguage } from "@/runtime/i18n/language" import { useWorkspaceLocation } from "@/workspaces/location" import { useProviders } from "@/providers/catalog/providers" +import { useIntegrations } from "@/providers/catalog/integrations" import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout" import { Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" @@ -24,6 +25,8 @@ import type { NewSessionWorkspaceController } from "./workspace/controller" import { NewSessionWordmark } from "./wordmark" import { SummaryPopover } from "@/session/summary/popover" import type { DraftMcpControls } from "./mcp" +import { ProviderSetup } from "@/providers/connect/setup" +import { usePlatform } from "@/runtime/platform/platform" const NewSessionSummary = lazy(async () => { const { NewSessionSummary } = await import("./summary") @@ -49,6 +52,10 @@ export function NewSessionView(props: { mcp: DraftMcpControls }) { const [store, setStore] = createStore({ summary: false }) + const platform = usePlatform() + const sdk = useWorkspaceLocation() + const providers = useProviders(() => sdk().directory) + const setup = () => platform.platform === "desktop" && providers.ready() && !providers.usable() const [onboarding, setOnboarding, , onboardingReady] = persisted( Persist.global("workspace-onboarding"), WorkspaceOnboardingSchema, @@ -88,11 +95,20 @@ export function NewSessionView(props: { -
+
+ @@ -147,8 +163,9 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => const dialog = useDialog() const sdk = useWorkspaceLocation() const providers = useProviders(() => sdk().directory) + const integrations = useIntegrations(() => sdk().directory) const [providerState, setProviderState, , providerReady] = persisted( - Persist.global("new-session.provider-tip"), + Persist.global("new-session.provider-tip-v3"), ProviderTipSchema, { dismissedAt: 0 }, ) @@ -167,7 +184,10 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => () => providers.ready() && providerReady() && - providers.paid().length === 0 && + !integrations.list().some((integration) => integration.connections.length > 0) && + !providers + .connected() + .some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) && Date.now() - providerState.dismissedAt >= providerTipDismissalDuration, ) const tip = createMemo<"workspace" | "provider" | undefined>(() => { diff --git a/packages/app/src/providers/catalog/providers.ts b/packages/app/src/providers/catalog/providers.ts index 27273c4a8224..b63c671aae02 100644 --- a/packages/app/src/providers/catalog/providers.ts +++ b/packages/app/src/providers/catalog/providers.ts @@ -45,6 +45,10 @@ export function useProviders(directory: Accessor) { }, all: () => providers().all, default: () => providers().default, + usable: () => + (data.location.model.list(location()) ?? []).some( + (model) => model.enabled && model.status !== "deprecated" && providers().connected.includes(model.providerID), + ), // V2 servers list only available providers, so the connectable catalog // comes from the integration list, with the provider catalog as fallback. popular: () => { @@ -72,15 +76,12 @@ export function useProviders(directory: Accessor) { }, paid: () => { const connected = new Set(providers().connected) - const paid = [ - ...Iterable.filter( - providers().all, - ([id]) => - connected.has(id) && - (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), - ), - ] - return paid + const paid = new Set( + (data.location.model.list(location()) ?? []) + .filter((model) => model.enabled && model.cost.some((cost) => cost.input > 0)) + .map((model) => model.providerID), + ) + return [...Iterable.filter(providers().all, ([id]) => connected.has(id) && paid.has(id))] }, } } diff --git a/packages/app/src/providers/connect/console.tsx b/packages/app/src/providers/connect/console.tsx new file mode 100644 index 000000000000..60c1c2a84a28 --- /dev/null +++ b/packages/app/src/providers/connect/console.tsx @@ -0,0 +1,68 @@ +import { For, Show } from "solid-js" +import { Button } from "@opencode/ui/button" +import { TextShimmer } from "@opencode/ui/text-shimmer" +import { useLanguage } from "@/runtime/i18n/language" + +export function ConsoleAuthorization(props: { + code: string + browserFailed: boolean + copied: boolean + copyFailed: boolean + onOpen: () => void + onCopy: () => void +}) { + const language = useLanguage() + return ( +
+

+ {language.t( + props.browserFailed ? "provider.connect.console.browserFailed" : "provider.connect.console.instructions", + )} +

+
+
{language.t("provider.connect.console.deviceCode")}
+
+ + {(character) => ( + + )} + +
+

+ +

+
+
+ {language.t("provider.connect.console.browserHint")} + + + + +
+ +

{language.t("provider.connect.console.copyFailed")}

+
+
+ ) +} diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts index b2138b68e200..eceb2732943d 100644 --- a/packages/app/src/providers/connect/controller.ts +++ b/packages/app/src/providers/connect/controller.ts @@ -13,18 +13,20 @@ export function createProviderConnectionController(options: { provider: () => string directory: () => string | undefined onComplete: () => void + initialMethod?: string pollInterval?: number }) { const language = useLanguage() const platform = usePlatform() const serverSDK = useServerSDK() const data = useData() - const location = () => { - const directory = options.directory() - return directory ? { directory } : undefined - } + // An authorization belongs to the server and Location where it began. + const directory = options.directory() + const integrationID = options.provider() + const desktopConsole = platform.platform === "desktop" && integrationID === "opencode" + const location = () => (directory ? { directory } : undefined) const [integration] = createResource( - () => ({ provider: options.provider(), directory: options.directory() }), + () => ({ provider: integrationID, directory }), (input) => serverSDK.api.integration .get({ integrationID: input.provider, location: location() }) @@ -41,8 +43,12 @@ export function createProviderConnectionController(options: { methodIndex: undefined as number | undefined, authorization: undefined as Authorization | undefined, formAnswer: undefined as FormAnswer | undefined, - state: "pending" as "pending" | "complete" | "error" | "form" | undefined, + state: "pending" as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined, error: undefined as string | undefined, + connected: false, + browserFailed: false, + statusFailed: false, + selectingIndex: undefined as number | undefined, }) const polling = { generation: 0, @@ -59,7 +65,7 @@ export function createProviderConnectionController(options: { | { type: "auth.form" } | { type: "auth.answer"; answer: FormAnswer | undefined } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: Authorization } + | { type: "auth.authorized"; index: number; authorization: Authorization } | { type: "auth.error"; error: string } const dispatch = (action: Action) => { @@ -71,6 +77,10 @@ export function createProviderConnectionController(options: { draft.formAnswer = undefined draft.state = undefined draft.error = undefined + draft.connected = false + draft.browserFailed = false + draft.statusFailed = false + draft.selectingIndex = undefined return } if (action.type === "method.reset") { @@ -79,6 +89,10 @@ export function createProviderConnectionController(options: { draft.formAnswer = undefined draft.state = undefined draft.error = undefined + draft.connected = false + draft.browserFailed = false + draft.statusFailed = false + draft.selectingIndex = undefined return } if (action.type === "auth.form") { @@ -95,12 +109,15 @@ export function createProviderConnectionController(options: { if (action.type === "auth.pending") { draft.state = "pending" draft.error = undefined + draft.selectingIndex = undefined return } - if (action.type === "auth.complete") { - draft.state = "complete" + if (action.type === "auth.authorized") { + draft.methodIndex = action.index + draft.state = "waiting" draft.authorization = action.authorization draft.error = undefined + draft.selectingIndex = undefined return } draft.state = "error" @@ -115,24 +132,59 @@ export function createProviderConnectionController(options: { clearTimeout(polling.timer) polling.timer = undefined } + const cancelAttempt = (authorization = store.authorization) => { + if (!desktopConsole) return + if (!authorization || (authorization.attemptID === store.authorization?.attemptID && store.connected)) return + void serverSDK.api.integration.oauth + .cancel({ + integrationID, + attemptID: authorization.attemptID, + location: location(), + }) + .catch(() => undefined) + } + const openBrowser = async () => { + const authorization = store.authorization + if (!authorization) return + const generation = polling.generation + const opened = await Promise.resolve() + .then(async () => { + if (platform.openBrowser) return platform.openBrowser(authorization.url) + platform.openExternal(authorization.url) + return true + }) + .then((result) => result !== false) + .catch(() => false) + if (polling.disposed || generation !== polling.generation) return + setStore("browserFailed", !opened) + } const finish = async () => { cancelPolling() + const generation = polling.generation + setStore({ connected: true, state: "refreshing", error: undefined }) const ref = location() data.location.integration.invalidate(ref) data.location.provider.invalidate(ref) data.location.model.invalidate(ref) - await Promise.all([ + const refreshed = await Promise.all([ data.location.integration.sync(ref), data.location.provider.sync(ref), data.location.model.sync(ref), - ]).catch(() => undefined) - if (polling.disposed) return + ]) + .then(() => true) + .catch(() => false) + if (polling.disposed || generation !== polling.generation) return + if (!refreshed && desktopConsole) { + dispatch({ type: "auth.error", error: language.t("provider.connect.console.refreshFailed") }) + return + } + setStore("state", "ready") options.onComplete() } const poll = async (authorization: Authorization, generation: number) => { const result = await serverSDK.api.integration.oauth .status({ - integrationID: options.provider(), + integrationID, attemptID: authorization.attemptID, location: location(), }) @@ -140,9 +192,14 @@ export function createProviderConnectionController(options: { .catch((error) => ({ ok: false as const, error })) if (polling.disposed || generation !== polling.generation) return if (!result.ok) { + setStore("statusFailed", true) dispatch({ type: "auth.error", - error: result.error instanceof Error ? result.error.message : String(result.error), + error: desktopConsole + ? language.t("provider.connect.console.statusFailed") + : result.error instanceof Error + ? result.error.message + : String(result.error), }) return } @@ -151,20 +208,37 @@ export function createProviderConnectionController(options: { return } if (result.status.status === "failed") { - dispatch({ type: "auth.error", error: result.status.message }) + const message = result.status.message + dispatch({ + type: "auth.error", + error: + desktopConsole && message.includes("expired_token") + ? language.t("provider.connect.console.expired") + : desktopConsole && message.includes("access_denied") + ? language.t("provider.connect.console.denied") + : message, + }) return } if (result.status.status === "expired") { - dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + dispatch({ + type: "auth.error", + error: language.t(desktopConsole ? "provider.connect.console.expired" : "common.requestFailed"), + }) return } - polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000) + polling.timer = setTimeout( + () => void poll(authorization, generation), + options.pollInterval ?? (desktopConsole ? 500 : 1_000), + ) } const select = async (index: number, answer?: FormAnswer) => { cancelPolling() + cancelAttempt() const generation = polling.generation const selected = methods()[index] - dispatch({ type: "method.select", index }) + const awaitAuthorization = desktopConsole && selected.type === "oauth" && selected.id === "device" + if (!awaitAuthorization) dispatch({ type: "method.select", index }) if (selected.form?.length && !answer) { dispatch({ type: "auth.form" }) return @@ -175,41 +249,62 @@ export function createProviderConnectionController(options: { } if (selected.type !== "oauth") return if (selected.form?.some((field) => field.type !== "string")) { - dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" }) + dispatch({ type: "auth.error", error: language.t("provider.connect.form.unsupported") }) return } - dispatch({ type: "auth.pending" }) + if (awaitAuthorization) { + setStore({ + selectingIndex: index, + authorization: undefined, + state: undefined, + error: undefined, + browserFailed: false, + statusFailed: false, + }) + } else { + dispatch({ type: "auth.pending" }) + } const result = await serverSDK.api.integration.oauth .connect({ - integrationID: options.provider(), + integrationID, methodID: selected.id, ...(answer ? { answer } : {}), location: location(), }) .then((response) => { - if (options.provider() === "opencode" && platform.platform === "desktop") { + if (integrationID === "opencode" && platform.platform === "desktop") { const url = new URL(response.data.url) url.searchParams.set("client_id", "opencode-desktop") + url.searchParams.set("return_window", platform.windowID) response.data.url = url.href } return { ok: true as const, authorization: response.data } }) .catch((error) => ({ ok: false as const, error })) - if (polling.disposed || generation !== polling.generation) return + if (polling.disposed || generation !== polling.generation) { + if (result.ok) cancelAttempt(result.authorization) + return + } if (!result.ok) { - dispatch({ type: "auth.error", error: String(result.error) }) + if (awaitAuthorization) dispatch({ type: "method.select", index }) + dispatch({ + type: "auth.error", + error: desktopConsole ? language.t("provider.connect.console.startFailed") : String(result.error), + }) return } - dispatch({ type: "auth.complete", authorization: result.authorization }) + dispatch({ type: "auth.authorized", index, authorization: result.authorization }) + if (desktopConsole && selected.id === "device") void openBrowser() if (result.authorization.mode === "auto") void poll(result.authorization, generation) } const reset = () => { cancelPolling() + cancelAttempt() dispatch({ type: "method.reset" }) } const connectKey = async (key: string) => { await serverSDK.api.integration.connect.key({ - integrationID: options.provider(), + integrationID, location: location(), key, ...(store.formAnswer ? { answer: store.formAnswer } : {}), @@ -221,7 +316,7 @@ export function createProviderConnectionController(options: { if (!authorization) return language.t("provider.connect.oauth.code.invalid") const result = await serverSDK.api.integration.oauth .complete({ - integrationID: options.provider(), + integrationID, attemptID: authorization.attemptID, location: location(), code, @@ -238,13 +333,20 @@ export function createProviderConnectionController(options: { let auto = false createEffect(() => { - if (auto || integration.loading || methods().length !== 1) return + if (auto || integration.loading) return + const index = options.initialMethod + ? methods().findIndex((method) => method.type === "oauth" && method.id === options.initialMethod) + : methods().length === 1 + ? 0 + : -1 + if (index < 0) return auto = true - void select(0) + void select(index) }) onCleanup(() => { polling.disposed = true cancelPolling() + cancelAttempt() }) return { @@ -254,6 +356,9 @@ export function createProviderConnectionController(options: { currentMethod, methodIndex: () => store.methodIndex, authorization: () => store.authorization, + browserFailed: () => store.browserFailed, + selecting: (index: number) => store.selectingIndex === index, + openBrowser, auth: { state: () => store.state, error: () => store.error, @@ -261,6 +366,15 @@ export function createProviderConnectionController(options: { reset, connectKey, completeCode, + refresh: finish, + retry: () => { + if (store.connected) return finish() + if (store.statusFailed && store.authorization) { + setStore({ state: "waiting", error: undefined, statusFailed: false }) + return poll(store.authorization, polling.generation) + } + return store.methodIndex === undefined ? Promise.resolve() : select(store.methodIndex, store.formAnswer) + }, }, } } diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index fd063605e09d..437df9301b2b 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -4,11 +4,23 @@ import { Icon } from "@opencode/ui/icon" import { List } from "@opencode/ui/list" import { ProviderIcon } from "@opencode/ui/provider-icon" import { Spinner } from "@opencode/ui/spinner" +import { Loader } from "@opencode/ui/loader" import { TextField } from "@opencode/ui/text-field" import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog" import { TextInput } from "@opencode/ui/text-input" import { showToast } from "@/shell/notifications/toast" -import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js" +import { + type Component, + createEffect, + createMemo, + createResource, + createUniqueId, + For, + Match, + onMount, + Show, + Switch, +} from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { ExternalLink } from "@/runtime/platform/external-link" @@ -18,6 +30,18 @@ import { useIntegrations } from "@/providers/catalog/integrations" import { CustomProviderForm } from "@/providers/credentials/dialog" import { decode64 } from "@/runtime/persistence/base64" import { createProviderConnectionController, type ProviderConnectMethod } from "./controller" +import { ConsoleAuthorization } from "./console" +import { OpenCodeLogo } from "@/providers/opencode-logo" +import { usePlatform } from "@/runtime/platform/platform" +import { useServerSDK } from "@/runtime/server/client" +import { useData } from "@/runtime/server/current" +import { authServerName, RemoteAuthNotice } from "./remote" +import type { ModelSelection } from "@/providers/models/selection" +import { ServerConnection } from "@/runtime/server/registry" +import { useTabs } from "@/shell/tabs/tabs" +import { useSettingsSurface } from "@/settings/surface" +import { SettingsList } from "@/settings/list" +import "./models.css" const CUSTOM_ID = "_custom" type IntegrationForm = NonNullable[number] @@ -37,9 +61,21 @@ export function useProviderConnectController(options: { onBack?: () => void } = export const DialogConnectProvider: Component<{ directory?: string controller?: ReturnType + provider?: string + initialMethod?: string + selection?: ModelSelection + onDone?: () => void + onConnected?: (provider: string) => void }> = (props) => { const fallback = useProviderConnectController() + if (props.provider) fallback.select(props.provider) const controller = props.controller ?? fallback + const platform = usePlatform() + const [state, setState] = createStore({ + completed: false, + modelProvider: undefined as { id: string; name: string } | undefined, + authorization: false, + }) const language = useLanguage() const reset = controller.back const back = { current: reset } @@ -56,13 +92,22 @@ export const DialogConnectProvider: Component<{ - + {(provider) => ( (back.current = handler)} + initialMethod={props.initialMethod} + selection={props.selection} + onDone={props.onDone ? () => setState("completed", true) : undefined} + onConnected={() => props.onConnected?.(provider)} + onFirstConnection={(provider) => setState("modelProvider", provider)} + onAuthorization={(authorization) => setState("authorization", authorization)} /> )} @@ -75,25 +120,58 @@ export const DialogConnectProvider: Component<{ return ( { + if (!state.completed || !props.onDone) return + event.preventDefault() + props.onDone() + }} class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" + classList={{ + "[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": + platform.platform === "desktop" && controller.selected() === "opencode" && !state.modelProvider, + "[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider, + }} > - {language.t("command.provider.connect")}} - > - - + + + {(provider) => ( +
+ } + > + + + {language.t("provider.connect.models.title", { provider: provider().name })} +
+ )} +
+ + + + + {language.t("command.provider.connect")} + +
- +
@@ -115,7 +193,9 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string const all = createMemo(() => { language.locale() const query = store.filter.trim().toLowerCase() - const values = [custom(), ...integrations.list()] + const values = [custom(), ...integrations.list()].map((provider) => + provider.id === "opencode" ? { ...provider, name: language.t("provider.connect.console.name") } : provider, + ) if (!query) return values return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) }) @@ -205,7 +285,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string aria-busy={store.connecting === provider.id} onClick={() => connect(provider.id)} > - + } + > + + {provider.name} @@ -254,18 +339,62 @@ function ProviderConnection(props: { directory?: string onBack: () => void setBack: (handler: () => void) => void + initialMethod?: string + selection?: ModelSelection + onDone?: () => void + onConnected?: () => void + onFirstConnection: (provider: { id: string; name: string }) => void + onAuthorization: (authorization: boolean) => void }) { const dialog = useDialog() const params = useParams() const language = useLanguage() const providers = useProviders(() => props.directory) - const directory = () => props.directory ?? decode64(params.dir) + const initialDirectory = props.directory ?? decode64(params.dir) + const directory = () => initialDirectory + const platform = usePlatform() + const sdk = useServerSDK() + const data = useData() + const tabs = useTabs() + const surface = useSettingsSurface() + const integrations = useIntegrations(directory) + const desktopConsole = platform.platform === "desktop" && props.provider === "opencode" + const remote = desktopConsole && authServerName(sdk.server) !== undefined + const initialModel = props.selection?.current() + const [consoleState, setConsoleState] = createStore({ + copied: false, + copyFailed: false, + firstConnection: undefined as boolean | undefined, + models: false, + selectedModel: "", + collapsed: {} as Record, + }) + const consoleMethod = () => { + const method = controller.currentMethod() + return desktopConsole && method?.type === "oauth" && method.id === "device" + } + const done = () => { + props.onDone?.() + dialog.close() + } const controller = createProviderConnectionController({ provider: () => props.provider, directory, + initialMethod: remote ? undefined : props.initialMethod, onComplete: () => { + props.onConnected?.() + if (consoleState.firstConnection) { + if (connectionModels().length > 0) { + const first = connectionGroups()[0]?.models[0] + setConsoleState({ models: true, selectedModel: first ? modelKey(first) : "" }) + props.onFirstConnection({ id: props.provider, name: connectedProviderName() }) + return + } + if (consoleMethod()) return + } dialog.close() + surface.open("providers") showToast({ variant: "success", icon: "circle-check", @@ -274,18 +403,98 @@ function ProviderConnection(props: { }) }, }) + createEffect(() => { + const current = controller.integration() + if (!current || consoleState.firstConnection !== undefined) return + const existingConnection = integrations.list().some((integration) => integration.connections.length > 0) + const existingProvider = providers + .connected() + .some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) + setConsoleState("firstConnection", !existingConnection && !existingProvider) + }) + const [defaultModel] = createResource( + () => controller.auth.state() === "ready" && consoleMethod(), + () => + sdk.api.model + .default({ location: initialDirectory ? { directory: initialDirectory } : undefined }) + .then((response) => response.data) + .catch(() => undefined), + ) + const consoleModels = createMemo(() => { + const location = initialDirectory ? { directory: initialDirectory } : undefined + const connected = new Set( + (data.location.provider.list(location) ?? []) + .filter((provider) => provider.integrationID === "opencode") + .map((provider) => provider.id), + ) + return (data.location.model.list(location) ?? []) + .filter((model) => connected.has(model.providerID) && model.enabled && model.status !== "deprecated") + .toSorted((a, b) => a.providerID.localeCompare(b.providerID) || a.id.localeCompare(b.id)) + }) + const connectionModels = createMemo(() => { + const location = initialDirectory ? { directory: initialDirectory } : undefined + const ids = new Set( + (data.location.provider.list(location) ?? []) + .filter((provider) => provider.id === props.provider || provider.integrationID === props.provider) + .map((provider) => provider.id), + ) + return (data.location.model.list(location) ?? []).filter( + (model) => ids.has(model.providerID) && model.enabled && model.status !== "deprecated", + ) + }) + const connectionGroups = createMemo(() => { + const location = initialDirectory ? { directory: initialDirectory } : undefined + const models = connectionModels() + return (data.location.provider.list(location) ?? []) + .filter((provider) => provider.id === props.provider || provider.integrationID === props.provider) + .map((provider) => ({ provider, models: models.filter((model) => model.providerID === provider.id) })) + .filter((group) => group.models.length > 0) + }) + const modelKey = (model: { providerID: string; id: string }) => `${model.providerID}:${model.id}` + const selectedModel = () => connectionModels().find((model) => modelKey(model) === consoleState.selectedModel) + const connectedProviderName = () => (props.provider === "opencode" ? "OpenCode" : provider().name) + const recommended = () => { + const current = props.selection?.current() + if (initialModel && current?.id === initialModel.id && current.provider.id === initialModel.provider.id) + return current + const preferred = defaultModel() + return ( + consoleModels().find((model) => model.providerID === preferred?.providerID && model.id === preferred.id) ?? + consoleModels()[0] + ) + } + const copyLink = async () => { + const url = controller.authorization()?.url + if (!url) return + const copied = await Promise.resolve() + .then(() => (platform.writeClipboardText ? platform.writeClipboardText(url) : navigator.clipboard.writeText(url))) + .then(() => true) + .catch(() => false) + if (controller.authorization()?.url !== url) return + setConsoleState({ copied, copyFailed: !copied }) + } + createEffect(() => { + controller.authorization()?.attemptID + setConsoleState({ copied: false, copyFailed: false }) + }) + createEffect(() => props.onAuthorization(controller.authorization() !== undefined)) const provider = createMemo(() => ({ id: props.provider, - name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider, + name: + props.provider === "opencode" + ? language.t("provider.connect.console.name") + : (providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider), })) const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "key") return language.t("provider.connect.method.apiKey") + if (value.type === "key") + return language.t(desktopConsole ? "provider.connect.console.serviceKey" : "provider.connect.method.apiKey") return value.label ?? "" } const methodDetails = (value?: { type?: string; label?: string }) => { const label = methodLabel(value) + if (desktopConsole && value?.type === "key") return { label } const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) const hint = suffix?.[1] return { @@ -432,33 +641,76 @@ function ProviderConnection(props: { props.setBack(goBack) function MethodSelection() { + const primary = () => + desktopConsole + ? controller.methods().findIndex((method) => method.type === "oauth" && method.id === "device") + : -1 + const serviceAccount = () => controller.methods().findIndex((method) => method.type === "key") return (
-
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- - {(item, index) => { - const details = () => methodDetails(item) - return ( - + = 0}> +
+ {language.t("provider.connect.console.serviceAccount")} + +
+
+
+ + +
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ + {(item, index) => { + const details = () => methodDetails(item) + return ( + + - - ) - }} - -
+ ) + }} + +
+
) } @@ -492,29 +744,38 @@ function ProviderConnection(props: { } 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.console.serviceKeyDescription")}

+
+ + +
+
{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")} +
-
+
-
-
+ } + > + void copyLink()} + onOpen={() => void controller.openBrowser()} /> -
- - {language.t("provider.connect.status.waiting")} + + ) + } + + const startWithModel = async () => { + const model = selectedModel() + if (!model) return + const selection = { providerID: model.providerID, modelID: model.id } + if (props.selection) { + props.selection.set(selection) + done() + return + } + dialog.close() + await tabs.newDraft( + { + server: ServerConnection.key(sdk.server), + directory: initialDirectory ?? data.location.default().directory, + }, + undefined, + selection, + ) + } + + function FirstConnectionModels() { + return ( +
+

+ {language.t("provider.connect.models.description")} +

+
+
+ + {(group) => { + const collapsible = () => connectionGroups().length > 1 + const expanded = () => !collapsible() || !consoleState.collapsed[group.provider.id] + const label = () => ( + + } + > + + + {group.provider.name} + + ) + return ( +
+

+ {label()}

}> + + + + + + + {(model) => { + const selected = () => consoleState.selectedModel === modelKey(model) + return ( + + ) + }} + + + + + ) + }} + +
+
+
+ +
) } return ( -
-
- -
- - - {language.t("provider.connect.title.anthropicProMax")} - - {language.t("provider.connect.title", { provider: provider().name })} - -
-
+ }>
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })} -
-
-
- - - - +
+ } + > + + +
+ - - - - - + {language.t("provider.connect.console.title")} + + {language.t("provider.connect.title.anthropicProMax")} + {language.t("provider.connect.title", { provider: provider().name })} - - + +
+
+
+ +
+ +
+
+
+ + +
+
+

+ + {language.t("provider.connect.console.connected")} +

+

+ {language.t( + consoleModels().length ? "provider.connect.console.ready" : "provider.connect.console.noModels", + )} +

+
+ + {(model) => ( +

+ {language.t("provider.connect.console.model")}{" "} + {model().name} +

+ )} +
+ 0} + fallback={ + <> + + + + } + > + + +
+
+ +

+ {language.t("provider.connect.console.refreshing")} +

+
+ +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + + + + + + + + + + + + + + + + +
+
-
+
) } diff --git a/packages/app/src/providers/connect/models.css b/packages/app/src/providers/connect/models.css new file mode 100644 index 000000000000..e7127b89acdb --- /dev/null +++ b/packages/app/src/providers/connect/models.css @@ -0,0 +1,17 @@ +[data-first-provider-model]:has(+ [data-first-provider-model]:hover), +[data-first-provider-model]:hover { + border-block-end-color: transparent; +} + +[data-first-provider-model]:hover { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="first-provider-models"] [data-component="settings-list"] { + background-color: var(--v2-background-bg-layer-02); + overflow: hidden; +} + +[data-component="first-provider-models"] .settings-section[data-expanded] { + padding-bottom: 0; +} diff --git a/packages/app/src/providers/connect/remote.test.ts b/packages/app/src/providers/connect/remote.test.ts new file mode 100644 index 000000000000..a34d72dce968 --- /dev/null +++ b/packages/app/src/providers/connect/remote.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { authServerName } from "./remote" + +test("SSH disclosure uses the remote identity even with a loopback proxy", () => { + expect(authServerName({ type: "ssh", host: "production.example", http: { url: "http://127.0.0.1:4096" } })).toBe( + "production.example", + ) + expect( + authServerName({ + type: "ssh", + host: "production.example", + displayName: "Production server", + http: { url: "http://127.0.0.1:4096" }, + }), + ).toBe("Production server") +}) + +test("local Desktop and loopback HTTP connections do not show remote disclosure", () => { + expect(authServerName({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBeUndefined() + for (const host of ["localhost", "127.0.0.1", "[::1]"]) { + expect(authServerName({ type: "http", http: { url: `http://${host}:4096` } })).toBeUndefined() + } +}) + +test("WSL and remote HTTP connections show their server identity", () => { + expect( + authServerName({ type: "sidecar", variant: "wsl", distro: "Ubuntu", http: { url: "http://127.0.0.1:4096" } }), + ).toBe("Ubuntu") + expect(authServerName({ type: "http", http: { url: "https://production.example" } })).toBe("production.example") +}) diff --git a/packages/app/src/providers/connect/remote.tsx b/packages/app/src/providers/connect/remote.tsx new file mode 100644 index 000000000000..97133307808f --- /dev/null +++ b/packages/app/src/providers/connect/remote.tsx @@ -0,0 +1,30 @@ +import { Show } from "solid-js" +import { useLanguage } from "@/runtime/i18n/language" +import { ServerConnection, serverName } from "@/runtime/server/registry" + +export function authServerName(server: ServerConnection.Any) { + if (ServerConnection.builtin(server)) return undefined + if (server.type === "http" && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(server.http.url).hostname)) + return undefined + if (server.type === "sidecar" && server.variant === "wsl") return server.displayName ?? server.distro + return serverName(server) +} + +export function RemoteAuthNotice(props: { server: ServerConnection.Any }) { + const language = useLanguage() + return ( + + {(name) => ( +
+

+ {language.t("provider.connect.remote.title", { server: name() })} +

+

{language.t("provider.connect.remote.description")}

+
+ )} +
+ ) +} diff --git a/packages/app/src/providers/connect/setup.tsx b/packages/app/src/providers/connect/setup.tsx new file mode 100644 index 000000000000..191dcc23acc7 --- /dev/null +++ b/packages/app/src/providers/connect/setup.tsx @@ -0,0 +1,51 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { useLanguage } from "@/runtime/i18n/language" +import { useServerSDK } from "@/runtime/server/client" +import type { ModelSelection } from "@/providers/models/selection" +import { RemoteAuthNotice } from "./remote" +import { Show } from "solid-js" + +export function ProviderSetup(props: { + visible: boolean + directory: string + selection: ModelSelection + onDone: () => void +}) { + const language = useLanguage() + const dialog = useDialog() + const sdk = useServerSDK() + const open = async (console: boolean) => { + const { DialogConnectProvider } = await import("./dialog") + void dialog.show(() => ( + + )) + } + return ( + +
+
+

{language.t("provider.setup.title")}

+

{language.t("provider.connect.console.intro")}

+
+ +
+ + +
+

{language.t("provider.setup.settings")}

+
+
+ ) +} diff --git a/packages/app/src/providers/opencode-logo.tsx b/packages/app/src/providers/opencode-logo.tsx new file mode 100644 index 000000000000..227257fa91e5 --- /dev/null +++ b/packages/app/src/providers/opencode-logo.tsx @@ -0,0 +1,17 @@ +export function OpenCodeLogo(props: { class?: string }) { + return ( + + ) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index ccb43658116e..441fd744aeb2 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -190,6 +190,53 @@ export const dict = { "dialog.provider.viewAll": "Show more providers", "provider.connect.title": "Connect {{provider}}", + "provider.connect.console.title": "Connect OpenCode Console account", + "provider.connect.console.name": "OpenCode Console", + "provider.connect.console.instructions": + "Continue in your browser. Confirm the code shown there matches the one below.", + "provider.connect.console.deviceCode": "Device code", + "provider.connect.console.waiting": "Waiting for confirmation…", + "provider.connect.console.browserHint": "Browser didn't open?", + "provider.connect.console.copyLink": "Copy sign-in link", + "provider.connect.console.linkCopied": "Sign-in link copied", + "provider.connect.console.copyFailed": "Couldn't copy the sign-in link. Open Console again to continue.", + "provider.connect.console.openAgain": "Open Console again", + "provider.connect.console.browserFailed": + "We couldn't open your browser. Try again or copy the sign-in link to continue.", + "provider.connect.console.expired": "This sign-in request has expired. Start again to get a new device code.", + "provider.connect.console.denied": "Access was denied in Console. Try again when you're ready to connect.", + "provider.connect.console.statusFailed": "Couldn't check authorization. Check your server connection and try again.", + "provider.connect.console.startFailed": "Couldn't start sign-in. Check your server connection and try again.", + "provider.connect.models.title": "Connected to {{provider}}", + "provider.connect.models.description": "Choose a model to start with. You can switch models anytime.", + "provider.connect.models.list": "Models available from {{provider}}", + "provider.connect.console.retry": "Try again", + "provider.connect.console.refreshing": "OpenCode connected. Loading your models...", + "provider.connect.console.refreshFailed": + "Your account is connected, but we couldn't load your models. Try again to refresh them.", + "provider.connect.console.connected": "OpenCode connected", + "provider.connect.console.ready": "Your models are ready.", + "provider.connect.console.noModels": + "Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.", + "provider.connect.console.refresh": "Refresh models", + "provider.connect.console.model": "Model", + "provider.connect.console.start": "Start coding", + "provider.connect.console.done": "Done", + "provider.connect.console.continue": "Continue with OpenCode Console", + "provider.connect.console.openingBrowser": "Opening browser…", + "provider.connect.console.serviceAccount": "Service account?", + "provider.connect.console.useApiKey": "Use API key", + "provider.connect.console.otherMethods": "Other methods", + "provider.connect.console.serviceKey": "API key (service account)", + "provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.", + "provider.connect.console.intro": "Sign in once to use the models available through your OpenCode account.", + "provider.connect.remote.title": "Connecting on “{{server}}”", + "provider.connect.remote.description": + "Your OpenCode credentials will be stored on this server. Models will be available through this server.", + "provider.connect.form.unsupported": "This authentication form contains unsupported fields", + "provider.setup.title": "Start with OpenCode", + "provider.setup.other": "Other providers", + "provider.setup.settings": "You can change this later in Settings → Providers.", "provider.connect.title.anthropicProMax": "Login with Claude Pro/Max", "provider.connect.selectMethod": "Select login method for {{provider}}.", "provider.connect.method.apiKey": "API key", @@ -1314,6 +1361,8 @@ export const dict = { "settings.providers.section.connected": "Connected providers", "settings.providers.connected.empty": "No connected providers", "settings.providers.connected.environmentDescription": "Connected from your environment variables", + "settings.providers.console.available.one": "{{count}} provider available", + "settings.providers.console.available.other": "{{count}} providers available", "settings.providers.section.popular": "Popular providers", "settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.", "settings.providers.tag.environment": "Environment", diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx index a8a14bc8eb92..0507ae3771f6 100644 --- a/packages/app/src/runtime/platform/platform.tsx +++ b/packages/app/src/runtime/platform/platform.tsx @@ -37,6 +37,9 @@ type PlatformBase = { /** Open a web or mail URL in the default system application */ openExternal(url: string): void + /** Open an authentication page, reporting whether the browser could be launched. */ + openBrowser?(url: string): Promise + /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise diff --git a/packages/desktop/src/main/ipc-handlers/files.ts b/packages/desktop/src/main/ipc-handlers/files.ts index d03b7f6010a3..884c31fcb0d2 100644 --- a/packages/desktop/src/main/ipc-handlers/files.ts +++ b/packages/desktop/src/main/ipc-handlers/files.ts @@ -1,4 +1,6 @@ import { Effect } from "effect" +import { shell } from "electron" +import { resolveExternalURL } from "../files/external-url" import { FileRpcs } from "../../shared/ipc-rpc" import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files" import { IpcPortHandoff } from "../ipc-transport" @@ -18,13 +20,22 @@ export const fileHandlers = FileRpcs.toLayer( ) .pipe(Effect.orDie), FilesReadPickedFile: ({ token, path }, context) => - files - .readPickedFile(sender(handoff, context).id, token, path) - .pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie), + files.readPickedFile(sender(handoff, context).id, token, path).pipe( + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.orDie, + ), FilesReleasePickedFiles: ({ token }, context) => Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)), FilesSaveFile: ({ options, content }) => files.saveFile(options, content).pipe(Effect.orDie), FilesOpenExternal: ({ url }) => openExternalURL(url), + FilesOpenBrowser: ({ url }) => { + const target = resolveExternalURL(url) + if (!target || !/^https?:/.test(target)) return Effect.succeed(false) + return Effect.tryPromise(() => shell.openExternal(target)).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + }, FilesOpenLocalFile: ({ url }) => openLocalFileURL(url), FilesOpenPath: ({ path, application }) => files.openPath(path, application).pipe( diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 3fe255855ea4..4b233b988e46 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -63,6 +63,7 @@ export type ElectronAPI = { getPathForFile(file: File): string saveFile(opts: SaveFilePickerOptions, content: string): Promise openExternal(url: string): void + openBrowser(url: string): Promise openLocalFile(url: string): void openPath(path: string, app?: string): Promise revealPath(path: string): Promise diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 8859413cb568..419d4f0db997 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -122,6 +122,7 @@ export const api: ElectronAPI = { getPathForFile: (file) => window.electron.getPathForFile(file), saveFile: (opts, content) => invoke("FilesSaveFile", { options: opts, content }), openExternal: (url) => send("FilesOpenExternal", { url }), + openBrowser: (url) => invoke("FilesOpenBrowser", { url }), openLocalFile: (url) => send("FilesOpenLocalFile", { url }), openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined), revealPath: (path) => invoke("FilesRevealPath", { path }), diff --git a/packages/desktop/src/renderer/platform/files.test.ts b/packages/desktop/src/renderer/platform/files.test.ts index 8ac4e418cda8..7e3c5cfa9e7f 100644 --- a/packages/desktop/src/renderer/platform/files.test.ts +++ b/packages/desktop/src/renderer/platform/files.test.ts @@ -21,6 +21,7 @@ function fileApi(events: string[]) { getPathForFile: () => "fallback", saveFile: async () => false, openExternal: () => {}, + openBrowser: async () => true, openLocalFile: () => {}, resolveAppPath: async () => null, openPath: async () => undefined, @@ -33,6 +34,10 @@ function fileApi(events: string[]) { } describe("desktop attachment files", () => { + test("reports native browser launch failure to the renderer", async () => { + const files = createDesktopFiles({ ...fileApi([]), openBrowser: async () => false }, "macos", []) + expect(await files.openBrowser("https://opencode.ai/console")).toBe(false) + }) test("reads selected files sequentially and releases the token", async () => { const events: string[] = [] const files = createDesktopFiles(fileApi(events), "windows", ["txt"]) diff --git a/packages/desktop/src/renderer/platform/files.ts b/packages/desktop/src/renderer/platform/files.ts index 401bb59bcaca..577735883dc2 100644 --- a/packages/desktop/src/renderer/platform/files.ts +++ b/packages/desktop/src/renderer/platform/files.ts @@ -11,6 +11,7 @@ type DesktopFileAPI = Pick< | "getPathForFile" | "saveFile" | "openExternal" + | "openBrowser" | "openLocalFile" | "resolveAppPath" | "openPath" @@ -55,6 +56,7 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS, acceptedE saveFile: (options: { title?: string; defaultPath?: string }, content: string) => api.saveFile({ title: options.title, defaultPath: options.defaultPath }, content), openExternal: (url: string) => api.openExternal(url), + openBrowser: (url: string) => api.openBrowser(url), openLocalFile: (url: string) => api.openLocalFile(url), async openPath(path: string, app?: string) { if (os !== "windows") { diff --git a/packages/desktop/src/shared/ipc-rpc/files.ts b/packages/desktop/src/shared/ipc-rpc/files.ts index 3c6afa24b8b7..828b7b1d5930 100644 --- a/packages/desktop/src/shared/ipc-rpc/files.ts +++ b/packages/desktop/src/shared/ipc-rpc/files.ts @@ -42,6 +42,10 @@ export const FilesSaveFile = Rpc.make("FilesSaveFile", { export const FilesOpenExternal = Rpc.make("FilesOpenExternal", { payload: { url: Schema.String }, }) +export const FilesOpenBrowser = Rpc.make("FilesOpenBrowser", { + payload: { url: Schema.String }, + success: Schema.Boolean, +}) export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", { payload: { url: Schema.String }, }) @@ -67,6 +71,7 @@ export const FileRpcs = RpcGroup.make( FilesReleasePickedFiles, FilesSaveFile, FilesOpenExternal, + FilesOpenBrowser, FilesOpenLocalFile, FilesOpenPath, FilesRevealPath, diff --git a/packages/ui/src/overlays/dialog/dialog.tsx b/packages/ui/src/overlays/dialog/dialog.tsx index 646e2e3c043e..afca7d62f7bd 100644 --- a/packages/ui/src/overlays/dialog/dialog.tsx +++ b/packages/ui/src/overlays/dialog/dialog.tsx @@ -10,6 +10,7 @@ export interface DialogProps extends ParentProps { containerClass?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] fit?: boolean + onCloseAutoFocus?: ComponentProps["onCloseAutoFocus"] } export interface DialogHeaderProps extends ParentProps { @@ -82,7 +83,16 @@ export function DialogHeader(props: DialogHeaderProps) { } export function Dialog(props: DialogProps) { - const [local] = splitProps(props, ["size", "variant", "class", "containerClass", "classList", "fit", "children"]) + const [local] = splitProps(props, [ + "size", + "variant", + "class", + "containerClass", + "classList", + "fit", + "children", + "onCloseAutoFocus", + ]) return (
Date: Fri, 11 Sep 2026 16:38:14 +0200 Subject: [PATCH 2/6] feat(app): group Console providers in settings --- packages/app/src/settings/models/models.tsx | 27 ++- .../app/src/settings/providers/providers.tsx | 224 +++++++++++++++--- packages/app/src/settings/settings.css | 132 +++++++++++ packages/app/src/settings/shell.tsx | 41 +++- 4 files changed, 383 insertions(+), 41 deletions(-) diff --git a/packages/app/src/settings/models/models.tsx b/packages/app/src/settings/models/models.tsx index 1d1fc843b1d4..abf93dc949af 100644 --- a/packages/app/src/settings/models/models.tsx +++ b/packages/app/src/settings/models/models.tsx @@ -24,7 +24,12 @@ export const ModelProvidersSchema = Schema.Struct({ collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)), }) -export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => { +export const SettingsModels: Component<{ + active?: boolean + autofocus?: boolean + provider?: string + onReveal?: () => void +}> = (props) => { const language = useLanguage() const models = useModels() const serverSdk = useServerSDK() @@ -47,6 +52,7 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean } ModelProvidersSchema, { collapsed: {} }, ) + const sections = new Map() const list = useFilteredList({ items: (_filter) => models.list(), @@ -70,6 +76,24 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean } }, }) + createEffect(() => { + if (!props.active || !props.provider) return + const provider = props.provider + if (list.filter()) { + list.clear() + return + } + if (!list.grouped.latest.some((group) => group.category === provider)) return + const section = sections.get(provider) + if (!section?.isConnected) return + list.grouped.latest.forEach((group) => setStore("collapsed", group.category, group.category !== provider)) + requestAnimationFrame(() => { + section.scrollIntoView({ block: "start" }) + section.querySelector(".settings-models-group-trigger")?.focus({ preventScroll: true }) + props.onReveal?.() + }) + }) + return ( <>
@@ -134,6 +158,7 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean } return (
sections.set(group.category, element)} class="settings-section" data-component="settings-models-provider" data-expanded={expanded() ? "" : undefined} diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index cc21e15fc920..2364b8e429ee 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -1,11 +1,14 @@ import { Button } from "@opencode/ui/button" import { Badge } from "@opencode/ui/badge" import { useDialog } from "@opencode/ui/context/dialog" +import { Icon } from "@opencode/ui/icon" import { ProviderIcon } from "@opencode/ui/provider-icon" +import { OpenCodeLogo } from "@/providers/opencode-logo" import { showToast } from "@/shell/notifications/toast" import { popularProviders, useProviders } from "@/providers/catalog/providers" import { useIntegrations } from "@/providers/catalog/integrations" -import { createMemo, type Component, For, Show } from "solid-js" +import { createEffect, createMemo, type Component, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog" @@ -31,6 +34,7 @@ const PROVIDER_ICON_SIZE = 16 export const SettingsProviders: Component<{ directory: string | undefined onBack?: () => void + onSelectProvider?: (providerID: string) => void }> = (props) => { const dialog = useDialog() const language = useLanguage() @@ -38,14 +42,33 @@ export const SettingsProviders: Component<{ const providers = useProviders(() => props.directory) const integrations = useIntegrations(() => props.directory) const providerConnect = useProviderConnectController({ onBack: props.onBack }) + const [state, setState] = createStore({ + disconnecting: {} as Record, + consoleExpanded: false, + }) + const updateDisconnecting = (ids: string[], status: "removing" | "removed" | undefined) => + setState("disconnecting", (current) => ({ + ...current, + ...Object.fromEntries(ids.map((id) => [id, status])), + })) const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID) const connect = (provider?: string) => { providerConnect.select(provider) - void dialog.show(() => ) + void dialog.show(() => ( + + setState("disconnecting", (current) => + providerID === "opencode" ? {} : { ...current, [providerID]: undefined }, + ) + } + /> + )) } - const connected = createMemo(() => { + const available = createMemo(() => { return providers .connected() .filter( @@ -55,6 +78,37 @@ export const SettingsProviders: Component<{ .toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")) }) + createEffect(() => { + const ids = new Set(available().map((item) => item.id)) + Object.entries(state.disconnecting).forEach(([id, status]) => { + if (status === "removing" && !ids.has(id)) setState("disconnecting", id, "removed") + }) + }) + + const connected = createMemo(() => available().filter((item) => !state.disconnecting[item.id])) + + const consoleGroup = createMemo(() => { + const root = available().find((item) => item.id === "opencode") + const suffix = " / OpenCode" + if (!root?.name.endsWith(suffix)) return + const workspace = root.name.slice(0, -suffix.length).trim() + if (!workspace) return + const prefix = `${workspace} / ` + return { + root, + workspace, + providers: available().filter((item) => item.name.startsWith(prefix)), + prefix, + } + }) + + const displayed = createMemo(() => { + const group = consoleGroup() + if (!group) return connected() + const grouped = new Set(group.providers.filter((item) => item.id !== group.root.id).map((item) => item.id)) + return connected().filter((item) => !grouped.has(item.id)) + }) + const popular = createMemo(() => { const connectedIDs = new Set(connected().map((p) => p.id)) const items = providers @@ -98,6 +152,10 @@ export const SettingsProviders: Component<{ const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const disconnect = async (providerID: string, name: string) => { + if (state.disconnecting[providerID]) return + const group = consoleGroup() + const ids = group?.root.id === providerID ? group.providers.map((provider) => provider.id) : [providerID] + updateDisconnecting(ids, "removing") const location = props.directory ? { directory: props.directory } : undefined await serverSdk.api.integration .get({ integrationID: providerID, location }) @@ -107,6 +165,7 @@ export const SettingsProviders: Component<{ await Promise.all( credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })), ) + updateDisconnecting(ids, "removed") showToast({ variant: "success", icon: "circle-check", @@ -115,6 +174,7 @@ export const SettingsProviders: Component<{ }) }) .catch((err: unknown) => { + updateDisconnecting(ids, undefined) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) @@ -136,38 +196,127 @@ export const SettingsProviders: Component<{

{language.t("settings.providers.section.connected")}

0} + when={displayed().length > 0} fallback={
{language.t("settings.providers.connected.empty")}
} > - - {(item) => ( -
-
- -
- {item.name} - {type(item)} -
-
+ + {(item) => { + const console = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined) + return ( - {language.t("settings.providers.connected.environmentDescription")} - +
+
+ + } + > + + +
+ {item.name} + {type(item)} +
+
+ + {language.t("settings.providers.connected.environmentDescription")} + + } + > + + +
} > - + {(group) => ( +
+
+
+ +
+
+ + {language.t("provider.connect.console.name")} + + {group().workspace} +
+ 1}> + + +
+
+ +
+ +
+ + +
+ )}
-
- )} + ) + }}
@@ -180,12 +329,19 @@ export const SettingsProviders: Component<{ {(item) => (
- + + } + > + +
{item.name} diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index b33937ce9b68..5858cac514ec 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -677,6 +677,129 @@ border-bottom: none; } +.settings-provider-console { + border-bottom: 0.5px solid var(--v2-border-border-base); +} + +.settings-provider-console:last-child { + border-bottom: none; +} + +.settings-provider-console-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 48px; + padding-block: 10px; +} + +.settings-provider-console-summary { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.settings-provider-console-toggle { + display: flex; + height: 28px; + align-items: center; + gap: 2px; + padding-inline: 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + cursor: pointer; +} + +@media (hover: hover) { + .settings-provider-console-toggle:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-text-text-base); + } +} + +.settings-provider-console-chevron { + color: var(--v2-icon-icon-muted); + transition: transform 150ms ease-out; +} + +.settings-provider-console-chevron.open { + transform: rotate(90deg); +} + +.settings-provider-console-list { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 10px; +} + +.settings-provider-console-separator { + height: 0.5px; + margin-bottom: 6px; + background: var(--v2-border-border-base); +} + +.settings-provider-console-item { + display: flex; + width: 100%; + min-height: 24px; + align-items: center; + gap: 2px; + padding-inline: 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + text-align: start; + cursor: pointer; +} + +.settings-provider-console-item-chevron { + color: var(--v2-icon-icon-base); + opacity: 0; +} + +@media (hover: hover) { + .settings-provider-console-item:hover { + background: var(--v2-overlay-simple-overlay-hover); + } + + .settings-provider-console-item:hover .settings-provider-console-item-chevron { + opacity: 1; + } +} + +.settings-provider-console-item:focus-visible { + background: var(--v2-overlay-simple-overlay-hover); +} + +.settings-provider-console-item:focus-visible .settings-provider-console-item-chevron { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .settings-provider-console-chevron { + transition: none; + } +} + +@container settings-panel (max-width: 520px) { + .settings-provider-console-header { + flex-wrap: wrap; + } +} + @media (min-width: 640px) { .settings-provider-row { flex-wrap: nowrap; @@ -776,6 +899,15 @@ color: var(--v2-text-text-accent-hover); } +.settings-providers [data-component="connected-providers-section"] [data-component="settings-list"] { + padding-inline: 16px; +} + +.settings-providers [data-component="connected-providers-section"] .settings-provider-row { + min-height: 48px; + padding-block: 10px; +} + .settings-tab-header.settings-tab-header--stacked { display: flex; flex-direction: column; diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 2f39054554be..f462fff08e27 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -187,7 +187,10 @@ function RootSettings() { const tabs = useTabs() const servers = useServerCollectionController() const inventory = useSettingsServers() - const [state, setState] = createStore({ worktreeFilterReset: 0 }) + const [state, setState] = createStore({ + worktreeFilterReset: 0, + modelProvider: undefined as string | undefined, + }) const list = servers.collection.items const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined)) const single = createMemo(() => singleEntry()?.connection) @@ -311,10 +314,22 @@ function RootSettings() { /> - surface.select("providers")} /> + surface.select("providers")} + onSelectProvider={(providerID) => { + setState("modelProvider", providerID) + surface.select("models") + }} + /> - + setState("modelProvider", undefined)} + /> surface.subtab(value)} /> @@ -338,7 +353,10 @@ function ServerSettings(props: { entry: SettingsServer }) { const surface = useSettingsSurface() const activeDirectory = useSettingsDirectory(() => props.entry.connection) const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection) - const [state, setState] = createStore({ worktreeFilterReset: 0 }) + const [state, setState] = createStore({ + worktreeFilterReset: 0, + modelProvider: undefined as string | undefined, + }) const groups = createMemo(() => [ { items: nestedServerTabs.map((item) => ({ @@ -394,10 +412,21 @@ function ServerSettings(props: { entry: SettingsServer }) { /> - surface.select("providers")} /> + surface.select("providers")} + onSelectProvider={(providerID) => { + setState("modelProvider", providerID) + surface.select("models") + }} + /> - + setState("modelProvider", undefined)} + /> surface.subtab(value)} /> From 3788f68f2eed4645ba2dd21f6ef31dd3b5b6cc30 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:38:24 +0200 Subject: [PATCH 3/6] feat(desktop): focus window after Console auth --- .../src/main/lifecycle/deep-link.test.ts | 17 ++++++++++++ .../desktop/src/main/lifecycle/deep-link.ts | 11 ++++++++ packages/desktop/src/main/lifecycle/index.ts | 26 +++++++++++++------ packages/desktop/src/main/windows/index.ts | 6 +++++ .../desktop/src/main/windows/registry.test.ts | 2 ++ packages/desktop/src/main/windows/registry.ts | 8 +++--- 6 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 packages/desktop/src/main/lifecycle/deep-link.test.ts create mode 100644 packages/desktop/src/main/lifecycle/deep-link.ts diff --git a/packages/desktop/src/main/lifecycle/deep-link.test.ts b/packages/desktop/src/main/lifecycle/deep-link.test.ts new file mode 100644 index 000000000000..c6e46239500c --- /dev/null +++ b/packages/desktop/src/main/lifecycle/deep-link.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { consoleReturnWindow } from "./deep-link" + +describe("Console return deep links", () => { + test("reads the originating Desktop window", () => { + expect(consoleReturnWindow("opencode://console/authorized?window=window-a")).toBe("window-a") + expect(consoleReturnWindow("opencode://console/authorized?window=window%20b")).toBe("window b") + }) + + test("rejects unrelated and malformed links", () => { + expect(consoleReturnWindow("opencode://console/other?window=window-a")).toBeUndefined() + expect(consoleReturnWindow("opencode://other/authorized?window=window-a")).toBeUndefined() + expect(consoleReturnWindow("https://console/authorized?window=window-a")).toBeUndefined() + expect(consoleReturnWindow("not a url")).toBeUndefined() + expect(consoleReturnWindow("opencode://console/authorized")).toBeUndefined() + }) +}) diff --git a/packages/desktop/src/main/lifecycle/deep-link.ts b/packages/desktop/src/main/lifecycle/deep-link.ts new file mode 100644 index 000000000000..15089090e918 --- /dev/null +++ b/packages/desktop/src/main/lifecycle/deep-link.ts @@ -0,0 +1,11 @@ +export function consoleReturnWindow(value: string) { + try { + const url = new URL(value) + if (url.protocol !== "opencode:" || url.hostname !== "console" || url.pathname !== "/authorized") return + const id = url.searchParams.get("window") + if (!id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id)) return + return id + } catch { + return + } +} diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index e41bdb4edcda..cd0f418c5573 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -8,10 +8,11 @@ import { emitIpcEvent } from "../ipc-events" import { DesktopLogging, scoped } from "../native/logging" import { DesktopStorage } from "../storage" import { safeWebContentsURL } from "../windows/state" -import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows" +import { getLastFocusedWindow, getWindowByID, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows" import { acquireApplicationLock, configureApplication } from "./environment" import { initializeFirstLaunchOnboarding } from "./onboarding" import { Shutdown } from "./shutdown" +import { consoleReturnWindow } from "./deep-link" export interface Interface { readonly relaunch: () => void @@ -34,11 +35,23 @@ const runtime = Layer.effect( const pendingDeepLinks: string[] = [] let shutdownReady = false const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true)))) + const focusWindow = (win: BrowserWindow | null) => { + if (!win) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() + } const emitDeepLinks = (urls: string[]) => { if (!urls.length) return pendingDeepLinks.push(...urls) - const win = getLastFocusedWindow() + const target = urls.flatMap((url) => { + const id = consoleReturnWindow(url) + const win = id ? getWindowByID(id) : null + return win ? [win] : [] + })[0] + const win = target ?? getLastFocusedWindow() if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls })) + return win } const relaunch = () => { setAppQuitting() @@ -57,17 +70,14 @@ const runtime = Layer.effect( const urls = argv.filter((arg) => arg.startsWith("opencode://")) if (urls.length) { runFork(Effect.logInfo("deep link received via second-instance", { urls })) - emitDeepLinks(urls) + focusWindow(emitDeepLinks(urls) ?? null) } - const win = getLastFocusedWindow() - if (!win) return - win.show() - win.focus() + if (!urls.length) focusWindow(getLastFocusedWindow()) } const openUrl = (event: Event, url: string) => { event.preventDefault() runFork(Effect.logInfo("deep link received via open-url", { url })) - emitDeepLinks([url]) + focusWindow(emitDeepLinks([url]) ?? null) } const beforeQuit = (event: Event) => { setAppQuitting() diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 07f38a008b89..ca2c31d9b6ad 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -70,6 +70,12 @@ export function getLastFocusedWindow() { return win } +export function getWindowByID(id: string) { + const win = registry.get(id) + if (!win || win.isDestroyed()) return null + return win +} + export function setWindowThemeReady(win: BrowserWindow) { themeReady.get(win)?.() } diff --git a/packages/desktop/src/main/windows/registry.test.ts b/packages/desktop/src/main/windows/registry.test.ts index cad9f63eca98..d47f7df53b6a 100644 --- a/packages/desktop/src/main/windows/registry.test.ts +++ b/packages/desktop/src/main/windows/registry.test.ts @@ -25,6 +25,8 @@ describe("window registry", () => { app.registry.register("a", { name: "a" }) app.registry.register("b", { name: "b" }) expect(app.state.stored).toEqual(["a", "b"]) + expect(app.registry.get("a")).toEqual({ name: "a" }) + expect(app.registry.get("missing")).toBeUndefined() }) test("forgets a deliberately closed window while others remain open", () => { diff --git a/packages/desktop/src/main/windows/registry.ts b/packages/desktop/src/main/windows/registry.ts index d5862daa3c13..7d7b844f6f8c 100644 --- a/packages/desktop/src/main/windows/registry.ts +++ b/packages/desktop/src/main/windows/registry.ts @@ -1,9 +1,6 @@ // Tracks open windows and the persisted window id list used to restore // windows (and their per-window persisted state) across app launches. -export function createWindowRegistry(persistence: { - read: () => unknown - write: (ids: string[]) => void -}) { +export function createWindowRegistry(persistence: { read: () => unknown; write: (ids: string[]) => void }) { const windows = new Map() let quitting = false let lastFocusedID: string | undefined @@ -31,6 +28,9 @@ export function createWindowRegistry(persistence: { if (!lastFocusedID) return return windows.get(lastFocusedID) }, + get(id: string) { + return windows.get(id) + }, closed(id: string) { windows.delete(id) if (lastFocusedID === id) lastFocusedID = windows.keys().next().value From f45f7b27c3e68ac3cb00c88e99d0f3abb91ed32b Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:38:44 +0200 Subject: [PATCH 4/6] test(desktop): cover Console onboarding --- packages/app/e2e/desktop/console-auth.spec.ts | 491 ++++++++++++++++++ packages/app/e2e/desktop/index.html | 11 + packages/app/e2e/desktop/main.tsx | 46 ++ packages/app/e2e/desktop/playwright.config.ts | 19 + packages/app/playwright.config.ts | 1 + 5 files changed, 568 insertions(+) create mode 100644 packages/app/e2e/desktop/console-auth.spec.ts create mode 100644 packages/app/e2e/desktop/index.html create mode 100644 packages/app/e2e/desktop/main.tsx create mode 100644 packages/app/e2e/desktop/playwright.config.ts diff --git a/packages/app/e2e/desktop/console-auth.spec.ts b/packages/app/e2e/desktop/console-auth.spec.ts new file mode 100644 index 000000000000..b070b97be419 --- /dev/null +++ b/packages/app/e2e/desktop/console-auth.spec.ts @@ -0,0 +1,491 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" + +const directory = "/console-auth-project" +const location = { directory, project: { id: "proj_console", directory, canonical: directory } } +const provider = { + id: "opencode", + integrationID: "opencode", + name: "Anomaly / OpenCode", + activation: "enabled", + package: "@ai-sdk/openai-compatible", +} +const secondProvider = { + ...provider, + id: "console-google", + name: "Anomaly / Google", + package: "@ai-sdk/google", +} +const model = { + id: "sonnet", + modelID: "sonnet", + providerID: provider.id, + name: "Console Sonnet", + enabled: true, + status: "active", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + cost: [], + time: { released: 1700000000000 }, + limit: { context: 200000, output: 32000 }, +} +const models = [ + model, + ...Array.from({ length: 18 }, (_, index) => ({ + ...model, + id: `model-${index + 2}`, + modelID: `model-${index + 2}`, + name: `Console Model ${index + 2}`, + })), + { ...model, id: "gemini", modelID: "gemini", providerID: secondProvider.id, name: "Console Gemini" }, +] +const integration = { + id: "opencode", + name: "OpenCode", + connections: [], + methods: [ + { id: "device", type: "oauth", label: "OpenCode Console account" }, + { type: "key", label: "API key (service account)" }, + ], +} + +async function fixture( + page: Page, + remote = false, + options: { + draft?: boolean + browserFailed?: boolean + slowStart?: Promise + existingConnection?: boolean + singleProvider?: boolean + } = {}, +) { + const state = { + status: "pending", + starts: 0, + cancelled: [] as string[], + models: true, + modelError: false, + statusError: false, + } + const server = remote ? "http://production.example:4096" : undefined + const currentIntegration = { + ...integration, + connections: options.existingConnection ? [{ type: "env", name: "OPENCODE_API_KEY" }] : [], + } + await mockOpenCodeServer(page, { + server, + directory, + provider: [], + sessions: [], + project: { + id: "proj_console", + canonical: directory, + name: "Console test", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + pageMessages: () => ({ items: [] }), + }) + await page + .context() + .route("https://console.example/**", (route) => + route.fulfill({ contentType: "text/html", body: "Console fixture

Authorize access

" }), + ) + await page.route("**/api/integration**", async (route) => { + const request = route.request() + const path = new URL(request.url()).pathname + if (request.method() === "OPTIONS") return route.fallback() + const headers = { "access-control-allow-origin": "*" } + const json = (data: unknown) => route.fulfill({ headers, json: { location, data } }) + if (path === "/api/integration") return json([currentIntegration]) + if (path === "/api/integration/opencode") return json(currentIntegration) + if (path === "/api/integration/opencode/connect/oauth") { + expect(request.postDataJSON()).toEqual({ methodID: "device" }) + state.starts++ + if (options.slowStart) await options.slowStart + return json({ + attemptID: `con_${state.starts}`, + mode: "auto", + instructions: "Confirmation code: TFXS-STXG", + url: "https://console.example/device?user_code=TFXS-STXG&client_id=opencode-cli", + time: { created: Date.now(), expires: Date.now() + 60000 }, + }) + } + if (path.includes("/connect/oauth/con_")) { + if (request.method() === "DELETE") { + state.cancelled.push(path.split("/").pop()!) + return route.fulfill({ status: 204, headers }) + } + if (state.statusError) return route.fulfill({ status: 503, headers }) + return json({ + status: state.status, + ...(state.status === "failed" ? { message: "Device authorization failed: access_denied" } : {}), + time: { created: 0, expires: Date.now() + 60000 }, + }) + } + return route.fallback() + }) + await page.route("**/api/provider**", (route) => { + if (route.request().method() === "OPTIONS") return route.fallback() + return route.fulfill({ + headers: { "access-control-allow-origin": "*" }, + json: { + location, + data: state.status === "complete" ? [provider, ...(options.singleProvider ? [] : [secondProvider])] : [], + }, + }) + }) + await page.route("**/api/model**", (route) => { + if (route.request().method() === "OPTIONS") return route.fallback() + if (state.modelError) return route.fulfill({ status: 503, headers: { "access-control-allow-origin": "*" } }) + const available = state.status === "complete" && state.models + return route.fulfill({ + headers: { "access-control-allow-origin": "*" }, + json: { + location, + data: new URL(route.request().url()).pathname.endsWith("/default") + ? available + ? model + : null + : available + ? options.singleProvider + ? models.filter((model) => model.providerID === provider.id) + : models + : [], + }, + }) + }) + await page.addInitScript( + ({ directory, server }) => { + if (server) localStorage.setItem("opencode.settings.dat:defaultServerUrl", server) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + list: server ? [{ type: "http", displayName: "Production server", http: { url: server } }] : [], + projects: { [server ?? "local"]: [{ worktree: directory, expanded: true }] }, + }), + ) + }, + { directory, server }, + ) + const params = new URLSearchParams() + if (server) params.set("server", server) + if (options.browserFailed) params.set("browserFailed", "1") + await page.goto(`/e2e/desktop/index.html?${params}`) + const dialog = page.locator('[data-component="dialog-v2"]').getByRole("dialog") + if (options.draft) { + await page.keyboard.press("Control+t") + const composer = page.locator('[data-component="composer-editor"]') + await expect(composer).toBeEditable() + await composer.fill("Keep this draft throughout sign-in") + await expect(page.locator('[data-component="provider-setup"]')).toBeVisible() + await expect(page.locator('[data-component="new-session-tip"]')).toContainText("Connect to 75+ providers") + await page.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + return { state, dialog } + } + await page.getByRole("button", { name: "Settings", exact: true }).click() + await page.getByRole("tab", { name: "Providers", exact: true }).click() + // Use the picker so this also exercises the existing Settings entry point. + await page.getByRole("button", { name: "Show more providers", exact: true }).click() + await page + .getByRole("dialog") + .getByRole("button", { name: /^OpenCode / }) + .click() + await expect(dialog.getByRole("button", { name: "Continue with OpenCode Console" })).toBeEnabled() + return { state, dialog } +} + +test("Console account is primary and the code is displayed without a copy-code step", async ({ page }) => { + const { state, dialog } = await fixture(page) + await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console", exact: true })).toBeVisible() + await expect(dialog.getByText("Service account?", { exact: true })).toBeVisible() + await expect(dialog.getByRole("button", { name: "Use API key", exact: true })).toBeVisible() + const shell = await dialog.boundingBox() + const back = await dialog.getByRole("button", { name: "Navigate back" }).boundingBox() + const heading = await dialog.getByRole("heading", { name: "Connect OpenCode Console" }).boundingBox() + const logo = await dialog.locator('[data-component="opencode-logo"]').boundingBox() + const description = await dialog + .getByText("Sign in once to use the models available through your OpenCode account.") + .boundingBox() + const primary = await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).boundingBox() + const service = await dialog.locator('[data-component="console-service-account"]').boundingBox() + if (!shell || !back || !heading || !logo || !description || !primary || !service) + throw new Error("Missing dialog layout") + expect(shell.height).toBe(512) + expect(back.x - shell.x).toBe(20) + expect(back.y - shell.y).toBe(16) + expect(heading.y - (back.y + back.height)).toBe(12) + expect(logo.y + logo.height / 2).toBe(heading.y + heading.height / 2) + expect(description.y - (heading.y + heading.height)).toBe(24) + expect(primary.y - (description.y + description.height)).toBe(20) + expect(service.y - (primary.y + primary.height)).toBe(20) + await page.screenshot({ path: test.info().outputPath("connect-console-light.png") }) + const popup = page.waitForEvent("popup") + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + const consolePage = await popup + await expect(consolePage).toHaveURL(/user_code=TFXS-STXG/) + await expect(consolePage).toHaveURL(/client_id=opencode-desktop/) + await expect(consolePage).toHaveURL(/return_window=console-auth-fixture/) + await expect( + dialog.getByText("Continue in your browser. Confirm the code shown there matches the one below."), + ).toBeVisible() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + await expect(dialog.getByRole("textbox")).toHaveCount(0) + await expect(dialog.getByRole("button", { name: "Copy sign-in link" })).toBeVisible() + const authHeading = await dialog.getByRole("heading", { name: "Connect OpenCode Console account" }).boundingBox() + const authDescription = await dialog + .getByText("Continue in your browser. Confirm the code shown there matches the one below.") + .boundingBox() + const label = await dialog.getByText("Device code", { exact: true }).boundingBox() + const code = await dialog.getByRole("group", { name: "Device code: TFXS-STXG" }).boundingBox() + const waiting = await dialog.getByRole("status").boundingBox() + const fallback = await dialog.locator('[data-component="console-browser-fallback"]').boundingBox() + const authShell = await dialog.boundingBox() + if (!authHeading || !authDescription || !label || !code || !waiting || !fallback || !authShell) + throw new Error("Missing authorization layout") + expect(authDescription.y - (authHeading.y + authHeading.height)).toBe(24) + expect(label.y - (authDescription.y + authDescription.height)).toBe(20) + expect(code.y - (label.y + label.height)).toBe(8) + expect(code.height).toBe(48) + expect(waiting.y - (code.y + code.height)).toBe(8) + expect(fallback.y - (waiting.y + waiting.height)).toBe(20) + expect(authShell.height).toBeLessThan(512) + expect(authShell.y + authShell.height - (fallback.y + fallback.height)).toBe(16) + await page.screenshot({ path: test.info().outputPath("console-auth-light.png") }) + await page.emulateMedia({ colorScheme: "dark" }) + await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark") + await page.screenshot({ path: test.info().outputPath("console-auth-dark.png") }) + state.status = "complete" + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + const list = dialog.getByRole("radiogroup", { name: "Models available from OpenCode" }) + await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toBeVisible() + await expect(dialog.getByRole("button", { name: "Anomaly / Google", exact: true })).toBeVisible() + await expect(list.getByRole("radio")).toHaveCount(models.length) + await page.mouse.move(0, 0) + const first = list.getByRole("radio", { name: "Console Sonnet" }) + await expect(first).toBeChecked() + await expect(first).toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + await expect(dialog.locator('[data-component="settings-list"]')).toHaveCount(2) + await expect(first.locator('[data-slot="settings-row-title"]')).toHaveCSS("font-weight", "440") + await expect(first).toHaveCSS("border-radius", "0px") + const providerHeading = dialog.locator(".settings-models-group-header").filter({ hasText: "Anomaly / OpenCode" }) + await expect(providerHeading).toHaveCSS("position", "sticky") + await expect(providerHeading).toHaveCSS("padding-bottom", "0px") + const google = dialog.getByRole("button", { name: "Anomaly / Google", exact: true }) + const googleHeading = dialog.locator(".settings-models-group-header").filter({ hasText: "Anomaly / Google" }) + await google.click() + await expect(googleHeading).toHaveCSS("padding-bottom", "8px") + await google.click() + await expect(googleHeading).toHaveCSS("padding-bottom", "0px") + await expect(dialog.locator('[data-slot="dialog-header"]')).toHaveCSS("padding-top", "20px") + const hovered = list.getByRole("radio", { name: "Console Model 3" }) + await hovered.hover() + await expect(hovered).toHaveCSS("border-bottom-color", "rgba(0, 0, 0, 0)") + await expect(list.getByRole("radio", { name: "Console Model 2" })).toHaveCSS( + "border-bottom-color", + "rgba(0, 0, 0, 0)", + ) + await page.screenshot({ path: test.info().outputPath("first-provider-models-dark.png") }) + await list.getByRole("radio", { name: "Console Model 2" }).click() + await expect(list.getByRole("radio", { name: "Console Model 2" })).toBeChecked() + const scroll = dialog.locator('[data-component="first-provider-model-scroll"]') + const footer = dialog.locator('[data-component="first-provider-model-footer"]') + const footerBefore = await footer.boundingBox() + expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + await scroll.evaluate((element) => element.scrollTo({ top: element.scrollHeight })) + await expect(list.getByRole("radio", { name: models.at(-1)!.name })).toBeInViewport() + expect(await footer.boundingBox()).toEqual(footerBefore) + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Model 2") + expect(state.starts).toBe(1) + expect(state.cancelled).toEqual([]) +}) + +test("Manage models opens the Models settings page", async ({ page }) => { + const { state, dialog } = await fixture(page) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "complete" + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + await dialog.getByRole("button", { name: "Manage models", exact: true }).click() + await expect(dialog).toBeHidden() + await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") +}) + +test("a single connected provider has a non-collapsible heading", async ({ page }) => { + const { state, dialog } = await fixture(page, false, { singleProvider: true }) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "complete" + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toHaveCount(0) + await expect(dialog.getByText("Anomaly / OpenCode", { exact: true })).toBeVisible() +}) + +test("model choice is skipped after a provider has already been connected", async ({ page }) => { + const { state, dialog } = await fixture(page, false, { existingConnection: true }) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "complete" + await expect(dialog).toBeHidden() + await expect(page.getByRole("tab", { name: "Providers", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(page.getByText("OpenCode Console connected", { exact: true })).toBeVisible() +}) + +test("service-account API key form matches the Console dialog layout", async ({ page }) => { + const { dialog } = await fixture(page) + const initialShell = await dialog.boundingBox() + await dialog.getByRole("button", { name: "Use API key", exact: true }).click() + await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console", exact: true })).toBeVisible() + const description = dialog.getByText("Connect using a service-account API key from OpenCode Console.") + const label = dialog.locator('[data-component="provider-api-key-label"]') + const input = dialog.getByLabel("OpenCode Console API key", { exact: true }) + const button = dialog.getByRole("button", { name: "Continue", exact: true }) + await expect(input).toBeFocused() + const shell = await dialog.boundingBox() + const heading = await dialog.getByRole("heading", { name: "Connect OpenCode Console" }).boundingBox() + const descriptionBox = await description.boundingBox() + const labelBox = await label.boundingBox() + const fieldBox = await input.locator("..").locator("..").boundingBox() + const buttonBox = await button.boundingBox() + if (!shell || !heading || !descriptionBox || !labelBox || !fieldBox || !buttonBox) + throw new Error("Missing API key dialog layout") + if (!initialShell) throw new Error("Missing initial Console dialog layout") + expect(shell.height).toBe(512) + expect(shell.height).toBe(initialShell.height) + expect(descriptionBox.y - (heading.y + heading.height)).toBe(24) + expect(labelBox.y - (descriptionBox.y + descriptionBox.height)).toBe(20) + expect(fieldBox.y - (labelBox.y + labelBox.height)).toBe(8) + expect(buttonBox.y - (fieldBox.y + fieldBox.height)).toBe(20) + await page.screenshot({ path: test.info().outputPath("console-api-key-light.png") }) +}) + +test("setup preserves the draft and Continue restores composer focus", async ({ page }) => { + const { state, dialog } = await fixture(page, false, { draft: true }) + state.status = "complete" + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + const composer = page.locator('[data-component="composer-editor"]') + await expect(composer).toHaveText("Keep this draft throughout sign-in") + await expect(composer).toBeFocused() + await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Sonnet") + await expect(page.locator('[data-component="provider-setup"]')).toBeHidden() +}) + +test("catalog refresh failure retries without asking for authorization again", async ({ page }) => { + const { state, dialog } = await fixture(page) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.modelError = true + state.status = "complete" + await expect(dialog.getByRole("alert")).toContainText("Your account is connected, but we couldn't load your models") + state.modelError = false + await dialog.getByRole("button", { name: "Try again", exact: true }).click() + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + expect(state.starts).toBe(1) +}) + +test("status request failure resumes the existing attempt", async ({ page }) => { + const { state, dialog } = await fixture(page) + state.statusError = true + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("alert")).toBeVisible() + state.statusError = false + state.status = "complete" + await dialog.getByRole("button", { name: "Try again", exact: true }).click() + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + expect(state.starts).toBe(1) + expect(state.cancelled).toEqual([]) +}) + +test("closing during authorization startup cancels the late server attempt", async ({ page }) => { + const start = Promise.withResolvers() + const { state, dialog } = await fixture(page, false, { slowStart: start.promise }) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect.poll(() => state.starts).toBe(1) + await dialog.getByRole("button", { name: "Close", exact: true }).click() + await expect(dialog).toBeHidden() + start.resolve() + await expect.poll(() => state.cancelled).toEqual(["con_1"]) +}) + +test("authorization startup stays on the Continue button until the device code is ready", async ({ page }) => { + const start = Promise.withResolvers() + const { dialog } = await fixture(page, false, { slowStart: start.promise }) + const button = dialog.getByRole("button", { name: "Continue with OpenCode Console" }) + await button.click() + await expect(dialog.getByRole("button", { name: "Opening browser…" })).toHaveAttribute("aria-busy", "true") + await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console" })).toBeVisible() + await expect(dialog.getByRole("group", { name: /Device code/ })).toHaveCount(0) + start.resolve() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() +}) + +test("browser failure offers a copyable sign-in link in a narrow RTL window", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + const { dialog } = await fixture(page, false, { browserFailed: true }) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByText(/We couldn't open your browser/)).toBeVisible() + await page.setViewportSize({ width: 380, height: 650 }) + await page.evaluate(() => { + document.documentElement.dir = "rtl" + }) + const code = dialog.getByRole("group", { name: "Device code: TFXS-STXG" }) + await expect(code).toHaveCSS("direction", "ltr") + await expect(code).toBeInViewport() + await dialog.getByRole("button", { name: "Copy sign-in link" }).click() + await expect(dialog.getByRole("button", { name: "Sign-in link copied" })).toBeVisible() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + "https://console.example/device?user_code=TFXS-STXG&client_id=opencode-desktop&return_window=console-auth-fixture", + ) + await expect(dialog.getByRole("button", { name: "Open Console again" })).toBeInViewport() + await page.screenshot({ path: test.info().outputPath("console-auth-narrow-rtl.png") }) +}) + +test("cancel releases the server attempt and retrying expiration creates a new attempt", async ({ page }) => { + const { state, dialog } = await fixture(page) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "expired" + await expect(dialog.getByRole("alert")).toContainText("has expired") + state.status = "pending" + await dialog.getByRole("button", { name: "Try again", exact: true }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + await expect.poll(() => state.starts).toBe(2) + await dialog.getByRole("button", { name: "Close", exact: true }).click() + await expect.poll(() => state.cancelled).toEqual(["con_1", "con_2"]) +}) + +test("an authorized workspace without models stays connected and can refresh", async ({ page }) => { + const { state, dialog } = await fixture(page) + state.models = false + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "complete" + await expect(dialog.getByText(/this Console workspace has no available models/)).toBeVisible() + state.models = true + await dialog.getByRole("button", { name: "Refresh models" }).click() + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + expect(state.starts).toBe(1) +}) + +test("remote disclosure precedes authorization and all auth requests target that server", async ({ page }) => { + const { state, dialog } = await fixture(page, true) + await expect(dialog.getByRole("note")).toContainText("Connecting on “Production server”") + await expect(dialog.getByRole("note")).toContainText("credentials will be stored on this server") + expect(state.starts).toBe(0) + const request = page.waitForRequest( + (request) => request.method() === "POST" && request.url().includes("/connect/oauth"), + ) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + expect(new URL((await request).url()).origin).toBe("http://production.example:4096") + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + const cancelled = page.waitForRequest( + (request) => request.method() === "DELETE" && request.url().includes("/connect/oauth"), + ) + await dialog.getByRole("button", { name: "Close", exact: true }).click() + expect(new URL((await cancelled).url()).origin).toBe("http://production.example:4096") +}) diff --git a/packages/app/e2e/desktop/index.html b/packages/app/e2e/desktop/index.html new file mode 100644 index 000000000000..1c8ee341d5f6 --- /dev/null +++ b/packages/app/e2e/desktop/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/packages/app/e2e/desktop/main.tsx b/packages/app/e2e/desktop/main.tsx new file mode 100644 index 000000000000..1f7bf44b6fdb --- /dev/null +++ b/packages/app/e2e/desktop/main.tsx @@ -0,0 +1,46 @@ +import { render } from "solid-js/web" +import { MemoryRouter } from "@solidjs/router" +import { AppBaseProviders, AppInterface } from "@/app" +import { PlatformProvider } from "@/runtime/platform/platform" +import { createBrowserDraftStore } from "@/runtime/persistence/drafts" +import { ServerConnection } from "@/runtime/server/registry" + +// Exercise the real Desktop renderer with local browser/clipboard adapters and +// an HTTP fixture. No Electron service or account credentials are touched. +const params = new URLSearchParams(window.location.search) +const remote = params.get("server") +const server: ServerConnection.Any = remote + ? { type: "http", displayName: "Production server", http: { url: remote } } + : { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } +const root = document.getElementById("root") +if (!root) throw new Error("Missing fixture root") +render( + () => ( + {}, + restart: async () => {}, + notify: async () => {}, + openDirectoryPickerDialog: async () => null, + writeClipboardText: (text) => navigator.clipboard.writeText(text), + openBrowser: async (url) => { + if (params.has("browserFailed")) return false + const browser = window.open("about:blank", "_blank") + if (!browser) return false + browser.opener = null + browser.location.replace(url) + return true + }, + }} + > + + + + + ), + root, +) diff --git a/packages/app/e2e/desktop/playwright.config.ts b/packages/app/e2e/desktop/playwright.config.ts new file mode 100644 index 000000000000..821dc7fc0ebb --- /dev/null +++ b/packages/app/e2e/desktop/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 4454) +export default defineConfig({ + testDir: ".", + outputDir: "../test-results/desktop", + timeout: 60000, + expect: { timeout: 10000 }, + workers: 1, + retries: 0, + use: { baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", serviceWorkers: "block" }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: true, + timeout: 120000, + }, +}) diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 045f374034cc..325e7b5a1a2a 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -19,6 +19,7 @@ const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0 export default defineConfig({ testDir: "./e2e", testIgnore: [ + "desktop/**", "service-worker/**", process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**", ], From 1c70fc945e06945d7db2658e8dee3c262ee90686 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:53:20 +1000 Subject: [PATCH 5/6] fix(app): preserve onboarding settings destinations --- packages/app/e2e/desktop/console-auth.spec.ts | 44 ++++++++++++++++++- packages/app/e2e/desktop/main.tsx | 13 +++++- packages/app/src/providers/connect/dialog.tsx | 4 +- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/desktop/console-auth.spec.ts b/packages/app/e2e/desktop/console-auth.spec.ts index b070b97be419..603474f3959b 100644 --- a/packages/app/e2e/desktop/console-auth.spec.ts +++ b/packages/app/e2e/desktop/console-auth.spec.ts @@ -26,7 +26,7 @@ const model = { status: "active", capabilities: { tools: true, input: ["text"], output: ["text"] }, variants: [], - cost: [], + cost: [{ input: 1, output: 2, cache: { read: 0, write: 0 } }], time: { released: 1700000000000 }, limit: { context: 200000, output: 32000 }, } @@ -59,6 +59,7 @@ async function fixture( slowStart?: Promise existingConnection?: boolean singleProvider?: boolean + multipleServers?: boolean } = {}, ) { const state = { @@ -172,6 +173,7 @@ async function fixture( const params = new URLSearchParams() if (server) params.set("server", server) if (options.browserFailed) params.set("browserFailed", "1") + if (options.multipleServers) params.set("multipleServers", "1") await page.goto(`/e2e/desktop/index.html?${params}`) const dialog = page.locator('[data-component="dialog-v2"]').getByRole("dialog") if (options.draft) { @@ -186,6 +188,7 @@ async function fixture( return { state, dialog } } await page.getByRole("button", { name: "Settings", exact: true }).click() + if (options.multipleServers) await page.getByRole("tab", { name: "Production server", exact: true }).click() await page.getByRole("tab", { name: "Providers", exact: true }).click() // Use the picker so this also exercises the existing Settings entry point. await page.getByRole("button", { name: "Show more providers", exact: true }).click() @@ -315,6 +318,45 @@ test("Manage models opens the Models settings page", async ({ page }) => { await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") }) +test("Manage models keeps the connected server in multi-server settings", async ({ page }) => { + const { state, dialog } = await fixture(page, true, { multipleServers: true }) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "complete" + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + await dialog.getByRole("button", { name: "Manage models", exact: true }).click() + await expect(dialog).toBeHidden() + const settings = page.getByTestId("settings-screen") + await expect(settings.getByRole("tab", { name: "Production server", exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(settings.getByRole("switch", { name: "Console Gemini", exact: true })).toBeEnabled() +}) + +for (const multipleServers of [false, true]) { + test(`Console provider links reveal their models with ${multipleServers ? "multiple servers" : "one server"}`, async ({ + page, + }) => { + const { state, dialog } = await fixture(page, multipleServers, { existingConnection: true, multipleServers }) + await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() + await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() + state.status = "complete" + await expect(dialog).toBeHidden() + const settings = page.getByTestId("settings-screen") + await expect(settings.getByRole("tab", { name: "Providers", exact: true })).toHaveAttribute("aria-selected", "true") + await settings.locator(".settings-provider-console-toggle").click() + await settings.getByRole("button", { name: "Google", exact: true }).click() + await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") + const google = settings.getByRole("button", { name: "Anomaly / Google", exact: true }) + await expect(google).toHaveAttribute("aria-expanded", "true") + await expect(google).toBeFocused() + await expect(settings.getByRole("switch", { name: "Console Gemini", exact: true })).toBeEnabled() + await expect(settings.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toHaveAttribute( + "aria-expanded", + "false", + ) + }) +} + test("a single connected provider has a non-collapsible heading", async ({ page }) => { const { state, dialog } = await fixture(page, false, { singleProvider: true }) await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() diff --git a/packages/app/e2e/desktop/main.tsx b/packages/app/e2e/desktop/main.tsx index 1f7bf44b6fdb..c46b501b1b0f 100644 --- a/packages/app/e2e/desktop/main.tsx +++ b/packages/app/e2e/desktop/main.tsx @@ -9,9 +9,14 @@ import { ServerConnection } from "@/runtime/server/registry" // an HTTP fixture. No Electron service or account credentials are touched. const params = new URLSearchParams(window.location.search) const remote = params.get("server") +const local: ServerConnection.Any = { + type: "sidecar", + variant: "base", + http: { url: "http://127.0.0.1:4096" }, +} const server: ServerConnection.Any = remote ? { type: "http", displayName: "Production server", http: { url: remote } } - : { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } + : local const root = document.getElementById("root") if (!root) throw new Error("Missing fixture root") render( @@ -38,7 +43,11 @@ render( }} > - + ), diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 437df9301b2b..c87342c092d7 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -394,7 +394,7 @@ function ProviderConnection(props: { if (consoleMethod()) return } dialog.close() - surface.open("providers") + surface.openServer(ServerConnection.key(sdk.server), "providers") showToast({ variant: "success", icon: "circle-check", @@ -1045,7 +1045,7 @@ function ProviderConnection(props: { icon="outline-sliders" onClick={() => { dialog.close() - surface.open("models") + surface.openServer(ServerConnection.key(sdk.server), "models") }} > {language.t("dialog.model.manage")} From eed99b8e7fd4cca5bd8fbc8bf8c231acdcd37cf1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:48:01 +1000 Subject: [PATCH 6/6] fix(app): preserve drafts through provider onboarding --- packages/app/e2e/desktop/console-auth.spec.ts | 37 ++++++++++++++++++- packages/app/src/composer/commands.tsx | 2 +- packages/app/src/composer/composer.tsx | 6 ++- packages/app/src/new-session/view.tsx | 16 +++++--- packages/app/src/providers/models/manage.tsx | 8 ++-- .../src/providers/models/select-dialog.tsx | 25 ++++++------- packages/app/src/providers/models/unpaid.tsx | 13 +++---- 7 files changed, 76 insertions(+), 31 deletions(-) diff --git a/packages/app/e2e/desktop/console-auth.spec.ts b/packages/app/e2e/desktop/console-auth.spec.ts index 603474f3959b..e1ab6f68e0d8 100644 --- a/packages/app/e2e/desktop/console-auth.spec.ts +++ b/packages/app/e2e/desktop/console-auth.spec.ts @@ -54,7 +54,7 @@ async function fixture( page: Page, remote = false, options: { - draft?: boolean + draft?: boolean | "tip" | "model picker" | "model command" | "manage models" browserFailed?: boolean slowStart?: Promise existingConnection?: boolean @@ -183,6 +183,22 @@ async function fixture( await composer.fill("Keep this draft throughout sign-in") await expect(page.locator('[data-component="provider-setup"]')).toBeVisible() await expect(page.locator('[data-component="new-session-tip"]')).toContainText("Connect to 75+ providers") + if (options.draft === "tip") { + await page + .locator('[data-component="new-session-tip"]') + .getByRole("button", { name: "Connect to 75+ providers" }) + .click() + } + if (options.draft === "model picker") await page.locator('[data-action="composer-model"]').click() + if (options.draft === "model command" || options.draft === "manage models") { + await page.keyboard.press("Control+'") + if (options.draft === "manage models") { + await dialog.getByRole("button", { name: "Manage models", exact: true }).click() + await expect(dialog.getByRole("heading", { name: "Manage models", exact: true })).toBeVisible() + } + await dialog.getByRole("button", { name: "Connect provider", exact: true }).click() + } + if (options.draft !== true) await dialog.getByRole("button", { name: /^OpenCode / }).click() await page.getByRole("button", { name: "Continue with OpenCode Console" }).click() await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible() return { state, dialog } @@ -417,6 +433,25 @@ test("setup preserves the draft and Continue restores composer focus", async ({ await expect(page.locator('[data-component="provider-setup"]')).toBeHidden() }) +for (const entry of ["tip", "model picker", "model command", "manage models"] as const) { + test(`connecting from the ${entry} preserves the existing draft and selected model`, async ({ page }) => { + const { state, dialog } = await fixture(page, false, { draft: entry }) + await expect(page.locator("[data-titlebar-tab]")).toHaveCount(1) + state.status = "complete" + await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible() + const selected = dialog.getByRole("radio", { name: "Console Model 2", exact: true }) + await selected.click() + await expect(selected).toBeChecked() + await dialog.getByRole("button", { name: "Continue", exact: true }).click() + await expect(dialog).toBeHidden() + const composer = page.locator('[data-component="composer-editor"]') + await expect(composer).toHaveText("Keep this draft throughout sign-in") + await expect(composer).toBeFocused() + await expect(page.locator("[data-titlebar-tab]")).toHaveCount(1) + await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Model 2") + }) +} + test("catalog refresh failure retries without asking for authorization again", async ({ page }) => { const { state, dialog } = await fixture(page) await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click() diff --git a/packages/app/src/composer/commands.tsx b/packages/app/src/composer/commands.tsx index 4cafbffaf8ef..f7289540dcd1 100644 --- a/packages/app/src/composer/commands.tsx +++ b/packages/app/src/composer/commands.tsx @@ -42,7 +42,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { } const { DialogSelectModel } = await import("@/providers/models/select-dialog") owner.run(() => { - void dialog.show(() => , restoreComposer) + void dialog.show(() => , restoreComposer) }) } diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index f80afc7a7c00..e4ffe0512ec6 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -38,7 +38,11 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn providerID={props.model.model.selection.current()?.provider?.id} modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")} onClose={props.model.restoreFocus} - onUnpaidClick={() => dialog.show(() => )} + onUnpaidClick={() => + dialog.show(() => ( + + )) + } /> } /> diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 379e33b7c4ca..870cdfc54c1e 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -145,6 +145,7 @@ export function NewSessionView(props: {
void }) { +function NewSessionTips(props: { composer: ComposerModel; workspaceEligible: boolean; onWorkspace: () => void }) { const language = useLanguage() const dialog = useDialog() const sdk = useWorkspaceLocation() @@ -200,7 +201,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => show: () => tip() !== undefined, element: () => ref() ?? null, }) - const open = () => { + const open = async () => { const current = tip() if (!current) return if (current === "workspace") { @@ -208,9 +209,14 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => props.onWorkspace() return } - void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => { - void dialog.show(() => ) - }) + const { DialogConnectProvider } = await import("@/providers/connect/dialog") + void dialog.show(() => ( + + )) } const dismiss = () => { const current = tip() diff --git a/packages/app/src/providers/models/manage.tsx b/packages/app/src/providers/models/manage.tsx index d278ac759ea1..31edc745cba7 100644 --- a/packages/app/src/providers/models/manage.tsx +++ b/packages/app/src/providers/models/manage.tsx @@ -8,7 +8,7 @@ import { TextInput } from "@opencode/ui/text-input" import { useFilteredList } from "@opencode/ui/hooks" import { For, Show, type Component } from "solid-js" import { createStore } from "solid-js/store" -import { useLocal } from "@/providers/models/selection" +import { useLocal, type ModelSelection } from "@/providers/models/selection" import { popularProviders } from "@/providers/catalog/providers" import { useLanguage } from "@/runtime/i18n/language" import { useDialog } from "@opencode/ui/context/dialog" @@ -20,7 +20,7 @@ import "@/settings/settings.css" type ModelItem = ReturnType["model"]["list"]>[number] -export const DialogManageModels: Component = () => { +export const DialogManageModels: Component<{ model?: ModelSelection; onDone?: () => void }> = (props) => { const local = useLocal() const language = useLanguage() const dialog = useDialog() @@ -28,7 +28,9 @@ export const DialogManageModels: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - void dialog.show(() => ) + void dialog.show(() => ( + + )) } const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerVisible = (providerID: string) => diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index 26f8789eeaee..661358a6b412 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -248,10 +248,9 @@ export function ModelSelectorPopover(props: { groups={controller.groups} current={controller.current()} select={controller.select} - onManage={() => { - void import("./manage").then((module) => { - void dialog.show(() => ) - }) + onManage={async () => { + const { DialogManageModels } = await import("./manage") + void dialog.show(() => ) }} onClose={() => props.onClose?.()} /> @@ -527,22 +526,22 @@ function ModelSelectorPopoverView(props: { ) } -export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { +export const DialogSelectModel: Component<{ provider?: string; model?: ModelState; onDone?: () => void }> = (props) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() const directory = () => decode64(local.slug()) - const provider = () => { - void import("@/providers/connect/dialog").then((x) => { - void dialog.show(() => ) - }) + const provider = async () => { + const { DialogConnectProvider } = await import("@/providers/connect/dialog") + void dialog.show(() => ( + + )) } - const manage = () => { - void import("./manage").then((x) => { - dialog.show(() => ) - }) + const manage = async () => { + const { DialogManageModels } = await import("./manage") + void dialog.show(() => ) } return ( diff --git a/packages/app/src/providers/models/unpaid.tsx b/packages/app/src/providers/models/unpaid.tsx index 40ebc8152a1c..db7d2dcc03de 100644 --- a/packages/app/src/providers/models/unpaid.tsx +++ b/packages/app/src/providers/models/unpaid.tsx @@ -16,7 +16,7 @@ type ModelState = ModelSelection const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"] const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "") -export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { +export const DialogSelectModelUnpaid: Component<{ model?: ModelState; onDone?: () => void }> = (props) => { const local = useLocal() const model = props.model ?? local.model const dialog = useDialog() @@ -33,12 +33,11 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) const freeModels = createMemo(() => model.list().filter(isFree)) - const openProviders = (provider?: string) => { - void import("@/providers/connect/dialog").then((x) => { - const controller = x.useProviderConnectController() - controller.select(provider) - void dialog.show(() => ) - }) + const openProviders = async (provider?: string) => { + const { DialogConnectProvider } = await import("@/providers/connect/dialog") + void dialog.show(() => ( + + )) } const selectModel = (item: ReturnType[number]) => {