From 650e5bd020b268019cfd377a5c8ac92b44e54017 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 19:07:31 -0400 Subject: [PATCH 1/7] feat(web): show the Inspector version in the lower-right corner (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display the single-source version (root package.json) in the web UI's lower-right corner, in grey — matching the CLI and TUI. The browser can't read the filesystem, so the version flows backend → browser via GET /api/config: - Add `version?: string` to InitialConfigPayload; the web backend (webServerConfigToInitialPayload) reads it once via the shared readInspectorVersion() and tags it onto the payload. - Add a `useInspectorVersion` hook mirroring useSandboxUrl/useServerListWritable. - Add a presentational `VersionBadge` element rendered fixed lower-right, grey, non-interactive; styling lives in a `versionBadge` Text theme variant (no inline styles, --inspector-* tokens). - Wire it into App.tsx. Verified live: /api/config serves `version: "2.0.0"` and the badge renders `v2.0.0` fixed at the lower-right in grey. Tests: useInspectorVersion (full branch coverage), VersionBadge (unit + Storybook), web-server-config payload. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/server/web-server-config.ts | 15 +- clients/web/src/App.tsx | 9 + .../VersionBadge/VersionBadge.stories.tsx | 34 ++++ .../VersionBadge/VersionBadge.test.tsx | 22 +++ .../elements/VersionBadge/VersionBadge.tsx | 25 +++ .../core/react/useInspectorVersion.test.tsx | 181 ++++++++++++++++++ .../server/web-server-config.test.ts | 6 + clients/web/src/theme/Text.ts | 17 ++ core/mcp/remote/node/server.ts | 6 + core/react/useInspectorVersion.ts | 83 ++++++++ 10 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/components/elements/VersionBadge/VersionBadge.stories.tsx create mode 100644 clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx create mode 100644 clients/web/src/components/elements/VersionBadge/VersionBadge.tsx create mode 100644 clients/web/src/test/core/react/useInspectorVersion.test.tsx create mode 100644 core/react/useInspectorVersion.ts diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 3dc833ac..bc34588e 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -14,8 +14,14 @@ import { LEGACY_AUTH_TOKEN_ENV, } from "../../../core/mcp/remote/constants.ts"; import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts"; +import { readInspectorVersion } from "../../../core/node/version.ts"; import { resolveSandboxPort } from "./sandbox-controller.js"; +// The single-source Inspector version (root package.json), read once at load. +// The browser can't read the filesystem the way the CLI/TUI do, so the backend +// reads it here and hands it to the client via GET /api/config. +const inspectorVersion = readInspectorVersion(import.meta.url); + export interface WebServerConfig { port: number; hostname: string; @@ -92,11 +98,18 @@ function defaultEnvironmentFromProcess( } /** - * Convert WebServerConfig.initialMcpConfig to the shape expected by GET /api/config. + * Convert WebServerConfig.initialMcpConfig to the shape expected by GET + * /api/config, tagging on the single-source Inspector `version` so the browser + * can display it. */ export function webServerConfigToInitialPayload( config: WebServerConfig, ): InitialConfigPayload { + return { ...transportDefaults(config), version: inspectorVersion }; +} + +/** The transport-specific defaults half of the `/api/config` payload. */ +function transportDefaults(config: WebServerConfig): InitialConfigPayload { const mc = config.initialMcpConfig; const defaultEnvironment = defaultEnvironmentFromProcess( mc && "env" in mc && mc.env ? mc.env : undefined, diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index c5fc9935..5e005100 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -98,8 +98,10 @@ import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js" import { useStderrLog } from "@inspector/core/react/useStderrLog.js"; import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js"; import { useServerListWritable } from "@inspector/core/react/useServerListWritable.js"; +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 type { ToolCallState, ToolsUiState, @@ -669,6 +671,12 @@ function App() { baseUrl: configBaseUrl, authToken: getAuthToken(), }); + // The Inspector version (root package.json), shown in the lower-right corner. + // The browser can't read it off disk, so the backend sends it via /api/config. + const { version: inspectorVersion } = useInspectorVersion({ + baseUrl: configBaseUrl, + authToken: getAuthToken(), + }); const [clientConfig, setClientConfig] = useState({}); useEffect(() => { @@ -3816,6 +3824,7 @@ function App() { onRefreshApps={onRefreshTools} /> + = { + title: "Elements/VersionBadge", + component: VersionBadge, + args: { version: "2.0.0" }, +}; + +export default meta; +type Story = StoryObj; + +// Default: a grey `v2.0.0` pinned to the lower-right corner of the viewport. +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const badge = await canvas.findByText("v2.0.0"); + await expect(badge).toBeInTheDocument(); + await expect(badge).toHaveAttribute( + "aria-label", + "Inspector version 2.0.0", + ); + }, +}; + +// No version yet (initial load / legacy backend): renders nothing. +export const Hidden: Story = { + args: { version: undefined }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText(/^v/)).toBeNull(); + }, +}; diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx new file mode 100644 index 00000000..3382d34c --- /dev/null +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { VersionBadge } from "./VersionBadge"; + +describe("VersionBadge", () => { + it("renders the version with a `v` prefix and an accessible label", () => { + renderWithMantine(); + const badge = screen.getByText("v2.0.0"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute("aria-label", "Inspector version 2.0.0"); + }); + + it("renders nothing when the version is undefined", () => { + renderWithMantine(); + expect(screen.queryByText(/^v/)).toBeNull(); + }); + + it("renders nothing when the version is an empty string", () => { + renderWithMantine(); + expect(screen.queryByText(/^v/)).toBeNull(); + }); +}); diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx new file mode 100644 index 00000000..db7468f5 --- /dev/null +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx @@ -0,0 +1,25 @@ +import { Text } from "@mantine/core"; + +export interface VersionBadgeProps { + /** The Inspector version to show, e.g. `"2.0.0"`. Renders nothing when absent. */ + version?: string; +} + +// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-right +// corner in grey and makes it non-interactive. +const VersionText = Text.withProps({ variant: "versionBadge" }); + +/** + * A small, unobtrusive build-version label fixed to the lower-right 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). + */ +export function VersionBadge({ version }: VersionBadgeProps) { + if (!version) return null; + return ( + + v{version} + + ); +} diff --git a/clients/web/src/test/core/react/useInspectorVersion.test.tsx b/clients/web/src/test/core/react/useInspectorVersion.test.tsx new file mode 100644 index 00000000..4d1f8492 --- /dev/null +++ b/clients/web/src/test/core/react/useInspectorVersion.test.tsx @@ -0,0 +1,181 @@ +/** + * Tests for useInspectorVersion — runs in happy-dom under the unit project. Uses + * a controlled fake `fetch` so each branch (present / absent / non-string / HTTP + * error / network throw / post-unmount guards) and the auth header are asserted + * directly. Mirrors useSandboxUrl's test (same `/api/config` fetch shape). + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion"; + +function jsonResponse(body: unknown, ok = true): Response { + return { + ok, + status: ok ? 200 : 401, + json: async () => body, + } as unknown as Response; +} + +describe("useInspectorVersion", () => { + it("starts loading, then resolves the version from the config payload", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ version: "2.0.0" })); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.version).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBe("2.0.0"); + }); + + it("sends the bearer auth header when a token is provided", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); + + renderHook(() => + useInspectorVersion({ + baseUrl: "http://test.local/", + authToken: "secret-token", + fetchFn, + }), + ); + + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + const [url, init] = fetchFn.mock.calls[0]; + // Trailing slash on baseUrl is normalized away. + expect(url).toBe("http://test.local/api/config"); + expect(init.method).toBe("GET"); + expect(init.headers["x-mcp-remote-auth"]).toBe("Bearer secret-token"); + }); + + it("omits the auth header when no token is provided", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); + + renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + const [, init] = fetchFn.mock.calls[0]; + expect(init.headers["x-mcp-remote-auth"]).toBeUndefined(); + }); + + it("leaves version undefined when the payload omits it", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ defaultEnvironment: {} })); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("leaves version undefined when the field is not a usable string", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ version: "" })); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("leaves version undefined on a non-ok response", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ version: "9.9.9" }, false)); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("leaves version undefined when the fetch throws", async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error("network down")); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { + const globalFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ version: "3.1.4" })); + const original = globalThis.fetch; + globalThis.fetch = globalFetch as unknown as typeof fetch; + try { + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local" }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(globalFetch).toHaveBeenCalledWith( + "http://test.local/api/config", + expect.objectContaining({ method: "GET" }), + ); + expect(result.current.version).toBe("3.1.4"); + } finally { + globalThis.fetch = original; + } + }); + + it("drops a response that resolves after unmount (no state update)", async () => { + let resolveFetch: ((r: Response) => void) | undefined; + const fetchFn = vi.fn().mockReturnValue( + new Promise((r) => { + resolveFetch = r; + }), + ); + + const { result, unmount } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + expect(result.current.loading).toBe(true); + + unmount(); + resolveFetch?.(jsonResponse({ version: "2.0.0" })); + await Promise.resolve(); + await Promise.resolve(); + expect(result.current.version).toBeUndefined(); + }); + + it("drops a response whose json resolves after unmount", async () => { + let resolveJson: ((v: unknown) => void) | undefined; + const res = { + ok: true, + status: 200, + json: () => + new Promise((r) => { + resolveJson = r; + }), + } as unknown as Response; + const fetchFn = vi.fn().mockResolvedValue(res); + + const { result, unmount } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + await Promise.resolve(); + + unmount(); + resolveJson?.({ version: "2.0.0" }); + await Promise.resolve(); + await Promise.resolve(); + expect(result.current.version).toBeUndefined(); + }); +}); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index a9d7839b..2b407118 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -260,6 +260,12 @@ describe("webServerConfigToInitialPayload", () => { expect(payload.defaultTransport).toBeUndefined(); }); + it("tags the payload with the single-source Inspector version", () => { + // Read from the root package.json via readInspectorVersion() at module load. + const payload = webServerConfigToInitialPayload(baseConfig()); + expect(payload.version).toMatch(/^\d+\.\d+\.\d+/); + }); + it("emits stdio defaults when initialMcpConfig.type === 'stdio'", () => { const cfg = baseConfig(); cfg.initialMcpConfig = { diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index 2ae8af76..1a635108 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -37,6 +37,23 @@ export const ThemeText = Text.extend({ }, }; } + // A small, unobtrusive build-version label pinned to the lower-right corner + // of the screen (#1639): grey, non-interactive (clicks pass through), and + // out of the tab/selection flow so it never interferes with the UI beneath. + if (props.variant === "versionBadge") { + return { + root: { + position: "fixed", + bottom: "0.35rem", + right: "0.6rem", + zIndex: 100, + color: "var(--inspector-text-secondary)", + fontSize: "var(--mantine-font-size-xs)", + pointerEvents: "none", + userSelect: "none", + }, + }; + } return { root: {} }; }, }); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 77804a09..bbfaf198 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -91,6 +91,12 @@ export interface InitialConfigPayload { * shows catalog CRUD affordances. */ writable?: boolean; + /** + * The Inspector version (from the root `package.json`), so the browser — which + * can't read the filesystem the way the CLI/TUI do — can display it. Absent on + * a legacy backend that predates the field; the UI just shows nothing then. + */ + version?: string; } export interface RemoteServerOptions { diff --git a/core/react/useInspectorVersion.ts b/core/react/useInspectorVersion.ts new file mode 100644 index 00000000..81300bfc --- /dev/null +++ b/core/react/useInspectorVersion.ts @@ -0,0 +1,83 @@ +/** + * React hook over the `GET /api/config` endpoint that recovers the Inspector + * `version` the backend reads from the root `package.json`. The browser can't + * read the filesystem the way the CLI/TUI do, so the version is delivered on the + * config payload (see `webServerConfigToInitialPayload`) and surfaced here. + * + * Mirrors {@link useSandboxUrl} / {@link useServerListWritable} (same + * `/api/config` source, same fetch shape). `version` is `undefined` until the + * fetch resolves, and stays `undefined` if the backend omits it (a legacy + * backend that predates the field) — the UI simply renders nothing then. + */ + +import { useCallback, useEffect, useState } from "react"; + +export interface UseInspectorVersionOptions { + /** Base URL of the remote server (typically `window.location.origin`). */ + baseUrl: string; + /** Optional auth token for the `x-mcp-remote-auth` header. */ + authToken?: string; + /** Fetch function to use (default: globalThis.fetch). Useful in tests. */ + fetchFn?: typeof fetch; +} + +export interface UseInspectorVersionResult { + /** The Inspector version, or undefined when unavailable / not yet loaded. */ + version: string | undefined; + /** True while the initial fetch is in flight. */ + loading: boolean; +} + +/** Minimal shape we read from the `/api/config` payload. */ +interface ConfigPayload { + version?: unknown; +} + +export function useInspectorVersion( + opts: UseInspectorVersionOptions, +): UseInspectorVersionResult { + const { baseUrl, authToken, fetchFn } = opts; + const doFetch = fetchFn ?? globalThis.fetch; + const base = baseUrl.replace(/\/$/, ""); + + const [version, setVersion] = useState(undefined); + const [loading, setLoading] = useState(true); + + const load = useCallback( + async (isCancelled: () => boolean): Promise => { + const headers: Record = {}; + if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`; + try { + const res = await doFetch(`${base}/api/config`, { + method: "GET", + headers, + }); + if (isCancelled() || !res.ok) return; + const body = (await res.json()) as ConfigPayload; + if (isCancelled()) return; + // Tolerate a missing/non-string field — a legacy backend that omits it + // leaves the version hidden rather than showing a bogus value. + setVersion( + typeof body.version === "string" && body.version + ? body.version + : undefined, + ); + } catch { + // Network error / aborted fetch: leave version undefined (hidden). + } finally { + if (!isCancelled()) setLoading(false); + } + }, + [base, authToken, doFetch], + ); + + useEffect(() => { + let cancelled = false; + void load(() => cancelled); + return () => { + cancelled = true; + }; + }, [load]); + + return { version, loading }; +} From 5d024ab54fc5f8a073e8717454aa9f0bfab7c175 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 20:11:52 -0400 Subject: [PATCH 2/7] feat(web): add copyright notice + footer/toolbar polish (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a grey CopyrightBadge ("Copyright © Model Context Protocol a Series of LF Projects, LLC.") to the footer. Version (bottom-left) and copyright (bottom-right) share the bottom `xl` margin band: vertically centered in it and inset by `xl` to line up with the content's left/right margins. - Move the list expand/collapse toggle to the right of the "Add Servers" button in ServerListControls. - Make the monitoring column's close-sidebar control match the open-sidebar PinColumnButton theme (Button size sm / variant subtle) instead of a gray ActionIcon, so the open/close affordances read as a pair. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/src/App.tsx | 2 ++ .../CopyrightBadge/CopyrightBadge.stories.tsx | 19 ++++++++++++++ .../CopyrightBadge/CopyrightBadge.test.tsx | 17 ++++++++++++ .../CopyrightBadge/CopyrightBadge.tsx | 18 +++++++++++++ .../elements/VersionBadge/VersionBadge.tsx | 4 +-- .../MonitoringControls/MonitoringControls.tsx | 9 +++---- .../ServerListControls.test.tsx | 5 ++-- .../ServerListControls/ServerListControls.tsx | 6 ++--- clients/web/src/theme/Text.ts | 26 ++++++++++++++----- 9 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.stories.tsx create mode 100644 clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.test.tsx create mode 100644 clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.tsx diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 5e005100..48295bcf 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -102,6 +102,7 @@ import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion.j 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, @@ -3824,6 +3825,7 @@ function App() { onRefreshApps={onRefreshTools} /> + = { + title: "Elements/CopyrightBadge", + component: CopyrightBadge, +}; + +export default meta; +type Story = StoryObj; + +// The grey copyright notice pinned to the lower-left corner of the viewport. +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(COPYRIGHT_NOTICE)).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.test.tsx b/clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.test.tsx new file mode 100644 index 00000000..271d5ba8 --- /dev/null +++ b/clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.test.tsx @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { CopyrightBadge, COPYRIGHT_NOTICE } from "./CopyrightBadge"; + +describe("CopyrightBadge", () => { + it("renders the project copyright notice", () => { + renderWithMantine(); + expect(screen.getByText(COPYRIGHT_NOTICE)).toBeInTheDocument(); + }); + + it("names the Model Context Protocol and LF Projects", () => { + renderWithMantine(); + expect( + screen.getByText(/Model Context Protocol.*Series of LF Projects, LLC\./), + ).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.tsx b/clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.tsx new file mode 100644 index 00000000..49f1fb7c --- /dev/null +++ b/clients/web/src/components/elements/CopyrightBadge/CopyrightBadge.tsx @@ -0,0 +1,18 @@ +import { Text } from "@mantine/core"; + +/** The project's copyright notice. */ +export const COPYRIGHT_NOTICE = + "Copyright © Model Context Protocol a Series of LF Projects, LLC."; + +// The `copyrightBadge` variant (src/theme/Text.ts) pins it to the lower-right +// corner in grey, on the same row as the version badge, and non-interactive. +const CopyrightText = Text.withProps({ variant: "copyrightBadge" }); + +/** + * The project copyright notice, fixed to the lower-right corner of the screen + * (#1639) — the grey, non-interactive twin of the lower-left version badge, + * sharing its row. + */ +export function CopyrightBadge() { + return {COPYRIGHT_NOTICE}; +} diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx index db7468f5..fbb834d7 100644 --- a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx @@ -5,12 +5,12 @@ export interface VersionBadgeProps { version?: string; } -// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-right +// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-left // corner in grey and makes it non-interactive. const VersionText = Text.withProps({ variant: "versionBadge" }); /** - * A small, unobtrusive build-version label fixed to the lower-right corner of + * 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). diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index 28c8fbcf..97e9f3ee 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core"; +import { Button, Group, SegmentedControl, TextInput } from "@mantine/core"; import { TbLayoutSidebarRightCollapse } from "react-icons/tb"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; @@ -69,15 +69,14 @@ export function MonitoringControls({ ) : null } /> - - + ); } diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx index d2d2e5ab..3cfde766 100644 --- a/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx @@ -39,8 +39,9 @@ describe("ServerListControls", () => { onToggleList={onToggleList} />, ); - const buttons = screen.getAllByRole("button"); - await user.click(buttons[0]); + await user.click( + screen.getByRole("button", { name: /Expand all|Collapse all/ }), + ); expect(onToggleList).toHaveBeenCalledTimes(1); }); diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx index a76c2f3c..ed6f0351 100644 --- a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx @@ -27,9 +27,6 @@ export function ServerListControls({ }: ServerListControlsProps) { return ( - {serverCount > 0 && ( - - )} @@ -40,6 +37,9 @@ export function ServerListControls({ onImportServerJson={onImportServerJson} /> )} + {serverCount > 0 && ( + + )} ); } diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index 1a635108..7a9ae782 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -37,15 +37,29 @@ export const ThemeText = Text.extend({ }, }; } - // A small, unobtrusive build-version label pinned to the lower-right corner - // of the screen (#1639): grey, non-interactive (clicks pass through), and - // out of the tab/selection flow so it never interferes with the UI beneath. - if (props.variant === "versionBadge") { + // 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. + 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.35rem", - right: "0.6rem", + 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)", From 13a1d774de8a2b620f4e10ee3bce3e0ddcc1a397 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 20:12:01 -0400 Subject: [PATCH 3/7] refactor(web): share screen fade-up animation with the monitoring column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the primary pane's per-screen enter/exit transition into a shared `ScreenStage` element (the `fade-up` Mantine Transition: incoming screen slides up + fades in while the outgoing fades down + out, both absolutely positioned). InspectorView now imports it (behavior unchanged), and the pinned monitoring column uses it too — via a new `fill` variant that stretches the stage to the column's height — so switching Logs/Protocol/Network animates the same way as switching the main screens. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .../elements/ScreenStage/ScreenStage.test.tsx | 32 ++++++++++ .../elements/ScreenStage/ScreenStage.tsx | 61 +++++++++++++++++++ .../MonitoringScreen/MonitoringScreen.tsx | 12 +++- .../views/InspectorView/InspectorView.tsx | 35 +---------- 4 files changed, 105 insertions(+), 35 deletions(-) create mode 100644 clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx create mode 100644 clients/web/src/components/elements/ScreenStage/ScreenStage.tsx diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx new file mode 100644 index 00000000..e21b7596 --- /dev/null +++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ScreenStage } from "./ScreenStage"; + +describe("ScreenStage", () => { + it("renders its screen when active", () => { + renderWithMantine( + +
active screen
+
, + ); + expect(screen.getByText("active screen")).toBeInTheDocument(); + }); + + it("renders nothing when inactive (outgoing screen unmounts)", () => { + renderWithMantine( + +
inactive screen
+
, + ); + expect(screen.queryByText("inactive screen")).toBeNull(); + }); + + it("still renders its screen in the fill variant", () => { + renderWithMantine( + +
filled screen
+
, + ); + expect(screen.getByText("filled screen")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx new file mode 100644 index 00000000..23ac4d14 --- /dev/null +++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { Box, Transition } from "@mantine/core"; + +/** Screen enter / exit durations for the shared `fade-up` stage transition. */ +export const SCREEN_ENTER_MS = 350; +export const SCREEN_EXIT_MS = 250; + +export interface ScreenStageProps { + /** True when this stage's screen is the active one. */ + active: boolean; + children: ReactNode; + /** + * Stretch the stage to fill its relative-positioned parent (adds `bottom: 0`). + * Needed where the screen relies on the parent for height (e.g. an inner + * ScrollArea in the monitoring column). Off by default so callers whose + * screens size themselves keep the top/left/right anchoring. + */ + fill?: boolean; +} + +/** + * Wraps a screen in a Mantine `fade-up` Transition so that, on switch, the + * incoming screen slides up and fades in while the outgoing one fades down and + * out — both mounted at once via absolute positioning. With Transition's default + * (`keepMounted={false}`) the outgoing screen unmounts after its exit animation, + * resetting any local screen state (search filters, scroll, expanded sections). + * + * Shared by the primary InspectorView pane and the pinned monitoring column so + * both use identical enter/exit motion (#1639-follow-up). Must be rendered + * inside a `position: relative` container. + */ +export function ScreenStage({ + active, + children, + fill = false, +}: ScreenStageProps) { + return ( + + {(styles) => ( + // `style={styles}` is the runtime transition state from Mantine's + // Transition API — interpolated values, not static styling. + + {children} + + )} + + ); +} diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index 577502e0..1667382e 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Divider, Stack } from "@mantine/core"; import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; +import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; export interface MonitoringScreenProps { /** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */ @@ -26,7 +27,10 @@ const ColumnLayout = Stack.withProps({ gap: 0, }); +// Relative-positioned host so each tab's `ScreenStage` (absolutely positioned) +// can cross-fade over it. `mih: 0` lets the inner ScrollArea bound its height. const ScreenSlot = Stack.withProps({ + pos: "relative", flex: 1, mih: 0, gap: 0, @@ -57,7 +61,13 @@ export function MonitoringScreen({ onClose={onClose} /> - {screens[value]} + + {tabs.map((tab) => ( + + {screens[tab]} + + ))} + ); } diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index b2a655d1..68264af4 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { AppShell, - Box, Flex, Group, Stack, @@ -84,6 +83,7 @@ import { type ConsoleUiState, } from "../../screens/ConsoleScreen/ConsoleScreen"; import type { SortDirection } from "../../elements/SortToggle/SortToggle"; +import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen"; import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle"; import { getServerType } from "@inspector/core/mcp/config.js"; @@ -241,9 +241,6 @@ function serializeMonitorWidth(value: number): string { return String(value); } -const SCREEN_ENTER_MS = 350; -const SCREEN_EXIT_MS = 250; - // Relative-positioned wrapper for the absolutely-positioned `ScreenStage` // children. `mih: "100%"` requires `AppShell.Main` to provide a definite // height for the stack itself to fill — the absolute children render @@ -306,36 +303,6 @@ const MonitorColumnGroup = Group.withProps({ flex: "0 0 auto", }); -// Wraps each screen in a Mantine Transition. With Transition's default -// (`keepMounted={false}`), the outgoing screen unmounts after its exit -// animation — any local screen state (search filters, scroll position, -// expanded sections) is reset on tab switch. -function ScreenStage({ - active, - children, -}: { - active: boolean; - children: ReactNode; -}) { - return ( - - {(styles) => ( - // `style={styles}` is the runtime transition state from Mantine's - // Transition API — these are interpolated values, not static styling. - - {children} - - )} - - ); -} - export interface InspectorViewProps { // Server list (static config; runtime connection state comes from the // separate fields below and is merged into each card by this component). From 0c6fafbf604473a7e4262f8dd6334012822e0bc8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 20:23:27 -0400 Subject: [PATCH 4/7] feat(web): add an open-monitoring-column button to the server list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a server is connected, has monitor screens to show, and the monitoring column isn't open, show an "open monitoring column" button to the right of the list expand/collapse toggle on the server list. It reuses PinColumnButton (the same right-sidebar affordance the monitor screens use, now with an optional label), so opening the column is reachable from the server list — not only from a monitor screen's pin button. InspectorView gates it on `canPin && monitorAvailable.length > 0 && !effectivePinned`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .../PinColumnButton/PinColumnButton.test.tsx | 10 ++++++++ .../PinColumnButton/PinColumnButton.tsx | 25 ++++++++++--------- .../ServerListControls.test.tsx | 25 +++++++++++++++++++ .../ServerListControls/ServerListControls.tsx | 11 ++++++++ .../ServerListScreen/ServerListScreen.tsx | 8 ++++++ .../views/InspectorView/InspectorView.tsx | 9 +++++++ 6 files changed, 76 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx index 50ce1b62..79ab1449 100644 --- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx @@ -18,4 +18,14 @@ describe("PinColumnButton", () => { await user.click(screen.getByRole("button", { name: "Pin as column" })); expect(onPin).toHaveBeenCalledTimes(1); }); + + it("uses a custom accessible label when provided", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Open monitoring column" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull(); + }); }); diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx index 8689eaae..d101d90a 100644 --- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -4,23 +4,24 @@ import { TbLayoutSidebarRightExpand } from "react-icons/tb"; export interface PinColumnButtonProps { /** Pin this screen into the monitoring column to the right. */ onPin: () => void; + /** Accessible label. Defaults to "Pin as column" (its monitor-screen use). */ + label?: string; } /** - * Toolbar button that pins the owning monitor screen (Logs / Protocol / Network) - * into the resizable column on the right of the InspectorView (#1616). Distinct - * from `PinToggle` (which pins individual history entries) — this one opens a - * side column, so it uses a right-sidebar glyph and an "as column" label. Styled - * to match the panel's expand/collapse `ListToggle` (subtle icon button). + * Toolbar button that opens the resizable monitoring column on the right of the + * InspectorView (#1616). On a monitor screen (Logs / Protocol / Network) it pins + * that screen in as a column; on the server list it just opens the column (a + * different `label`). Distinct from `PinToggle` (which pins individual history + * entries) — this one opens a side column, so it uses a right-sidebar glyph. + * Styled to match the panel's expand/collapse `ListToggle` (subtle icon button). */ -export function PinColumnButton({ onPin }: PinColumnButtonProps) { +export function PinColumnButton({ + onPin, + label = "Pin as column", +}: PinColumnButtonProps) { return ( - ); diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx index 3cfde766..247ce910 100644 --- a/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.test.tsx @@ -24,6 +24,31 @@ describe("ServerListControls", () => { ).toBeInTheDocument(); }); + it("hides the open-monitor button unless onOpenMonitor is provided", () => { + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "Open monitoring column" }), + ).toBeNull(); + }); + + it("shows and wires the open-monitor button when onOpenMonitor is provided", async () => { + const user = userEvent.setup(); + const onOpenMonitor = vi.fn(); + renderWithMantine( + , + ); + const openBtn = screen.getByRole("button", { + name: "Open monitoring column", + }); + expect(openBtn).toBeInTheDocument(); + await user.click(openBtn); + expect(onOpenMonitor).toHaveBeenCalledTimes(1); + }); + it("shows the list toggle alongside Export + Add Servers when servers exist", () => { renderWithMantine(); expect(screen.getAllByRole("button")).toHaveLength(3); diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx index ed6f0351..3c559a70 100644 --- a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx @@ -1,5 +1,6 @@ import { Button, Group } from "@mantine/core"; import { ListToggle } from "../../elements/ListToggle/ListToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; import { ServerAddMenu, type AddServerMenuProps, @@ -13,6 +14,12 @@ export interface ServerListControlsProps extends AddServerMenuProps { onExport: () => void; /** When false (read-only session), the Add menu is hidden. Defaults to true. */ writable?: boolean; + /** + * Open the monitoring column. Provided (by the caller) only when a server is + * connected, the column can open, and it isn't already shown — otherwise + * omitted so the open-sidebar affordance is hidden. + */ + onOpenMonitor?: () => void; } export function ServerListControls({ @@ -24,6 +31,7 @@ export function ServerListControls({ onImportServerJson, onExport, writable = true, + onOpenMonitor, }: ServerListControlsProps) { return ( @@ -40,6 +48,9 @@ export function ServerListControls({ {serverCount > 0 && ( )} + {onOpenMonitor && ( + + )} ); } diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index f27b0d33..f282d071 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -67,6 +67,12 @@ export interface ServerListScreenProps { onClearHighlight?: (id: string) => void; compact: boolean; onToggleCompact: () => void; + /** + * Open the monitoring column. Provided only when a server is connected, the + * column can open, and it isn't already shown; forwarded to the controls to + * reveal an open-sidebar button beside the list toggle. + */ + onOpenMonitor?: () => void; } const PageContainer = Stack.withProps({ @@ -99,6 +105,7 @@ export function ServerListScreen({ onClearHighlight, compact, onToggleCompact, + onOpenMonitor, }: ServerListScreenProps) { const sensors = useSensors( useSensor(PointerSensor), @@ -196,6 +203,7 @@ export function ServerListScreen({ onImportConfig={onImportConfig} onImportServerJson={onImportServerJson} onExport={onExport} + onOpenMonitor={onOpenMonitor} /> 0 && !effectivePinned; function pinMonitor(tab: MonitorTab) { setMonitorTab(tab); setMonitorPinned(true); @@ -1068,6 +1072,11 @@ export function InspectorView({ onClearHighlight={onClearHighlight} compact={serversCompact} onToggleCompact={() => setServersCompact((c) => !c)} + onOpenMonitor={ + canOpenMonitor + ? () => pinMonitor(effectiveMonitorTab) + : undefined + } /> From ea8715c9c4dedb98a3ec220e55cd0ceb216deb23 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 20:49:18 -0400 Subject: [PATCH 5/7] style(web): size + space the toolbar icon buttons to match the header Make the server-list toolbar's expand/collapse toggle and open/close-monitoring icons the same width and spacing as the header's theme / client-settings icons: - ListToggle (default variant), PinColumnButton, and the MonitoringControls close button now render as `ActionIcon variant="subtle" size={36}` (glyph 20), matching the header's 36px ActionIcons, instead of wider `Button`s. - ServerListControls uses `gap="sm"` so its buttons sit the same 12px apart as the header's RightSection controls. Update a TaskListPanel test that found the toggle by a brittle `mantine-Button-root` class to select it by its accessible name instead. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .../components/elements/ListToggle/ListToggle.tsx | 13 ++++++++++--- .../elements/PinColumnButton/PinColumnButton.tsx | 9 +++++---- .../MonitoringControls/MonitoringControls.tsx | 8 ++++---- .../ServerListControls/ServerListControls.tsx | 4 +++- .../groups/TaskListPanel/TaskListPanel.test.tsx | 13 ++++++------- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx index 44085f66..cd7a0336 100644 --- a/clients/web/src/components/elements/ListToggle/ListToggle.tsx +++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Button } from "@mantine/core"; +import { ActionIcon } from "@mantine/core"; import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri"; export interface ListToggleProps { @@ -29,9 +29,16 @@ export function ListToggle({ ); } + // `size={36}` matches the header's theme / client-settings ActionIcons so the + // toolbar's toggle reads as the same size icon button. return ( - + ); } diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx index d101d90a..33204590 100644 --- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -1,4 +1,4 @@ -import { Button } from "@mantine/core"; +import { ActionIcon } from "@mantine/core"; import { TbLayoutSidebarRightExpand } from "react-icons/tb"; export interface PinColumnButtonProps { @@ -14,15 +14,16 @@ export interface PinColumnButtonProps { * that screen in as a column; on the server list it just opens the column (a * different `label`). Distinct from `PinToggle` (which pins individual history * entries) — this one opens a side column, so it uses a right-sidebar glyph. - * Styled to match the panel's expand/collapse `ListToggle` (subtle icon button). + * `size={36}` matches the header's theme / client-settings ActionIcons and the + * toolbar's `ListToggle`, so all these icon buttons share one width. */ export function PinColumnButton({ onPin, label = "Pin as column", }: PinColumnButtonProps) { return ( - + ); } diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index 97e9f3ee..23575337 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -1,4 +1,4 @@ -import { Button, Group, SegmentedControl, TextInput } from "@mantine/core"; +import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core"; import { TbLayoutSidebarRightCollapse } from "react-icons/tb"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; @@ -69,14 +69,14 @@ export function MonitoringControls({ ) : null } /> - + ); } diff --git a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx index 3c559a70..c64c2eaa 100644 --- a/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx +++ b/clients/web/src/components/groups/ServerListControls/ServerListControls.tsx @@ -34,7 +34,9 @@ export function ServerListControls({ onOpenMonitor, }: ServerListControlsProps) { return ( - + // `gap="sm"` matches the header's control spacing (its RightSection group), + // so these buttons sit the same distance apart as the header icons. + diff --git a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx index 184bcec5..ff025541 100644 --- a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx +++ b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx @@ -169,13 +169,12 @@ describe("TaskListPanel", () => { screen.getAllByRole("button", { name: "Collapse" }).length, ).toBeGreaterThan(0); - const buttons = screen.getAllByRole("button"); - const toggle = buttons.find( - (b) => - b.textContent === "" && b.classList.contains("mantine-Button-root"), - ); - expect(toggle).toBeDefined(); - await user.click(toggle!); + // The ListToggle is labelled "Collapse all" / "Expand all" — distinct from + // the per-entry "Collapse" / "Expand" buttons. + const toggle = screen.getByRole("button", { + name: /Collapse all|Expand all/, + }); + await user.click(toggle); // After toggle, entries collapsed — they show Expand expect( From a26cbac609dbf4a9efb1583949abcebb5c6329f4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 22:40:34 -0400 Subject: [PATCH 6/7] review: harden version read + fix MonitoringScreen docstring Address PR #1641 review feedback: - Add readInspectorVersionSafe() (non-throwing) in core/node/version.ts and use it in the web backend, so a failure to resolve the root manifest hides the (cosmetic) version badge instead of crashing the backend at startup. Covered by tests for both the resolved and unresolvable paths. - Update MonitoringScreen's docstrings: it now wraps every supplied screen in a ScreenStage (only the active tab mounts, cross-fading) rather than rendering just the active tab's node. The third fetch-consolidation suggestion (one useInitialConfig instead of three GET /api/config hooks) is tracked as a follow-up in #1643. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/server/web-server-config.ts | 8 +++++--- .../MonitoringScreen/MonitoringScreen.tsx | 8 +++++--- .../src/test/integration/node/version.test.ts | 17 ++++++++++++++++ core/node/version.ts | 20 +++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index bc34588e..d40a18ac 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -14,13 +14,15 @@ import { LEGACY_AUTH_TOKEN_ENV, } from "../../../core/mcp/remote/constants.ts"; import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts"; -import { readInspectorVersion } from "../../../core/node/version.ts"; +import { readInspectorVersionSafe } from "../../../core/node/version.ts"; import { resolveSandboxPort } from "./sandbox-controller.js"; // The single-source Inspector version (root package.json), read once at load. // The browser can't read the filesystem the way the CLI/TUI do, so the backend -// reads it here and hands it to the client via GET /api/config. -const inspectorVersion = readInspectorVersion(import.meta.url); +// reads it here and hands it to the client via GET /api/config. Uses the +// non-throwing read: the version is cosmetic, so a resolution failure hides the +// badge (version omitted from the payload) rather than crashing the backend. +const inspectorVersion = readInspectorVersionSafe(import.meta.url); export interface WebServerConfig { port: number; diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index 1667382e..2e577ca9 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -6,7 +6,8 @@ import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; export interface MonitoringScreenProps { /** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */ tabs: string[]; - /** Active monitor tab; keys into `screens` to pick what renders below. */ + /** Active monitor tab; the matching screen is the mounted one below (the rest + * are cross-faded out via `ScreenStage`). */ value: string; onChange: (tab: string) => void; /** Search text for the active screen; wired by the caller to its filter state. */ @@ -38,8 +39,9 @@ const ScreenSlot = Stack.withProps({ /** * The pinned monitoring column's content (#1616): a `MonitoringControls` tab row - * over the currently-selected monitor screen. Layout-only — it renders whichever - * embedded screen node the caller supplies for the active tab. + * over the selected monitor screen. Layout-only — it wraps each supplied screen + * node in a `ScreenStage`, so switching tabs cross-fades (only the active tab's + * screen is mounted) the same way the primary pane does. */ export function MonitoringScreen({ tabs, diff --git a/clients/web/src/test/integration/node/version.test.ts b/clients/web/src/test/integration/node/version.test.ts index 6bb8d0bb..459822f7 100644 --- a/clients/web/src/test/integration/node/version.test.ts +++ b/clients/web/src/test/integration/node/version.test.ts @@ -11,6 +11,7 @@ import { join, dirname } from "path"; import { pathToFileURL, fileURLToPath } from "url"; import { readInspectorVersion, + readInspectorVersionSafe, ROOT_PACKAGE_NAME, } from "@inspector/core/node/version.js"; @@ -67,3 +68,19 @@ describe("readInspectorVersion", () => { } }); }); + +describe("readInspectorVersionSafe", () => { + it("returns the version when the root manifest resolves", () => { + expect(readInspectorVersionSafe(import.meta.url)).toBe(readRootVersion()); + }); + + it("returns undefined instead of throwing when the root can't be resolved", () => { + // A caller rooted in the OS temp dir (outside the repo) has no inspector + // root manifest above it — readInspectorVersion would throw, but the safe + // variant swallows it so a cosmetic version read never crashes the caller. + const callerUrl = pathToFileURL( + join(tmpdir(), "nowhere", "caller.js"), + ).href; + expect(readInspectorVersionSafe(callerUrl)).toBeUndefined(); + }); +}); diff --git a/core/node/version.ts b/core/node/version.ts index 063612cc..367f8cef 100644 --- a/core/node/version.ts +++ b/core/node/version.ts @@ -64,3 +64,23 @@ export function readInspectorVersion(callerUrl: string): string { `Could not locate the ${ROOT_PACKAGE_NAME} root package.json above ${startDir}`, ); } + +/** + * Non-throwing variant of {@link readInspectorVersion}: returns `undefined` + * instead of throwing when the root manifest can't be resolved. For callers + * where the version is cosmetic (e.g. the web backend surfacing it to the + * browser) — a resolution failure should hide the version, not take the caller + * down at startup. + * + * @param callerUrl the calling module's `import.meta.url` + * @returns the root version, or `undefined` if it can't be resolved + */ +export function readInspectorVersionSafe( + callerUrl: string, +): string | undefined { + try { + return readInspectorVersion(callerUrl); + } catch { + return undefined; + } +} From 1e6718e2fdca351c246f70d315dae2b314f54a09 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 22:52:48 -0400 Subject: [PATCH 7/7] test(web): disable Mantine transition timers in the unit env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed with an unhandled `ReferenceError: window is not defined` thrown from a Mantine `Transition` `setTimeout` (`use-transition.mjs`) firing after the test environment was torn down — every test passed, but the leaked timer failed the run. happy-dom has no `matchMedia`, so `useReducedMotion` resolves to "motion allowed" and every Transition (ScreenStage, Modal, …) schedules real enter/exit timers; a timer that outlives its test throws post-teardown. This surfaced now because MonitoringScreen started wrapping its screens in ScreenStage (a Transition) where it previously rendered them directly. Mock `window.matchMedia` in the test setup to report `prefers-reduced-motion: reduce`, so Mantine transitions render instantly (no timers). All other queries return `false`, matching the prior absent-matchMedia behavior, so `useMediaQuery`-driven layout is unchanged. Full unit suite passes with no unhandled errors across repeated runs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/src/test/setup.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index 05defc1d..004f10a9 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -57,6 +57,32 @@ Object.defineProperty(globalThis, "fetch", { ), }); +// happy-dom doesn't implement `matchMedia`, so Mantine's `useReducedMotion` +// resolves to "motion allowed" and every `Transition` (ScreenStage, Modal, …) +// schedules real enter/exit `setTimeout`s. A timer that outlives its test fires +// after the environment is torn down and throws `window is not defined` from +// deep in react-dom — an unhandled error that fails the run even though every +// test passed. Report `prefers-reduced-motion: reduce` so Mantine transitions +// render instantly (no timers); all other queries stay `false`, matching the +// prior (absent-matchMedia) behavior so `useMediaQuery`-driven layout is +// unchanged. Tests that need specific media results still mock `@mantine/hooks` +// or stub `matchMedia` themselves. +Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: (query: string): MediaQueryList => + ({ + matches: /prefers-reduced-motion/.test(query), + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList, +}); + afterEach(() => { cleanup(); window.localStorage.clear();