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
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:
parent
590359abc5
commit
8d33e76f97
35 changed files with 707 additions and 274 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue