Merge pull request #1644 from modelcontextprotocol/1639-web-version-display
Web UI polish: footer (version/copyright), toolbar + monitoring column tweaks
This commit is contained in:
commit
5a86bd11ac
28 changed files with 747 additions and 71 deletions
|
|
@ -14,8 +14,16 @@ import {
|
||||||
LEGACY_AUTH_TOKEN_ENV,
|
LEGACY_AUTH_TOKEN_ENV,
|
||||||
} from "../../../core/mcp/remote/constants.ts";
|
} from "../../../core/mcp/remote/constants.ts";
|
||||||
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
||||||
|
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
|
||||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
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. 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 {
|
export interface WebServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
|
|
@ -92,11 +100,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(
|
export function webServerConfigToInitialPayload(
|
||||||
config: WebServerConfig,
|
config: WebServerConfig,
|
||||||
): InitialConfigPayload {
|
): 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 mc = config.initialMcpConfig;
|
||||||
const defaultEnvironment = defaultEnvironmentFromProcess(
|
const defaultEnvironment = defaultEnvironmentFromProcess(
|
||||||
mc && "env" in mc && mc.env ? mc.env : undefined,
|
mc && "env" in mc && mc.env ? mc.env : undefined,
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,11 @@ import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"
|
||||||
import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
|
import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
|
||||||
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
|
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
|
||||||
import { useServerListWritable } from "@inspector/core/react/useServerListWritable.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 { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
|
||||||
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
||||||
|
import { VersionBadge } from "./components/elements/VersionBadge/VersionBadge";
|
||||||
|
import { CopyrightBadge } from "./components/elements/CopyrightBadge/CopyrightBadge";
|
||||||
import type {
|
import type {
|
||||||
ToolCallState,
|
ToolCallState,
|
||||||
ToolsUiState,
|
ToolsUiState,
|
||||||
|
|
@ -669,6 +672,12 @@ function App() {
|
||||||
baseUrl: configBaseUrl,
|
baseUrl: configBaseUrl,
|
||||||
authToken: getAuthToken(),
|
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<ClientConfig>({});
|
const [clientConfig, setClientConfig] = useState<ClientConfig>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3816,6 +3825,8 @@ function App() {
|
||||||
onRefreshApps={onRefreshTools}
|
onRefreshApps={onRefreshTools}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<CopyrightBadge />
|
||||||
|
<VersionBadge version={inspectorVersion} />
|
||||||
<ServerConfigModal
|
<ServerConfigModal
|
||||||
opened={configModal !== null}
|
opened={configModal !== null}
|
||||||
mode={configModal?.mode ?? "add"}
|
mode={configModal?.mode ?? "add"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, within } from "storybook/test";
|
||||||
|
import { CopyrightBadge, COPYRIGHT_NOTICE } from "./CopyrightBadge";
|
||||||
|
|
||||||
|
const meta: Meta<typeof CopyrightBadge> = {
|
||||||
|
title: "Elements/CopyrightBadge",
|
||||||
|
component: CopyrightBadge,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof CopyrightBadge>;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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(<CopyrightBadge />);
|
||||||
|
expect(screen.getByText(COPYRIGHT_NOTICE)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the Model Context Protocol and LF Projects", () => {
|
||||||
|
renderWithMantine(<CopyrightBadge />);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Model Context Protocol.*Series of LF Projects, LLC\./),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 <CopyrightText>{COPYRIGHT_NOTICE}</CopyrightText>;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ActionIcon, Button } from "@mantine/core";
|
import { ActionIcon } from "@mantine/core";
|
||||||
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
|
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
|
||||||
|
|
||||||
export interface ListToggleProps {
|
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 (
|
return (
|
||||||
<Button size="sm" variant="subtle" aria-label={label} onClick={onToggle}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size={36}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,14 @@ describe("PinColumnButton", () => {
|
||||||
await user.click(screen.getByRole("button", { name: "Pin as column" }));
|
await user.click(screen.getByRole("button", { name: "Pin as column" }));
|
||||||
expect(onPin).toHaveBeenCalledTimes(1);
|
expect(onPin).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a custom accessible label when provided", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<PinColumnButton onPin={vi.fn()} label="Open monitoring column" />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Open monitoring column" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,29 @@
|
||||||
import { Button } from "@mantine/core";
|
import { ActionIcon } from "@mantine/core";
|
||||||
import { TbLayoutSidebarRightExpand } from "react-icons/tb";
|
import { TbLayoutSidebarRightExpand } from "react-icons/tb";
|
||||||
|
|
||||||
export interface PinColumnButtonProps {
|
export interface PinColumnButtonProps {
|
||||||
/** Pin this screen into the monitoring column to the right. */
|
/** Pin this screen into the monitoring column to the right. */
|
||||||
onPin: () => void;
|
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)
|
* Toolbar button that opens the resizable monitoring column on the right of the
|
||||||
* into the resizable column on the right of the InspectorView (#1616). Distinct
|
* InspectorView (#1616). On a monitor screen (Logs / Protocol / Network) it pins
|
||||||
* from `PinToggle` (which pins individual history entries) — this one opens a
|
* that screen in as a column; on the server list it just opens the column (a
|
||||||
* side column, so it uses a right-sidebar glyph and an "as column" label. Styled
|
* different `label`). Distinct from `PinToggle` (which pins individual history
|
||||||
* to match the panel's expand/collapse `ListToggle` (subtle icon button).
|
* entries) — this one opens a side column, so it uses a right-sidebar glyph.
|
||||||
|
* `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 }: PinColumnButtonProps) {
|
export function PinColumnButton({
|
||||||
|
onPin,
|
||||||
|
label = "Pin as column",
|
||||||
|
}: PinColumnButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ActionIcon variant="subtle" size={36} aria-label={label} onClick={onPin}>
|
||||||
size="sm"
|
|
||||||
variant="subtle"
|
|
||||||
aria-label="Pin as column"
|
|
||||||
onClick={onPin}
|
|
||||||
>
|
|
||||||
<TbLayoutSidebarRightExpand size={20} />
|
<TbLayoutSidebarRightExpand size={20} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
<ScreenStage active>
|
||||||
|
<div>active screen</div>
|
||||||
|
</ScreenStage>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("active screen")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when inactive (outgoing screen unmounts)", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ScreenStage active={false}>
|
||||||
|
<div>inactive screen</div>
|
||||||
|
</ScreenStage>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("inactive screen")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders its screen in the fill variant", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ScreenStage active fill>
|
||||||
|
<div>filled screen</div>
|
||||||
|
</ScreenStage>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("filled screen")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 (
|
||||||
|
<Transition
|
||||||
|
mounted={active}
|
||||||
|
transition="fade-up"
|
||||||
|
duration={SCREEN_ENTER_MS}
|
||||||
|
exitDuration={SCREEN_EXIT_MS}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
// `style={styles}` is the runtime transition state from Mantine's
|
||||||
|
// Transition API — interpolated values, not static styling.
|
||||||
|
<Box
|
||||||
|
style={styles}
|
||||||
|
pos="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={fill ? 0 : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, within } from "storybook/test";
|
||||||
|
import { VersionBadge } from "./VersionBadge";
|
||||||
|
|
||||||
|
const meta: Meta<typeof VersionBadge> = {
|
||||||
|
title: "Elements/VersionBadge",
|
||||||
|
component: VersionBadge,
|
||||||
|
args: { version: "2.0.0" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof VersionBadge>;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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(<VersionBadge version="2.0.0" />);
|
||||||
|
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(<VersionBadge version={undefined} />);
|
||||||
|
expect(screen.queryByText(/^v/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when the version is an empty string", () => {
|
||||||
|
renderWithMantine(<VersionBadge version="" />);
|
||||||
|
expect(screen.queryByText(/^v/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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-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-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).
|
||||||
|
*/
|
||||||
|
export function VersionBadge({ version }: VersionBadgeProps) {
|
||||||
|
if (!version) return null;
|
||||||
|
return (
|
||||||
|
<VersionText aria-label={`Inspector version ${version}`}>
|
||||||
|
v{version}
|
||||||
|
</VersionText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -71,8 +71,7 @@ export function MonitoringControls({
|
||||||
/>
|
/>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
size={36}
|
||||||
size="lg"
|
|
||||||
aria-label="Close monitoring column"
|
aria-label="Close monitoring column"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Divider, Stack } from "@mantine/core";
|
import { Divider, Stack } from "@mantine/core";
|
||||||
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
|
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
|
||||||
|
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
|
||||||
|
|
||||||
export interface MonitoringScreenProps {
|
export interface MonitoringScreenProps {
|
||||||
/** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */
|
/** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */
|
||||||
tabs: string[];
|
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;
|
value: string;
|
||||||
onChange: (tab: string) => void;
|
onChange: (tab: string) => void;
|
||||||
/** Search text for the active screen; wired by the caller to its filter state. */
|
/** Search text for the active screen; wired by the caller to its filter state. */
|
||||||
|
|
@ -26,7 +28,10 @@ const ColumnLayout = Stack.withProps({
|
||||||
gap: 0,
|
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({
|
const ScreenSlot = Stack.withProps({
|
||||||
|
pos: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
mih: 0,
|
mih: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
|
|
@ -34,8 +39,9 @@ const ScreenSlot = Stack.withProps({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
|
* The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
|
||||||
* over the currently-selected monitor screen. Layout-only — it renders whichever
|
* over the selected monitor screen. Layout-only — it wraps each supplied screen
|
||||||
* embedded screen node the caller supplies for the active tab.
|
* 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({
|
export function MonitoringScreen({
|
||||||
tabs,
|
tabs,
|
||||||
|
|
@ -57,7 +63,13 @@ export function MonitoringScreen({
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ScreenSlot>{screens[value]}</ScreenSlot>
|
<ScreenSlot>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<ScreenStage key={tab} active={tab === value} fill>
|
||||||
|
{screens[tab]}
|
||||||
|
</ScreenStage>
|
||||||
|
))}
|
||||||
|
</ScreenSlot>
|
||||||
</ColumnLayout>
|
</ColumnLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,31 @@ describe("ServerListControls", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the open-monitor button unless onOpenMonitor is provided", () => {
|
||||||
|
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
|
||||||
|
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(
|
||||||
|
<ServerListControls
|
||||||
|
{...baseProps}
|
||||||
|
serverCount={2}
|
||||||
|
onOpenMonitor={onOpenMonitor}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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", () => {
|
it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
|
||||||
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
|
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
|
||||||
expect(screen.getAllByRole("button")).toHaveLength(3);
|
expect(screen.getAllByRole("button")).toHaveLength(3);
|
||||||
|
|
@ -39,8 +64,9 @@ describe("ServerListControls", () => {
|
||||||
onToggleList={onToggleList}
|
onToggleList={onToggleList}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const buttons = screen.getAllByRole("button");
|
await user.click(
|
||||||
await user.click(buttons[0]);
|
screen.getByRole("button", { name: /Expand all|Collapse all/ }),
|
||||||
|
);
|
||||||
expect(onToggleList).toHaveBeenCalledTimes(1);
|
expect(onToggleList).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Button, Group } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||||
|
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||||
import {
|
import {
|
||||||
ServerAddMenu,
|
ServerAddMenu,
|
||||||
type AddServerMenuProps,
|
type AddServerMenuProps,
|
||||||
|
|
@ -13,6 +14,12 @@ export interface ServerListControlsProps extends AddServerMenuProps {
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
/** When false (read-only session), the Add menu is hidden. Defaults to true. */
|
/** When false (read-only session), the Add menu is hidden. Defaults to true. */
|
||||||
writable?: boolean;
|
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({
|
export function ServerListControls({
|
||||||
|
|
@ -24,12 +31,12 @@ export function ServerListControls({
|
||||||
onImportServerJson,
|
onImportServerJson,
|
||||||
onExport,
|
onExport,
|
||||||
writable = true,
|
writable = true,
|
||||||
|
onOpenMonitor,
|
||||||
}: ServerListControlsProps) {
|
}: ServerListControlsProps) {
|
||||||
return (
|
return (
|
||||||
<Group justify="flex-end">
|
// `gap="sm"` matches the header's control spacing (its RightSection group),
|
||||||
{serverCount > 0 && (
|
// so these buttons sit the same distance apart as the header icons.
|
||||||
<ListToggle compact={compact} onToggle={onToggleList} />
|
<Group justify="flex-end" gap="sm">
|
||||||
)}
|
|
||||||
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
|
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -40,6 +47,12 @@ export function ServerListControls({
|
||||||
onImportServerJson={onImportServerJson}
|
onImportServerJson={onImportServerJson}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{serverCount > 0 && (
|
||||||
|
<ListToggle compact={compact} onToggle={onToggleList} />
|
||||||
|
)}
|
||||||
|
{onOpenMonitor && (
|
||||||
|
<PinColumnButton onPin={onOpenMonitor} label="Open monitoring column" />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,13 +169,12 @@ describe("TaskListPanel", () => {
|
||||||
screen.getAllByRole("button", { name: "Collapse" }).length,
|
screen.getAllByRole("button", { name: "Collapse" }).length,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
const buttons = screen.getAllByRole("button");
|
// The ListToggle is labelled "Collapse all" / "Expand all" — distinct from
|
||||||
const toggle = buttons.find(
|
// the per-entry "Collapse" / "Expand" buttons.
|
||||||
(b) =>
|
const toggle = screen.getByRole("button", {
|
||||||
b.textContent === "" && b.classList.contains("mantine-Button-root"),
|
name: /Collapse all|Expand all/,
|
||||||
);
|
});
|
||||||
expect(toggle).toBeDefined();
|
await user.click(toggle);
|
||||||
await user.click(toggle!);
|
|
||||||
|
|
||||||
// After toggle, entries collapsed — they show Expand
|
// After toggle, entries collapsed — they show Expand
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ export interface ServerListScreenProps {
|
||||||
onClearHighlight?: (id: string) => void;
|
onClearHighlight?: (id: string) => void;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
onToggleCompact: () => void;
|
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({
|
const PageContainer = Stack.withProps({
|
||||||
|
|
@ -99,6 +105,7 @@ export function ServerListScreen({
|
||||||
onClearHighlight,
|
onClearHighlight,
|
||||||
compact,
|
compact,
|
||||||
onToggleCompact,
|
onToggleCompact,
|
||||||
|
onOpenMonitor,
|
||||||
}: ServerListScreenProps) {
|
}: ServerListScreenProps) {
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
|
|
@ -196,6 +203,7 @@ export function ServerListScreen({
|
||||||
onImportConfig={onImportConfig}
|
onImportConfig={onImportConfig}
|
||||||
onImportServerJson={onImportServerJson}
|
onImportServerJson={onImportServerJson}
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
|
onOpenMonitor={onOpenMonitor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea.Autosize
|
<ScrollArea.Autosize
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
Box,
|
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
|
|
@ -84,6 +83,7 @@ import {
|
||||||
type ConsoleUiState,
|
type ConsoleUiState,
|
||||||
} from "../../screens/ConsoleScreen/ConsoleScreen";
|
} from "../../screens/ConsoleScreen/ConsoleScreen";
|
||||||
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
||||||
|
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
|
||||||
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
||||||
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
|
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
|
||||||
import { getServerType } from "@inspector/core/mcp/config.js";
|
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||||
|
|
@ -241,9 +241,6 @@ function serializeMonitorWidth(value: number): string {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCREEN_ENTER_MS = 350;
|
|
||||||
const SCREEN_EXIT_MS = 250;
|
|
||||||
|
|
||||||
// Relative-positioned wrapper for the absolutely-positioned `ScreenStage`
|
// Relative-positioned wrapper for the absolutely-positioned `ScreenStage`
|
||||||
// children. `mih: "100%"` requires `AppShell.Main` to provide a definite
|
// children. `mih: "100%"` requires `AppShell.Main` to provide a definite
|
||||||
// height for the stack itself to fill — the absolute children render
|
// height for the stack itself to fill — the absolute children render
|
||||||
|
|
@ -306,36 +303,6 @@ const MonitorColumnGroup = Group.withProps({
|
||||||
flex: "0 0 auto",
|
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 (
|
|
||||||
<Transition
|
|
||||||
mounted={active}
|
|
||||||
transition="fade-up"
|
|
||||||
duration={SCREEN_ENTER_MS}
|
|
||||||
exitDuration={SCREEN_EXIT_MS}
|
|
||||||
timingFunction="ease"
|
|
||||||
>
|
|
||||||
{(styles) => (
|
|
||||||
// `style={styles}` is the runtime transition state from Mantine's
|
|
||||||
// Transition API — these are interpolated values, not static styling.
|
|
||||||
<Box style={styles} pos="absolute" top={0} left={0} right={0}>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InspectorViewProps {
|
export interface InspectorViewProps {
|
||||||
// Server list (static config; runtime connection state comes from the
|
// Server list (static config; runtime connection state comes from the
|
||||||
// separate fields below and is merged into each card by this component).
|
// separate fields below and is merged into each card by this component).
|
||||||
|
|
@ -883,6 +850,10 @@ export function InspectorView({
|
||||||
// column. Gated by `canPin` so the pin affordance only appears when a click
|
// column. Gated by `canPin` so the pin affordance only appears when a click
|
||||||
// would actually take effect (wide + connected).
|
// would actually take effect (wide + connected).
|
||||||
const canPin = !!isWide && connected;
|
const canPin = !!isWide && connected;
|
||||||
|
// Offer an "open monitoring column" affordance on the server list when a
|
||||||
|
// connected server has monitor screens to show but the column isn't open yet.
|
||||||
|
const canOpenMonitor =
|
||||||
|
canPin && monitorAvailable.length > 0 && !effectivePinned;
|
||||||
function pinMonitor(tab: MonitorTab) {
|
function pinMonitor(tab: MonitorTab) {
|
||||||
setMonitorTab(tab);
|
setMonitorTab(tab);
|
||||||
setMonitorPinned(true);
|
setMonitorPinned(true);
|
||||||
|
|
@ -1101,6 +1072,11 @@ export function InspectorView({
|
||||||
onClearHighlight={onClearHighlight}
|
onClearHighlight={onClearHighlight}
|
||||||
compact={serversCompact}
|
compact={serversCompact}
|
||||||
onToggleCompact={() => setServersCompact((c) => !c)}
|
onToggleCompact={() => setServersCompact((c) => !c)}
|
||||||
|
onOpenMonitor={
|
||||||
|
canOpenMonitor
|
||||||
|
? () => pinMonitor(effectiveMonitorTab)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Tools"}>
|
<ScreenStage active={activeTab === "Tools"}>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue