fix(app): refine MCP status controls

This commit is contained in:
usrnk1
2026-10-20 15:43:12 +02:00
Unverified
parent a4d8041b06
commit 4fdba27bbd
14 changed files with 397 additions and 168 deletions
@@ -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()
}
+16 -5
View File
@@ -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
+4
View File
@@ -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>
+12
View File
@@ -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;
+57
View File
@@ -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;
}
}
+13 -16
View File
@@ -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>
)
}