diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index 6ac6987f7a0d..0476c90b094a 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -1179,6 +1179,66 @@ test("providers: connecting OpenCode Go keeps the Console row in place", async ( await expect(connected).not.toHaveAttribute("data-flashed") }) +test("providers: signing in to OpenCode Go without a subscription says why there are no Go models", async ({ + page, + context, +}) => { + await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" })) + await context.route("https://opencode.ai/go", (route) => route.fulfill({ body: "OpenCode Go" })) + + // The Console only returns `opencode-go` for accounts with a Go subscription; this one has none. + const state = { authorized: false } + const free = { "big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 } } } + + const { settings } = await open(page, { + provider: () => + state.authorized + ? { + // The catalog still lists OpenCode Go, unconnected and without models. + all: [ + { id: "opencode", integrationID: "opencode", name: "New User / OpenCode", models: free }, + { id: "opencode-go", name: "OpenCode Go", models: {} }, + ], + connected: ["opencode"], + default: {}, + } + : NO_PROVIDER, + integrations: () => [ + { + id: "opencode", + name: "OpenCode Console", + methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }], + connections: state.authorized + ? [{ type: "credential", id: "cred_console", label: "New User", method: "oauth" }] + : [], + }, + { id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }, + ], + onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }), + onIntegrationOAuthStatus: () => { + state.authorized = true + + return "complete" + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await settings + .locator(".settings-provider-row", { hasText: "OpenCode Go" }) + .getByRole("button", { name: "Connect", exact: true }) + .click() + const signIn = page.waitForEvent("popup") + await page.getByRole("button", { name: "Continue in the browser", exact: true }).click() + await signIn + + const connected = page.getByRole("dialog", { name: "Connected to OpenCode Go" }) + await expect(connected.getByRole("radio", { name: "Big Pickle", exact: true })).toBeVisible() + await expect(connected.getByText("No Go subscription on this account", { exact: true })).toBeVisible() + const subscribe = page.waitForEvent("popup") + await connected.getByRole("button", { name: "Subscribe", exact: true }).click() + await expect(await subscribe).toHaveURL("https://opencode.ai/go") +}) + test("providers: switching Console accounts keeps the list until the new workspace loads", async ({ page }) => { const accounts = [ { type: "credential", id: "cred_clara", label: "Clara", method: "oauth" }, diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 15fe5379bf3b..0453fe8a87bf 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -61,6 +61,8 @@ import "./models.css" const CUSTOM_ID = "_custom" +const GO_URL = "https://opencode.ai/go" + type IntegrationForm = NonNullable[number] type StringForm = Extract @@ -514,11 +516,17 @@ function ProviderConnection(props: { } dialog.close() + const noGo = props.provider === "opencode-go" && noGoSubscription() showToast({ variant: "success", icon: "circle-check", title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), - description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + description: noGo + ? language.t("provider.connect.models.noGo") + : language.t("provider.connect.toast.connected.description", { provider: provider().name }), + actions: noGo + ? [{ label: language.t("provider.connect.models.subscribe"), onClick: subscribeToGo }] + : undefined, }) }, }) @@ -556,6 +564,13 @@ function ProviderConnection(props: { const managedProviders = createMemo(() => (isConsole ? consoleProviderGroup(connectionProviders()) : undefined)) + // The Console only grants `opencode-go` to accounts with a Go subscription, so a signed-in account without one gets no + // Go models and nothing else would say why. + const noGoSubscription = () => + isConsole && !(data.location.model.list(location()) ?? []).some((model) => model.providerID === "opencode-go") + + const subscribeToGo = () => platform.openExternal(GO_URL) + // The server loads the Console workspace's providers after the grant lands, so the first refresh // can still show only the free catalog. Poll briefly for the workspace providers before moving on. async function prepareConsoleCatalog(active: () => boolean) { @@ -1435,6 +1450,17 @@ function ProviderConnection(props: {

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

+ +

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

+
connected() && !props.providerID) + const noGoSubscription = createMemo(() => { + const scoped = props.providerID ? providers().get(props.providerID) : undefined + if (props.providerID && scoped?.integrationID !== "opencode" && props.providerID !== "opencode") return false + if (models().some((model) => model.providerID === "opencode-go")) return false + const consoleIntegration = data.location.integration.list(location.ref)?.find((item) => item.id === "opencode") + const active = consoleIntegration ? credentialConnections(consoleIntegration)[0] : undefined + return Boolean(active && !active.status) + }) + const options = createMemo(() => { const needle = query().trim() const showSections = showExtra() && needle.length === 0 @@ -178,10 +192,25 @@ function DialogConnectedModels(props: { return results.map((option) => ({ ...option, description: option.providerName, category: undefined })) } + const noGoOptions = noGoSubscription() + ? [ + { + value: NO_GO_SUBSCRIPTION, + title: "No Go subscription on this account", + category: connected() ? "OpenCode Go" : undefined, + footer: newDesigns() ? "subscribe" : "Subscribe", + onSelect() { + Open.openUrl(GO_URL).catch(() => {}) + }, + }, + ] + : [] + return [ ...favoriteOptions, ...recentOptions, ...modelOptions.filter((option) => option.category !== "Free"), + ...noGoOptions, ...modelOptions.filter((option) => option.category === "Free"), ] }) @@ -217,6 +246,7 @@ function DialogConnectedModels(props: { title: newDesigns() ? (option) => (isFavorite(option?.value) ? "unfavorite" : "favorite") : "Favorite", hidden: !connected(), onTrigger: (option) => { + if (option.value === NO_GO_SUBSCRIPTION) return local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, }, diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d3050c43cefb..152c256bbc1f 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -2325,6 +2325,9 @@ test("free usage limit Subscribe handles mid-auth/mid-sync Escape, sync failure, const oauthCallsBeforeCachedExpired = oauthConnectCalls setup.mockInput.pressEnter() await setup.waitForFrame((frame) => frame.includes("Select model") && frame.includes("Big Pickle")) + // Signed in without a Go subscription: the picker says why there are no Go models. + await setup.waitForFrame((frame) => frame.includes("No Go subscription on this account")) + expect(setup.captureCharFrame()).toMatch(/subscribe/i) expect(oauthConnectCalls).toBe(oauthCallsBeforeCachedExpired + 1) setup.mockInput.pressEscape() await setup.waitForFrame((frame) => !frame.includes("Select model"))