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. */ otherwise defaults to pure white. */
--inspector-surface-body: var(--mantine-color-gray-1); --inspector-surface-body: var(--mantine-color-gray-1);
--inspector-surface-card: var(--mantine-color-white); --inspector-surface-card: var(--mantine-color-white);
/* Recessed list-item surface (Protocol/Network message cards): light-grey in /* Server-card fill: a hair lighter than the grey app background (`gray-0` vs
light mode so the entries read as sunk into their white panel; keeps the the body's `gray-1`) so the cards read as faintly raised on the white
standard card tone in dark (overridden below). */ Servers box. Light mode only; dark tracks the standard card surface. */
--inspector-surface-inset: var(--mantine-color-gray-1); --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 /* 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 against the white cards (the Code block has no border of its own). Dark
mode keeps the darker `dark-9` inset below. */ 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. */ tab bar share `gray-1`. Cards (`gray-9`) still sit a step above it. */
--inspector-surface-body: var(--mantine-color-dark-8); --inspector-surface-body: var(--mantine-color-dark-8);
--inspector-surface-card: var(--mantine-color-gray-9); --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 /* Dark keeps the message cards at the standard card tone (they already
contrast against the darker panel/body), so only light mode goes grey. */ contrast against the darker panel/body), so only light mode goes grey. */
--inspector-surface-inset: var(--mantine-color-gray-9); --inspector-surface-inset: var(--mantine-color-gray-9);
@ -225,6 +234,21 @@ body {
background-color: var(--inspector-surface-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 ──────────────────────────────────────────── */ /* ── Animations ──────────────────────────────────────────── */
@keyframes inspector-pulse { @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 { useInspectorVersion } from "@inspector/core/react/useInspectorVersion.js";
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView"; import { InspectorView } from "./components/views/InspectorView/InspectorView";
import { VersionBadge } from "./components/elements/VersionBadge/VersionBadge";
import { CopyrightBadge } from "./components/elements/CopyrightBadge/CopyrightBadge";
import type { import type {
ToolCallState, ToolCallState,
ToolsUiState, ToolsUiState,
@ -654,6 +652,14 @@ function App() {
undefined, 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 // InspectorClient + per-primitive state managers. All recreated together
// whenever the user switches active servers, then destroyed when the // whenever the user switches active servers, then destroyed when the
// next switch happens (or when the component unmounts). // next switch happens (or when the component unmounts).
@ -993,6 +999,18 @@ function App() {
} }
}, [connectionStatus]); }, [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 // Disconnect the previous InspectorClient when it's replaced (server
// switch) or when App unmounts (HMR, tests). Without this the prior // switch) or when App unmounts (HMR, tests). Without this the prior
// session's transport — a spawned stdio subprocess, an SSE stream, or // session's transport — a spawned stdio subprocess, an SSE stream, or
@ -3821,6 +3839,8 @@ function App() {
serverListWritable={serverListWritable} serverListWritable={serverListWritable}
activeServer={activeServerId} activeServer={activeServerId}
erroredServerId={failedServerId} erroredServerId={failedServerId}
connectedServerId={connectedServerId}
version={inspectorVersion}
connectionStatus={connectionStatus} connectionStatus={connectionStatus}
connectErrorMessage={connectErrorMessage} connectErrorMessage={connectErrorMessage}
initializeResult={initializeResult} initializeResult={initializeResult}
@ -3965,8 +3985,6 @@ function App() {
onRefreshApps={onRefreshTools} onRefreshApps={onRefreshTools}
/> />
</Box> </Box>
<CopyrightBadge />
<VersionBadge version={inspectorVersion} />
<ServerConfigModal <ServerConfigModal
opened={configModal !== null} opened={configModal !== null}
mode={configModal?.mode ?? "add"} mode={configModal?.mode ?? "add"}

View file

@ -4,14 +4,14 @@ import { Text } from "@mantine/core";
export const COPYRIGHT_NOTICE = export const COPYRIGHT_NOTICE =
"Copyright © Model Context Protocol a Series of LF Projects, LLC."; "Copyright © Model Context Protocol a Series of LF Projects, LLC.";
// The `copyrightBadge` variant (src/theme/Text.ts) pins it to the lower-right // The `copyrightBadge` variant (src/theme/Text.ts) styles it as a small grey
// corner in grey, on the same row as the version badge, and non-interactive. // single-line label; the footer row positions it at the right (#1682).
const CopyrightText = Text.withProps({ variant: "copyrightBadge" }); const CopyrightText = Text.withProps({ variant: "copyrightBadge" });
/** /**
* The project copyright notice, fixed to the lower-right corner of the screen * The project copyright notice, shown at the right of the footer row (#1682,
* (#1639) — the grey, non-interactive twin of the lower-left version badge, * superseding the fixed lower-right badge of #1639) — the grey twin of the
* sharing its row. * left-aligned version badge, sharing the footer.
*/ */
export function CopyrightBadge() { export function CopyrightBadge() {
return <CopyrightText>{COPYRIGHT_NOTICE}</CopyrightText>; 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 // Full-size, the monitor stream panels bound their scroll to the viewport
// minus the header and the panel's own chrome. // minus the header and the panel's own chrome.
const FULLSIZE_MAH = 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 { 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 = { export const Error: Story = {
args: { args: {
status: "error", status: "error",

View file

@ -39,6 +39,27 @@ describe("ServerStatusIndicator", () => {
expect(screen.getByText("Error")).toBeInTheDocument(); 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", () => { it("hides label and uses title when showLabel is false", () => {
renderWithMantine( renderWithMantine(
<ServerStatusIndicator <ServerStatusIndicator

View file

@ -6,6 +6,13 @@ export interface ServerStatusIndicatorProps {
status: ConnectionStatus; status: ConnectionStatus;
latencyMs?: number; latencyMs?: number;
retryCount?: 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 // Override for the default viewport-based label visibility. Useful when
// the indicator renders inside a constrained container (sidebar, card) // the indicator renders inside a constrained container (sidebar, card)
// where the global media query doesn't reflect available space. // where the global media query doesn't reflect available space.
@ -46,16 +53,30 @@ export function ServerStatusIndicator({
status, status,
latencyMs, latencyMs,
retryCount, retryCount,
failed = false,
showLabel: showLabelProp, showLabel: showLabelProp,
}: ServerStatusIndicatorProps) { }: 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 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 ( return (
<Group gap="xs"> <Group gap="xs">
<Dot <Dot
bg={statusColorVar[status]} bg={color}
className={status === "connecting" ? "inspector-pulse" : undefined} className={
!failed && status === "connecting" ? "inspector-pulse" : undefined
}
title={showLabel ? undefined : label} title={showLabel ? undefined : label}
/> />
{showLabel && <Text size="sm">{label}</Text>} {showLabel && <Text size="sm">{label}</Text>}

View file

@ -5,15 +5,15 @@ export interface VersionBadgeProps {
version?: string; version?: string;
} }
// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-left // The `versionBadge` variant (src/theme/Text.ts) styles it as a small grey
// corner in grey and makes it non-interactive. // single-line label; the footer row positions it at the left (#1682).
const VersionText = Text.withProps({ variant: "versionBadge" }); const VersionText = Text.withProps({ variant: "versionBadge" });
/** /**
* A small, unobtrusive build-version label fixed to the lower-left corner of * A small, unobtrusive build-version label shown at the left of the footer row
* the screen (#1639). Sourced from the root `package.json` via the backend's * (#1682, superseding the fixed lower-left badge of #1639). Sourced from the
* `GET /api/config`; renders nothing until the version is known (or on a legacy * root `package.json` via the backend's `GET /api/config`; renders nothing until
* backend that omits it). * the version is known (or on a legacy backend that omits it).
*/ */
export function VersionBadge({ version }: VersionBadgeProps) { export function VersionBadge({ version }: VersionBadgeProps) {
if (!version) return null; if (!version) return null;

View file

@ -25,7 +25,7 @@ export interface AppControlsProps {
} }
const LIST_MAX_HEIGHT = 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({ const EmptyState = Text.withProps({
c: "dimmed", 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 // 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 // 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 // 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({ const ControlsBar = Group.withProps({
wrap: "nowrap", wrap: "nowrap",
gap: "sm", gap: "sm",
p: "sm", px: "xl",
py: "sm",
}); });
// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0` // 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 type { ReactNode } from "react";
import { Divider, Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; 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 // 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). // 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({ const ColumnLayout = Stack.withProps({
h: "100%", h: "100%",
gap: 0, gap: 0,
pt: "md",
}); });
// Relative-positioned host so each tab's `ScreenStage` (absolutely positioned) // Relative-positioned host so each tab's `ScreenStage` (absolutely positioned)
@ -59,7 +62,6 @@ export function MonitoringScreen({
searchValue={searchValue} searchValue={searchValue}
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
/> />
<Divider />
<ScreenSlot> <ScreenSlot>
{tabs.map((tab) => ( {tabs.map((tab) => (
<ScreenStage key={tab} active={tab === value} fill> <ScreenStage key={tab} active={tab === value} fill>

View file

@ -14,6 +14,7 @@ import {
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge"; import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge";
@ -281,6 +282,7 @@ export function NetworkEntry({
<ControlsCluster>{metaBadges}</ControlsCluster> <ControlsCluster>{metaBadges}</ControlsCluster>
</HeaderRow> </HeaderRow>
<Group gap="xs" wrap="nowrap" justify="space-between"> <Group gap="xs" wrap="nowrap" justify="space-between">
<CopyButton value={entry.url} />
<ScrollArea <ScrollArea
scrollbarSize={6} scrollbarSize={6}
flex={1} flex={1}
@ -304,6 +306,7 @@ export function NetworkEntry({
</TimestampText> </TimestampText>
<MethodBadge method={entry.method} /> <MethodBadge method={entry.method} />
<CategoryBadge category={entry.category} /> <CategoryBadge category={entry.category} />
<CopyButton value={entry.url} />
<UrlText>{entry.url}</UrlText> <UrlText>{entry.url}</UrlText>
</Group> </Group>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Box } from "@mantine/core";
import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { ProtocolEntry } from "./ProtocolEntry"; import { ProtocolEntry } from "./ProtocolEntry";
@ -76,6 +77,36 @@ const resourceReadEntry: MessageEntry = {
duration: 45, 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 = { const pendingEntry: MessageEntry = {
id: "req-4", id: "req-4",
timestamp: new Date("2026-03-17T10:34:00Z"), timestamp: new Date("2026-03-17T10:34:00Z"),
@ -128,3 +159,22 @@ export const Pending: Story = {
isListExpanded: false, 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(); 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", () => { it("renders Error status when response has error", () => {
renderWithMantine(<ProtocolEntry {...baseProps} entry={errorEntry} />); renderWithMantine(<ProtocolEntry {...baseProps} entry={errorEntry} />);
expect(screen.getByText("Error")).toBeInTheDocument(); expect(screen.getByText("Error")).toBeInTheDocument();

View file

@ -6,11 +6,13 @@ import {
Collapse, Collapse,
Divider, Divider,
Group, Group,
ScrollArea,
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
@ -67,6 +69,15 @@ const TargetText = Text.withProps({
fw: 500, 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({ const DurationText = Text.withProps({
size: "sm", size: "sm",
c: "dimmed", c: "dimmed",
@ -100,6 +111,15 @@ function extractTarget(entry: MessageEntry): string | undefined {
return 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 // The pending → OK/Error lifecycle only applies to requests: messageLogState
// attaches a `response` to request entries by JSON-RPC id. A notification is // 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 // 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 [isExpanded, setIsExpanded] = useState(isListExpanded);
const method = extractMethod(entry); const method = extractMethod(entry);
const target = extractTarget(entry); const target = extractTarget(entry);
const resourceUri = extractResourceUri(entry);
const status = extractStatus(entry); const status = extractStatus(entry);
const canReplay = isReplayableProtocolMethod(method); const canReplay = isReplayableProtocolMethod(method);
@ -184,9 +205,21 @@ export function ProtocolEntry({
<HeaderCluster flex={1}> <HeaderCluster flex={1}>
<MethodBadge method={method} /> <MethodBadge method={method} />
{target && ( {target && (
<TargetText truncate="end" miw={0}> <>
{target} {resourceUri && <CopyButton value={resourceUri} />}
</TargetText> <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> </HeaderCluster>
<ControlsCluster> <ControlsCluster>
@ -208,7 +241,12 @@ export function ProtocolEntry({
</TimestampText> </TimestampText>
{directionBadge} {directionBadge}
<MethodBadge method={method} /> <MethodBadge method={method} />
{target && <TargetText>{target}</TargetText>} {target && (
<>
{resourceUri && <CopyButton value={resourceUri} />}
<TargetText>{target}</TargetText>
</>
)}
</Group> </Group>
<Group gap="sm"> <Group gap="sm">
{durationText} {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 = { export const LongServerName: Story = {
args: { args: {
id: "a1b2c3d4-e5f6-4789-9abc-def012345678", 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 { TransportBadge } from "../../elements/TransportBadge/TransportBadge";
import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle"; import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; 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 { export interface ServerCardProps extends ServerEntry {
activeServer?: string; activeServer?: string;
@ -45,6 +45,13 @@ export interface ServerCardProps extends ServerEntry {
* is connected or a new connection is attempted. * is connected or a new connection is attempted.
*/ */
errored?: boolean; 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 * 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 * highlighted at once (a batch import) only the first should scroll, so the
@ -96,6 +103,16 @@ const SubtleButton = Button.withProps({
size: "xs", 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({ const RemoveButton = Button.withProps({
variant: "subtle", variant: "subtle",
size: "xs", size: "xs",
@ -146,6 +163,7 @@ export function ServerCard({
dragHandle, dragHandle,
highlighted = false, highlighted = false,
errored = false, errored = false,
justConnected = false,
scrollOnHighlight = true, scrollOnHighlight = true,
onClearHighlight, onClearHighlight,
}: ServerCardProps) { }: ServerCardProps) {
@ -173,9 +191,24 @@ export function ServerCard({
if (!justErrored) return; if (!justErrored) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, FAILED_CARD_SCROLL_DELAY_MS); }, CARD_SCROLL_DELAY_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [errored]); }, [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 transport = getTransport(config);
const commandOrUrl = getCommandOrUrl(config); const commandOrUrl = getCommandOrUrl(config);
const version = info?.version; const version = info?.version;
@ -184,12 +217,12 @@ export function ServerCard({
// A dimmed card (another server is active) is inert, so the disabled variant // 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 // 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 const variant = isDimmed
? "disabled" ? "disabled"
: errored : errored
? "errored" ? "errored"
: highlighted : highlighted || justConnected
? "highlighted" ? "highlighted"
: undefined; : undefined;
@ -197,6 +230,7 @@ export function ServerCard({
<Card <Card
ref={rootRef} ref={rootRef}
variant={variant} variant={variant}
styles={CARD_STYLES}
onClick={highlighted ? onClearHighlight : undefined} onClick={highlighted ? onClearHighlight : undefined}
{...(isDimmed ? { "aria-disabled": true, inert: true } : {})} {...(isDimmed ? { "aria-disabled": true, inert: true } : {})}
> >
@ -210,6 +244,11 @@ export function ServerCard({
<ServerStatusIndicator <ServerStatusIndicator
status={connection.status} status={connection.status}
retryCount={connection.retryCount} 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 <ConnectionToggle
status={connection.status} status={connection.status}

View file

@ -70,6 +70,19 @@ describe("ViewHeader", () => {
).toBeInTheDocument(); ).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 () => { it("invokes onOpenClientSettings when the client settings button is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onOpenClientSettings = vi.fn(); const onOpenClientSettings = vi.fn();
@ -179,10 +192,10 @@ describe("ViewHeader", () => {
renderWithMantine( renderWithMantine(
<ViewHeader {...connectedProps} onDisconnect={onDisconnect} />, <ViewHeader {...connectedProps} onDisconnect={onDisconnect} />,
); );
// Either label-button "Disconnect" (wide) or icon-only with aria-label // Disconnect is always the icon, labelled by its aria-label / tooltip.
const disconnectButton = const disconnectButton = screen.getByRole("button", {
screen.queryByRole("button", { name: "Disconnect" }) ?? name: "Disconnect from server",
screen.getByRole("button", { name: /disconnect/i }); });
await user.click(disconnectButton); await user.click(disconnectButton);
expect(onDisconnect).toHaveBeenCalledTimes(1); expect(onDisconnect).toHaveBeenCalledTimes(1);
}); });
@ -193,7 +206,7 @@ describe("ViewHeader", () => {
expect(screen.getAllByText("Prompts").length).toBeGreaterThan(0); 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; mediaQueryMock.value = true;
const user = userEvent.setup(); const user = userEvent.setup();
const onTabChange = vi.fn(); const onTabChange = vi.fn();
@ -205,8 +218,10 @@ describe("ViewHeader", () => {
onDisconnect={onDisconnect} onDisconnect={onDisconnect}
/>, />,
); );
// Disconnect renders as a labelled button on wide viewport. // Disconnect is always the icon (labelled by its tooltip / aria-label).
const disconnectBtn = screen.getByRole("button", { name: "Disconnect" }); const disconnectBtn = screen.getByRole("button", {
name: "Disconnect from server",
});
await user.click(disconnectBtn); await user.click(disconnectBtn);
expect(onDisconnect).toHaveBeenCalledTimes(1); expect(onDisconnect).toHaveBeenCalledTimes(1);
@ -307,7 +322,7 @@ describe("ViewHeader", () => {
).toBe("in"); ).toBe("in");
expect( expect(
screen screen
.getByRole("button", { name: "Disconnect" }) .getByRole("button", { name: "Disconnect from server" })
.closest("[data-anim]") .closest("[data-anim]")
?.getAttribute("data-anim"), ?.getAttribute("data-anim"),
).toBe("in"); ).toBe("in");
@ -328,7 +343,7 @@ describe("ViewHeader", () => {
).toBe("out"); ).toBe("out");
expect( expect(
screen screen
.getByRole("button", { name: "Disconnect" }) .getByRole("button", { name: "Disconnect from server" })
.closest("[data-anim]") .closest("[data-anim]")
?.getAttribute("data-anim"), ?.getAttribute("data-anim"),
).toBe("out"); ).toBe("out");
@ -337,7 +352,7 @@ describe("ViewHeader", () => {
expect(screen.queryByText("my-mcp-server")).not.toBeInTheDocument(), expect(screen.queryByText("my-mcp-server")).not.toBeInTheDocument(),
); );
expect( expect(
screen.queryByRole("button", { name: "Disconnect" }), screen.queryByRole("button", { name: "Disconnect from server" }),
).not.toBeInTheDocument(); ).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 user = userEvent.setup();
const onTabChange = vi.fn(); const onTabChange = vi.fn();
renderWithMantine( renderWithMantine(
<ViewHeader {...connectedProps} onTabChange={onTabChange} />, <ViewHeader {...connectedProps} onTabChange={onTabChange} />,
); );
// On narrow viewport (default mediaQuery=false), tabs use a Select. // Tabs always render as a SegmentedControl (radios); pick another one.
const select = screen.getByRole("textbox"); await user.click(screen.getByRole("radio", { name: "Resources" }));
await user.click(select);
const option = await screen.findByRole("option", {
name: "Resources",
hidden: true,
});
await user.click(option);
expect(onTabChange).toHaveBeenCalledWith("Resources"); expect(onTabChange).toHaveBeenCalledWith("Resources");
}); });
}); });

View file

@ -3,11 +3,9 @@ import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Box, Box,
Button,
Group, Group,
Image, Image,
SegmentedControl, SegmentedControl,
Select,
Text, Text,
Title, Title,
Tooltip, Tooltip,
@ -15,9 +13,9 @@ import {
useComputedColorScheme, useComputedColorScheme,
type MantineTransition, type MantineTransition,
} from "@mantine/core"; } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; 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 type { ConnectionStatus } from "@inspector/core/mcp/types.js";
import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator"; import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator";
import { import {
@ -62,8 +60,6 @@ export type ViewHeaderProps = ConnectedProps | UnconnectedProps;
// duration. The motion itself is CSS (`.header-anim`); keep the 300ms / // duration. The motion itself is CSS (`.header-anim`); keep the 300ms /
// 150ms-stagger there in sync with this value. // 150ms-stagger there in sync with this value.
const HEADER_ANIM_MS = 300; 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 // Grace window after a connection is established before the new-tab glow arms
// (#1450). Primitive lists (prompts/resources/tasks) are fetched asynchronously // (#1450). Primitive lists (prompts/resources/tasks) are fetched asynchronously
// just after the handshake, so their tabs appear a few renders into the // just after the handshake, so their tabs appear a few renders into the
@ -181,20 +177,10 @@ const RightConnectedGroup = Group.withProps({
wrap: "nowrap", 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({ const DisconnectIcon = ActionIcon.withProps({
variant: "subtle", variant: "subtle",
c: "red",
size: 36, size: 36,
"aria-label": "Disconnect", "aria-label": "Disconnect from server",
}); });
const ClientSettingsToggle = ActionIcon.withProps({ const ClientSettingsToggle = ActionIcon.withProps({
@ -249,8 +235,6 @@ export function ViewHeader(props: ViewHeaderProps) {
} }
const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle; const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle;
const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode; 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 // Retain the latest connected display data so each region can keep rendering
// it while animating out after disconnect (#1450). Uses React's "adjust state // it while animating out after disconnect (#1450). Uses React's "adjust state
@ -318,9 +302,11 @@ export function ViewHeader(props: ViewHeaderProps) {
return ( return (
<HeaderBar> <HeaderBar>
<LeftSection> <LeftSection>
<LogoLink> <Tooltip label="MCP Documentation">
<LogoImage src={logoSrc} /> <LogoLink>
</LogoLink> <LogoImage src={logoSrc} />
</LogoLink>
</Tooltip>
<Transition <Transition
mounted={props.connected} mounted={props.connected}
transition="fade" transition="fade"
@ -364,26 +350,12 @@ export function ViewHeader(props: ViewHeaderProps) {
className="header-anim header-stack-cell" className="header-anim header-stack-cell"
data-anim={connectedAnim} data-anim={connectedAnim}
> >
{showSegmented ? ( <SegmentedControl
<SegmentedControl value={headerData.activeTab}
value={headerData.activeTab} onChange={handleTabChange}
onChange={handleTabChange} data={toGlowingTabData(headerData.availableTabs, glowing)}
data={toGlowingTabData(headerData.availableTabs, glowing)} size="sm"
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}
/>
)}
</Box> </Box>
) : ( ) : (
// Unreachable in practice — the Transition only mounts while // Unreachable in practice — the Transition only mounts while
@ -425,15 +397,11 @@ export function ViewHeader(props: ViewHeaderProps) {
status={headerData.status} status={headerData.status}
latencyMs={headerData.latencyMs} latencyMs={headerData.latencyMs}
/> />
{showDisconnectLabel ? ( <Tooltip label="Disconnect from server">
<DisconnectButton onClick={handleDisconnect}> <DisconnectIcon onClick={handleDisconnect}>
Disconnect <VscDebugDisconnect size={20} />
</DisconnectButton>
) : (
<DisconnectIcon onClick={handleDisconnect} title="Disconnect">
<MdLinkOff size={20} />
</DisconnectIcon> </DisconnectIcon>
)} </Tooltip>
</RightConnectedGroup> </RightConnectedGroup>
) : ( ) : (
/* v8 ignore next -- unreachable: this Transition only mounts /* v8 ignore next -- unreachable: this Transition only mounts

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