diff --git a/packages/app/component-tests/fork-dialog.spec.ts b/packages/app/component-tests/fork-dialog.spec.ts new file mode 100644 index 000000000000..59e7fc4dbb1b --- /dev/null +++ b/packages/app/component-tests/fork-dialog.spec.ts @@ -0,0 +1,61 @@ +import { expect, story } from "../../stories/playwright/story" + +story.use({ viewport: { width: 1000, height: 400 } }) + +story("fork search anchors filtering and keyboard scrolling", async ({ mount, page }) => { + await mount("app-dialog-fork-session--default") + const dialog = page.getByRole("dialog", { name: "Fork from message", exact: true }) + const input = dialog.getByRole("searchbox", { name: "Search", exact: true }) + const container = page.locator('[data-component="dialog-v2"] [data-slot="dialog-container"]') + + const geometry = () => + container.evaluate((element) => { + const field = element.querySelector('input[type="search"]')!.getBoundingClientRect() + + return { + height: element.getBoundingClientRect().height, + inputTop: field.top, + inputBottom: field.bottom, + instructionTop: element.querySelector('[data-slot="dialog-description"]')!.getBoundingClientRect().top, + } + }) + + await expect(input).toBeFocused() + await expect(dialog.getByRole("option")).toHaveCount(8) + await expect(dialog).toHaveAccessibleDescription("Choose a message to fork from") + await expect(dialog.getByText("Choose a message to fork from", { exact: true })).toBeVisible() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await input.hover() + const initial = await geometry() + expect(initial.instructionTop).toBeGreaterThanOrEqual(initial.inputBottom) + + await input.fill("keyboard navigation") + await expect(dialog.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(dialog.getByRole("option")).toHaveCount(8) + const scroll = dialog.locator("[data-scrollable]") + + await expect.poll(() => scroll.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0) + await input.press("ArrowUp") + + const oldest = dialog.getByRole("option", { + name: /^Investigate how the command palette handles keyboard navigation\./, + }) + + await expect(oldest).toHaveAttribute("aria-selected", "true") + await expect(oldest).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() + await input.press("Enter") + await expect(dialog).toBeHidden() + await expect(page.getByRole("status")).toHaveText("story-message-0") + + await page.getByRole("button", { name: "Open fork session dialog", exact: true }).click() + await expect(input).toBeFocused() + await input.press("Escape") + await expect(dialog).toBeHidden() +}) diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index e160098bf6d9..cb8ef2fc0473 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -47,12 +47,11 @@ async function open( await expectSessionTitle(page, title) await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - const panel = - input.surface === "settings" - ? page.getByTestId("settings-screen") - : input.surface === "popover" - ? page.getByRole("group", { name: "MCP", exact: true }) - : page.getByRole("dialog", { name: "MCPs", exact: true }) + const panel = { + settings: page.getByTestId("settings-screen"), + popover: page.getByRole("group", { name: "MCP", exact: true }), + dialog: page.getByRole("dialog", { name: "MCPs", exact: true }), + }[input.surface] const show = async () => { if (input.surface === "dialog") return page.keyboard.press("Control+;") @@ -255,6 +254,49 @@ 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.stories.tsx b/packages/app/src/providers/connect/mcp-dialog.stories.tsx new file mode 100644 index 000000000000..294b9e68dddd --- /dev/null +++ b/packages/app/src/providers/connect/mcp-dialog.stories.tsx @@ -0,0 +1,64 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import type { McpServer } from "@opencode/client/promise" +import { Predicate } from "effect" +import { onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { McpDialog } from "./mcp-dialog" + +function McpDialogStory(props: { server?: string }) { + const dialog = useDialog() + + const [servers, setServers] = createStore([ + { name: "figma-remote", integrationID: "figma-oauth", status: { status: "needs_auth", error: "Sign in required" } }, + { name: "local-files", status: { status: "disabled" } }, + { name: "paper", status: { status: "connected" } }, + { name: "search-index", status: { status: "pending" } }, + { + name: "unreachable-server", + status: { + status: "failed", + error: "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found\nCheck the server URL and try again.", + }, + }, + ]) + + const open = () => + dialog.show(() => ( + { + const action = Predicate.isString(input) + ? { name: input, enabled: servers.find((server) => server.name === input)?.status.status !== "connected" } + : input + + setServers((server) => server.name === action.name, "status", { + status: action.enabled ? "connected" : "disabled", + }) + }} + /> + )) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/MCP", + id: "app-dialog-mcp", +} + +export const Default = { + render: () => , +} + +export const ConnectionError = { + render: () => , +} diff --git a/packages/app/src/providers/connect/mcp-dialog.tsx b/packages/app/src/providers/connect/mcp-dialog.tsx index 7e0023061f59..3fdfdc3a4290 100644 --- a/packages/app/src/providers/connect/mcp-dialog.tsx +++ b/packages/app/src/providers/connect/mcp-dialog.tsx @@ -1,6 +1,8 @@ import { Component, createEffect, createMemo, on, Show } from "solid-js" import { createStore } from "solid-js/store" import fuzzysort from "fuzzysort" +import { Predicate } from "effect" +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,22 +36,47 @@ 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 Predicate.isString(current) ? 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, copied: false, 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 +104,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 +153,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 - @@ -199,19 +226,17 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro {(item, row) => { const server = () => servers().find((entry) => entry.name === item().name) const status = () => server()?.status + const error = () => { const current = status() return current?.status === "failed" ? current.error : undefined } + // 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 +246,11 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro } return ( -
+
-