From 78dea0e2723c11bc0a3f941225cc380adf3cad47 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 19:56:20 -0400 Subject: [PATCH 01/12] web: connected-card status treatment, footer row, and MCP-docs tooltip (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-screen and chrome polish, all under #1682: - Connected server card: on a successful connect the card draws the green highlight border and scrolls itself into view once the monitoring sidebar has opened — the success mirror of the errored-card path (#1621). Threaded via a new `connectedServerId` (App → InspectorView → ServerListScreen → ServerCard `justConnected`), reusing the shared post-animation scroll delay (renamed `CARD_SCROLL_DELAY_MS`). - Connection status: ServerStatusIndicator gains a `failed` flag so a failed connection reads as a red dot + "Failed" instead of the settled grey "Disconnected", distinguishing it from a deliberate disconnect. - Footer: the version + copyright move from fixed bottom-corner badges into a real full-width AppShell.Footer row (header-matching background + top border). Because it reserves its own height, the primary screen and the monitoring sidebar now stop above it instead of the badges overlapping the sidebar. All screen height calcs subtract `--app-shell-footer-height` alongside the header. - Header: an "MCP Documentation" tooltip on the MCP logo link. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.tsx | 26 ++++- .../CopyrightBadge/CopyrightBadge.tsx | 10 +- .../EmbeddableScrollArea.tsx | 2 +- .../ServerStatusIndicator.stories.tsx | 11 ++ .../ServerStatusIndicator.test.tsx | 21 ++++ .../ServerStatusIndicator.tsx | 22 +++- .../elements/VersionBadge/VersionBadge.tsx | 12 +- .../groups/AppControls/AppControls.tsx | 2 +- .../groups/ServerCard/ServerCard.stories.tsx | 14 +++ .../groups/ServerCard/ServerCard.test.tsx | 103 ++++++++++++++++++ .../groups/ServerCard/ServerCard.tsx | 32 +++++- .../groups/ViewHeader/ViewHeader.test.tsx | 13 +++ .../groups/ViewHeader/ViewHeader.tsx | 8 +- .../screens/AppsScreen/AppsScreen.tsx | 2 +- .../screens/ConsoleScreen/ConsoleScreen.tsx | 2 +- .../screens/LoggingScreen/LoggingScreen.tsx | 2 +- .../screens/NetworkScreen/NetworkScreen.tsx | 2 +- .../screens/PromptsScreen/PromptsScreen.tsx | 4 +- .../screens/ProtocolScreen/ProtocolScreen.tsx | 2 +- .../ResourcesScreen/ResourcesScreen.tsx | 4 +- .../ServerListScreen.test.tsx | 26 +++++ .../ServerListScreen/ServerListScreen.tsx | 10 +- .../screens/TasksScreen/TasksScreen.tsx | 2 +- .../screens/ToolsScreen/ToolsScreen.tsx | 4 +- .../InspectorView/InspectorView.test.tsx | 10 ++ .../views/InspectorView/InspectorView.tsx | 45 +++++++- .../InspectorView/monitorColumnAnimation.ts | 13 ++- clients/web/src/theme/Text.ts | 25 +---- 28 files changed, 363 insertions(+), 66 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 350213f4c..0dd217cf8 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -101,8 +101,6 @@ import { useServerListWritable } from "@inspector/core/react/useServerListWritab import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion.js"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; -import { VersionBadge } from "./components/elements/VersionBadge/VersionBadge"; -import { CopyrightBadge } from "./components/elements/CopyrightBadge/CopyrightBadge"; import type { ToolCallState, ToolsUiState, @@ -654,6 +652,14 @@ function App() { undefined, ); + // Id of the server that just connected successfully (#1682) — the success + // mirror of `failedServerId`. Drives the green highlight + scroll-into-view on + // that ServerCard. Set on the →connected transition, cleared when a new + // connection attempt starts or the session disconnects. + const [connectedServerId, setConnectedServerId] = useState< + string | undefined + >(undefined); + // InspectorClient + per-primitive state managers. All recreated together // whenever the user switches active servers, then destroyed when the // next switch happens (or when the component unmounts). @@ -993,6 +999,18 @@ function App() { } }, [connectionStatus]); + // Track the just-connected server so its card gets the green highlight + + // scroll-into-view (#1682). Unlike `failedServerId` (which must survive the + // `disconnect` event a failed connect fires), "connected" is a stable status, + // so a status-driven effect can both set and clear it: set on connect, clear + // whenever the session isn't connected (disconnect, a new attempt's + // "connecting", or an error). + useEffect(() => { + setConnectedServerId( + connectionStatus === "connected" ? activeServerId : undefined, + ); + }, [connectionStatus, activeServerId]); + // Disconnect the previous InspectorClient when it's replaced (server // switch) or when App unmounts (HMR, tests). Without this the prior // session's transport — a spawned stdio subprocess, an SSE stream, or @@ -3821,6 +3839,8 @@ function App() { serverListWritable={serverListWritable} activeServer={activeServerId} erroredServerId={failedServerId} + connectedServerId={connectedServerId} + version={inspectorVersion} connectionStatus={connectionStatus} connectErrorMessage={connectErrorMessage} initializeResult={initializeResult} @@ -3965,8 +3985,6 @@ function App() { onRefreshApps={onRefreshTools} /> - - {COPYRIGHT_NOTICE}; diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx index 5927fab69..0ad636754 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -4,7 +4,7 @@ import { ScrollArea, Stack } from "@mantine/core"; // Full-size, the monitor stream panels bound their scroll to the viewport // minus the header and the panel's own chrome. const FULLSIZE_MAH = - "calc(100vh - var(--app-shell-header-height, 0px) - 150px)"; + "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - 150px)"; export interface EmbeddableScrollAreaProps { /** diff --git a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx index 2a4ef092b..2b5471130 100644 --- a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx +++ b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.stories.tsx @@ -28,6 +28,17 @@ export const Disconnected: Story = { }, }; +// A failed connection settles the status back to "disconnected", but the +// `failed` flag surfaces it as a red "Failed" instead of the grey +// "Disconnected" (#1682). +export const Failed: Story = { + args: { + status: "disconnected", + failed: true, + showLabel: true, + }, +}; + export const Error: Story = { args: { status: "error", diff --git a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx index 4eb628945..ce0494559 100644 --- a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx +++ b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.test.tsx @@ -39,6 +39,27 @@ describe("ServerStatusIndicator", () => { expect(screen.getByText("Error")).toBeInTheDocument(); }); + it("renders 'Failed' with the error color when failed, overriding the disconnected status (#1682)", () => { + const { container } = renderWithMantine( + , + ); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.queryByText("Disconnected")).not.toBeInTheDocument(); + // The dot uses the error color, not the disconnected grey. + const dot = container.querySelector(".mantine-Paper-root"); + expect(dot?.getAttribute("style") ?? "").toContain( + "inspector-status-error", + ); + }); + + it("does not show 'Failed' when the failed flag is unset (a deliberate disconnect)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + expect(screen.queryByText("Failed")).not.toBeInTheDocument(); + }); + it("hides label and uses title when showLabel is false", () => { renderWithMantine( {showLabel && {label}} diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx index fbb834d73..170db866e 100644 --- a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx @@ -5,15 +5,15 @@ export interface VersionBadgeProps { version?: string; } -// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-left -// corner in grey and makes it non-interactive. +// The `versionBadge` variant (src/theme/Text.ts) styles it as a small grey +// single-line label; the footer row positions it at the left (#1682). const VersionText = Text.withProps({ variant: "versionBadge" }); /** - * A small, unobtrusive build-version label fixed to the lower-left corner of - * the screen (#1639). Sourced from the root `package.json` via the backend's - * `GET /api/config`; renders nothing until the version is known (or on a legacy - * backend that omits it). + * A small, unobtrusive build-version label shown at the left of the footer row + * (#1682, superseding the fixed lower-left badge of #1639). Sourced from the + * root `package.json` via the backend's `GET /api/config`; renders nothing until + * the version is known (or on a legacy backend that omits it). */ export function VersionBadge({ version }: VersionBadgeProps) { if (!version) return null; diff --git a/clients/web/src/components/groups/AppControls/AppControls.tsx b/clients/web/src/components/groups/AppControls/AppControls.tsx index 14d02908b..2389fbdef 100644 --- a/clients/web/src/components/groups/AppControls/AppControls.tsx +++ b/clients/web/src/components/groups/AppControls/AppControls.tsx @@ -25,7 +25,7 @@ export interface AppControlsProps { } const LIST_MAX_HEIGHT = - "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)"; + "calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)"; const EmptyState = Text.withProps({ c: "dimmed", diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx index ac1265e65..74e05e86e 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx @@ -102,6 +102,20 @@ export const ErroredBorder: Story = { }, }; +// The green border flagging the server that just connected (#1682) — the +// success mirror of ErroredBorder. It persists while connected and clears on +// disconnect or a new connection attempt. +export const JustConnected: Story = { + args: { + id: "3d2c1b0a-9f8e-4d6c-8b7a-1a2b3c4d5e6f", + name: "My MCP Server", + config: stdioConfig, + info: { name: "My MCP Server", version: "1.2.0" }, + connection: connected, + justConnected: true, + }, +}; + export const LongServerName: Story = { args: { id: "a1b2c3d4-e5f6-4789-9abc-def012345678", diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx index 0ad12edc8..49d89ff6f 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx @@ -471,4 +471,107 @@ describe("ServerCard", () => { } }); }); + + describe("just-connected highlight (#1682)", () => { + it("draws the green highlight-variant border when justConnected", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[data-variant="highlighted"]'), + ).not.toBeNull(); + }); + + it("does not draw the highlight border when not justConnected", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[data-variant="highlighted"]'), + ).toBeNull(); + }); + + it("prefers the dimmed (disabled) variant over the just-connected highlight", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[data-variant="disabled"]'), + ).not.toBeNull(); + }); + + it("does not clear on click when justConnected (unlike the freshly-added highlight)", async () => { + const user = userEvent.setup(); + const onClearHighlight = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByText("My MCP Server")); + expect(onClearHighlight).not.toHaveBeenCalled(); + }); + + it("scrolls the card into view on the just-connected transition, deferred past the sidebar", () => { + vi.useFakeTimers(); + const scrollIntoView = vi.fn(); + const orig = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = scrollIntoView; + try { + const { rerender } = renderWithMantine( + , + ); + vi.advanceTimersByTime(1000); + expect(scrollIntoView).not.toHaveBeenCalled(); + + // Becoming connected schedules a deferred scroll (past the column open). + rerender( + , + ); + expect(scrollIntoView).not.toHaveBeenCalled(); + vi.advanceTimersByTime(320); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // A further re-render while still connected does not scroll again. + rerender( + , + ); + vi.advanceTimersByTime(1000); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + } finally { + Element.prototype.scrollIntoView = orig; + vi.useRealTimers(); + } + }); + + it("does not scroll when mounted already connected (no transition)", () => { + vi.useFakeTimers(); + const scrollIntoView = vi.fn(); + const orig = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = scrollIntoView; + try { + renderWithMantine( + , + ); + vi.advanceTimersByTime(1000); + expect(scrollIntoView).not.toHaveBeenCalled(); + } finally { + Element.prototype.scrollIntoView = orig; + vi.useRealTimers(); + } + }); + }); }); diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index 5ffd872f8..6adb747d4 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -9,7 +9,7 @@ import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/Serv import { TransportBadge } from "../../elements/TransportBadge/TransportBadge"; import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { FAILED_CARD_SCROLL_DELAY_MS } from "../../views/InspectorView/monitorColumnAnimation"; +import { CARD_SCROLL_DELAY_MS } from "../../views/InspectorView/monitorColumnAnimation"; export interface ServerCardProps extends ServerEntry { activeServer?: string; @@ -45,6 +45,13 @@ export interface ServerCardProps extends ServerEntry { * is connected or a new connection is attempted. */ errored?: boolean; + /** + * When true, this server just connected successfully: the card draws the green + * highlight border and scrolls itself into view once the monitoring sidebar + * has opened — the success mirror of `errored` (#1682). The parent clears this + * on disconnect or a new connection attempt. + */ + justConnected?: boolean; /** * Whether a highlighted card scrolls itself into view. When several cards are * highlighted at once (a batch import) only the first should scroll, so the @@ -146,6 +153,7 @@ export function ServerCard({ dragHandle, highlighted = false, errored = false, + justConnected = false, scrollOnHighlight = true, onClearHighlight, }: ServerCardProps) { @@ -173,9 +181,24 @@ export function ServerCard({ if (!justErrored) return; const timer = setTimeout(() => { rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, FAILED_CARD_SCROLL_DELAY_MS); + }, CARD_SCROLL_DELAY_MS); return () => clearTimeout(timer); }, [errored]); + + // Success mirror of the errored scroll (#1682): on the →connected transition, + // scroll the just-connected card into view, deferred past the monitoring + // sidebar's open (same shared delay) so the grid reflow settles first. Edge- + // guarded so a re-render while still connected doesn't re-scroll. + const wasConnectedRef = useRef(justConnected); + useEffect(() => { + const justBecameConnected = justConnected && !wasConnectedRef.current; + wasConnectedRef.current = justConnected; + if (!justBecameConnected) return; + const timer = setTimeout(() => { + rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, CARD_SCROLL_DELAY_MS); + return () => clearTimeout(timer); + }, [justConnected]); const transport = getTransport(config); const commandOrUrl = getCommandOrUrl(config); const version = info?.version; @@ -184,12 +207,12 @@ export function ServerCard({ // A dimmed card (another server is active) is inert, so the disabled variant // wins; otherwise a failed-connection card draws the red error border, then a - // freshly-added card draws the highlighted green border. + // freshly-added or just-connected card draws the highlighted green border. const variant = isDimmed ? "disabled" : errored ? "errored" - : highlighted + : highlighted || justConnected ? "highlighted" : undefined; @@ -210,6 +233,7 @@ export function ServerCard({ { ).toBeInTheDocument(); }); + it("shows an 'MCP Documentation' tooltip on the logo link (#1682)", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.hover(screen.getByRole("link")); + expect(await screen.findByText("MCP Documentation")).toBeInTheDocument(); + }); + it("invokes onOpenClientSettings when the client settings button is clicked", async () => { const user = userEvent.setup(); const onOpenClientSettings = vi.fn(); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 4d7c1eaf6..5d8f0353f 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -318,9 +318,11 @@ export function ViewHeader(props: ViewHeaderProps) { return ( - - - + + + + + 0; diff --git a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx index cac9f83dc..f748c72a4 100644 --- a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx +++ b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx @@ -41,7 +41,7 @@ export interface ProtocolUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", gap: "md", p: "xl", }); diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 61e6a1745..672d9292e 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -74,7 +74,7 @@ export interface ResourcesUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100dvh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", gap: "md", p: "xl", }); @@ -126,7 +126,7 @@ const EmptyState = Text.withProps({ }); const SCROLL_MAX_HEIGHT = - "calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)"; + "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; export function ResourcesScreen({ resources, diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx index 88c4810e3..b548fef2d 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx @@ -157,6 +157,32 @@ describe("ServerListScreen", () => { }); }); + describe("just-connected highlight (#1682)", () => { + it("draws the green highlight border on the just-connected server's card", () => { + const { container } = renderWithMantine( + , + ); + const highlighted = container.querySelectorAll( + '[data-variant="highlighted"]', + ); + expect(highlighted).toHaveLength(1); + expect(highlighted[0]).toHaveTextContent("Beta"); + }); + + it("draws no highlight border when connectedServerId is unset", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelectorAll('[data-variant="highlighted"]'), + ).toHaveLength(0); + }); + }); + describe("freshly-added highlight", () => { it("draws a green highlight border on every highlighted server", () => { const { container } = renderWithMantine( diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index f27b0d33f..f3ad90e5d 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -44,6 +44,12 @@ export interface ServerListScreenProps { * draws a red border until another server is connected or attempted. */ erroredServerId?: string; + /** + * Id of the server that just connected successfully (#1682). Its card draws + * the green highlight border and scrolls into view — the success mirror of + * `erroredServerId`. + */ + connectedServerId?: string; onAddManually: () => void; onImportConfig: () => void; onImportServerJson: () => void; @@ -84,6 +90,7 @@ export function ServerListScreen({ writable = true, activeServer, erroredServerId, + connectedServerId, onAddManually, onImportConfig, onImportServerJson, @@ -145,6 +152,7 @@ export function ServerListScreen({ onRemove, highlighted: highlightedServerIds?.includes(server.id) ?? false, errored: server.id === erroredServerId, + justConnected: server.id === connectedServerId, scrollOnHighlight: server.id === firstHighlightedId, onClearHighlight: onClearHighlight ? () => onClearHighlight(server.id) @@ -199,7 +207,7 @@ export function ServerListScreen({ /> { expect(screen.getByText("Alpha")).toBeInTheDocument(); }); + it("renders the footer row with the version and copyright (#1682)", () => { + renderWithMantine( + , + ); + 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 }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 0e397a268..58e111761 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -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, @@ -273,6 +275,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 +362,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 +561,8 @@ export function InspectorView({ serverListWritable = true, activeServer, erroredServerId, + connectedServerId, + version, connectionStatus, connectErrorMessage, initializeResult, @@ -1132,7 +1164,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. - + + + + + + + ); } diff --git a/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts b/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts index 619558b28..db84e021e 100644 --- a/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts +++ b/clients/web/src/components/views/InspectorView/monitorColumnAnimation.ts @@ -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; diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index 0a84285bd..8ee4f53d2 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -37,33 +37,20 @@ export const ThemeText = Text.extend({ }, }; } - // Two small, unobtrusive labels pinned to the bottom corners of the screen - // on the same row (#1639): the build version (bottom-left) and the - // copyright notice (bottom-right). Each occupies the full-height `xl` bottom - // margin band (`height: xl` + flex centering) so the text sits vertically - // centered in it, and insets by `xl` horizontally to line up with the - // content's left/right margins. Both are grey, non-interactive (clicks pass - // through), and out of the tab/selection flow. + // Two small, unobtrusive labels that sit in the footer row (#1682): the + // build version (left) and the copyright notice (right), positioned by the + // footer's `space-between` Group. Both are grey, single-line, and out of the + // text-selection flow. (Superseded the fixed bottom-corner badges of #1639 + // now that the footer is a real, full-width AppShell row.) if ( props.variant === "versionBadge" || props.variant === "copyrightBadge" ) { - const horizontal = - props.variant === "versionBadge" - ? { left: "var(--mantine-spacing-xl)" } - : { right: "var(--mantine-spacing-xl)" }; return { root: { - position: "fixed", - bottom: 0, - height: "var(--mantine-spacing-xl)", - display: "flex", - alignItems: "center", - ...horizontal, - zIndex: 100, color: "var(--inspector-text-secondary)", fontSize: "var(--mantine-font-size-xs)", - pointerEvents: "none", + whiteSpace: "nowrap", userSelect: "none", }, }; From 5ef21501461df6b302506668d785e98c8f2098cc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 20:04:50 -0400 Subject: [PATCH 02/12] web: box the Servers screen like the Messages panel (#1682) Wrap the Servers screen in a bordered, container-filling `panel`-variant Paper matching the Messages/Protocol panel: a header row with a "Servers" title on the left and the Export / Add Servers / collapse controls on the right, over the server-card grid which now scrolls inside the box (the page itself no longer scrolls, so the footer stays put). The screen fills the viewport minus the header and footer via the shared `ScreenLayout` pattern, dropping the old viewport-`mah` calc. The title is a semantic h3 (visually h4) so it doesn't skip a level under the disconnected header's h2 (heading-order a11y). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ServerListScreen.test.tsx | 7 + .../ServerListScreen/ServerListScreen.tsx | 140 +++++++++++++----- 2 files changed, 107 insertions(+), 40 deletions(-) diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx index b548fef2d..2c72927a6 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx @@ -43,6 +43,13 @@ describe("ServerListScreen", () => { expect(screen.getByText("Beta")).toBeInTheDocument(); }); + it("renders the 'Servers' box heading", () => { + renderWithMantine(); + expect( + screen.getByRole("heading", { name: "Servers" }), + ).toBeInTheDocument(); + }); + it("renders the empty state with no servers", () => { renderWithMantine(); expect( diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index f3ad90e5d..98b57922e 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -1,10 +1,14 @@ import { Alert, Code, + Flex, + Group, + Paper, ScrollArea, SimpleGrid, Stack, Text, + Title, } from "@mantine/core"; import { DndContext, @@ -75,14 +79,53 @@ export interface ServerListScreenProps { onToggleCompact: () => void; } -const PageContainer = Stack.withProps({ +// Full-height screen wrapper (matches the Protocol/Logs screens): fills the +// viewport minus the header and footer, clips overflow, and lays the optional +// read-only banner above the box in a column. +const ScreenLayout = Flex.withProps({ + variant: "screen", + direction: "column", + h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", + gap: "md", p: "xl", }); +// The bordered box that fills the container (#1682 follow-up), styled like the +// "Messages" panel: a `panel`-variant Paper (display:flex column) holding the +// title + controls header and the scrolling card grid. +const PanelContainer = Paper.withProps({ + withBorder: true, + p: "lg", + flex: 1, + variant: "panel", +}); + +// Header row inside the box: "Servers" title on the left, the Export / Add / +// collapse controls on the right. +const PanelHeader = Group.withProps({ + justify: "space-between", + mb: "sm", +}); + +// Wraps the scroll region so it fills the box below the header (`flex:1/mih:0`) +// and the inner ScrollArea can bound against it with `mah:100%`. +const CardScrollWrap = Stack.withProps({ + flex: 1, + mih: 0, + gap: 0, +}); + +// Centered in the full-height box so the empty message sits mid-panel rather +// than clinging to the top (matches the Messages panel's empty state). +const EmptyCenter = Stack.withProps({ + flex: 1, + align: "center", + justify: "center", +}); + const EmptyState = Text.withProps({ c: "dimmed", ta: "center", - py: "xl", }); export function ServerListScreen({ @@ -187,7 +230,7 @@ export function ServerListScreen({ ); return ( - + {!writable && ( This server list was launched with --config or an ad-hoc @@ -195,45 +238,62 @@ export function ServerListScreen({ --catalog (or no flag) to manage a writable catalog. )} - - - + + + {/* Semantic h3 (visually h4) so it doesn't skip a level under the + disconnected header's h2 "MCP Inspector" (heading-order a11y). The + connected panels can use h4 since their header shows no heading. */} + + Servers + + + + {servers.length === 0 ? ( - - No servers configured. Add a server to get started. - - ) : reorderable ? ( - - - {grid} - - + + + No servers configured. Add a server to get started. + + ) : ( - grid + + + {reorderable ? ( + + + {grid} + + + ) : ( + grid + )} + + )} - - + + ); } From 38a437ff0dcfe014f64fa5d7b75f64b1f334cda2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 20:20:20 -0400 Subject: [PATCH 03/12] web: give the Servers box the header/footer white surface (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set the Servers panel background explicitly to `--mantine-color-body` — the same white surface the header and footer use — so the box reads as white against the grey app background rather than relying on Paper's implicit default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/screens/ServerListScreen/ServerListScreen.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 98b57922e..55c0af0aa 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -98,6 +98,9 @@ const PanelContainer = Paper.withProps({ p: "lg", flex: 1, variant: "panel", + // The box takes the same surface as the header and footer (white in light + // mode) rather than the grey app background. + bg: "var(--mantine-color-body)", }); // Header row inside the box: "Servers" title on the left, the Export / Add / From edb0fb567227df945834a21e6cd41b1491d0bcbd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 20:46:27 -0400 Subject: [PATCH 04/12] web: soften the entry-card surfaces to a faint grey with raised white code (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A light-mode card-surface refresh so the content cards read as soft, layered surfaces on their white panels instead of blending into them: - Server cards take a faint `gray-0` fill (a hair lighter than the `gray-1` app background), scoped to the Servers grid via an `--inspector-surface-card` override so every card state (default/highlighted/errored/disabled) picks it up without touching the shared token. - The server card's command/URL ContentViewer Code block is raised to the white on-card surface, mirroring the Protocol/Network message cards. - The Protocol / Network / Task entry cards (the `inset` variant) move from `gray-1` to the same `gray-0`, so all the monitoring/entry cards share one faint-grey tone with white code blocks. Structural white cards (sidebar containers, previews) are intentionally left white — a grey card on the grey body would nearly vanish. Full gate green, including the 409-test axe a11y suite (still AA-clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.css | 17 +++++++++++++---- .../components/groups/ServerCard/ServerCard.tsx | 11 +++++++++++ .../ServerListScreen/ServerListScreen.tsx | 9 +++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 3c4e03bf9..c60f3a386 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -31,10 +31,16 @@ otherwise defaults to pure white. */ --inspector-surface-body: var(--mantine-color-gray-1); --inspector-surface-card: var(--mantine-color-white); - /* Recessed list-item surface (Protocol/Network message cards): light-grey in - light mode so the entries read as sunk into their white panel; keeps the - standard card tone in dark (overridden below). */ - --inspector-surface-inset: var(--mantine-color-gray-1); + /* Server-card fill: a hair lighter than the grey app background (`gray-0` vs + the body's `gray-1`) so the cards read as faintly raised on the white + Servers box. Light mode only; dark tracks the standard card surface. */ + --inspector-surface-card-server: var(--mantine-color-gray-0); + /* Entry-card surface (Protocol / Network / Task message cards): the faint + `gray-0` grey — a hair lighter than the `gray-1` app background and matching + the server cards — so the entries read as soft cards on their white panel + (their inner Code blocks are raised to white via the `inset` variant). Keeps + the standard card tone in dark (overridden below). */ + --inspector-surface-inset: var(--mantine-color-gray-0); /* Code/content surfaces match the light-grey body so they stay distinct against the white cards (the Code block has no border of its own). Dark mode keeps the darker `dark-9` inset below. */ @@ -125,6 +131,9 @@ tab bar share `gray-1`. Cards (`gray-9`) still sit a step above it. */ --inspector-surface-body: var(--mantine-color-dark-8); --inspector-surface-card: var(--mantine-color-gray-9); + /* Dark keeps server cards at the standard card surface (the lighter grey is a + light-mode-only treatment). */ + --inspector-surface-card-server: var(--inspector-surface-card); /* Dark keeps the message cards at the standard card tone (they already contrast against the darker panel/body), so only light mode goes grey. */ --inspector-surface-inset: var(--mantine-color-gray-9); diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index 6adb747d4..c89eb6525 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -103,6 +103,16 @@ const SubtleButton = Button.withProps({ size: "xs", }); +// Raise the card's ContentViewer Code block (the command/URL) to the "on-card" +// surface — white in light mode — mirroring the Protocol/Network `inset` message +// cards, so it reads as raised on the card instead of blending with it. Set as a +// cascade var on the card root; the Code theme reads `--inspector-code-surface`. +const CARD_STYLES = { + root: { + "--inspector-code-surface": "var(--inspector-surface-code-oncard)", + }, +} as const; + const RemoveButton = Button.withProps({ variant: "subtle", size: "xs", @@ -220,6 +230,7 @@ export function ServerCard({ diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 55c0af0aa..4459b82a5 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -221,6 +221,15 @@ export function ServerListScreen({ cols={{ base: 1, "1040px": 2, "1560px": 3 }} spacing="lg" className="grid-align-start" + // Override the card-surface var for the whole grid so every server card + // (in any state — the theme's default/highlighted/errored/disabled Card + // variants all read `--inspector-surface-card`) picks up the faintly-grey + // server tone, without touching the shared token or the variant logic. + styles={{ + root: { + "--inspector-surface-card": "var(--inspector-surface-card-server)", + }, + }} > {servers.map((server) => reorderable ? ( From 0fa4cb5a4673be429eaa1cd8b6814d70a88dba78 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 21:01:19 -0400 Subject: [PATCH 05/12] =?UTF-8?q?web:=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20hoist=20grid=20styles=20const,=20clarify=20heading?= =?UTF-8?q?=20comment=20(#1682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hoist the SimpleGrid card-surface `styles` override to a module-level `GRID_SURFACE_STYLES` const, matching the `CARD_STYLES` pattern in ServerCard. - Reword the Servers `Title` comment so it no longer reads as if h4 is used in this file; clarifies the other panels use h4 only because they render while connected (no h2 header to skip under). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ServerListScreen/ServerListScreen.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 4459b82a5..43772d920 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -131,6 +131,16 @@ const EmptyState = Text.withProps({ ta: "center", }); +// Override the card-surface var for the whole grid so every server card (in any +// state — the theme's default/highlighted/errored/disabled Card variants all +// read `--inspector-surface-card`) picks up the faintly-grey server tone, +// without touching the shared token or the variant logic. +const GRID_SURFACE_STYLES = { + root: { + "--inspector-surface-card": "var(--inspector-surface-card-server)", + }, +} as const; + export function ServerListScreen({ servers, writable = true, @@ -221,15 +231,7 @@ export function ServerListScreen({ cols={{ base: 1, "1040px": 2, "1560px": 3 }} spacing="lg" className="grid-align-start" - // Override the card-surface var for the whole grid so every server card - // (in any state — the theme's default/highlighted/errored/disabled Card - // variants all read `--inspector-surface-card`) picks up the faintly-grey - // server tone, without touching the shared token or the variant logic. - styles={{ - root: { - "--inspector-surface-card": "var(--inspector-surface-card-server)", - }, - }} + styles={GRID_SURFACE_STYLES} > {servers.map((server) => reorderable ? ( @@ -254,7 +256,8 @@ export function ServerListScreen({ {/* Semantic h3 (visually h4) so it doesn't skip a level under the disconnected header's h2 "MCP Inspector" (heading-order a11y). The - connected panels can use h4 since their header shows no heading. */} + other panels render h4 directly because they only show while + connected, when the header carries no heading to skip under. */} Servers From a1ad39b7058867833af36c93cc33a5752699c7c2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 21:26:09 -0400 Subject: [PATCH 06/12] =?UTF-8?q?web:=20tidy=20the=20monitoring=20sidebar?= =?UTF-8?q?=20layout=20=E2=80=94=20align=20controls=20to=20content,=20drop?= =?UTF-8?q?=20divider=20(#1682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the pinned monitoring sidebar read as a cohesive column: - Drop the horizontal Divider between the tab/search controls and the content. - Align the controls to the content panel's width: `MonitoringControls` gets the same `xl` horizontal inset (`px: xl`) the embedded screen gives its panel, so the tabs + search span exactly the panel width and line up with its edges (previously the controls were full-width, 64px wider than the panel). - Give the controls a little breathing room below the header (`ColumnLayout pt: md`) instead of sitting flush against it. - Halve the gap between the controls and the content: each embedded monitor screen uses `pt: md` (half the `xl` default) so the panel sits closer to the controls (32px → 16px), keeping the `xl` horizontal inset the controls align to. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../groups/MonitoringControls/MonitoringControls.tsx | 7 +++++-- .../groups/MonitoringScreen/MonitoringScreen.tsx | 6 ++++-- .../src/components/screens/ConsoleScreen/ConsoleScreen.tsx | 2 +- .../src/components/screens/LoggingScreen/LoggingScreen.tsx | 4 +++- .../src/components/screens/NetworkScreen/NetworkScreen.tsx | 2 +- .../components/screens/ProtocolScreen/ProtocolScreen.tsx | 2 +- .../web/src/components/screens/TasksScreen/TasksScreen.tsx | 4 +++- 7 files changed, 18 insertions(+), 9 deletions(-) diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index 5e10a6e1a..b9b14cd0c 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -15,11 +15,14 @@ export interface MonitoringControlsProps { // The controls row at the top of the pinned monitoring sidebar: a segmented tab // switcher on the left and a search box filling the rest. The column is closed // from the single header MonitoringToggle (#1661), so there is no close button -// here. +// here. `px: xl` matches the `xl` horizontal inset the embedded screen below +// gives its panel (its `ScreenLayout` padding), so the tabs + search span the +// same width as the content and line up with its left/right edges. const ControlsBar = Group.withProps({ wrap: "nowrap", gap: "sm", - p: "sm", + px: "xl", + py: "sm", }); // Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0` diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index ea15a8ff8..4917f5456 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Divider, Stack } from "@mantine/core"; +import { Stack } from "@mantine/core"; import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; @@ -22,9 +22,12 @@ export interface MonitoringScreenProps { // Fills the pinned column: a fixed controls row on top and the selected screen // filling the remaining height below (mih:0 lets the inner ScrollArea bound). +// `pt: md` gives the controls a little breathing room below the header instead +// of sitting flush against it. const ColumnLayout = Stack.withProps({ h: "100%", gap: 0, + pt: "md", }); // Relative-positioned host so each tab's `ScreenStage` (absolutely positioned) @@ -59,7 +62,6 @@ export function MonitoringScreen({ searchValue={searchValue} onSearchChange={onSearchChange} /> - {tabs.map((tab) => ( diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx index 3a0d8f2b4..3529ec023 100644 --- a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx +++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx @@ -127,7 +127,7 @@ export function ConsoleScreen({ // See LoggingScreen: only override `h` when embedded, so the standalone // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` // would clobber it and collapse an empty screen to its controls' height). - + {embedded ? null : ( diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index b92a56a9a..58e254dfc 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -82,7 +82,9 @@ export function LoggingScreen({ // ScreenLayout's default full-screen height. Passing `h={undefined}` here // would clobber that default (withProps plain-spreads), collapsing an empty // screen to its controls' height — so only override `h` when embedded. - + // Embedded also halves the top padding (`pt: md` vs the `xl` default) so the + // panel sits closer to the sidebar's tab/search controls above it. + {embedded ? null : ( diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx index 64ab17e14..ce135227e 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -87,7 +87,7 @@ export function NetworkScreen({ // See LoggingScreen: only override `h` when embedded, so the standalone // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` // would clobber it and collapse an empty screen to its controls' height). - + {embedded ? null : ( diff --git a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx index f748c72a4..a707ae637 100644 --- a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx +++ b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx @@ -107,7 +107,7 @@ export function ProtocolScreen({ // See LoggingScreen: only override `h` when embedded, so the standalone // screen keeps ScreenLayout's default full-screen height (a `h={undefined}` // would clobber it and collapse an empty screen to its controls' height). - + {embedded ? null : ( diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx index 7376fad32..a54253a21 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx @@ -59,7 +59,9 @@ export function TasksScreen({ // Embedded fills the monitoring sidebar column (100%); standalone keeps the // ScreenLayout's default full-screen height. Only override `h` when embedded // — passing `h={undefined}` would clobber the default (withProps spreads). - + // Embedded also halves the top padding (`pt: md` vs `xl`) so the panel sits + // closer to the sidebar's tab/search controls (see LoggingScreen). + {embedded ? null : ( From 609ddb471ff5fa7367382855154f454da238f66d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 21:49:20 -0400 Subject: [PATCH 07/12] web: floor the app at 1280px and collapse the header controls there (#1682) At the tight end of the layout the header ran out of room (with every tab shown, the connection status text wrapped and the Disconnect label clipped). Fix it by flooring the app and unifying the header's collapse breakpoints at that floor: - Add a 1280px min-width on the app mount (`#root`, so it doesn't leak into Storybook, which shares App.css). Below it the page scrolls horizontally rather than reflowing into a broken header. - Collapse the Disconnect control to its icon at the 1280 floor (was 768px) and drop the connection status text to a dot-only indicator there (was 1200px), so the two now compact together at the app's minimum width. - Swap the Disconnect icon to `VscDebugDisconnect` (react-icons/vsc). Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.css | 10 ++++++++++ .../ServerStatusIndicator/ServerStatusIndicator.tsx | 5 ++++- .../src/components/groups/ViewHeader/ViewHeader.tsx | 11 ++++++++--- .../views/InspectorView/InspectorView.test.tsx | 7 ++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/clients/web/src/App.css b/clients/web/src/App.css index c60f3a386..8a0cda90d 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -234,6 +234,16 @@ body { background-color: var(--inspector-surface-body); } +/* Floor the app at 1280px so the header (tabs + status + Disconnect) and the + split panes never reflow into a broken layout; below this the page scrolls + horizontally instead. The header collapses its Disconnect + status controls + to icons at this same floor (see ViewHeader / ServerStatusIndicator). 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. */ +#root { + min-width: 1280px; +} + /* ── Animations ──────────────────────────────────────────── */ @keyframes inspector-pulse { diff --git a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx index 09ed328e0..40438768e 100644 --- a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx +++ b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx @@ -56,7 +56,10 @@ export function ServerStatusIndicator({ failed = false, showLabel: showLabelProp, }: ServerStatusIndicatorProps) { - const wideViewport = useMediaQuery("(min-width: 1200px)"); + // Show the text label only above the 1280px app min-width; at/below that floor + // the header is tight (see ViewHeader's Disconnect collapse), so the indicator + // drops to a dot-only status with the label moved to its `title` tooltip. + const wideViewport = useMediaQuery("(min-width: 1281px)"); const showLabel = showLabelProp ?? wideViewport; // A failed connection settles the status back to "disconnected"; the `failed` // flag distinguishes it as a failure (red + "Failed") from a deliberate diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 5d8f0353f..491d0ab91 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -17,7 +17,8 @@ import { } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import { MdLightMode, MdDarkMode, MdLinkOff, MdSettings } from "react-icons/md"; +import { MdLightMode, MdDarkMode, MdSettings } from "react-icons/md"; +import { VscDebugDisconnect } from "react-icons/vsc"; import type { ConnectionStatus } from "@inspector/core/mcp/types.js"; import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator"; import { @@ -250,7 +251,11 @@ export function ViewHeader(props: ViewHeaderProps) { const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle; const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode; const showSegmented = useMediaQuery("(min-width: 992px)"); - const showDisconnectLabel = useMediaQuery("(min-width: 768px)"); + // Below the 1280px app min-width the header runs out of room (with every tab + // shown, the connection text wraps and the Disconnect label clips), so collapse + // the Disconnect control to its icon at that floor. Matches the connection + // status indicator, which drops its text at the same breakpoint. + const showDisconnectLabel = useMediaQuery("(min-width: 1281px)"); // Retain the latest connected display data so each region can keep rendering // it while animating out after disconnect (#1450). Uses React's "adjust state @@ -433,7 +438,7 @@ export function ViewHeader(props: ViewHeaderProps) { ) : ( - + )} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 4e2c2a299..e2b837580 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -22,9 +22,10 @@ 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. +// gate controllable per test. Every other query — ViewHeader's 992px +// SegmentedControl gate and the 1281px Disconnect/status-label gate — is forced +// "wide" by the mock, so the connected header renders its full form and every +// existing test below is unaffected. const monitorWide = vi.hoisted(() => ({ value: false })); vi.mock("@mantine/hooks", async (importOriginal) => { const actual = await importOriginal(); From 03c05eb2094e932245e03952240af6d301ecafd3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 22:16:21 -0400 Subject: [PATCH 08/12] web: drop connection-status text at 1500px and neutralize the Disconnect icon (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the connection status text's collapse to 1500px (was tied to the 1280px floor). It's the header's widest optional element, so shedding it first — while the Disconnect control still collapses at the 1280px floor — keeps the tab row from crowding across the 1280–1500 range. - Make the collapsed Disconnect a plain grey icon like the other header icons (drop the red tint) and give it a "Disconnect from server" tooltip, matching the Client-settings / theme toggles (Tooltip-wrapped, subtle grey). The aria-label now reads "Disconnect from server" too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ServerStatusIndicator.tsx | 10 ++++++---- .../groups/ViewHeader/ViewHeader.tsx | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx index 40438768e..0dbe6e489 100644 --- a/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx +++ b/clients/web/src/components/elements/ServerStatusIndicator/ServerStatusIndicator.tsx @@ -56,10 +56,12 @@ export function ServerStatusIndicator({ failed = false, showLabel: showLabelProp, }: ServerStatusIndicatorProps) { - // Show the text label only above the 1280px app min-width; at/below that floor - // the header is tight (see ViewHeader's Disconnect collapse), so the indicator - // drops to a dot-only status with the label moved to its `title` tooltip. - const wideViewport = useMediaQuery("(min-width: 1281px)"); + // 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; // A failed connection settles the status back to "disconnected"; the `failed` // flag distinguishes it as a failure (red + "Failed") from a deliberate diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 491d0ab91..7ead8a936 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -193,9 +193,8 @@ const DisconnectButton = Button.withProps({ const DisconnectIcon = ActionIcon.withProps({ variant: "subtle", - c: "red", size: 36, - "aria-label": "Disconnect", + "aria-label": "Disconnect from server", }); const ClientSettingsToggle = ActionIcon.withProps({ @@ -252,9 +251,10 @@ export function ViewHeader(props: ViewHeaderProps) { const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode; const showSegmented = useMediaQuery("(min-width: 992px)"); // Below the 1280px app min-width the header runs out of room (with every tab - // shown, the connection text wraps and the Disconnect label clips), so collapse - // the Disconnect control to its icon at that floor. Matches the connection - // status indicator, which drops its text at the same breakpoint. + // shown, the Disconnect label clips), so collapse the Disconnect control to its + // icon at that floor. The connection status text drops earlier, at 1500px — + // it's the wider element — so the two shed independently (see + // ServerStatusIndicator). const showDisconnectLabel = useMediaQuery("(min-width: 1281px)"); // Retain the latest connected display data so each region can keep rendering @@ -437,9 +437,11 @@ export function ViewHeader(props: ViewHeaderProps) { Disconnect ) : ( - - - + + + + + )} ) : ( From 81b1cfa81d5fb5913a27bb6fc05b93ad4d9e44b3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 13 Jul 2026 22:37:30 -0400 Subject: [PATCH 09/12] web: honor the 1280px floor in the header and simplify the responsive rules (#1682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-through on the 1280px minimum so the whole app — header included — behaves as one floored, horizontally-scrolling unit, and drop the now-redundant sub-1280 breakpoints: - Header respects the 1280px floor: make `#root` a containing block (a `transform`) so the AppShell's position:fixed header + footer span the full 1280 and scroll horizontally with the content below the floor, instead of staying pinned to a narrower viewport. Body-portalled overlays are unaffected. - Disconnect is always the (grey, tooltip'd) icon — drop the label-button variant and its breakpoint. - Tabs are always a SegmentedControl — drop the narrow-viewport Select fallback. - Remove the 1040px media-query gate on the monitoring sidebar: with the 1280px floor there's always room, so the column/toggle are available whenever a server is connected (or a connect attempt failed) and a monitor tab exists. Tests updated to the always-icon / always-SegmentedControl / always-available behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.css | 17 +++-- .../groups/ViewHeader/ViewHeader.test.tsx | 34 +++++----- .../groups/ViewHeader/ViewHeader.tsx | 63 ++++--------------- .../InspectorView/InspectorView.test.tsx | 34 ++-------- .../views/InspectorView/InspectorView.tsx | 26 +++----- 5 files changed, 48 insertions(+), 126 deletions(-) diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 8a0cda90d..a5d50ba28 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -234,14 +234,19 @@ body { background-color: var(--inspector-surface-body); } -/* Floor the app at 1280px so the header (tabs + status + Disconnect) and the - split panes never reflow into a broken layout; below this the page scrolls - horizontally instead. The header collapses its Disconnect + status controls - to icons at this same floor (see ViewHeader / ServerStatusIndicator). 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. */ +/* 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 ──────────────────────────────────────────── */ diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx index 2b2d4142f..705857f6e 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.test.tsx @@ -192,10 +192,10 @@ describe("ViewHeader", () => { renderWithMantine( , ); - // Either label-button "Disconnect" (wide) or icon-only with aria-label - const disconnectButton = - screen.queryByRole("button", { name: "Disconnect" }) ?? - screen.getByRole("button", { name: /disconnect/i }); + // Disconnect is always the icon, labelled by its aria-label / tooltip. + const disconnectButton = screen.getByRole("button", { + name: "Disconnect from server", + }); await user.click(disconnectButton); expect(onDisconnect).toHaveBeenCalledTimes(1); }); @@ -206,7 +206,7 @@ describe("ViewHeader", () => { expect(screen.getAllByText("Prompts").length).toBeGreaterThan(0); }); - it("renders the SegmentedControl and label disconnect button on wide viewports", async () => { + it("renders the SegmentedControl and the disconnect icon on wide viewports", async () => { mediaQueryMock.value = true; const user = userEvent.setup(); const onTabChange = vi.fn(); @@ -218,8 +218,10 @@ describe("ViewHeader", () => { onDisconnect={onDisconnect} />, ); - // Disconnect renders as a labelled button on wide viewport. - const disconnectBtn = screen.getByRole("button", { name: "Disconnect" }); + // Disconnect is always the icon (labelled by its tooltip / aria-label). + const disconnectBtn = screen.getByRole("button", { + name: "Disconnect from server", + }); await user.click(disconnectBtn); expect(onDisconnect).toHaveBeenCalledTimes(1); @@ -320,7 +322,7 @@ describe("ViewHeader", () => { ).toBe("in"); expect( screen - .getByRole("button", { name: "Disconnect" }) + .getByRole("button", { name: "Disconnect from server" }) .closest("[data-anim]") ?.getAttribute("data-anim"), ).toBe("in"); @@ -341,7 +343,7 @@ describe("ViewHeader", () => { ).toBe("out"); expect( screen - .getByRole("button", { name: "Disconnect" }) + .getByRole("button", { name: "Disconnect from server" }) .closest("[data-anim]") ?.getAttribute("data-anim"), ).toBe("out"); @@ -350,7 +352,7 @@ describe("ViewHeader", () => { expect(screen.queryByText("my-mcp-server")).not.toBeInTheDocument(), ); expect( - screen.queryByRole("button", { name: "Disconnect" }), + screen.queryByRole("button", { name: "Disconnect from server" }), ).not.toBeInTheDocument(); }); @@ -428,20 +430,14 @@ describe("ViewHeader", () => { } }); - it("invokes onTabChange when a different tab is picked from the Select", async () => { + it("invokes onTabChange when a different tab is picked", async () => { const user = userEvent.setup(); const onTabChange = vi.fn(); renderWithMantine( , ); - // On narrow viewport (default mediaQuery=false), tabs use a Select. - const select = screen.getByRole("textbox"); - await user.click(select); - const option = await screen.findByRole("option", { - name: "Resources", - hidden: true, - }); - await user.click(option); + // Tabs always render as a SegmentedControl (radios); pick another one. + await user.click(screen.getByRole("radio", { name: "Resources" })); expect(onTabChange).toHaveBeenCalledWith("Resources"); }); }); diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index 7ead8a936..f4bb44f48 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -3,11 +3,9 @@ import { ActionIcon, Anchor, Box, - Button, Group, Image, SegmentedControl, - Select, Text, Title, Tooltip, @@ -15,7 +13,6 @@ import { useComputedColorScheme, type MantineTransition, } from "@mantine/core"; -import { useMediaQuery } from "@mantine/hooks"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; import { MdLightMode, MdDarkMode, MdSettings } from "react-icons/md"; import { VscDebugDisconnect } from "react-icons/vsc"; @@ -63,8 +60,6 @@ export type ViewHeaderProps = ConnectedProps | UnconnectedProps; // duration. The motion itself is CSS (`.header-anim`); keep the 300ms / // 150ms-stagger there in sync with this value. const HEADER_ANIM_MS = 300; -// Fixed Select width on narrow viewports (fits the longest tab label). -const SELECT_WIDTH = 140; // Grace window after a connection is established before the new-tab glow arms // (#1450). Primitive lists (prompts/resources/tasks) are fetched asynchronously // just after the handshake, so their tabs appear a few renders into the @@ -182,15 +177,6 @@ const RightConnectedGroup = Group.withProps({ wrap: "nowrap", }); -const DisconnectButton = Button.withProps({ - variant: "subtle", - // `color` drives the subtle hover/active tint; `c` overrides just the label - // to the AA-compliant danger red (red.6 text alone fell under contrast). - color: "red.6", - size: "sm", - c: "var(--inspector-danger-text)", -}); - const DisconnectIcon = ActionIcon.withProps({ variant: "subtle", size: 36, @@ -249,13 +235,6 @@ export function ViewHeader(props: ViewHeaderProps) { } const monitorToggleForRender = props.monitorToggle ?? lastMonitorToggle; const ThemeIcon = colorScheme === "dark" ? MdLightMode : MdDarkMode; - const showSegmented = useMediaQuery("(min-width: 992px)"); - // Below the 1280px app min-width the header runs out of room (with every tab - // shown, the Disconnect label clips), so collapse the Disconnect control to its - // icon at that floor. The connection status text drops earlier, at 1500px — - // it's the wider element — so the two shed independently (see - // ServerStatusIndicator). - const showDisconnectLabel = useMediaQuery("(min-width: 1281px)"); // Retain the latest connected display data so each region can keep rendering // it while animating out after disconnect (#1450). Uses React's "adjust state @@ -371,26 +350,12 @@ export function ViewHeader(props: ViewHeaderProps) { className="header-anim header-stack-cell" data-anim={connectedAnim} > - {showSegmented ? ( - - ) : ( - // Narrow viewport: the new-tab glow doesn't apply to the - // dropdown (a collapsed Select can't pulse a single option), - // so the labels stay plain strings here. -