diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 3c4e03bf9..a5d50ba28 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -31,10 +31,16 @@ otherwise defaults to pure white. */ --inspector-surface-body: var(--mantine-color-gray-1); --inspector-surface-card: var(--mantine-color-white); - /* Recessed list-item surface (Protocol/Network message cards): light-grey in - light mode so the entries read as sunk into their white panel; keeps the - standard card tone in dark (overridden below). */ - --inspector-surface-inset: var(--mantine-color-gray-1); + /* Server-card fill: a hair lighter than the grey app background (`gray-0` vs + the body's `gray-1`) so the cards read as faintly raised on the white + Servers box. Light mode only; dark tracks the standard card surface. */ + --inspector-surface-card-server: var(--mantine-color-gray-0); + /* Entry-card surface (Protocol / Network / Task message cards): the faint + `gray-0` grey — a hair lighter than the `gray-1` app background and matching + the server cards — so the entries read as soft cards on their white panel + (their inner Code blocks are raised to white via the `inset` variant). Keeps + the standard card tone in dark (overridden below). */ + --inspector-surface-inset: var(--mantine-color-gray-0); /* Code/content surfaces match the light-grey body so they stay distinct against the white cards (the Code block has no border of its own). Dark mode keeps the darker `dark-9` inset below. */ @@ -125,6 +131,9 @@ tab bar share `gray-1`. Cards (`gray-9`) still sit a step above it. */ --inspector-surface-body: var(--mantine-color-dark-8); --inspector-surface-card: var(--mantine-color-gray-9); + /* Dark keeps server cards at the standard card surface (the lighter grey is a + light-mode-only treatment). */ + --inspector-surface-card-server: var(--inspector-surface-card); /* Dark keeps the message cards at the standard card tone (they already contrast against the darker panel/body), so only light mode goes grey. */ --inspector-surface-inset: var(--mantine-color-gray-9); @@ -225,6 +234,21 @@ body { background-color: var(--inspector-surface-body); } +/* Floor the app at 1280px so the header, split panes, and footer never reflow + into a broken layout; below this the whole app scrolls horizontally instead. + Scoped to the app mount (#root) rather than `body` so it doesn't leak into + Storybook, which shares App.css but renders stories into its own root. + + The `transform` makes #root the containing block for the AppShell's + position:fixed header + footer, so they honor the 1280px floor too — spanning + the full 1280 and scrolling horizontally with the content — instead of staying + pinned to a narrower viewport. Body-portalled overlays (modals, tooltips, + dropdowns) render outside #root, so they stay viewport-fixed as intended. */ +#root { + min-width: 1280px; + transform: translateZ(0); +} + /* ── Animations ──────────────────────────────────────────── */ @keyframes inspector-pulse { diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 350213f4c..0dd217cf8 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -101,8 +101,6 @@ import { useServerListWritable } from "@inspector/core/react/useServerListWritab import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion.js"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; -import { VersionBadge } from "./components/elements/VersionBadge/VersionBadge"; -import { CopyrightBadge } from "./components/elements/CopyrightBadge/CopyrightBadge"; import type { ToolCallState, ToolsUiState, @@ -654,6 +652,14 @@ function App() { undefined, ); + // Id of the server that just connected successfully (#1682) — the success + // mirror of `failedServerId`. Drives the green highlight + scroll-into-view on + // that ServerCard. Set on the →connected transition, cleared when a new + // connection attempt starts or the session disconnects. + const [connectedServerId, setConnectedServerId] = useState< + string | undefined + >(undefined); + // InspectorClient + per-primitive state managers. All recreated together // whenever the user switches active servers, then destroyed when the // next switch happens (or when the component unmounts). @@ -993,6 +999,18 @@ function App() { } }, [connectionStatus]); + // Track the just-connected server so its card gets the green highlight + + // scroll-into-view (#1682). Unlike `failedServerId` (which must survive the + // `disconnect` event a failed connect fires), "connected" is a stable status, + // so a status-driven effect can both set and clear it: set on connect, clear + // whenever the session isn't connected (disconnect, a new attempt's + // "connecting", or an error). + useEffect(() => { + setConnectedServerId( + connectionStatus === "connected" ? activeServerId : undefined, + ); + }, [connectionStatus, activeServerId]); + // Disconnect the previous InspectorClient when it's replaced (server // switch) or when App unmounts (HMR, tests). Without this the prior // session's transport — a spawned stdio subprocess, an SSE stream, or @@ -3821,6 +3839,8 @@ function App() { serverListWritable={serverListWritable} activeServer={activeServerId} erroredServerId={failedServerId} + connectedServerId={connectedServerId} + version={inspectorVersion} connectionStatus={connectionStatus} connectErrorMessage={connectErrorMessage} initializeResult={initializeResult} @@ -3965,8 +3985,6 @@ function App() { onRefreshApps={onRefreshTools} /> - - {COPYRIGHT_NOTICE}; diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx index 5927fab69..0ad636754 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -4,7 +4,7 @@ import { ScrollArea, Stack } from "@mantine/core"; // Full-size, the monitor stream panels bound their scroll to the viewport // minus the header and the panel's own chrome. const FULLSIZE_MAH = - "calc(100vh - var(--app-shell-header-height, 0px) - 150px)"; + "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - 150px)"; export interface EmbeddableScrollAreaProps { /** diff --git a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx index 2a4ef092b..2b5471130 100644 --- a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx +++ b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx @@ -28,6 +28,17 @@ export const Disconnected: Story = { }, }; +// A failed connection settles the status back to "disconnected", but the +// `failed` flag surfaces it as a red "Failed" instead of the grey +// "Disconnected" (#1682). +export const Failed: Story = { + args: { + status: "disconnected", + failed: true, + showLabel: true, + }, +}; + export const Error: Story = { args: { status: "error", diff --git a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx index 4eb628945..ce0494559 100644 --- a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx +++ b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx @@ -39,6 +39,27 @@ describe("ServerStatusIndicator", () => { expect(screen.getByText("Error")).toBeInTheDocument(); }); + it("renders 'Failed' with the error color when failed, overriding the disconnected status (#1682)", () => { + const { container } = renderWithMantine( + , + ); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.queryByText("Disconnected")).not.toBeInTheDocument(); + // The dot uses the error color, not the disconnected grey. + const dot = container.querySelector(".mantine-Paper-root"); + expect(dot?.getAttribute("style") ?? "").toContain( + "inspector-status-error", + ); + }); + + it("does not show 'Failed' when the failed flag is unset (a deliberate disconnect)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + expect(screen.queryByText("Failed")).not.toBeInTheDocument(); + }); + it("hides label and uses title when showLabel is false", () => { renderWithMantine( {showLabel && {label}} diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx index fbb834d73..170db866e 100644 --- a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx @@ -5,15 +5,15 @@ export interface VersionBadgeProps { version?: string; } -// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-left -// corner in grey and makes it non-interactive. +// The `versionBadge` variant (src/theme/Text.ts) styles it as a small grey +// single-line label; the footer row positions it at the left (#1682). const VersionText = Text.withProps({ variant: "versionBadge" }); /** - * A small, unobtrusive build-version label fixed to the lower-left corner of - * the screen (#1639). Sourced from the root `package.json` via the backend's - * `GET /api/config`; renders nothing until the version is known (or on a legacy - * backend that omits it). + * A small, unobtrusive build-version label shown at the left of the footer row + * (#1682, superseding the fixed lower-left badge of #1639). Sourced from the + * root `package.json` via the backend's `GET /api/config`; renders nothing until + * the version is known (or on a legacy backend that omits it). */ export function VersionBadge({ version }: VersionBadgeProps) { if (!version) return null; diff --git a/clients/web/src/components/groups/AppControls/AppControls.tsx b/clients/web/src/components/groups/AppControls/AppControls.tsx index 14d02908b..2389fbdef 100644 --- a/clients/web/src/components/groups/AppControls/AppControls.tsx +++ b/clients/web/src/components/groups/AppControls/AppControls.tsx @@ -25,7 +25,7 @@ export interface AppControlsProps { } const LIST_MAX_HEIGHT = - "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)"; + "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)"; const EmptyState = Text.withProps({ c: "dimmed", diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index 5e10a6e1a..b9b14cd0c 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -15,11 +15,14 @@ export interface MonitoringControlsProps { // The controls row at the top of the pinned monitoring sidebar: a segmented tab // switcher on the left and a search box filling the rest. The column is closed // from the single header MonitoringToggle (#1661), so there is no close button -// here. +// here. `px: xl` matches the `xl` horizontal inset the embedded screen below +// gives its panel (its `ScreenLayout` padding), so the tabs + search span the +// same width as the content and line up with its left/right edges. const ControlsBar = Group.withProps({ wrap: "nowrap", gap: "sm", - p: "sm", + px: "xl", + py: "sm", }); // Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0` diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index ea15a8ff8..4917f5456 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Divider, Stack } from "@mantine/core"; +import { Stack } from "@mantine/core"; import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; @@ -22,9 +22,12 @@ export interface MonitoringScreenProps { // Fills the pinned column: a fixed controls row on top and the selected screen // filling the remaining height below (mih:0 lets the inner ScrollArea bound). +// `pt: md` gives the controls a little breathing room below the header instead +// of sitting flush against it. const ColumnLayout = Stack.withProps({ h: "100%", gap: 0, + pt: "md", }); // Relative-positioned host so each tab's `ScreenStage` (absolutely positioned) @@ -59,7 +62,6 @@ export function MonitoringScreen({ searchValue={searchValue} onSearchChange={onSearchChange} /> - {tabs.map((tab) => ( diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index b0e47bc15..f2c7f33fc 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -14,6 +14,7 @@ import { import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge"; @@ -281,6 +282,7 @@ export function NetworkEntry({ {metaBadges} + + {entry.url} diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx index 54e7ecfd8..3b185eb07 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box } from "@mantine/core"; import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import { fn } from "storybook/test"; import { ProtocolEntry } from "./ProtocolEntry"; @@ -76,6 +77,36 @@ const resourceReadEntry: MessageEntry = { duration: 45, }; +// A resources/read whose URI overflows the narrow monitoring column, so the +// embedded header shows it in a horizontal scroll area rather than truncating. +const longUriResourceEntry: MessageEntry = { + id: "req-5", + timestamp: new Date("2026-03-17T10:35:00Z"), + direction: "request", + origin: "client", + message: { + jsonrpc: "2.0", + id: 5, + method: "resources/read", + params: { + uri: "demo://resource/static/document/architecture/overview/system-design-and-data-flow.md", + }, + }, + response: { + jsonrpc: "2.0", + id: 5, + result: { + contents: [ + { + uri: "demo://resource/static/document/architecture/overview/system-design-and-data-flow.md", + text: "# Architecture Overview", + }, + ], + }, + }, + duration: 41, +}; + const pendingEntry: MessageEntry = { id: "req-4", timestamp: new Date("2026-03-17T10:34:00Z"), @@ -128,3 +159,22 @@ export const Pending: Story = { isListExpanded: false, }, }; + +// Embedded (monitoring-sidebar) layout with a long resource URI: the target sits +// in a horizontal scroll area rather than truncating with an ellipsis. Wrapped +// in the pinned column's width so the overflow is visible. +export const EmbeddedLongUri: Story = { + args: { + entry: longUriResourceEntry, + isPinned: false, + isListExpanded: false, + embedded: true, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index 165602204..a8bdce1e4 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -131,6 +131,22 @@ describe("ProtocolEntry", () => { expect(screen.getByText("resources/read")).toBeInTheDocument(); }); + it("adds a Copy button beside a resource URI target, but not a tool name", () => { + const { rerender } = renderWithMantine( + , + ); + const withoutResource = screen.queryAllByRole("button", { + name: "Copy", + }).length; + rerender(); + // The resource entry has exactly one more Copy button — the one beside its + // URI target (both entries otherwise carry the same expanded ContentViewer + // copy affordances). + expect(screen.queryAllByRole("button", { name: "Copy" }).length).toBe( + withoutResource + 1, + ); + }); + it("renders Error status when response has error", () => { renderWithMantine(); expect(screen.getByText("Error")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index 0b84193f6..01cb94222 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -6,11 +6,13 @@ import { Collapse, Divider, Group, + ScrollArea, Stack, Text, } from "@mantine/core"; import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; @@ -67,6 +69,15 @@ const TargetText = Text.withProps({ fw: 500, }); +// Compact-header target (e.g. a long resource URI): never wraps, so it scrolls +// horizontally inside its ScrollArea instead of truncating with an ellipsis +// (mirrors NetworkEntry's URL). +const TargetScroll = Text.withProps({ + size: "sm", + fw: 500, + variant: "nowrap", +}); + const DurationText = Text.withProps({ size: "sm", c: "dimmed", @@ -100,6 +111,15 @@ function extractTarget(entry: MessageEntry): string | undefined { return undefined; } +// The resource URI when the target is one (e.g. `resources/read`), so it can be +// copied. Tool/prompt targets are plain names, not URIs, and get no copy button. +function extractResourceUri(entry: MessageEntry): string | undefined { + const msg = entry.message; + if (!("params" in msg) || !msg.params) return undefined; + const params = msg.params as Record; + return typeof params.uri === "string" ? params.uri : undefined; +} + // The pending → OK/Error lifecycle only applies to requests: messageLogState // attaches a `response` to request entries by JSON-RPC id. A notification is // fire-and-forget (no id, no response, ever) and an unmatched standalone @@ -141,6 +161,7 @@ export function ProtocolEntry({ const [isExpanded, setIsExpanded] = useState(isListExpanded); const method = extractMethod(entry); const target = extractTarget(entry); + const resourceUri = extractResourceUri(entry); const status = extractStatus(entry); const canReplay = isReplayableProtocolMethod(method); @@ -184,9 +205,21 @@ export function ProtocolEntry({ {target && ( - - {target} - + <> + {resourceUri && } + + {target} + + )} @@ -208,7 +241,12 @@ export function ProtocolEntry({ {directionBadge} - {target && {target}} + {target && ( + <> + {resourceUri && } + {target} + + )} {durationText} diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx index ac1265e65..74e05e86e 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx @@ -102,6 +102,20 @@ export const ErroredBorder: Story = { }, }; +// The green border flagging the server that just connected (#1682) — the +// success mirror of ErroredBorder. It persists while connected and clears on +// disconnect or a new connection attempt. +export const JustConnected: Story = { + args: { + id: "3d2c1b0a-9f8e-4d6c-8b7a-1a2b3c4d5e6f", + name: "My MCP Server", + config: stdioConfig, + info: { name: "My MCP Server", version: "1.2.0" }, + connection: connected, + justConnected: true, + }, +}; + export const LongServerName: Story = { args: { id: "a1b2c3d4-e5f6-4789-9abc-def012345678", diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx index 0ad12edc8..49d89ff6f 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx @@ -471,4 +471,107 @@ describe("ServerCard", () => { } }); }); + + describe("just-connected highlight (#1682)", () => { + it("draws the green highlight-variant border when justConnected", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[data-variant="highlighted"]'), + ).not.toBeNull(); + }); + + it("does not draw the highlight border when not justConnected", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[data-variant="highlighted"]'), + ).toBeNull(); + }); + + it("prefers the dimmed (disabled) variant over the just-connected highlight", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[data-variant="disabled"]'), + ).not.toBeNull(); + }); + + it("does not clear on click when justConnected (unlike the freshly-added highlight)", async () => { + const user = userEvent.setup(); + const onClearHighlight = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByText("My MCP Server")); + expect(onClearHighlight).not.toHaveBeenCalled(); + }); + + it("scrolls the card into view on the just-connected transition, deferred past the sidebar", () => { + vi.useFakeTimers(); + const scrollIntoView = vi.fn(); + const orig = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = scrollIntoView; + try { + const { rerender } = renderWithMantine( + , + ); + vi.advanceTimersByTime(1000); + expect(scrollIntoView).not.toHaveBeenCalled(); + + // Becoming connected schedules a deferred scroll (past the column open). + rerender( + , + ); + expect(scrollIntoView).not.toHaveBeenCalled(); + vi.advanceTimersByTime(320); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // A further re-render while still connected does not scroll again. + rerender( + , + ); + vi.advanceTimersByTime(1000); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + } finally { + Element.prototype.scrollIntoView = orig; + vi.useRealTimers(); + } + }); + + it("does not scroll when mounted already connected (no transition)", () => { + vi.useFakeTimers(); + const scrollIntoView = vi.fn(); + const orig = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = scrollIntoView; + try { + renderWithMantine( + , + ); + vi.advanceTimersByTime(1000); + expect(scrollIntoView).not.toHaveBeenCalled(); + } finally { + Element.prototype.scrollIntoView = orig; + vi.useRealTimers(); + } + }); + }); }); diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index 5ffd872f8..e3f3c5d07 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -9,7 +9,7 @@ import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/Serv import { TransportBadge } from "../../elements/TransportBadge/TransportBadge"; import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { FAILED_CARD_SCROLL_DELAY_MS } from "../../views/InspectorView/monitorColumnAnimation"; +import { CARD_SCROLL_DELAY_MS } from "../../views/InspectorView/monitorColumnAnimation"; export interface ServerCardProps extends ServerEntry { activeServer?: string; @@ -45,6 +45,13 @@ export interface ServerCardProps extends ServerEntry { * is connected or a new connection is attempted. */ errored?: boolean; + /** + * When true, this server just connected successfully: the card draws the green + * highlight border and scrolls itself into view once the monitoring sidebar + * has opened — the success mirror of `errored` (#1682). The parent clears this + * on disconnect or a new connection attempt. + */ + justConnected?: boolean; /** * Whether a highlighted card scrolls itself into view. When several cards are * highlighted at once (a batch import) only the first should scroll, so the @@ -96,6 +103,16 @@ const SubtleButton = Button.withProps({ size: "xs", }); +// Raise the card's ContentViewer Code block (the command/URL) to the "on-card" +// surface — white in light mode — mirroring the Protocol/Network `inset` message +// cards, so it reads as raised on the card instead of blending with it. Set as a +// cascade var on the card root; the Code theme reads `--inspector-code-surface`. +const CARD_STYLES = { + root: { + "--inspector-code-surface": "var(--inspector-surface-code-oncard)", + }, +} as const; + const RemoveButton = Button.withProps({ variant: "subtle", size: "xs", @@ -146,6 +163,7 @@ export function ServerCard({ dragHandle, highlighted = false, errored = false, + justConnected = false, scrollOnHighlight = true, onClearHighlight, }: ServerCardProps) { @@ -173,9 +191,24 @@ export function ServerCard({ if (!justErrored) return; const timer = setTimeout(() => { rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, FAILED_CARD_SCROLL_DELAY_MS); + }, CARD_SCROLL_DELAY_MS); return () => clearTimeout(timer); }, [errored]); + + // Success mirror of the errored scroll (#1682): on the →connected transition, + // scroll the just-connected card into view, deferred past the monitoring + // sidebar's open (same shared delay) so the grid reflow settles first. Edge- + // guarded so a re-render while still connected doesn't re-scroll. + const wasConnectedRef = useRef(justConnected); + useEffect(() => { + const justBecameConnected = justConnected && !wasConnectedRef.current; + wasConnectedRef.current = justConnected; + if (!justBecameConnected) return; + const timer = setTimeout(() => { + rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, CARD_SCROLL_DELAY_MS); + return () => clearTimeout(timer); + }, [justConnected]); const transport = getTransport(config); const commandOrUrl = getCommandOrUrl(config); const version = info?.version; @@ -184,12 +217,12 @@ export function ServerCard({ // A dimmed card (another server is active) is inert, so the disabled variant // wins; otherwise a failed-connection card draws the red error border, then a - // freshly-added card draws the highlighted green border. + // freshly-added or just-connected card draws the highlighted green border. const variant = isDimmed ? "disabled" : errored ? "errored" - : highlighted + : highlighted || justConnected ? "highlighted" : undefined; @@ -197,6 +230,7 @@ export function ServerCard({ @@ -210,6 +244,11 @@ export function ServerCard({ { ).toBeInTheDocument(); }); + it("shows an 'MCP Documentation' tooltip on the logo link (#1682)", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.hover(screen.getByRole("link")); + expect(await screen.findByText("MCP Documentation")).toBeInTheDocument(); + }); + it("invokes onOpenClientSettings when the client settings button is clicked", async () => { const user = userEvent.setup(); const onOpenClientSettings = vi.fn(); @@ -179,10 +192,10 @@ describe("ViewHeader", () => { renderWithMantine( , ); - // Either label-button "Disconnect" (wide) or icon-only with aria-label - const disconnectButton = - screen.queryByRole("button", { name: "Disconnect" }) ?? - screen.getByRole("button", { name: /disconnect/i }); + // Disconnect is always the icon, labelled by its aria-label / tooltip. + const disconnectButton = screen.getByRole("button", { + name: "Disconnect from server", + }); await user.click(disconnectButton); expect(onDisconnect).toHaveBeenCalledTimes(1); }); @@ -193,7 +206,7 @@ describe("ViewHeader", () => { expect(screen.getAllByText("Prompts").length).toBeGreaterThan(0); }); - it("renders the SegmentedControl and label disconnect button on wide viewports", async () => { + it("renders the SegmentedControl and the disconnect icon on wide viewports", async () => { mediaQueryMock.value = true; const user = userEvent.setup(); const onTabChange = vi.fn(); @@ -205,8 +218,10 @@ describe("ViewHeader", () => { onDisconnect={onDisconnect} />, ); - // Disconnect renders as a labelled button on wide viewport. - const disconnectBtn = screen.getByRole("button", { name: "Disconnect" }); + // Disconnect is always the icon (labelled by its tooltip / aria-label). + const disconnectBtn = screen.getByRole("button", { + name: "Disconnect from server", + }); await user.click(disconnectBtn); expect(onDisconnect).toHaveBeenCalledTimes(1); @@ -307,7 +322,7 @@ describe("ViewHeader", () => { ).toBe("in"); expect( screen - .getByRole("button", { name: "Disconnect" }) + .getByRole("button", { name: "Disconnect from server" }) .closest("[data-anim]") ?.getAttribute("data-anim"), ).toBe("in"); @@ -328,7 +343,7 @@ describe("ViewHeader", () => { ).toBe("out"); expect( screen - .getByRole("button", { name: "Disconnect" }) + .getByRole("button", { name: "Disconnect from server" }) .closest("[data-anim]") ?.getAttribute("data-anim"), ).toBe("out"); @@ -337,7 +352,7 @@ describe("ViewHeader", () => { expect(screen.queryByText("my-mcp-server")).not.toBeInTheDocument(), ); expect( - screen.queryByRole("button", { name: "Disconnect" }), + screen.queryByRole("button", { name: "Disconnect from server" }), ).not.toBeInTheDocument(); }); @@ -415,20 +430,14 @@ describe("ViewHeader", () => { } }); - it("invokes onTabChange when a different tab is picked from the Select", async () => { + it("invokes onTabChange when a different tab is picked", async () => { const user = userEvent.setup(); const onTabChange = vi.fn(); renderWithMantine( , ); - // On narrow viewport (default mediaQuery=false), tabs use a Select. - const select = screen.getByRole("textbox"); - await user.click(select); - const option = await screen.findByRole("option", { - name: "Resources", - hidden: true, - }); - await user.click(option); + // Tabs always render as a SegmentedControl (radios); pick another one. + await user.click(screen.getByRole("radio", { name: "Resources" })); expect(onTabChange).toHaveBeenCalledWith("Resources"); }); }); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 4d7c1eaf6..f4bb44f48 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -3,11 +3,9 @@ import { ActionIcon, Anchor, Box, - Button, Group, Image, SegmentedControl, - Select, Text, Title, Tooltip, @@ -15,9 +13,9 @@ import { useComputedColorScheme, type MantineTransition, } from "@mantine/core"; -import { useMediaQuery } from "@mantine/hooks"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import { MdLightMode, MdDarkMode, MdLinkOff, MdSettings } from "react-icons/md"; +import { MdLightMode, MdDarkMode, MdSettings } from "react-icons/md"; +import { VscDebugDisconnect } from "react-icons/vsc"; import type { ConnectionStatus } from "@inspector/core/mcp/types.js"; import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator"; import { @@ -62,8 +60,6 @@ export type ViewHeaderProps = ConnectedProps | UnconnectedProps; // duration. The motion itself is CSS (`.header-anim`); keep the 300ms / // 150ms-stagger there in sync with this value. const HEADER_ANIM_MS = 300; -// Fixed Select width on narrow viewports (fits the longest tab label). -const SELECT_WIDTH = 140; // Grace window after a connection is established before the new-tab glow arms // (#1450). Primitive lists (prompts/resources/tasks) are fetched asynchronously // just after the handshake, so their tabs appear a few renders into the @@ -181,20 +177,10 @@ const RightConnectedGroup = Group.withProps({ wrap: "nowrap", }); -const DisconnectButton = Button.withProps({ - variant: "subtle", - // `color` drives the subtle hover/active tint; `c` overrides just the label - // to the AA-compliant danger red (red.6 text alone fell under contrast). - color: "red.6", - size: "sm", - c: "var(--inspector-danger-text)", -}); - const DisconnectIcon = ActionIcon.withProps({ variant: "subtle", - c: "red", size: 36, - "aria-label": "Disconnect", + "aria-label": "Disconnect from server", }); const ClientSettingsToggle = ActionIcon.withProps({ @@ -249,8 +235,6 @@ export function ViewHeader(props: ViewHeaderProps) { } const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle; const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode; - const showSegmented = useMediaQuery("(min-width: 992px)"); - const showDisconnectLabel = useMediaQuery("(min-width: 768px)"); // Retain the latest connected display data so each region can keep rendering // it while animating out after disconnect (#1450). Uses React's "adjust state @@ -318,9 +302,11 @@ export function ViewHeader(props: ViewHeaderProps) { return ( - - - + + + + + - {showSegmented ? ( - - ) : ( - // Narrow viewport: the new-tab glow doesn't apply to the - // dropdown (a collapsed Select can't pulse a single option), - // so the labels stay plain strings here. -