web: server-screen, footer, monitoring-sidebar, and responsive-header polish (#1683)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

Closes #1682

Server-screen, footer, monitoring-sidebar, and responsive-header polish: connected-card highlight/scroll + Failed status, footer row + MCP-docs tooltip, Servers box, card-surface refresh (gray-0 entry cards + white code), monitoring-sidebar layout, a 1280px responsive floor honored by the header (always-icon Disconnect, always-SegmentedControl tabs, 1500px status text, always-available sidebar), a horizontally-scrollable long resource URI in the compact Protocol entry, and copy buttons beside resource URIs / network URLs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Cliff Hall 2026-07-14 08:55:39 -04:00 • committed by GitHub
commit 8d33e76f97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 707 additions and 274 deletions

View file

@ -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 {

View file

@ -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}
/>
</Box>
<CopyrightBadge />
<VersionBadge version={inspectorVersion} />
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}

View file

@ -4,14 +4,14 @@ import { Text } from "@mantine/core";
export const COPYRIGHT_NOTICE =
"Copyright © Model Context Protocol a Series of LF Projects, LLC.";
// The `copyrightBadge` variant (src/theme/Text.ts) pins it to the lower-right
// corner in grey, on the same row as the version badge, and non-interactive.
// The `copyrightBadge` variant (src/theme/Text.ts) styles it as a small grey
// single-line label; the footer row positions it at the right (#1682).
const CopyrightText = Text.withProps({ variant: "copyrightBadge" });
/**
* The project copyright notice, fixed to the lower-right corner of the screen
* (#1639) — the grey, non-interactive twin of the lower-left version badge,
* sharing its row.
* The project copyright notice, shown at the right of the footer row (#1682,
* superseding the fixed lower-right badge of #1639) — the grey twin of the
* left-aligned version badge, sharing the footer.
*/
export function CopyrightBadge() {
return <CopyrightText>{COPYRIGHT_NOTICE}</CopyrightText>;

View file

@ -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 {
/**

View file

@ -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",

View file

@ -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(
<ServerStatusIndicator status="disconnected" failed showLabel />,
);
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(
<ServerStatusIndicator status="disconnected" showLabel />,
);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
});
it("hides label and uses title when showLabel is false", () => {
renderWithMantine(
<ServerStatusIndicator

View file

@ -6,6 +6,13 @@ export interface ServerStatusIndicatorProps {
status: ConnectionStatus;
latencyMs?: number;
retryCount?: number;
/**
* True when the server's last connection attempt failed (#1682). The
* indicator then shows a red dot + "Failed" instead of the settled
* grey "Disconnected", so a failed connect reads distinctly from a
* deliberate disconnect. Overrides `status` for the label and color.
*/
failed?: boolean;
// Override for the default viewport-based label visibility. Useful when
// the indicator renders inside a constrained container (sidebar, card)
// where the global media query doesn't reflect available space.
@ -46,16 +53,30 @@ export function ServerStatusIndicator({
status,
latencyMs,
retryCount,
failed = false,
showLabel: showLabelProp,
}: ServerStatusIndicatorProps) {
const wideViewport = useMediaQuery("(min-width: 1200px)");
// Drop the text label below 1500px. "Connected (Nms)" is the header's widest
// optional element, so shedding it first — earlier than the Disconnect control,
// which collapses at the 1280px floor (see ViewHeader) — keeps the tab row from
// crowding. Below the breakpoint the indicator is a dot only, with the label
// moved to its `title` tooltip.
const wideViewport = useMediaQuery("(min-width: 1500px)");
const showLabel = showLabelProp ?? wideViewport;
const label = getLabel(status, latencyMs, retryCount);
// A failed connection settles the status back to "disconnected"; the `failed`
// flag distinguishes it as a failure (red + "Failed") from a deliberate
// disconnect (grey), overriding the status-derived label and color.
const label = failed ? "Failed" : getLabel(status, latencyMs, retryCount);
const color = failed
? "var(--inspector-status-error)"
: statusColorVar[status];
return (
<Group gap="xs">
<Dot
bg={statusColorVar[status]}
className={status === "connecting" ? "inspector-pulse" : undefined}
bg={color}
className={
!failed && status === "connecting" ? "inspector-pulse" : undefined
}
title={showLabel ? undefined : label}
/>
{showLabel && <Text size="sm">{label}</Text>}

View file

@ -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;

View file

@ -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",

View file

@ -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`

View file

@ -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}
/>
<Divider />
<ScreenSlot>
{tabs.map((tab) => (
<ScreenStage key={tab} active={tab === value} fill>

View file

@ -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({
<ControlsCluster>{metaBadges}</ControlsCluster>
</HeaderRow>
<Group gap="xs" wrap="nowrap" justify="space-between">
<CopyButton value={entry.url} />
<ScrollArea
scrollbarSize={6}
flex={1}
@ -304,6 +306,7 @@ export function NetworkEntry({
</TimestampText>
<MethodBadge method={entry.method} />
<CategoryBadge category={entry.category} />
<CopyButton value={entry.url} />
<UrlText>{entry.url}</UrlText>
</Group>
<Group gap="sm" wrap="nowrap">

View file

@ -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) => (
<Box w={340}>
<Story />
</Box>
),
],
};

View file

@ -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(
<ProtocolEntry {...baseProps} entry={successEntry} />,
);
const withoutResource = screen.queryAllByRole("button", {
name: "Copy",
}).length;
rerender(<ProtocolEntry {...baseProps} entry={resourceReadEntry} />);
// 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(<ProtocolEntry {...baseProps} entry={errorEntry} />);
expect(screen.getByText("Error")).toBeInTheDocument();

View file

@ -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<string, unknown>;
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({
<HeaderCluster flex={1}>
<MethodBadge method={method} />
{target && (
<TargetText truncate="end" miw={0}>
{target}
</TargetText>
<>
{resourceUri && <CopyButton value={resourceUri} />}
<ScrollArea
scrollbarSize={6}
flex={1}
miw={0}
// The target scrolls horizontally but has no focusable
// child, so make the viewport itself keyboard-scrollable
// (WCAG SC 2.1.1). Scrollbar auto-hides via the
// `type="scroll"` theme default.
viewportProps={{ tabIndex: 0 }}
>
<TargetScroll>{target}</TargetScroll>
</ScrollArea>
</>
)}
</HeaderCluster>
<ControlsCluster>
@ -208,7 +241,12 @@ export function ProtocolEntry({
</TimestampText>
{directionBadge}
<MethodBadge method={method} />
{target && <TargetText>{target}</TargetText>}
{target && (
<>
{resourceUri && <CopyButton value={resourceUri} />}
<TargetText>{target}</TargetText>
</>
)}
</Group>
<Group gap="sm">
{durationText}

View file

@ -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",

View file

@ -471,4 +471,107 @@ describe("ServerCard", () => {
}
});
});
describe("just-connected highlight (#1682)", () => {
it("draws the green highlight-variant border when justConnected", () => {
const { container } = renderWithMantine(
<ServerCard {...baseProps} connection={connected} justConnected />,
);
expect(
container.querySelector('[data-variant="highlighted"]'),
).not.toBeNull();
});
it("does not draw the highlight border when not justConnected", () => {
const { container } = renderWithMantine(
<ServerCard {...baseProps} connection={connected} />,
);
expect(
container.querySelector('[data-variant="highlighted"]'),
).toBeNull();
});
it("prefers the dimmed (disabled) variant over the just-connected highlight", () => {
const { container } = renderWithMantine(
<ServerCard
{...baseProps}
connection={connected}
activeServer="other"
justConnected
/>,
);
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(
<ServerCard
{...baseProps}
connection={connected}
justConnected
onClearHighlight={onClearHighlight}
/>,
);
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(
<ServerCard {...baseProps} connection={connecting} />,
);
vi.advanceTimersByTime(1000);
expect(scrollIntoView).not.toHaveBeenCalled();
// Becoming connected schedules a deferred scroll (past the column open).
rerender(
<ServerCard {...baseProps} connection={connected} justConnected />,
);
expect(scrollIntoView).not.toHaveBeenCalled();
vi.advanceTimersByTime(320);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
// A further re-render while still connected does not scroll again.
rerender(
<ServerCard
{...baseProps}
name="Renamed"
connection={connected}
justConnected
/>,
);
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(
<ServerCard {...baseProps} connection={connected} justConnected />,
);
vi.advanceTimersByTime(1000);
expect(scrollIntoView).not.toHaveBeenCalled();
} finally {
Element.prototype.scrollIntoView = orig;
vi.useRealTimers();
}
});
});
});

View file

@ -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({
<Card
ref={rootRef}
variant={variant}
styles={CARD_STYLES}
onClick={highlighted ? onClearHighlight : undefined}
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
>
@ -210,6 +244,11 @@ export function ServerCard({
<ServerStatusIndicator
status={connection.status}
retryCount={connection.retryCount}
failed={errored}
// The card has its own width and isn't subject to the header's
// tab-row crowding, so always show the status label rather than
// inheriting the header-motivated viewport breakpoint.
showLabel
/>
<ConnectionToggle
status={connection.status}

View file

@ -70,6 +70,19 @@ describe("ViewHeader", () => {
).toBeInTheDocument();
});
it("shows an 'MCP Documentation' tooltip on the logo link (#1682)", async () => {
const user = userEvent.setup();
renderWithMantine(
<ViewHeader
connected={false}
onToggleTheme={vi.fn()}
onOpenClientSettings={vi.fn()}
/>,
);
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(
<ViewHeader {...connectedProps} onDisconnect={onDisconnect} />,
);
// 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(
<ViewHeader {...connectedProps} onTabChange={onTabChange} />,
);
// 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");
});
});

View file

@ -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 (
<HeaderBar>
<LeftSection>
<LogoLink>
<LogoImage src={logoSrc} />
</LogoLink>
<Tooltip label="MCP Documentation">
<LogoLink>
<LogoImage src={logoSrc} />
</LogoLink>
</Tooltip>
<Transition
mounted={props.connected}
transition="fade"
@ -364,26 +350,12 @@ export function ViewHeader(props: ViewHeaderProps) {
className="header-anim header-stack-cell"
data-anim={connectedAnim}
>
{showSegmented ? (
<SegmentedControl
value={headerData.activeTab}
onChange={handleTabChange}
data={toGlowingTabData(headerData.availableTabs, glowing)}
size="sm"
/>
) : (
// 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.
<Select
value={headerData.activeTab}
onChange={(value) => value && handleTabChange?.(value)}
data={headerData.availableTabs}
size="sm"
allowDeselect={false}
w={SELECT_WIDTH}
/>
)}
<SegmentedControl
value={headerData.activeTab}
onChange={handleTabChange}
data={toGlowingTabData(headerData.availableTabs, glowing)}
size="sm"
/>
</Box>
) : (
// Unreachable in practice — the Transition only mounts while
@ -425,15 +397,11 @@ export function ViewHeader(props: ViewHeaderProps) {
status={headerData.status}
latencyMs={headerData.latencyMs}
/>
{showDisconnectLabel ? (
<DisconnectButton onClick={handleDisconnect}>
Disconnect
</DisconnectButton>
) : (
<DisconnectIcon onClick={handleDisconnect} title="Disconnect">
<MdLinkOff size={20} />
<Tooltip label="Disconnect from server">
<DisconnectIcon onClick={handleDisconnect}>
<VscDebugDisconnect size={20} />
</DisconnectIcon>
)}
</Tooltip>
</RightConnectedGroup>
) : (
/* v8 ignore next -- unreachable: this Transition only mounts

Some files were not shown because too many files have changed in this diff Show more