mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): refine MCP status controls
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { NO_PROVIDER, sessionHref } from "../utils/app"
|
||||
import { holdRoute, NO_PROVIDER, sessionHref } from "../utils/app"
|
||||
import type { MockServerConfig } from "../utils/mock-server"
|
||||
import { mockWorkspace } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -54,7 +54,7 @@ async function open(
|
||||
}
|
||||
|
||||
await show()
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(panel.locator('[data-slot="truncated-text-label"]').filter({ hasText: "figma-desktop" })).toBeVisible()
|
||||
|
||||
return { requests, panel, show, push: mock.push, toggle: panel.getByRole("switch") }
|
||||
}
|
||||
@@ -85,7 +85,7 @@ for (const shared of [true, false]) {
|
||||
expect(view.requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
|
||||
view.requests.length = 0
|
||||
await view.panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await view.panel.locator('[data-slot="truncated-text-label"]').filter({ hasText: "figma-desktop" }).click()
|
||||
await expect(view.toggle).not.toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
expect(actions).toEqual([
|
||||
@@ -136,16 +136,39 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
])
|
||||
expect(view.requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
|
||||
// Both surfaces replace a failed server's switch with a link that opens its error.
|
||||
const failed = view.panel.getByRole("button", { name: "figma-desktop failed, view error", exact: true })
|
||||
const details = page.getByRole("dialog", { name: "MCP server: figma-desktop", exact: true })
|
||||
// Status is separate from the shared v2 button that opens the error.
|
||||
const failed = view.panel.getByRole("button", { name: "Details for figma-desktop", exact: true })
|
||||
const details = page.getByRole("dialog", { name: "figma-desktop", exact: true })
|
||||
await expect(view.toggle).toHaveCount(0)
|
||||
await expect(view.panel.getByText("Failed", { exact: true })).toHaveCount(1)
|
||||
await expect(failed).toHaveText("Details")
|
||||
await expect(failed).toHaveAttribute("data-component", "button-v2")
|
||||
await failed.click()
|
||||
await expect(details).toHaveAccessibleDescription("Connection failed")
|
||||
await expect(details.getByText(error, { exact: true })).toBeVisible()
|
||||
|
||||
if (surface === "dialog") {
|
||||
await details.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
const back = details.getByRole("button", { name: "Navigate back", exact: true })
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const navigation = await back.boundingBox()
|
||||
const heading = await details.getByRole("heading", { name: "figma-desktop", exact: true }).boundingBox()
|
||||
const error = await details.locator('[data-slot="error-details-text"]').boundingBox()
|
||||
const status = await details.getByText("Connection failed", { exact: true }).boundingBox()
|
||||
|
||||
return (
|
||||
!!navigation &&
|
||||
!!heading &&
|
||||
!!error &&
|
||||
!!status &&
|
||||
Math.abs(heading.y - navigation.y) < 1 &&
|
||||
heading.x >= navigation.x + navigation.width &&
|
||||
Math.abs(status.x - error.x) < 1
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await back.click()
|
||||
await expect(failed).toBeVisible()
|
||||
await failed.click()
|
||||
}
|
||||
@@ -191,7 +214,19 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await expect(view.toggle).toHaveCount(0)
|
||||
await expect(view.panel.getByText("Sign in required", { exact: true })).toHaveCount(0)
|
||||
const popup = page.waitForEvent("popup")
|
||||
await view.panel.getByRole("button", { name: "Sign in to figma-desktop", exact: true }).click()
|
||||
const signIn = view.panel.getByRole("button", { name: "Sign in to figma-desktop", exact: true })
|
||||
|
||||
const authorization = await holdRoute(page, /\/api\/integration\/figma-oauth\/connect\/oauth(?:\?|$)/, {
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
await expect(signIn).toHaveAttribute("data-component", "button-v2")
|
||||
await expect(signIn.locator("svg")).toHaveCount(0)
|
||||
await signIn.click()
|
||||
await expect(signIn).toHaveText("Signing in…")
|
||||
await expect(signIn).toBeDisabled()
|
||||
await authorization.arrived
|
||||
authorization.release()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
expect(attempts).toEqual([workspace])
|
||||
|
||||
@@ -212,3 +247,60 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await expect(view.panel.getByRole("button", { name: "Sign in to figma-desktop", exact: true })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const surface of ["popover", "dialog"] as const) {
|
||||
test(`long MCP names keep their status and controls visible in the ${surface}`, async ({ page }) => {
|
||||
const names = ["connected", "disabled", "failed", "needs_auth"].map(
|
||||
(status) => `long-${status}-mcp-server-for-the-internal-knowledge-base-search`,
|
||||
)
|
||||
|
||||
const actions: string[] = []
|
||||
|
||||
const view = await open(page, {
|
||||
surface,
|
||||
mcp: [
|
||||
{ name: "figma-desktop", status: { status: "disabled" } },
|
||||
...names.map((name, index) => ({
|
||||
name,
|
||||
integrationID: index === 3 ? "long-oauth" : undefined,
|
||||
status: { status: ["connected", "disabled", "failed", "needs_auth"][index], error: "Connection refused" },
|
||||
})),
|
||||
],
|
||||
onMcpAction: (input) => void actions.push(input.server),
|
||||
})
|
||||
|
||||
for (const [index, name] of names.entries()) {
|
||||
const row = view.panel
|
||||
.locator(surface === "dialog" ? '[role="option"]' : ".session-mcp-row")
|
||||
.filter({ hasText: name })
|
||||
|
||||
const status = row.getByText(["Connected", "Disabled", "Failed", "Sign in"][index], { exact: true })
|
||||
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(() => status.evaluate((el) => el.scrollWidth <= el.clientWidth && el.getBoundingClientRect().width > 0))
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const name = view.panel.locator('[data-slot="truncated-text-label"]').filter({ hasText: names[0] })
|
||||
await name.hover()
|
||||
// The modal aria-hides body portals; the full name remains the trigger's accessible text.
|
||||
const tooltip = page.getByRole("tooltip", { includeHidden: true })
|
||||
await expect(tooltip).toHaveText(names[0])
|
||||
await expect(tooltip).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await view.panel.locator(".truncated-text").filter({ hasText: names[0] }).focus()
|
||||
await expect(tooltip).toHaveText(names[0])
|
||||
expect(actions).toEqual([])
|
||||
await view.panel.locator(".truncated-text").filter({ hasText: names[0] }).blur()
|
||||
await view.panel.locator('[data-slot="truncated-text-label"]').filter({ hasText: "figma-desktop" }).hover()
|
||||
await expect(tooltip).toBeHidden()
|
||||
|
||||
if (surface !== "dialog") return
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(name).toHaveCSS("white-space", "normal")
|
||||
await expect.poll(() => name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true)
|
||||
await view.panel.getByRole("button", { name: `Details for ${names[2]}`, exact: true }).scrollIntoViewIfNeeded()
|
||||
await expect(view.panel.getByRole("button", { name: `Details for ${names[2]}`, exact: true })).toBeInViewport()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,7 +419,7 @@ test("summary catalogs load, refresh while cached, and tell errors from empty",
|
||||
await expect(menu("LSP").getByText("rust", { exact: true })).toHaveCount(0)
|
||||
await expect(menu("LSP").locator(".session-service-dot")).toHaveCount(0)
|
||||
await open("MCP")
|
||||
await expect(menu("MCP").getByText("summary-mcp", { exact: true })).toBeVisible()
|
||||
await expect(menu("MCP").locator('[data-slot="truncated-text-label"]').filter({ hasText: "summary-mcp" })).toBeVisible()
|
||||
|
||||
for (const service of [
|
||||
{ name: "MCP", path: "/api/mcp", item: "summary-mcp" },
|
||||
@@ -475,10 +475,11 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
|
||||
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
|
||||
|
||||
const row = submenu
|
||||
.locator('[data-component="switch"]')
|
||||
.locator(".session-mcp-row")
|
||||
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
|
||||
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(row.getByText("Connected", { exact: true })).toBeVisible()
|
||||
// A pointer open focuses the menu itself, so its first row shows no focus ring; a keyboard open focuses that row.
|
||||
await expect(submenu).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
@@ -492,7 +493,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
|
||||
const enabled = index % 2 !== 0
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
if (target === "label") await row.getByText("figma", { exact: true }).click()
|
||||
if (target === "label") await row.locator('[data-slot="truncated-text-label"]').click()
|
||||
|
||||
if (target === "dot") await row.locator(".session-service-dot").click()
|
||||
|
||||
@@ -502,6 +503,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
|
||||
|
||||
if (target === "keyboard") await toggle.press("Space")
|
||||
await expect(toggle).toBeChecked({ checked: enabled })
|
||||
await expect(row.getByText(enabled ? "Connected" : "Disabled", { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(submenu).toBeVisible()
|
||||
|
||||
@@ -548,7 +550,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
|
||||
|
||||
try {
|
||||
const popup = page.waitForEvent("popup")
|
||||
await submenu.getByText("linear", { exact: true }).click()
|
||||
await submenu.locator('[data-slot="truncated-text-label"]').filter({ hasText: "linear" }).click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await resources.arrived
|
||||
// The switch hands over to the sign-in link, which waits for the catalog before it is ready again.
|
||||
@@ -732,10 +734,10 @@ async function expectRefreshKeeps(page: Page, name: string, path: string, conten
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await refresh.arrived
|
||||
await expect(menu).toHaveAttribute("aria-busy", "true")
|
||||
await expect(menu.getByText(content, { exact: true })).toBeVisible()
|
||||
await expect(menu.getByText(content, { exact: true }).filter({ visible: true })).toBeVisible()
|
||||
await expect(menu.getByRole("status")).toHaveCount(0)
|
||||
await expect(summary).toBeVisible()
|
||||
refresh.release()
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(menu.getByText(content, { exact: true })).toBeVisible()
|
||||
await expect(menu.getByText(content, { exact: true }).filter({ visible: true })).toBeVisible()
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ test.describe("pages open before slow data", () => {
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
})
|
||||
|
||||
test("extensions show failed and auth-gated MCPs as links", async ({ page, context }) => {
|
||||
test("extensions separate MCP status from details, sign-in, and switch controls", async ({ page, context }) => {
|
||||
const error = "Connection closed: MCP server process exited with code 1"
|
||||
const signIns: string[] = []
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
@@ -496,6 +496,7 @@ test.describe("pages open before slow data", () => {
|
||||
mcp: [
|
||||
{ name: "blender", status: { status: "failed", error } },
|
||||
{ name: "demo-mcp", status: { status: "connected" } },
|
||||
{ name: "disabled-mcp", status: { status: "disabled" } },
|
||||
{ name: "stripe", integrationID: "stripe-oauth", status: { status: "needs_auth", error: "Sign in required" } },
|
||||
],
|
||||
integrations: [
|
||||
@@ -511,15 +512,25 @@ test.describe("pages open before slow data", () => {
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
await expect(settings.getByRole("switch")).toHaveCount(1)
|
||||
await expect(settings.getByRole("switch")).toHaveCount(2)
|
||||
await expect(settings.getByText("Connected", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Disabled", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Failed", { exact: true })).toBeVisible()
|
||||
|
||||
const popup = page.waitForEvent("popup")
|
||||
await settings.getByRole("button", { name: "Sign in to stripe", exact: true }).click()
|
||||
const signIn = settings.getByRole("button", { name: "Sign in to stripe", exact: true })
|
||||
await expect(signIn).toHaveAttribute("data-component", "button-v2")
|
||||
await expect(signIn.locator("svg")).toHaveCount(0)
|
||||
await signIn.click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
expect(signIns).toEqual(["stripe-oauth"])
|
||||
|
||||
await settings.getByRole("button", { name: "blender failed, view error", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "MCP server: blender", exact: true })
|
||||
const action = settings.getByRole("button", { name: "Details for blender", exact: true })
|
||||
await expect(action).toHaveText("Details")
|
||||
await expect(action).toHaveAttribute("data-component", "button-v2")
|
||||
await action.click()
|
||||
const details = page.getByRole("dialog", { name: "blender", exact: true })
|
||||
await expect(details).toHaveAccessibleDescription("Connection failed")
|
||||
await expect(details.getByText(error, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { ErrorDetails } from "@opencode/ui/error-details"
|
||||
import { StatusLink } from "@opencode/ui/status-link"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { TruncatedText } from "@opencode/ui/truncated-text"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useMcpToggle } from "@/providers/connect/mcp"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
@@ -102,7 +102,7 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
|
||||
|
||||
return (platform.writeClipboardText?.(details) ?? navigator.clipboard.writeText(details))
|
||||
.then(() => setStore("copied", true))
|
||||
.catch((error: unknown) =>
|
||||
.catch((error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("dialog.mcp.error.copyFailed"),
|
||||
@@ -151,7 +151,7 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog class="search-list-dialog">
|
||||
<Dialog class="search-list-dialog mcp-dialog">
|
||||
<Show
|
||||
when={failure()}
|
||||
fallback={
|
||||
@@ -235,16 +235,9 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
|
||||
onClick={() => select(item.name)}
|
||||
>
|
||||
<div class="search-list-row-text">
|
||||
<span class="search-list-title">{item.name}</span>
|
||||
<Show when={detail()}>
|
||||
<span class="search-list-detail">{detail()}</span>
|
||||
</Show>
|
||||
<TruncatedText text={item.name} class="search-list-title" />
|
||||
</div>
|
||||
{/* Sized for the failed and sign-in links so every row's text truncates at the same edge. */}
|
||||
<div
|
||||
class="search-list-trailing min-w-15 justify-end"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="search-list-trailing mcp-trailing" onClick={(event) => event.stopPropagation()}>
|
||||
<Show
|
||||
when={status()?.status === "failed"}
|
||||
fallback={
|
||||
@@ -255,27 +248,34 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
|
||||
checked={status()?.status === "connected"}
|
||||
disabled={status()?.status === "pending" || busy()}
|
||||
onChange={() => toggleServer(item.name)}
|
||||
/>
|
||||
>
|
||||
<span class="sr-only">{item.name}</span>
|
||||
<span aria-hidden="true">{detail()}</span>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<StatusLink
|
||||
tone="accent"
|
||||
busy={busy()}
|
||||
<Button
|
||||
size="small"
|
||||
variant="neutral"
|
||||
disabled={busy()}
|
||||
aria-busy={busy()}
|
||||
aria-label={language.t("dialog.mcp.signIn.open", { name: item.name })}
|
||||
onClick={() => toggleServer(item.name)}
|
||||
>
|
||||
{language.t("dialog.mcp.signIn")}
|
||||
</StatusLink>
|
||||
{language.t(busy() ? "dialog.mcp.signingIn" : "dialog.mcp.signIn")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<StatusLink
|
||||
tone="danger"
|
||||
aria-label={language.t("dialog.mcp.error.open", { name: item.name })}
|
||||
<span class="text-v2-state-fg-danger">{language.t("mcp.status.failed")}</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="neutral"
|
||||
aria-label={language.t("dialog.mcp.error.actionLabel", { name: item.name })}
|
||||
onClick={() => select(item.name)}
|
||||
>
|
||||
{language.t("mcp.status.failed")}
|
||||
</StatusLink>
|
||||
{language.t("dialog.mcp.error.action")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +293,7 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
|
||||
// Escape steps back to the list, like the TUI, instead of closing the dialog.
|
||||
<div class="contents" data-owns-escape onKeyDown={onDetailKeyDown}>
|
||||
<DialogHeader>
|
||||
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||
<div class="mcp-error-heading">
|
||||
<button
|
||||
ref={backRef}
|
||||
type="button"
|
||||
@@ -304,12 +304,24 @@ export const DialogMcp: Component<{ directory: string; server?: string }> = (pro
|
||||
>
|
||||
<Icon name="arrow-left" size="small" />
|
||||
</button>
|
||||
<DialogTitleGroup title={language.t("dialog.mcp.error.title", { name: current().name })} />
|
||||
<DialogTitleGroup
|
||||
title={
|
||||
<span dir="auto" class="[overflow-wrap:anywhere]">
|
||||
{current().name}
|
||||
</span>
|
||||
}
|
||||
description={language.t("dialog.mcp.error.status")}
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody class="search-list-body">
|
||||
<ErrorDetails error={current().error}>
|
||||
<Button variant="ghost" icon={store.copied ? "check" : "copy"} onClick={() => void copy(current())}>
|
||||
<Button
|
||||
class="error-details-copy"
|
||||
variant="ghost-muted"
|
||||
icon={store.copied ? "check" : "copy"}
|
||||
onClick={() => void copy(current())}
|
||||
>
|
||||
{language.t(store.copied ? "common.copied" : "dialog.mcp.error.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -327,6 +327,10 @@ export const dict = {
|
||||
"dialog.mcp.error.copy": "Copy details",
|
||||
"dialog.mcp.error.copyFailed": "Couldn't copy error details",
|
||||
"dialog.mcp.error.open": "{{name}} failed, view error",
|
||||
"dialog.mcp.error.action": "Details",
|
||||
"dialog.mcp.error.status": "Connection failed",
|
||||
"dialog.mcp.signingIn": "Signing in…",
|
||||
"dialog.mcp.error.actionLabel": "Details for {{name}}",
|
||||
"dialog.mcp.error.disable": "Disable",
|
||||
"dialog.mcp.signIn": "Sign in",
|
||||
"dialog.mcp.signIn.open": "Sign in to {{name}}",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component, For, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { StatusLink } from "@opencode/ui/status-link"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { TruncatedText } from "@opencode/ui/truncated-text"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -105,13 +106,22 @@ export const SettingsExtensions: Component<{
|
||||
{(item) => {
|
||||
const busy = () => toggleMcp.isPending && toggleMcp.variables === item.name
|
||||
|
||||
const label = () => {
|
||||
if (item.status.status === "pending") return
|
||||
|
||||
if (item.status.status === "connected") return language.t("mcp.status.connected")
|
||||
|
||||
if (item.status.status === "disabled") return language.t("mcp.status.disabled")
|
||||
|
||||
return language.t("mcp.status.needs_auth")
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-row settings-mcp-row">
|
||||
<div class="settings-extension-lead">
|
||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="settings-extension-name truncate">{item.name}</span>
|
||||
<TruncatedText text={item.name} class="settings-extension-name" />
|
||||
</div>
|
||||
{/* The same slot as the MCPs dialog, so switches and links line up. */}
|
||||
<div class="settings-extension-trailing">
|
||||
<Show
|
||||
when={item.status.status === "failed"}
|
||||
@@ -123,34 +133,38 @@ export const SettingsExtensions: Component<{
|
||||
checked={item.status.status === "connected"}
|
||||
disabled={item.status.status === "pending" || busy()}
|
||||
onChange={() => toggle(item.name)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
<span class="sr-only">{item.name}</span>
|
||||
<span aria-hidden="true">{label()}</span>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<StatusLink
|
||||
tone="accent"
|
||||
busy={busy()}
|
||||
<Button
|
||||
size="small"
|
||||
variant="neutral"
|
||||
disabled={busy()}
|
||||
aria-busy={busy()}
|
||||
aria-label={language.t("dialog.mcp.signIn.open", { name: item.name })}
|
||||
onClick={() => toggle(item.name)}
|
||||
>
|
||||
{language.t("dialog.mcp.signIn")}
|
||||
</StatusLink>
|
||||
{language.t(busy() ? "dialog.mcp.signingIn" : "dialog.mcp.signIn")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<StatusLink
|
||||
tone="danger"
|
||||
aria-label={language.t("dialog.mcp.error.open", { name: item.name })}
|
||||
<span class="text-v2-state-fg-danger">{language.t("mcp.status.failed")}</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="neutral"
|
||||
aria-label={language.t("dialog.mcp.error.actionLabel", { name: item.name })}
|
||||
onClick={() =>
|
||||
dialog.show(() => (
|
||||
<DialogMcp directory={data.location.default().directory} server={item.name} />
|
||||
))
|
||||
}
|
||||
>
|
||||
{language.t("mcp.status.failed")}
|
||||
</StatusLink>
|
||||
{language.t("dialog.mcp.error.action")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2147,6 +2147,18 @@
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.settings-mcp-row .settings-extension-trailing {
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-mcp-row {
|
||||
min-height: 56px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-extension-lead {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -113,3 +113,60 @@
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
.mcp-dialog .search-list-row {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mcp-dialog .search-list-row-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mcp-dialog .search-list-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mcp-trailing {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcp-error-heading {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"]
|
||||
[data-slot="dialog-container"]
|
||||
.mcp-dialog[data-slot="dialog-content"]
|
||||
.mcp-error-heading
|
||||
> [data-slot="dialog-title-group"] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mcp-error-heading [data-slot="dialog-title"] {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.mcp-error-heading [data-slot="dialog-description"] {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 639px), (hover: none) {
|
||||
.mcp-dialog .search-list-row {
|
||||
height: auto;
|
||||
min-height: 44px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
|
||||
.mcp-dialog .search-list-title {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,34 +254,31 @@
|
||||
color: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
/* Every row ends in the same 60px slot as the MCPs dialog, so switches and status links line up. */
|
||||
.session-mcp-trailing {
|
||||
display: flex;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-inline-start: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="switch"].session-mcp-row [data-slot="switch-control"] {
|
||||
margin-inline-start: 36px;
|
||||
.session-mcp-failed {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
div.session-mcp-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 639px), (hover: none) {
|
||||
.session-mcp-row {
|
||||
min-height: 44px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-mcp-error-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
|
||||
@@ -34,6 +34,12 @@ export default {
|
||||
"workspace.new": "New worktree",
|
||||
"mcp.interactiveAuth": "MCP server {{name}} requires an interactive authentication form",
|
||||
"mcp.signIn": "Sign in",
|
||||
"mcp.signingIn": "Signing in…",
|
||||
"mcp.connected": "Connected",
|
||||
"mcp.disabled": "Disabled",
|
||||
"mcp.error.action": "Details",
|
||||
"mcp.error.status": "Connection failed",
|
||||
"mcp.error.actionLabel": "Details for {{name}}",
|
||||
"mcp.signIn.open": "Sign in to {{name}}",
|
||||
"mcp.error.open": "{{name}} failed, view error",
|
||||
"mcp.error.title": "MCP server: {{name}}",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { ErrorDetails } from "@opencode/ui/error-details"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { StatusLink } from "@opencode/ui/status-link"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { showToast } from "@opencode/ui/toast"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { TruncatedText } from "@opencode/ui/truncated-text"
|
||||
import { getDirectory } from "@opencode/util/path"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -221,17 +221,11 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
{(server) => {
|
||||
const enabled = () => server().status.status !== "disabled"
|
||||
const pending = () => toggle.isPending || server().status.status === "pending"
|
||||
// Like the MCPs dialog, a failed or auth-gated server shows its follow-up where the switch would be.
|
||||
// Status stays beside its control rather than competing with a long server name.
|
||||
const failed = () => server().status.status === "failed"
|
||||
const signIn = () => server().status.status === "needs_auth" && !!server().integrationID
|
||||
const busy = () => toggle.isPending && toggle.variables?.name === server().name
|
||||
|
||||
const error = () => {
|
||||
const status = server().status
|
||||
|
||||
return status.status === "failed" ? status.error : undefined
|
||||
}
|
||||
|
||||
const label = () => {
|
||||
const status = server().status.status
|
||||
|
||||
@@ -239,7 +233,7 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
|
||||
if (status === "needs_auth") return ctx.t("needsAuth")
|
||||
|
||||
return undefined
|
||||
return ctx.t(status === "connected" ? "mcp.connected" : "mcp.disabled")
|
||||
}
|
||||
|
||||
const change = (value: boolean) => {
|
||||
@@ -249,90 +243,51 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
|
||||
const follow = () => {
|
||||
if (failed()) return showError(server().name)
|
||||
change(true)
|
||||
change(server().status.status === "needs_auth" || !enabled())
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={failed() || signIn()}
|
||||
fallback={
|
||||
<Switch
|
||||
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
|
||||
description={label()}
|
||||
checked={enabled()}
|
||||
readOnly={pending()}
|
||||
aria-disabled={pending()}
|
||||
aria-busy={toggle.isPending}
|
||||
onChange={change}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (
|
||||
!(event.target instanceof Element) ||
|
||||
event.target.closest('[data-slot="switch-control"], [data-slot="switch-input"]')
|
||||
)
|
||||
return
|
||||
|
||||
// Outside the switch itself, a row that requires sign-in starts sign-in instead of toggling.
|
||||
if (server().status.status === "needs_auth") {
|
||||
event.preventDefault()
|
||||
|
||||
return change(true)
|
||||
}
|
||||
|
||||
if (event.target === event.currentTarget) change(!enabled())
|
||||
}}
|
||||
title={error() ?? server().name}
|
||||
>
|
||||
<span class="session-service-dot" data-status={server().status.status} aria-hidden="true" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{server().name}
|
||||
</span>
|
||||
<Show when={label()}>
|
||||
{(status) => (
|
||||
<span class="session-service-status" aria-hidden="true">
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="session-mcp-row"
|
||||
onClick={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest('[data-component="status-link"]'))
|
||||
return
|
||||
follow()
|
||||
}}
|
||||
>
|
||||
<span class="session-service-dot" data-status={server().status.status} aria-hidden="true" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{server().name}
|
||||
</span>
|
||||
<span class="session-mcp-trailing">
|
||||
<Show
|
||||
when={failed()}
|
||||
fallback={
|
||||
<StatusLink
|
||||
tone="accent"
|
||||
busy={busy()}
|
||||
aria-label={ctx.t("mcp.signIn.open", { name: server().name })}
|
||||
onClick={follow}
|
||||
>
|
||||
{ctx.t("mcp.signIn")}
|
||||
</StatusLink>
|
||||
}
|
||||
>
|
||||
<StatusLink
|
||||
tone="danger"
|
||||
aria-label={ctx.t("mcp.error.open", { name: server().name })}
|
||||
onClick={follow}
|
||||
<div class="session-mcp-row" data-disabled={pending() || undefined} onClick={follow}>
|
||||
<span class="session-service-dot" data-status={server().status.status} aria-hidden="true" />
|
||||
<TruncatedText text={server().name} class="session-summary-label" />
|
||||
<span class="session-mcp-trailing" onClick={(event) => event.stopPropagation()}>
|
||||
<Show
|
||||
when={failed() || signIn()}
|
||||
fallback={
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
readOnly={pending()}
|
||||
aria-disabled={pending()}
|
||||
aria-busy={busy()}
|
||||
description={server().status.status === "pending" ? label() : undefined}
|
||||
class="[&_[data-slot=switch-description]]:sr-only"
|
||||
onChange={change}
|
||||
>
|
||||
{ctx.t("failed")}
|
||||
</StatusLink>
|
||||
<span class="sr-only">{server().name}</span>
|
||||
<span aria-hidden="true">{label()}</span>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<Show when={failed()}>
|
||||
<span class="session-mcp-failed">{ctx.t("failed")}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Button
|
||||
size="small"
|
||||
variant="neutral"
|
||||
disabled={signIn() && pending()}
|
||||
aria-busy={busy()}
|
||||
aria-label={ctx.t(failed() ? "mcp.error.actionLabel" : "mcp.signIn.open", {
|
||||
name: server().name,
|
||||
})}
|
||||
onClick={follow}
|
||||
>
|
||||
<Show when={failed()} fallback={ctx.t(busy() ? "mcp.signingIn" : "mcp.signIn")}>
|
||||
{ctx.t("mcp.error.action")}
|
||||
</Show>
|
||||
</Button>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
@@ -462,6 +417,7 @@ function ServicePopover(props: ServiceMenuProps & ServiceBodyProps & { empty: bo
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : locale.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
|
||||
// How the trigger last opened the menu: a keyboard open moves focus to the first row, a pointer open does not.
|
||||
const opening = { pointer: false }
|
||||
|
||||
@@ -771,18 +727,26 @@ function McpErrorDialog(props: { session: MountedSession; directory: string; nam
|
||||
{(current) => (
|
||||
<Dialog class="session-mcp-error">
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup title={ctx.t("mcp.error.title", { name: current().name })} />
|
||||
<DialogTitleGroup
|
||||
title={
|
||||
<span dir="auto" class="[overflow-wrap:anywhere]">
|
||||
{current().name}
|
||||
</span>
|
||||
}
|
||||
description={ctx.t("mcp.error.status")}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogBody class="session-mcp-error-body">
|
||||
<ErrorDetails error={current().error}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="error-details-copy"
|
||||
variant="ghost-muted"
|
||||
icon={copied() ? "check" : "copy"}
|
||||
onClick={() =>
|
||||
void ctx.system
|
||||
.copy(ctx.t("mcp.error.details", { name: current().name, error: current().error }))
|
||||
.then(() => setCopied(true))
|
||||
.catch((error: unknown) =>
|
||||
.catch((error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: ctx.t("mcp.error.copyFailed"),
|
||||
|
||||
@@ -33,4 +33,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-details-copy {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.truncated-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.truncated-text > [data-slot="truncated-text-label"] {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.truncated-text-tooltip {
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
overflow-wrap: anywhere;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
@media (max-width: 639px), (hover: none) {
|
||||
.truncated-text > [data-slot="truncated-text-label"] {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import "./truncated-text.css"
|
||||
|
||||
/** Reveals an ellipsized name on hover or focus; touch layouts wrap the complete name instead. */
|
||||
export function TruncatedText(props: { text: string; class?: string }) {
|
||||
let label: HTMLSpanElement | undefined
|
||||
const [store, setStore] = createStore({ truncated: false })
|
||||
const measure = () => setStore("truncated", !!label && label.scrollWidth > label.clientWidth)
|
||||
|
||||
createResizeObserver(() => label, measure)
|
||||
createEffect(on(() => props.text, measure))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={<span dir="auto">{props.text}</span>}
|
||||
disabled={!store.truncated}
|
||||
triggerTabIndex={store.truncated ? 0 : -1}
|
||||
class={`truncated-text ${props.class ?? ""}`}
|
||||
contentClass="truncated-text-tooltip"
|
||||
>
|
||||
<span ref={label} dir="auto" data-slot="truncated-text-label">
|
||||
{props.text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user