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
3 changed files with 707 additions and 274 deletions

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
InitializeResult,
@ -20,18 +20,16 @@ import {
} from "../../../test/renderWithMantine";
import { InspectorView, type InspectorViewProps } from "./InspectorView";
// The monitoring sidebar (#1616) is gated on a 1040px viewport media query.
// happy-dom's viewport is 1024px, so that query is really false; make just that
// gate controllable per test. ViewHeader's own 992/768 queries stay "wide" (as
// they are in the real 1024px happy-dom viewport), so header rendering — and
// every existing test below — is unaffected.
const monitorWide = vi.hoisted(() => ({ value: false }));
// happy-dom's viewport is 1024px, narrower than the app's 1280px floor, so any
// viewport media query would read "narrow". Force every `useMediaQuery` "wide"
// so the connected header renders its full form (the only surviving query is
// ServerStatusIndicator's 1500px status-text gate — the monitoring sidebar's old
// 1040px gate was removed when the app gained its 1280px floor).
vi.mock("@mantine/hooks", async (importOriginal) => {
const actual = await importOriginal<typeof import("@mantine/hooks")>();
return {
...actual,
useMediaQuery: (query: string): boolean =>
query === "(min-width: 1040px)" ? monitorWide.value : true,
useMediaQuery: (): boolean => true,
};
});
import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer";
@ -247,6 +245,16 @@ describe("InspectorView", () => {
expect(screen.getByText("Alpha")).toBeInTheDocument();
});
it("renders the footer row with the version and copyright (#1682)", () => {
renderWithMantine(
<StatefulInspectorViewHost {...makeProps({ version: "9.9.9" })} />,
);
expect(screen.getByText("v9.9.9")).toBeInTheDocument();
expect(
screen.getByText(/Model Context Protocol.*Series of LF Projects, LLC\./),
).toBeInTheDocument();
});
it("dispatches onToggleConnection with the server id when the card toggle is clicked", async () => {
const onToggleConnection = vi.fn();
const user = userEvent.setup({ delay: null });
@ -1441,11 +1449,6 @@ describe("InspectorView", () => {
};
const httpInit = initWithCapabilities(allCapabilities);
beforeEach(() => {
// Default narrow so a test only pins when it opts in.
monitorWide.value = false;
});
function connectedHttp(overrides: Partial<InspectorViewProps> = {}) {
return makeProps({
servers: [httpServer],
@ -1464,17 +1467,7 @@ describe("InspectorView", () => {
return user;
}
it("shows the header monitoring toggle only when wide", async () => {
monitorWide.value = false;
const { unmount } = renderWithMantine(
<StatefulInspectorViewHost {...connectedHttp()} />,
);
expect(
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
).not.toBeInTheDocument();
unmount();
monitorWide.value = true;
it("shows the header monitoring toggle when connected with monitor tabs", async () => {
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
await screen.findByRole("button", { name: "Open monitoring sidebar" }),
@ -1483,7 +1476,6 @@ describe("InspectorView", () => {
it("hides the header monitoring toggle when there is no connected or failed server", () => {
// Disconnected, wide viewport: nothing to monitor, so no toggle appears.
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -1502,7 +1494,6 @@ describe("InspectorView", () => {
});
it("opens the monitoring sidebar when a connection is established", async () => {
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -1527,7 +1518,6 @@ describe("InspectorView", () => {
it("does not auto-open on a mount that starts already connected", () => {
// No disconnected → connected transition, and no stored preference, so the
// column stays closed (a user who closed it isn't fought on remount).
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
@ -1550,7 +1540,6 @@ describe("InspectorView", () => {
}
it("opens the monitoring sidebar on a failed connection attempt (#1621)", async () => {
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -1600,7 +1589,6 @@ describe("InspectorView", () => {
});
it("surfaces Console (stderr), not Network, in the failure column for a stdio server (#1621)", async () => {
monitorWide.value = true;
const stdioErr: ServerEntry = {
id: "beta",
name: "Beta",
@ -1647,7 +1635,6 @@ describe("InspectorView", () => {
// was Protocol must still land on the Console diagnostic, not the (empty)
// Protocol — Protocol is content-gated out of the failure column.
window.localStorage.setItem("inspector.monitor.tab", "Protocol");
monitorWide.value = true;
const stdioErr: ServerEntry = {
id: "beta",
name: "Beta",
@ -1684,7 +1671,6 @@ describe("InspectorView", () => {
// A connect failure with nothing captured yet (no stderr, no fetch, and
// Protocol cleared on the error transition) opens onto nothing — so the
// column stays closed rather than showing an empty pane.
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -1707,7 +1693,6 @@ describe("InspectorView", () => {
it("offers Protocol in the failure column when it has captured content (#1621)", async () => {
// The failure Protocol tab is content-gated, so when the message log *does*
// hold entries it's offered alongside the diagnostic (Network here).
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -1753,7 +1738,6 @@ describe("InspectorView", () => {
it("does not auto-open on a mount that starts already errored (#1621)", () => {
// No transition into error, and no stored preference, so the column stays
// closed — a user who closed it isn't fought on remount.
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...failedHttp()} />);
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
@ -1766,7 +1750,6 @@ describe("InspectorView", () => {
// attempts). That must NOT reorganize the column into the failure tab set
// — it closes like any session teardown, so a user who closed the column
// mid-session isn't reopened onto diagnostics.
monitorWide.value = true;
window.localStorage.setItem("inspector.monitor.pinned", "true");
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost {...connectedHttp()} />,
@ -1803,7 +1786,6 @@ describe("InspectorView", () => {
};
it("offers Console (not Network) as a monitor tab for a connected stdio server (#1621)", async () => {
monitorWide.value = true;
window.localStorage.setItem("inspector.monitor.pinned", "true");
renderWithMantine(
<StatefulInspectorViewHost
@ -1831,7 +1813,6 @@ describe("InspectorView", () => {
// Column not pinned, so the monitor group (incl. Console) lives in the
// header menu — the user can reach the stderr stream during a live
// session, not only on failure.
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -1853,7 +1834,6 @@ describe("InspectorView", () => {
});
it("pins the monitor group into the column and removes it from the header", async () => {
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
await user.click(
@ -1892,7 +1872,6 @@ describe("InspectorView", () => {
});
it("returns the monitor group to the header when the column is closed", async () => {
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
await user.click(
@ -1915,7 +1894,6 @@ describe("InspectorView", () => {
});
it("keeps the current header screen (not the column's) when closed", async () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
// Navigate the primary onto a monitor tab (Logs) first, then open the
// column — which moves the monitor group out of the header, so the primary
@ -1951,25 +1929,9 @@ describe("InspectorView", () => {
).toBeInTheDocument();
});
it("persists the pin preference and reopens the column when wide", () => {
// Stored preference from a prior wide session.
it("reopens the column from the stored pin preference", () => {
// A stored preference from a prior session reopens the column on load.
window.localStorage.setItem("inspector.monitor.pinned", "true");
// Narrow: the column stays closed and the group is in the header.
monitorWide.value = false;
const { unmount } = renderWithMantine(
<StatefulInspectorViewHost {...connectedHttp()} />,
);
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
).toBeNull();
expect(
within(screen.getByRole("banner")).getByRole("radio", { name: "Logs" }),
).toBeInTheDocument();
unmount();
// Wide: the preserved preference reopens the column without re-pinning.
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
screen.getByRole("button", { name: "Close monitoring sidebar" }),
@ -1978,7 +1940,6 @@ describe("InspectorView", () => {
it("keeps the column closed when the stored preference is explicitly false", () => {
window.localStorage.setItem("inspector.monitor.pinned", "false");
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
expect(
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
@ -1987,7 +1948,6 @@ describe("InspectorView", () => {
it("hides the column on disconnect but keeps the pin preference", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
const { rerender } = renderWithMantine(
<StatefulInspectorViewHost {...connectedHttp()} />,
);
@ -2018,7 +1978,6 @@ describe("InspectorView", () => {
it("drops Network from the column tabs for a stdio server", () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -2041,7 +2000,6 @@ describe("InspectorView", () => {
// Stored tab is Network, but a stdio + logging-only server can't offer it.
window.localStorage.setItem("inspector.monitor.pinned", "true");
window.localStorage.setItem("inspector.monitor.tab", "Network");
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -2061,7 +2019,6 @@ describe("InspectorView", () => {
// stdio + logging only ⇒ availableTabs = [Servers, Logs, Protocol]; pinning
// moves both monitor tabs out, leaving [Servers] as the only header tab.
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
@ -2085,7 +2042,6 @@ describe("InspectorView", () => {
it("persists the selected column tab", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
await user.click(await screen.findByRole("radio", { name: "Protocol" }));
@ -2098,7 +2054,6 @@ describe("InspectorView", () => {
it("keeps the column search across tabs and filters each screen", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(
<StatefulInspectorViewHost
@ -2139,7 +2094,6 @@ describe("InspectorView", () => {
it("resizes and persists the column width from the keyboard", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
window.localStorage.setItem("inspector.monitor.width", "420");
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
const handle = await screen.findByRole("separator", {
name: "Resize monitoring sidebar",

View file

@ -15,7 +15,7 @@ import {
Transition,
type MantineTransition,
} from "@mantine/core";
import { useLocalStorage, useMediaQuery } from "@mantine/hooks";
import { useLocalStorage } from "@mantine/hooks";
import type {
InitializeResult,
LoggingLevel,
@ -37,6 +37,8 @@ import type {
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
import { isAppTool } from "@inspector/core/mcp/apps.js";
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
import {
ToolsScreen,
@ -196,11 +198,6 @@ function isMonitorTab(tab: string): tab is MonitorTab {
);
}
// The viewport width below which the split collapses to a single column: matches
// the point where ServerListScreen drops to one card, so the primary area always
// has room for at least one full-width card beside the column.
const MONITOR_WIDE_QUERY = "(min-width: 1040px)";
// Monitoring sidebar width bounds (px). MIN keeps the stream readable; MAX stops
// the column from crowding out the primary area.
const MONITOR_WIDTH_MIN = 320;
@ -273,6 +270,22 @@ const SplitRow = Flex.withProps({
w: "100%",
});
// Footer row (#1682): a full-width AppShell.Footer band, styled like the header
// (Mantine gives AppShell.Footer the same `--mantine-color-body` background and
// a matching top border by default). Because it's a real AppShell region it
// reserves its own height (`--app-shell-footer-height`), so the primary screen
// and the monitoring sidebar both stop above it instead of the old fixed badges
// overlapping the sidebar. `32` keeps it the same height as the previous badge
// band (`spacing.xl`). The version sits at the left, the copyright at the right.
const FOOTER_HEIGHT = 32;
const FooterRow = Group.withProps({
h: "100%",
px: "xl",
justify: "space-between",
align: "center",
wrap: "nowrap",
});
// The pinned monitoring sidebar. Fixed-basis (its width is driven live via the
// `w` style prop at the call site); `miw: 0` so its inner ScrollArea can bound.
const MonitoringColumn = Stack.withProps({
@ -344,6 +357,18 @@ export interface InspectorViewProps {
* parent clears on the failure's `disconnect` event.
*/
erroredServerId?: string;
/**
* Id of the server that just connected successfully (#1682). Its card draws
* the green highlight border and scrolls into view once the monitoring
* sidebar has opened — the success mirror of `erroredServerId`.
*/
connectedServerId?: string;
/**
* The Inspector build version (root `package.json`), shown at the left of the
* footer row (#1682). Absent on a legacy backend that omits it — the version
* label then renders nothing.
*/
version?: string;
connectionStatus: ConnectionStatus;
/**
* Last connection-level error message (handshake failure, OAuth start
@ -531,6 +556,8 @@ export function InspectorView({
serverListWritable = true,
activeServer,
erroredServerId,
connectedServerId,
version,
connectionStatus,
connectErrorMessage,
initializeResult,
@ -687,13 +714,6 @@ export function InspectorView({
const [dragWidth, setDragWidth] = useState<number | null>(null);
const columnWidth = dragWidth ?? monitorWidth;
// The split only exists with enough horizontal room. `true` initial value so
// the first synchronous paint assumes wide (the common desktop case) rather
// than flashing the collapsed layout.
const isWide = useMediaQuery(MONITOR_WIDE_QUERY, true, {
getInitialValueInEffect: false,
});
// Open the monitoring sidebar when a connection is established (#1616) OR when a
// connect *attempt* fails (#1621). Gated on the *transition into* the target
// status (via the ref) rather than the status itself, so it fires once on an
@ -838,13 +858,13 @@ export function InspectorView({
}
return [];
}, [connected, failed, availableTabs, stderrLogs, network, protocol]);
// The column can exist when: the viewport is wide enough, the session is
// connected OR a connect attempt failed, and at least one monitor tab is
// actually available. This is the same rule the server list used to gate its
// open-sidebar button on, and it now also gates the header MonitoringToggle
// (#1661) — the toggle is hidden entirely when the column can't exist.
// The column can exist when the session is connected OR a connect attempt
// failed, and at least one monitor tab is actually available. (The app floors
// at a 1280px min-width, so there's always room for the split — no viewport
// gate.) This also gates the header MonitoringToggle (#1661) — the toggle is
// hidden entirely when the column can't exist.
const monitorColumnAvailable =
!!isWide && (connected || failed) && monitorAvailable.length > 0;
(connected || failed) && monitorAvailable.length > 0;
const effectivePinned = monitorPinned && monitorColumnAvailable;
// The header loses the monitor group while the column is open (its screens
@ -1132,7 +1152,11 @@ export function InspectorView({
// past the viewport and made the whole InspectorView scroll — the theme's
// Main-slot height clamp + overflow:hidden keep that scroll on the inner
// ScrollArea regions only.
<AppShell header={{ height: 60 }} padding={0}>
<AppShell
header={{ height: 60 }}
footer={{ height: FOOTER_HEIGHT }}
padding={0}
>
<AppShell.Header
data-testid="connection-status"
data-status={connectionStatus}
@ -1171,6 +1195,7 @@ export function InspectorView({
writable={serverListWritable}
activeServer={dimCardsAgainst}
erroredServerId={erroredServerId}
connectedServerId={connectedServerId}
onAddManually={onServerAdd}
onImportConfig={onServerImportConfig}
onImportServerJson={onServerImportJson}
@ -1305,6 +1330,12 @@ export function InspectorView({
</Transition>
</SplitRow>
</AppShell.Main>
<AppShell.Footer>
<FooterRow>
<VersionBadge version={version} />
<CopyrightBadge />
</FooterRow>
</AppShell.Footer>
</AppShell>
);
}

View file

@ -2,14 +2,15 @@
* Duration (ms) of the monitoring sidebar's open/close slide (#1616). Shared so
* dependent timings can't silently drift from the animation:
* - `InspectorView` drives the column's Mantine `Transition` with it.
* - `ServerCard` waits just past it before scrolling a newly-failed card into
* view (#1621), so the column's open + the resulting grid reflow settle
* before `scrollIntoView` measures the card's position.
* - `ServerCard` waits just past it before scrolling a newly-failed (#1621) or
* newly-connected (#1682) card into view, so the column's open + the
* resulting grid reflow settle before `scrollIntoView` measures the card.
*/
export const MONITOR_COLUMN_ANIM_MS = 300;
/**
* Delay (ms) before scrolling a newly-failed server card into view — the column
* animation plus a small buffer for the reflow to commit.
* Delay (ms) before scrolling a just-failed or just-connected server card into
* view — the column animation plus a small buffer for the reflow to commit.
* The sidebar auto-opens on both transitions, so both scrolls wait for it.
*/
export const FAILED_CARD_SCROLL_DELAY_MS = MONITOR_COLUMN_ANIM_MS + 20;
export const CARD_SCROLL_DELAY_MS = MONITOR_COLUMN_ANIM_MS + 20;