Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/app/component-tests/fork-dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
54 changes: 48 additions & 6 deletions packages/app/e2e/regression/mcp-workspace-toggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+;")
Expand Down Expand Up @@ -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<string>()
const names = ["alpha-mcp", "figma-desktop", "zeta-mcp"]
Expand Down
64 changes: 64 additions & 0 deletions packages/app/src/providers/connect/mcp-dialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<McpServer[]>([
{ 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(() => (
<McpDialog
servers={servers}
server={props.server}
pending={false}
onToggle={(input) => {
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 (
<Button variant="neutral" onClick={open}>
Open MCP dialog
</Button>
)
}

export default {
title: "App/Dialogs/MCP",
id: "app-dialog-mcp",
}

export const Default = {
render: () => <McpDialogStory />,
}

export const ConnectionError = {
render: () => <McpDialogStory server="unreachable-server" />,
}
67 changes: 47 additions & 20 deletions packages/app/src/providers/connect/mcp-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 (
<McpDialog
servers={data.location.mcp.server.list({ directory: directory() })}
server={props.server}
pending={toggle.isPending}
target={target()}
onToggle={(input) => 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 }))
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
<Dialog class="command-palette search-list-dialog mcp-dialog" size="large">
<Dialog class="command-palette command-palette-compact search-list-dialog mcp-dialog" size="large" fit>
<Show
when={failure()}
fallback={
Expand Down Expand Up @@ -172,12 +199,12 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
</Button>
<Button
variant="ghost"
disabled={toggle.isPending}
onClick={() => toggle.mutate({ name: current().name, enabled: false })}
disabled={props.pending}
onClick={() => props.onToggle({ name: current().name, enabled: false })}
>
{language.t("dialog.mcp.error.disable")}
</Button>
<Button variant="neutral" disabled={toggle.isPending} onClick={() => toggleServer(current().name)}>
<Button variant="neutral" disabled={props.pending} onClick={() => toggleServer(current().name)}>
{language.t("common.retry")}
</Button>
</ErrorDetails>
Expand All @@ -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 = () => {
Expand All @@ -221,9 +246,11 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
}

return (
<div {...row} data-option-key={item().name} data-status={status()?.status}>
<div {...row} data-option-key={item().name} data-status={status()?.status} data-size="compact">
<span class="mcp-dialog-lead">
<span class="mcp-status-dot" data-status={status()?.status} aria-hidden="true" />
<span class="mcp-dialog-status" aria-hidden="true">
<span class="mcp-status-dot" data-status={status()?.status} />
</span>
<TruncatedText text={item().name} class="mcp-dialog-name" />
</span>
<div class="mcp-trailing" onClick={(event) => event.stopPropagation()}>
Expand All @@ -247,8 +274,8 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
description={label()}
class="[&_[data-slot=switch-description]]:sr-only"
onChange={(enabled) => {
if (toggle.isPending) return
toggle.mutate({ name: item().name, enabled })
if (props.pending) return
props.onToggle({ name: item().name, enabled })
}}
>
<span class="sr-only">{item().name}</span>
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export const dict = {
"dialog.mcp.error.details":
"MCP server: {{name}}\nStatus: failed\nConfiguration: mcp.servers.{{name}}\nError: {{error}}",
"dialog.fork.search.empty": "No messages found",
"dialog.fork.description": "Edit an earlier message in a new session",
"dialog.fork.description": "Choose a message to fork from",

"mcp.status.connected": "Connected",
"mcp.status.failed": "Failed",
Expand Down
Loading
Loading