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.
-