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,
|
||||
} from "../../../core/mcp/remote/constants.ts";
|
||||
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.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. 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;
|
||||
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(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ 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 { CopyrightBadge } from "./components/elements/CopyrightBadge/CopyrightBadge";
|
||||
import type {
|
||||
ToolCallState,
|
||||
ToolsUiState,
|
||||
|
|
@ -669,6 +672,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<ClientConfig>({});
|
||||
useEffect(() => {
|
||||
|
|
@ -3816,6 +3825,8 @@ function App() {
|
|||
onRefreshApps={onRefreshTools}
|
||||
/>
|
||||
</Box>
|
||||
<CopyrightBadge />
|
||||
<VersionBadge version={inspectorVersion} />
|
||||
<ServerConfigModal
|
||||
opened={configModal !== null}
|
||||
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";
|
||||
|
||||
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 (
|
||||
<Button size="sm" variant="subtle" aria-label={label} onClick={onToggle}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size={36}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</Button>
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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";
|
||||
|
||||
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.
|
||||
* `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 (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
aria-label="Pin as column"
|
||||
onClick={onPin}
|
||||
>
|
||||
<ActionIcon variant="subtle" size={36} aria-label={label} onClick={onPin}>
|
||||
<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
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
size={36}
|
||||
aria-label="Close monitoring column"
|
||||
onClick={onClose}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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). */
|
||||
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. */
|
||||
|
|
@ -26,7 +28,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,
|
||||
|
|
@ -34,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,
|
||||
|
|
@ -57,7 +63,13 @@ export function MonitoringScreen({
|
|||
onClose={onClose}
|
||||
/>
|
||||
<Divider />
|
||||
<ScreenSlot>{screens[value]}</ScreenSlot>
|
||||
<ScreenSlot>
|
||||
{tabs.map((tab) => (
|
||||
<ScreenStage key={tab} active={tab === value} fill>
|
||||
{screens[tab]}
|
||||
</ScreenStage>
|
||||
))}
|
||||
</ScreenSlot>
|
||||
</ColumnLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,31 @@ describe("ServerListControls", () => {
|
|||
).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", () => {
|
||||
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(3);
|
||||
|
|
@ -39,8 +64,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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +31,12 @@ export function ServerListControls({
|
|||
onImportServerJson,
|
||||
onExport,
|
||||
writable = true,
|
||||
onOpenMonitor,
|
||||
}: ServerListControlsProps) {
|
||||
return (
|
||||
<Group justify="flex-end">
|
||||
{serverCount > 0 && (
|
||||
<ListToggle compact={compact} onToggle={onToggleList} />
|
||||
)}
|
||||
// `gap="sm"` matches the header's control spacing (its RightSection group),
|
||||
// so these buttons sit the same distance apart as the header icons.
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
|
||||
Export
|
||||
</Button>
|
||||
|
|
@ -40,6 +47,12 @@ export function ServerListControls({
|
|||
onImportServerJson={onImportServerJson}
|
||||
/>
|
||||
)}
|
||||
{serverCount > 0 && (
|
||||
<ListToggle compact={compact} onToggle={onToggleList} />
|
||||
)}
|
||||
{onOpenMonitor && (
|
||||
<PinColumnButton onPin={onOpenMonitor} label="Open monitoring column" />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<ScrollArea.Autosize
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 {
|
||||
// Server list (static config; runtime connection state comes from the
|
||||
// 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
|
||||
// would actually take effect (wide + 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) {
|
||||
setMonitorTab(tab);
|
||||
setMonitorPinned(true);
|
||||
|
|
@ -1101,6 +1072,11 @@ export function InspectorView({
|
|||
onClearHighlight={onClearHighlight}
|
||||
compact={serversCompact}
|
||||
onToggleCompact={() => setServersCompact((c) => !c)}
|
||||
onOpenMonitor={
|
||||
canOpenMonitor
|
||||
? () => pinMonitor(effectiveMonitorTab)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<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