From 99408f1e3f528224e8b17095845219769ed5e8d8 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:27:34 +0100 Subject: [PATCH 1/3] feat(app): align searchable dialog layouts --- .../regression/mcp-workspace-toggle.spec.ts | 41 ++++++++++++ .../app/src/providers/connect/mcp-dialog.tsx | 63 ++++++++++++------ packages/app/src/runtime/i18n/en.ts | 2 +- packages/app/src/runtime/plugin/dialogs.tsx | 8 +-- .../app/src/session/commands/fork-dialog.tsx | 59 ++++++++++------- packages/app/src/shell/search-list.css | 66 +++++++++++++++++-- 6 files changed, 186 insertions(+), 53 deletions(-) diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index e160098bf6d9..03cad2d1d3dd 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -255,6 +255,47 @@ for (const surface of ["dialog"] as const) { }) } +test("the MCP list shrinks with matches and keeps its search anchored while scrolling", async ({ page }) => { + const view = await open(page, { + surface: "dialog", + mcp: ["figma-desktop", ...Array.from({ length: 24 }, (_, index) => `server-${String(index).padStart(2, "0")}`)].map( + (name) => ({ name, status: { status: "disabled" } }), + ), + }) + const input = view.panel.getByRole("combobox", { name: "Search MCPs", exact: true }) + const container = page.locator('[data-component="dialog-v2"] [data-slot="dialog-container"]') + const geometry = () => + container.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + inputTop: element.querySelector('[role="combobox"]')!.getBoundingClientRect().top, + })) + + await expect(input).toBeFocused() + await expect(view.panel.getByRole("option")).toHaveCount(25) + await input.hover() + const initial = await geometry() + + await input.fill("figma-desktop") + await expect(view.panel.getByRole("option")).toHaveCount(1) + await expect.poll(async () => (await geometry()).height).toBeLessThan(initial.height - 100) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + + await input.clear() + await expect(view.panel.getByRole("option")).toHaveCount(25) + const scroll = view.panel.locator('[data-slot="search-list-scroll"]') + + await expect.poll(() => scroll.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).height).toBeLessThanOrEqual(480) + await input.press("End") + const last = view.panel.getByRole("option", { name: "server-23", exact: true }) + + await expect(last).toHaveAttribute("aria-selected", "true") + await expect(last).toBeInViewport() + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + await expect(input).toBeFocused() +}) + test("toggling an MCP from the dialog keeps it highlighted", async ({ page }) => { const connected = new Set() const names = ["alpha-mcp", "figma-desktop", "zeta-mcp"] diff --git a/packages/app/src/providers/connect/mcp-dialog.tsx b/packages/app/src/providers/connect/mcp-dialog.tsx index 7e0023061f59..b2c6cadf462a 100644 --- a/packages/app/src/providers/connect/mcp-dialog.tsx +++ b/packages/app/src/providers/connect/mcp-dialog.tsx @@ -1,6 +1,7 @@ import { Component, createEffect, createMemo, on, Show } from "solid-js" import { createStore } from "solid-js/store" import fuzzysort from "fuzzysort" +import type { McpServer } from "@opencode/client/promise" import { useData } from "@/runtime/server/current" import { useWorkspaceLocation } from "@/workspaces/location" import { Button } from "@opencode/ui/button" @@ -34,10 +35,36 @@ export const DialogSelectMcp: Component = () => { /** Lists the MCP servers of `directory`; `server` opens directly on that server's error. */ export const DialogMcp: Component<{ directory: string; server?: string }> = (props) => { const data = useData() - const language = useLanguage() - const platform = usePlatform() const directory = () => props.directory const toggle = useMcpToggle(directory) + const target = () => { + const current = toggle.isPending ? toggle.variables : undefined + + return typeof current === "string" ? current : current?.name + } + + createEffect(() => void data.location.mcp.server.sync({ directory: directory() }).catch(() => undefined)) + + return ( + toggle.mutate(input)} + /> + ) +} + +export function McpDialog(props: { + servers: McpServer[] | undefined + server?: string + pending: boolean + target?: string + onToggle: (input: string | { name: string; enabled: boolean }) => void +}) { + const language = useLanguage() + const platform = usePlatform() const [store, setStore] = createStore<{ detail?: string; copied: boolean; query: string }>({ detail: props.server, @@ -45,11 +72,9 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro query: "", }) - const loaded = () => data.location.mcp.server.list({ directory: directory() }) + const loaded = () => props.servers const servers = createMemo(() => loaded() ?? []) - createEffect(() => void data.location.mcp.server.sync({ directory: directory() }).catch(() => undefined)) - const items = createMemo(() => servers() .map((server) => ({ name: server.name, status: server.status.status })) @@ -77,8 +102,8 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro const toggleServer = (name: string) => { const status = servers().find((server) => server.name === name)?.status.status - if (!status || status === "pending" || toggle.isPending) return - toggle.mutate(name) + if (!status || status === "pending" || props.pending) return + props.onToggle(name) } const select = (name: string) => { @@ -126,7 +151,7 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro return ( // Like the model picker and the TUI, the list has no visible header; the search field leads. - + = (pro - @@ -207,11 +232,7 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro // The switch would start sign-in anyway, so an auth-gated server shows that action instead. const signIn = () => status()?.status === "needs_auth" && !!server()?.integrationID - const busy = () => { - const target = toggle.isPending ? toggle.variables : undefined - - return (typeof target === "string" ? target : target?.name) === item().name - } + const busy = () => props.target === item().name // The dot shows the status, as in the session's MCP list; the words stay for assistive technology. const label = () => { @@ -221,9 +242,11 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro } return ( -
+
-