From 48dcd2fa4029a2462c5f6d6185ff9fc6afe22bce Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:59:30 +0000 Subject: [PATCH] fix(app): fit connect dialog steps to their content under a fixed top edge (#57006) Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com> --- packages/app/e2e/regression/settings.spec.ts | 45 +++++++++++++++++- packages/app/src/providers/connect/dialog.tsx | 46 +++++++++++++------ .../src/session/usage-exceeded-dialogs.tsx | 2 +- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index 3632ad010d3..9bfe1ad9dcf 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test" import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise" import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app" import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server" -import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace" +import { mockRemoteServer, mockWorkspace, openSession, openSettings, type WorkspaceInput } from "../utils/workspace" const directory = "C:/Projects/settings-demo" @@ -138,6 +138,7 @@ test("project list menus rename, edit, add, and stay inside the scrollport", asy const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => project({ id: `project-${index}`, directory: `/projects/${name}`, name }), ) + const added = project({ id: "project-added", directory: "/projects/added", name: "added" }) const { settings } = await open(page, { @@ -842,6 +843,42 @@ test("providers: OpenCode Go connects with its own methods, not the Console sign expect(attempts).toEqual([]) }) +test("providers: the free tier upsell opens Go on its own, sized to its content", async ({ page }) => { + const view = await openSession(page, { name: "FreeTier", integrations: consoleIntegrations }) + await view.push([ + { + id: "evt_free_tier_limit", + created: 1700000000000, + type: "session.status", + location: { directory: view.directory }, + data: { + sessionID: view.session.id, + status: { + type: "retry", + attempt: 1, + message: "Free tier limit reached", + next: 1700000060000, + action: { + reason: "free_tier_limit", + provider: "opencode", + title: "Free tier limit reached", + message: "Subscribe to OpenCode Go to keep going.", + label: "Continue with OpenCode Go", + }, + }, + }, + }, + ]) + await page.getByRole("button", { name: "Continue with OpenCode Go", exact: true }).click() + + const dialog = page.getByRole("dialog", { name: "Connect OpenCode Go", exact: true }) + const container = page.locator('[data-slot="dialog-container"]') + await expect(dialog.getByPlaceholder("API key")).toBeVisible() + // Nothing to go back to: the upsell opened the dialog straight on Go. + await expect(dialog.getByRole("button", { name: "Navigate back", exact: true })).toHaveCount(0) + await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(512) +}) + test("providers: the connect dialog lists connected providers and manages their accounts", async ({ page }) => { const accounts = [ { type: "credential", id: "cred_work", label: "Work", method: "key" }, @@ -880,9 +917,15 @@ test("providers: the connect dialog lists connected providers and manages their await expect( dialog.locator('[data-provider-id="anthropic"]').locator('[data-slot="provider-connected"]'), ).toHaveCount(0) + const container = page.locator('[data-slot="dialog-container"]') + await expect.poll(async () => (await container.boundingBox())?.height).toBe(512) + const picker = await container.boundingBox() await openai.click() await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible() + // The provider step fits its content below the list's top edge. + await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(picker!.height) + expect((await container.boundingBox())?.y).toBe(picker!.y) const list = dialog.getByRole("radiogroup", { name: "Connected accounts", exact: true }) await expect(list.getByRole("radio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true") // Rows keep their order when the active account changes. diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index bc324ebcfac..70a6133c116 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -74,6 +74,8 @@ export const DialogConnectProvider: Component<{ /** Connects at the server's default Location instead of the current route's directory. */ defaultLocation?: boolean controller?: ReturnType + /** Opened for one provider from another flow, with no way back to the provider list. */ + standalone?: boolean selection?: ModelSelection onDone?: () => void onConnected?: (provider: string) => void @@ -87,11 +89,13 @@ export const DialogConnectProvider: Component<{ authorization: boolean chatgptWelcome: boolean picked: boolean + stepBack: boolean }>({ completed: false, authorization: false, chatgptWelcome: false, picked: false, + stepBack: false, }) const language = useLanguage() @@ -136,6 +140,7 @@ export const DialogConnectProvider: Component<{ }} onFirstConnection={(provider) => setState("modelProvider", provider)} onAuthorization={(authorization) => setState("authorization", authorization)} + onStepBack={(stepBack) => setState("stepBack", stepBack)} /> )} @@ -149,13 +154,14 @@ export const DialogConnectProvider: Component<{ return ( { if (state.completed && props.onDone) { event.preventDefault() @@ -186,7 +192,7 @@ export const DialogConnectProvider: Component<{ )} - + - + {language.t("command.provider.connect")} + + {/* Holds the back button's space so the content doesn't shift when it appears. */} + void onFirstConnection: (provider: { id: string; name: string }) => void onAuthorization: (authorization: boolean) => void + onStepBack: (stepBack: boolean) => void accounts?: boolean }) { const dialog = useDialog() @@ -1037,20 +1048,25 @@ function ProviderConnection(props: { ) } + const backToAccounts = () => + !!props.accounts && + state.adding && + accounts().length > 0 && + (state.direct || controller.methodIndex() === undefined) + + const backToMethods = () => (isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined + + createEffect(() => props.onStepBack(backToAccounts() || backToMethods())) + function goBack() { - if ( - props.accounts && - state.adding && - accounts().length > 0 && - (state.direct || controller.methodIndex() === undefined) - ) { + if (backToAccounts()) { controller.auth.reset() setState({ adding: false, direct: false }) return } - if ((isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined) { + if (backToMethods()) { controller.auth.reset() return diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index fbc1ff55d00..950f0e66004 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -128,7 +128,7 @@ export function useUsageExceededDialogs() { void import("@/providers/connect/dialog").then((x) => { const controller = x.useProviderConnectController() controller.select("opencode-go") - void dialog.show(() => ) + void dialog.show(() => ) }) } }}