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>
This commit is contained in:
opencode-agent[bot]
2026-10-19 07:59:30 +00:00
committed by GitHub
Unverified
parent b9ed73b24f
commit 48dcd2fa40
3 changed files with 76 additions and 17 deletions
+44 -1
View File
@@ -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.
+31 -15
View File
@@ -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<typeof useProviderConnectController>
/** 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)}
/>
)}
</Match>
@@ -149,13 +154,14 @@ export const DialogConnectProvider: Component<{
return (
<Dialog
preventBackdropDismiss={state.authorization}
containerClass={
// The top edge stays where the provider list puts it, so a step that fits its content only moves the bottom edge.
containerClass={`!self-start [--connect-top:max(8px,calc((100vh_-_512px)/2))] !mt-[var(--connect-top)] !w-[min(calc(100vw_-_16px),640px)] ${
state.modelProvider
? "!h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: consoleSelected() && state.authorization
? "!h-auto !max-h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
}
? "!h-[min(calc(100vh_-_8px_-_var(--connect-top)),560px)]"
: controller.selected()
? "!h-auto !max-h-[min(calc(100vh_-_8px_-_var(--connect-top)),560px)]"
: "!h-[min(calc(100vh_-_16px),512px)]"
}`}
onCloseAutoFocus={(event) => {
if (state.completed && props.onDone) {
event.preventDefault()
@@ -186,7 +192,7 @@ export const DialogConnectProvider: Component<{
</div>
)}
</Match>
<Match when={controller.selected()}>
<Match when={controller.selected() && (!props.standalone || state.stepBack)}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
@@ -196,9 +202,13 @@ export const DialogConnectProvider: Component<{
<Icon name="arrow-left" size="small" />
</button>
</Match>
<Match when={true}>
<Match when={!props.standalone}>
<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>
</Match>
<Match when={true}>
{/* Holds the back button's space so the content doesn't shift when it appears. */}
<span aria-hidden="true" class="size-5" />
</Match>
</Switch>
</DialogHeader>
<DialogBody
@@ -425,6 +435,7 @@ function ProviderConnection(props: {
onConnected?: (methodID?: string) => 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
@@ -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(() => <x.DialogConnectProvider controller={controller} />)
void dialog.show(() => <x.DialogConnectProvider controller={controller} standalone />)
})
}
}}