From eb06c3cfd2ba69c50d7004ad71d0832631784128 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:02:41 +0530 Subject: [PATCH] feat(app): redesign the integrations dialog (#59976) --- .../regression/mobile-model-pickers.spec.ts | 2 +- packages/app/e2e/regression/settings.spec.ts | 46 ++-- packages/app/src/providers/connect/dialog.tsx | 199 ++++++++++++------ packages/app/src/providers/models/manage.css | 11 +- packages/app/src/providers/models/manage.tsx | 10 +- packages/app/src/runtime/i18n/en.ts | 3 + packages/app/src/shell/search-list.css | 72 +++++++ 7 files changed, 241 insertions(+), 102 deletions(-) diff --git a/packages/app/e2e/regression/mobile-model-pickers.spec.ts b/packages/app/e2e/regression/mobile-model-pickers.spec.ts index 0b888921135..1c4793358da 100644 --- a/packages/app/e2e/regression/mobile-model-pickers.spec.ts +++ b/packages/app/e2e/regression/mobile-model-pickers.spec.ts @@ -116,7 +116,7 @@ for (const direction of ["ltr", "rtl"] as const) { await model.click() await models.getByRole("button", { name: "Connect an integration", exact: true }).click() await expect(models).toBeHidden() - const providers = page.getByRole("dialog", { name: "Connect an integration", exact: true }) + const providers = page.getByRole("dialog", { name: "Integrations", exact: true }) await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeFocused() await expect(page.getByRole("dialog")).toHaveCount(1) }) diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index b2d4ad555be..133c22f45b9 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -102,6 +102,7 @@ for (const locale of ["en", "ar"]) { page.on("request", (request) => { if (request.method() === "POST" && new URL(request.url()).pathname.endsWith("/interrupt")) stopped.push(request.url()) + if (request.method() === "DELETE" && new URL(request.url()).pathname.startsWith("/api/shell/")) stopped.push(request.url()) }) @@ -113,7 +114,7 @@ for (const locale of ["en", "ar"]) { ], sessionStatus: { [id]: { type: "busy" }, [childID]: { type: "busy" } }, seed: { locale }, - pageMessages: (): { items: SessionMessageInfo[] } => ({ + pageMessages: () => ({ items: [ { id: "msg_activity_user", type: "user", text: "Inspect the activity capsule", time: { created: 1 } }, { @@ -168,7 +169,7 @@ for (const locale of ["en", "ar"]) { await expect(capsule).toBeVisible() await expect(page.locator("html")).toHaveAttribute("dir", locale === "ar" ? "rtl" : "ltr") - for (const value of ["Center", "Right", "Hidden", "Left", "Right"]) { + for (const value of ["Center", "Right", "Hidden", "Left", "Right"] as const) { await page.keyboard.press("Control+,") const settings = page.getByTestId("settings-screen") const control = settings.getByLabel("Background activity", { exact: true }) @@ -179,24 +180,28 @@ for (const locale of ["en", "ar"]) { await options.getByRole("option", { name: value, exact: true }).click() await expect(control).toHaveText(value) await page.goBack() + if (value === "Hidden") { await expect(capsule).toHaveCount(0) await expect(panel).toHaveCount(0) continue } + await expect(capsule).toBeVisible() await expect .poll(async () => { const anchor = await capsule.boundingBox() const container = await page.locator('[data-component="session-composer-lane"]').boundingBox() + if (!anchor || !container) return Infinity - return Math.abs( - value === "Center" - ? anchor.x + anchor.width / 2 - container.x - container.width / 2 - : value === "Left" - ? anchor.x - container.x - : anchor.x + anchor.width - container.x - container.width, - ) + + const offset = { + Left: anchor.x - container.x, + Center: anchor.x + anchor.width / 2 - container.x - container.width / 2, + Right: anchor.x + anchor.width - container.x - container.width, + } + + return Math.abs(offset[value]) }) .toBeLessThan(1) await capsule.click() @@ -205,18 +210,21 @@ for (const locale of ["en", "ar"]) { .poll(async () => { const anchor = await capsule.boundingBox() const bounds = await panel.boundingBox() + if (!anchor || !bounds) return Infinity - return Math.abs( - value === "Center" - ? anchor.x + anchor.width / 2 - bounds.x - bounds.width / 2 - : value === "Left" - ? anchor.x - bounds.x - : anchor.x + anchor.width - bounds.x - bounds.width, - ) + + const offset = { + Left: anchor.x - bounds.x, + Center: anchor.x + anchor.width / 2 - bounds.x - bounds.width / 2, + Right: anchor.x + anchor.width - bounds.x - bounds.width, + } + + return Math.abs(offset[value]) }) .toBeLessThan(1) await page.keyboard.press("Escape") } + expect(stopped).toEqual([]) await expect .poll(() => @@ -585,6 +593,7 @@ test("shortcuts list Search sessions once and unbind a shortcut while recording await expect(binding).toHaveText("Press keys") await expect(unbind).toHaveCount(0) }) + for (const own of [false, true]) { test(`shortcuts list a command that shares Close tab only with its own shortcut: ${own}`, async ({ page }) => { const { settings } = await open(page, { @@ -621,6 +630,7 @@ test("single extension rows stay aligned when switching tabs", async ({ page }) plugins: [{ id: "demo-plugin", source: { type: "package", target: "demo-plugin" } }], skills: [{ name: "demo-skill", description: "Demo skill", path: "/skills/demo/SKILL.md" }], }) + await settings.getByRole("tab", { name: "Extensions", exact: true }).click() await expect(settings.getByRole("switch", { name: "demo-mcp", exact: true })).toBeChecked() const mcp = settings.getByRole("tabpanel", { name: "MCPs", exact: true }).locator(".settings-extension-row") @@ -1197,7 +1207,7 @@ test("providers: the connect dialog lists connected providers and manages their await settings.getByRole("button", { name: "Show more providers", exact: true }).click() const dialog = page.getByRole("dialog") const openai = dialog.locator('[data-provider-id="openai"]') - await expect(openai.locator('[data-slot="provider-connected"]')).toHaveText("2 connections") + await expect(openai.locator('[data-slot="provider-connected"]')).toHaveText("Work") await expect( dialog.locator('[data-provider-id="anthropic"]').locator('[data-slot="provider-connected"]'), ).toHaveCount(0) @@ -1290,7 +1300,7 @@ test("providers: adding an account starts a lone method once", async ({ page, co await dialog.getByRole("button", { name: "Navigate back", exact: true }).click() await expect(dialog.getByRole("radio", { name: "Go key", exact: true })).toBeVisible() - await dialog.getByRole("button", { name: "Navigate back", exact: true }).click() + await dialog.getByRole("button", { name: "Integrations", exact: true }).click() await dialog.locator('[data-provider-id="github-copilot"]').click() const popup = page.waitForEvent("popup") await dialog.getByRole("button", { name: "Add account", exact: true }).click() diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index ca5e17629c5..28b2830bd62 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -6,7 +6,7 @@ import { RadioGroup, RadioItem } from "@opencode/ui/radio" import { Checkbox } from "@opencode/ui/checkbox" import { Loader } from "@opencode/ui/loader" import { TextField } from "@opencode/ui/text-field" -import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog" +import { DialogBody, DialogHeader, DialogTitle, DialogTitleGroup, Dialog } from "@opencode/ui/dialog" import { TextInput } from "@opencode/ui/text-input" import { showToast } from "@/shell/notifications/toast" import { @@ -17,6 +17,7 @@ import { For, type JSX, Match, + onCleanup, onMount, Show, Switch, @@ -56,6 +57,7 @@ import { AccountNameInput } from "@/settings/providers/account-name" import { useProviderAccountActions } from "@/settings/providers/account-actions" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" +import "@/shell/search-list.css" import "./models.css" export function useProviderConnectController() { @@ -90,6 +92,7 @@ export const DialogConnectProvider: Component<{ chatgptWelcome: boolean picked: boolean stepBack: boolean + accounts: boolean title: string }>({ completed: false, @@ -97,6 +100,7 @@ export const DialogConnectProvider: Component<{ chatgptWelcome: false, picked: false, stepBack: false, + accounts: false, title: "", }) @@ -112,6 +116,7 @@ export const DialogConnectProvider: Component<{ const reset = controller.reset const back = { current: reset } const consoleSelected = () => controller.selected() === CONSOLE_INTEGRATION + const accounts = () => state.accounts && !!controller.selected() && !state.modelProvider let focusHost: HTMLDivElement | undefined const holdFocus = () => focusHost?.focus({ preventScroll: true }) @@ -143,6 +148,7 @@ export const DialogConnectProvider: Component<{ onFirstConnection={(provider) => setState("modelProvider", provider)} onAuthorization={(authorization) => setState("authorization", authorization)} onStepBack={(stepBack) => setState("stepBack", stepBack)} + onAccounts={(accounts) => setState("accounts", accounts)} onTitle={(title) => setState("title", title)} /> )} @@ -158,7 +164,7 @@ export const DialogConnectProvider: Component<{ ) }) }} - 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-5": !!state.modelProvider }} + class="[font-family:var(--v2-font-family-sans)]" + classList={{ + "[&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]": + !!controller.selected() && !accounts(), + "[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider, + "search-dialog": !controller.selected() || accounts(), + }} > @@ -191,6 +202,21 @@ export const DialogConnectProvider: Component<{ )} + +
+ + + + + {state.title} +
+
{(provider) => (
@@ -213,13 +239,24 @@ export const DialogConnectProvider: Component<{ )} - {language.t("command.provider.connect")} + + {/* Outside the scrolling content, so it can reach across the body's padding without scrolling sideways. */} + +
+
@@ -228,12 +265,17 @@ export const DialogConnectProvider: Component<{ ) } -// Same groups and order as the TUI's integration list. +// The TUI's integration groups and order, with connected integrations pulled up into their own group first. const POPULAR = ["opencode", "opencode-go", "openai", "github-copilot", "anthropic", "google"] function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { const integrations = useIntegrations(() => props.directory) const language = useLanguage() + const data = useData() + const location = () => (props.directory ? { directory: props.directory } : undefined) + const mcpServers = () => data.location.mcp.server.list(location()) ?? [] + + createEffect(() => void data.location.mcp.server.sync(location()).catch(() => undefined)) const [store, setStore] = createStore<{ filter: string @@ -250,6 +292,17 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string return integrations.list().filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query)) }) + // MCP servers keep their own group whether or not they are signed in. + const providers = createMemo(() => all().filter((row) => row.metadata?.source !== "mcp")) + + const connected = createMemo(() => + providers() + .filter((row) => row.connections.length > 0) + .toSorted((a, b) => a.name.localeCompare(b.name)), + ) + + const unconnected = createMemo(() => providers().filter((row) => row.connections.length === 0)) + const mcp = createMemo(() => all() .filter((row) => row.metadata?.source === "mcp") @@ -257,18 +310,19 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string ) const popular = createMemo(() => - all() - .filter((row) => row.metadata?.source !== "mcp" && POPULAR.includes(row.id)) + unconnected() + .filter((row) => POPULAR.includes(row.id)) .toSorted((a, b) => POPULAR.indexOf(a.id) - POPULAR.indexOf(b.id)), ) const services = createMemo(() => - all() - .filter((row) => row.metadata?.source !== "mcp" && !POPULAR.includes(row.id)) + unconnected() + .filter((row) => !POPULAR.includes(row.id)) .toSorted((a, b) => a.name.localeCompare(b.name)), ) const groups = () => [ + { title: language.t("dialog.provider.connected"), items: connected }, { title: language.t("dialog.provider.group.mcp"), items: mcp }, { title: language.t("dialog.provider.group.popular"), items: popular }, { title: language.t("dialog.provider.group.services"), items: services }, @@ -277,7 +331,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string // An integration that only reads environment variables has nothing to connect from here. const disabled = (row: IntegrationInfo) => connectMethods(row).length === 0 && providerAccounts(row).length === 0 - const rows = createMemo(() => [...mcp(), ...popular(), ...services()].filter((row) => !disabled(row))) + const rows = createMemo(() => [...connected(), ...mcp(), ...popular(), ...services()].filter((row) => !disabled(row))) let picker: HTMLDivElement | undefined let search: HTMLInputElement | undefined @@ -313,14 +367,15 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string } return ( -
-
+
+
-
+
{(group) => ( 0}> -
-
+
+
{group.title}
{(provider) => { - const connections = () => provider.connections - const status = () => connections()[0]?.status + const mcp = provider.metadata?.source === "mcp" + const connection = () => provider.connections[0] - const summary = () => { - const list = connections() + const needsSignIn = () => + !!connection()?.status || + (mcp && + mcpServers().some( + (server) => server.integrationID === provider.id && server.status.status === "needs_auth", + )) - if (list.length > 1) - return language.plural("dialog.provider.connections.count", list.length, { - count: list.length, - }) - const connection = list[0] + // The server lists the active account first. + const account = () => { + const current = connection() - if (!connection) return "" + if (mcp || !current) return - return connection.type === "credential" ? connection.label : `$${connection.name}` + return current.type === "credential" ? current.label : `$${current.name}` } return ( ) }} @@ -423,7 +471,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
@@ -442,6 +490,7 @@ function ProviderConnection(props: { onFirstConnection: (provider: { id: string; name: string }) => void onAuthorization: (authorization: boolean) => void onStepBack: (stepBack: boolean) => void + onAccounts: (accounts: boolean) => void onTitle: (title: string) => void accounts?: boolean }) { @@ -1087,6 +1136,14 @@ function ProviderConnection(props: { const backToMethods = () => (isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined createEffect(() => props.onStepBack(backToAccounts() || backToMethods())) + onCleanup(() => props.onAccounts(false)) + createEffect(() => + props.onAccounts( + showAccounts() && + controller.methodIndex() === undefined && + !(state.noModels && controller.auth.state() !== "error"), + ), + ) function goBack() { if (backToAccounts()) { @@ -1388,14 +1445,18 @@ function ProviderConnection(props: { function AccountsView() { return ( -
-
+
+
{(account) => ( +
{language.t("dialog.provider.accounts.removeConfirm", { account: account.label })} @@ -1408,11 +1469,11 @@ function ProviderConnection(props: {
} > -
+
+
selectAccount(account)} > @@ -1443,13 +1504,13 @@ function ProviderConnection(props: { title={status().message} class="ms-auto shrink-0 font-[440] text-[var(--v2-state-fg-warning)]" > - {language.t("dialog.provider.signInRequired")} + {language.t("dialog.provider.needsSignIn")} )} -
+
- + {language.t("dialog.provider.accounts.add")} @@ -1623,7 +1684,7 @@ function ProviderConnection(props: { return (
-

+

-