Merge pull request #1617 from modelcontextprotocol/v2/1616-pin-monitoring-column

feat(web): pin Logging/History/Network as a resizable column (#1616)
This commit is contained in:
Cliff Hall 2026-07-08 22:35:22 -04:00 • committed by GitHub
commit bd74c356ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2577 additions and 332 deletions

View file

@ -422,3 +422,30 @@
z-index: 200;
box-shadow: var(--mantine-shadow-sm);
}
/*
* Draggable vertical divider between the primary screen area and the pinned
* monitoring column (#1616). The col-resize cursor, the hover/active accent,
* and the drag-time user-select suppression are all pseudo-selector / cursor /
* cross-element concerns that can't be expressed as Mantine props, so the whole
* unit lives here rather than split across the theme.
*/
.resize-handle {
flex: 0 0 auto;
width: 6px;
cursor: col-resize;
background-color: var(--inspector-border-default);
transition: background-color 120ms ease;
touch-action: none;
}
.resize-handle:hover,
.resize-handle:focus-visible {
background-color: var(--mantine-primary-color-filled);
}
/* Suppress text selection anywhere on the page while a drag is in progress. */
body.resizing-col {
user-select: none;
cursor: col-resize;
}

View file

@ -0,0 +1,43 @@
import { createRef } from "react";
import { Stack, Text } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { EmbeddableScrollArea } from "./EmbeddableScrollArea";
const meta: Meta<typeof EmbeddableScrollArea> = {
title: "Elements/EmbeddableScrollArea",
component: EmbeddableScrollArea,
};
export default meta;
type Story = StoryObj<typeof EmbeddableScrollArea>;
const rows = Array.from({ length: 40 }, (_, i) => (
<Text key={i}>Row {i + 1}</Text>
));
export const FullSize: Story = {
render: () => (
<EmbeddableScrollArea embedded={false} viewportRef={createRef()}>
<Stack gap="xs">{rows}</Stack>
</EmbeddableScrollArea>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};
export const Embedded: Story = {
render: () => (
<Stack h={240} gap={0}>
<EmbeddableScrollArea embedded viewportRef={createRef()}>
<Stack gap="xs">{rows}</Stack>
</EmbeddableScrollArea>
</Stack>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};

View file

@ -0,0 +1,34 @@
import { createRef } from "react";
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { EmbeddableScrollArea } from "./EmbeddableScrollArea";
describe("EmbeddableScrollArea", () => {
it("renders its children full-size", () => {
renderWithMantine(
<EmbeddableScrollArea embedded={false} viewportRef={createRef()}>
<div>full-size content</div>
</EmbeddableScrollArea>,
);
expect(screen.getByText("full-size content")).toBeInTheDocument();
});
it("renders its children when embedded", () => {
renderWithMantine(
<EmbeddableScrollArea embedded={true} viewportRef={createRef()}>
<div>embedded content</div>
</EmbeddableScrollArea>,
);
expect(screen.getByText("embedded content")).toBeInTheDocument();
});
it("attaches the viewport ref to the scroll viewport", () => {
const ref = createRef<HTMLDivElement>();
renderWithMantine(
<EmbeddableScrollArea embedded={false} viewportRef={ref}>
<div>content</div>
</EmbeddableScrollArea>,
);
expect(ref.current).toBeInstanceOf(HTMLElement);
});
});

View file

@ -0,0 +1,56 @@
import type { ReactNode, Ref } from "react";
import { ScrollArea, Stack } from "@mantine/core";
// Full-size, the monitor stream panels bound their scroll to the viewport
// minus the header and the panel's own chrome.
const FULLSIZE_MAH =
"calc(100vh - var(--app-shell-header-height, 0px) - 150px)";
export interface EmbeddableScrollAreaProps {
/**
* True when rendered inside the pinned monitoring column (#1616): the scroll
* region fills its flex parent instead of using the viewport calc. A
* `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining
* below the column's controls (via `mah:100%`), so no viewport math is needed
* and the final rows never clip.
*/
embedded: boolean;
viewportRef: Ref<HTMLDivElement>;
children: ReactNode;
}
/**
* The scroll region shared by the Logs / History / Network stream panels, which
* render both full-size (their own tab) and embedded (the monitoring column).
* Centralizes the one layout difference between those two hosts.
*/
export function EmbeddableScrollArea({
embedded,
viewportRef,
children,
}: EmbeddableScrollAreaProps) {
if (embedded) {
return (
<Stack flex={1} mih={0} gap={0}>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="100%"
type="scroll"
offsetScrollbars
>
{children}
</ScrollArea.Autosize>
</Stack>
);
}
return (
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={FULLSIZE_MAH}
type="scroll"
offsetScrollbars
>
{children}
</ScrollArea.Autosize>
);
}

View file

@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { PinColumnButton } from "./PinColumnButton";
const meta: Meta<typeof PinColumnButton> = {
title: "Elements/PinColumnButton",
component: PinColumnButton,
args: { onPin: fn() },
};
export default meta;
type Story = StoryObj<typeof PinColumnButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: "Pin as column" }),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { PinColumnButton } from "./PinColumnButton";
describe("PinColumnButton", () => {
it("renders a labelled pin-as-column button", () => {
renderWithMantine(<PinColumnButton onPin={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Pin as column" }),
).toBeInTheDocument();
});
it("invokes onPin when clicked", async () => {
const user = userEvent.setup();
const onPin = vi.fn();
renderWithMantine(<PinColumnButton onPin={onPin} />);
await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,27 @@
import { Button } from "@mantine/core";
import { TbLayoutSidebarRightExpand } from "react-icons/tb";
export interface PinColumnButtonProps {
/** Pin this screen into the monitoring column to the right. */
onPin: () => void;
}
/**
* Toolbar button that pins the owning monitor screen (Logs / History / 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).
*/
export function PinColumnButton({ onPin }: PinColumnButtonProps) {
return (
<Button
size="sm"
variant="subtle"
aria-label="Pin as column"
onClick={onPin}
>
<TbLayoutSidebarRightExpand size={20} />
</Button>
);
}

View file

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { ReplayButton } from "./ReplayButton";
const meta: Meta<typeof ReplayButton> = {
title: "Elements/ReplayButton",
component: ReplayButton,
args: { onReplay: fn() },
};
export default meta;
type Story = StoryObj<typeof ReplayButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument();
},
};

View file

@ -0,0 +1,19 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ReplayButton } from "./ReplayButton";
describe("ReplayButton", () => {
it("renders a labelled replay button", () => {
renderWithMantine(<ReplayButton onReplay={vi.fn()} />);
expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument();
});
it("invokes onReplay when clicked", async () => {
const user = userEvent.setup();
const onReplay = vi.fn();
renderWithMantine(<ReplayButton onReplay={onReplay} />);
await user.click(screen.getByRole("button", { name: "Replay" }));
expect(onReplay).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,26 @@
import { ActionIcon } from "@mantine/core";
import { MdReplay } from "react-icons/md";
export interface ReplayButtonProps {
/** Re-send the owning history request. */
onReplay: () => void;
}
/**
* Icon form of the "Replay" action, used in the compact (column) HistoryEntry
* layout where the text button is replaced by a replay icon sitting next to the
* pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling.
*/
export function ReplayButton({ onReplay }: ReplayButtonProps) {
return (
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label="Replay"
onClick={onReplay}
>
<MdReplay size={18} />
</ActionIcon>
);
}

View file

@ -0,0 +1,51 @@
import { useState } from "react";
import { Box, Group, Text } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { ResizeHandle } from "./ResizeHandle";
const meta: Meta<typeof ResizeHandle> = {
title: "Elements/ResizeHandle",
component: ResizeHandle,
};
export default meta;
type Story = StoryObj<typeof ResizeHandle>;
// A minimal split so the handle has a panel to resize in the docs view. The
// panel sits to the right; dragging the handle left widens it.
function ResizeDemo() {
const [width, setWidth] = useState(320);
const [dragWidth, setDragWidth] = useState<number | null>(null);
return (
<Group h={200} gap={0} wrap="nowrap">
<Box flex={1} bg="var(--inspector-surface-subtle)" p="md">
<Text>Primary</Text>
</Box>
<ResizeHandle
value={dragWidth ?? width}
min={200}
max={520}
onChange={setDragWidth}
onCommit={(next) => {
setWidth(next);
setDragWidth(null);
}}
aria-label="Resize demo panel"
/>
<Box w={dragWidth ?? width} p="md" bg="var(--inspector-surface-card)">
<Text>Column ({dragWidth ?? width}px)</Text>
</Box>
</Group>
);
}
export const Default: Story = {
render: () => <ResizeDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("separator", { name: "Resize demo panel" }),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,166 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
renderWithMantine,
screen,
fireEvent,
} from "../../../test/renderWithMantine";
import { ResizeHandle } from "./ResizeHandle";
// The panel sits to the RIGHT of the handle, so moving the pointer LEFT
// (decreasing clientX) widens it: next = startWidth + (startX - clientX).
function setup(overrides: Partial<Parameters<typeof ResizeHandle>[0]> = {}) {
const onChange = vi.fn();
const onCommit = vi.fn();
renderWithMantine(
<ResizeHandle
value={400}
min={320}
max={720}
onChange={onChange}
onCommit={onCommit}
{...overrides}
/>,
);
return { handle: screen.getByRole("separator"), onChange, onCommit };
}
describe("ResizeHandle", () => {
afterEach(() => {
document.body.classList.remove("resizing-col");
});
it("exposes the separator role with live aria values", () => {
const { handle } = setup();
expect(handle).toHaveAttribute("aria-orientation", "vertical");
expect(handle).toHaveAttribute("aria-valuenow", "400");
expect(handle).toHaveAttribute("aria-valuemin", "320");
expect(handle).toHaveAttribute("aria-valuemax", "720");
expect(handle).toHaveAccessibleName("Resize panel");
});
it("accepts a custom aria-label", () => {
setup({ "aria-label": "Resize monitoring column" });
expect(
screen.getByRole("separator", { name: "Resize monitoring column" }),
).toBeInTheDocument();
});
it("reports the widened value while dragging left and commits on release", () => {
const { handle, onChange, onCommit } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 });
// startWidth 400 + (500 - 460) = 440
expect(onChange).toHaveBeenLastCalledWith(440);
expect(onCommit).not.toHaveBeenCalled();
fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 });
expect(onCommit).toHaveBeenCalledWith(440);
});
it("clamps to max when dragged past the upper bound", () => {
const { handle, onChange } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerMove(handle, { clientX: 0, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(720);
});
it("clamps to min when dragged past the lower bound", () => {
const { handle, onChange } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerMove(handle, { clientX: 900, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(320);
});
it("toggles the drag body class across the gesture", () => {
const { handle } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
expect(document.body.classList.contains("resizing-col")).toBe(true);
fireEvent.pointerUp(handle, { clientX: 500, pointerId: 1 });
expect(document.body.classList.contains("resizing-col")).toBe(false);
});
it("ends the drag on pointer cancel", () => {
const { handle, onCommit } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerCancel(handle, { clientX: 480, pointerId: 1 });
expect(onCommit).toHaveBeenCalledWith(420);
expect(document.body.classList.contains("resizing-col")).toBe(false);
});
it("clears the drag body class if it unmounts mid-drag", () => {
const onChange = vi.fn();
const onCommit = vi.fn();
const { unmount } = renderWithMantine(
<ResizeHandle
value={400}
min={320}
max={720}
onChange={onChange}
onCommit={onCommit}
/>,
);
fireEvent.pointerDown(screen.getByRole("separator"), {
clientX: 500,
pointerId: 1,
});
expect(document.body.classList.contains("resizing-col")).toBe(true);
// No pointerup/cancel — the handle is removed from the tree first.
unmount();
expect(document.body.classList.contains("resizing-col")).toBe(false);
});
it("ignores pointer moves when no drag is active", () => {
const { handle, onChange, onCommit } = setup();
fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 });
fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
});
it("uses pointer capture when the platform supports it", () => {
const setCapture = vi.fn();
const releaseCapture = vi.fn();
const proto = HTMLElement.prototype as unknown as {
setPointerCapture?: (id: number) => void;
releasePointerCapture?: (id: number) => void;
};
proto.setPointerCapture = setCapture;
proto.releasePointerCapture = releaseCapture;
try {
const { handle } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 7 });
fireEvent.pointerUp(handle, { clientX: 500, pointerId: 7 });
expect(setCapture).toHaveBeenCalledWith(7);
expect(releaseCapture).toHaveBeenCalledWith(7);
} finally {
delete proto.setPointerCapture;
delete proto.releasePointerCapture;
}
});
it("widens by one step on ArrowLeft and commits", () => {
const { handle, onChange, onCommit } = setup({ step: 20 });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith(420);
expect(onCommit).toHaveBeenCalledWith(420);
});
it("narrows by one step on ArrowRight", () => {
const { handle, onChange } = setup();
fireEvent.keyDown(handle, { key: "ArrowRight" });
// default step 16: 400 - 16 = 384
expect(onChange).toHaveBeenCalledWith(384);
});
it("clamps keyboard steps to the bounds", () => {
const { handle, onChange } = setup({ value: 712 });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith(720);
});
it("ignores non-arrow keys", () => {
const { handle, onChange, onCommit } = setup();
fireEvent.keyDown(handle, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,119 @@
import {
useEffect,
useRef,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { Box } from "@mantine/core";
export interface ResizeHandleProps {
/** Current width (px) of the panel this handle resizes. */
value: number;
/** Lower / upper clamp bounds (px). */
min: number;
max: number;
/** Keyboard arrow step in px (default 16). */
step?: number;
/**
* Fired continuously while dragging and on each keyboard step, with the
* clamped next width. The parent renders from this for live feedback.
*/
onChange: (next: number) => void;
/**
* Fired once when the gesture ends (pointer up / key up) with the final
* clamped width, so the parent can persist it in a single write.
*/
onCommit: (next: number) => void;
"aria-label"?: string;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/**
* A thin draggable vertical divider that resizes a panel sitting to its RIGHT
* (so dragging left widens the panel). Pointer-capture keeps the gesture alive
* even when the pointer crosses an iframe (the Apps screen renders one), which
* would otherwise swallow the move stream and make the drag stick. Exposes the
* ARIA `separator` role with live value so it's operable by keyboard too.
*/
export function ResizeHandle({
value,
min,
max,
step = 16,
onChange,
onCommit,
"aria-label": ariaLabel = "Resize panel",
}: ResizeHandleProps) {
// Gesture origin, captured on pointer down. `null` while idle.
const drag = useRef<{ startX: number; startWidth: number } | null>(null);
// If the handle unmounts mid-drag — the window narrows past the split
// breakpoint or the server disconnects, either of which removes the handle
// from the tree before a pointerup/cancel fires — the drag body class would
// otherwise linger and leave `user-select: none` + `col-resize` applied
// page-wide. Clear it on unmount so the class is self-healing.
useEffect(() => () => document.body.classList.remove("resizing-col"), []);
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
drag.current = { startX: e.clientX, startWidth: value };
// Optional chaining: happy-dom (unit tests) doesn't implement pointer
// capture, and it's a progressive enhancement, not required for correctness.
e.currentTarget.setPointerCapture?.(e.pointerId);
document.body.classList.add("resizing-col");
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
if (!drag.current) return;
const next = clamp(
drag.current.startWidth + (drag.current.startX - e.clientX),
min,
max,
);
onChange(next);
}
function endDrag(e: PointerEvent<HTMLDivElement>) {
if (!drag.current) return;
const next = clamp(
drag.current.startWidth + (drag.current.startX - e.clientX),
min,
max,
);
drag.current = null;
e.currentTarget.releasePointerCapture?.(e.pointerId);
document.body.classList.remove("resizing-col");
onCommit(next);
}
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
// Left widens (panel is on the right), Right narrows — mirrors the drag.
const delta =
e.key === "ArrowLeft" ? step : e.key === "ArrowRight" ? -step : 0;
if (delta === 0) return;
e.preventDefault();
const next = clamp(value + delta, min, max);
onChange(next);
onCommit(next);
}
return (
<Box
role="separator"
aria-orientation="vertical"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-label={ariaLabel}
tabIndex={0}
className="resize-handle"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={handleKeyDown}
/>
);
}

View file

@ -208,6 +208,54 @@ describe("HistoryEntry", () => {
expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand"));
});
it("renders the compact two-line layout with Replay as an icon when embedded", () => {
renderWithMantine(
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
);
// Line 1 essentials plus the method are still shown. The timestamp is the
// compact time-only form (not the full ISO) to fit the narrow line.
expect(screen.getByText("10:30:00")).toBeInTheDocument();
expect(
screen.queryByText("2026-03-17T10:30:00.000Z"),
).not.toBeInTheDocument();
expect(screen.getByText("client → server")).toBeInTheDocument();
expect(screen.getByText("142ms")).toBeInTheDocument();
expect(screen.getByText("OK")).toBeInTheDocument();
expect(screen.getByText("tools/call")).toBeInTheDocument();
// Replay is an icon button (aria-label), not the text button.
expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument();
expect(screen.queryByText("Replay")).toBeNull();
});
it("keeps action order Replay, Pin, Expand in the compact layout", () => {
renderWithMantine(
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
);
const names = screen
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(names.indexOf("Replay")).toBeLessThan(names.indexOf("Pin"));
expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand"));
});
it("does not render a Replay icon for a non-replayable method when embedded", () => {
// A server→client response isn't replayable.
const responseEntry: MessageEntry = {
id: "resp-1",
timestamp: new Date("2026-03-17T10:30:00Z"),
direction: "response",
origin: "server",
message: { jsonrpc: "2.0", id: 1, result: {} },
};
renderWithMantine(
<HistoryEntry {...baseProps} entry={responseEntry} embedded />,
);
expect(
screen.queryByRole("button", { name: "Replay" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument();
});
it("invokes onTogglePin when Pin button is clicked", async () => {
const user = userEvent.setup();
const onTogglePin = vi.fn();

View file

@ -14,6 +14,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { PinToggle } from "../../elements/PinToggle/PinToggle";
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
import { extractMethod, isReplayableHistoryMethod } from "../historyUtils.js";
export interface HistoryEntryProps {
@ -22,6 +23,12 @@ export interface HistoryEntryProps {
isListExpanded: boolean;
onReplay: () => void;
onTogglePin: () => void;
/**
* Compact two-line header for the narrow monitoring column (#1616): line 1 is
* time + direction + duration + status; line 2 is the method (and target) with
* the controls — Replay as an icon — on the right.
*/
embedded?: boolean;
}
const EntryContainer = Card.withProps({
@ -34,6 +41,19 @@ const HeaderRow = Group.withProps({
wrap: "nowrap",
});
// Left / right clusters within a compact header line. The left cluster shrinks
// (`miw: 0`) so a long target can truncate rather than push the row wider.
const HeaderCluster = Group.withProps({
gap: "sm",
wrap: "nowrap",
miw: 0,
});
const ControlsCluster = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const TimestampText = Text.withProps({
size: "sm",
c: "dimmed",
@ -63,6 +83,12 @@ function formatTimestamp(date: Date): string {
return date.toISOString();
}
// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO
// string would eat most of the narrow line-1 width (#1616).
function formatTimestampCompact(date: Date): string {
return date.toISOString().slice(11, 19);
}
function extractTarget(entry: MessageEntry): string | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
@ -108,6 +134,7 @@ export function HistoryEntry({
isListExpanded,
onReplay,
onTogglePin,
embedded = false,
}: HistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded);
const method = extractMethod(entry);
@ -119,44 +146,88 @@ export function HistoryEntry({
setIsExpanded(isListExpanded);
}, [isListExpanded]);
const directionBadge = entry.origin && (
<MessageDirectionBadge
direction={entry.origin === "client" ? "outgoing" : "incoming"}
/>
);
const statusBadge = status !== "none" && (
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
);
const durationText = entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
);
return (
<EntryContainer>
<Stack gap="sm">
<HeaderRow>
<Group gap="sm">
{entry.origin && (
<MessageDirectionBadge
direction={entry.origin === "client" ? "outgoing" : "incoming"}
/>
)}
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
<Badge color="dark">{method}</Badge>
{target && <TargetText>{target}</TargetText>}
</Group>
<Group gap="sm">
{entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
)}
{status !== "none" && (
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
)}
</Group>
</HeaderRow>
{embedded ? (
// Compact two-line header for the narrow column.
<Stack gap="xs">
<HeaderRow>
<HeaderCluster>
<TimestampText>
{formatTimestampCompact(entry.timestamp)}
</TimestampText>
{directionBadge}
</HeaderCluster>
<ControlsCluster>
{durationText}
{statusBadge}
</ControlsCluster>
</HeaderRow>
<HeaderRow>
<HeaderCluster flex={1}>
<Badge color="dark">{method}</Badge>
{target && (
<TargetText truncate="end" miw={0}>
{target}
</TargetText>
)}
</HeaderCluster>
<ControlsCluster>
{canReplay && <ReplayButton onReplay={onReplay} />}
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</ControlsCluster>
</HeaderRow>
</Stack>
) : (
<>
<HeaderRow>
<Group gap="sm">
<TimestampText>
{formatTimestamp(entry.timestamp)}
</TimestampText>
{directionBadge}
<Badge color="dark">{method}</Badge>
{target && <TargetText>{target}</TargetText>}
</Group>
<Group gap="sm">
{durationText}
{statusBadge}
</Group>
</HeaderRow>
<Group gap="xs" justify="space-between">
<Group gap="xs">
{canReplay && (
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
)}
</Group>
<Group gap="xs">
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</Group>
</Group>
<Group gap="xs" justify="space-between">
<Group gap="xs">
{canReplay && (
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
)}
</Group>
<Group gap="xs">
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</Group>
</Group>
</>
)}
<Collapse in={isExpanded}>
<Stack gap="sm">

View file

@ -98,11 +98,14 @@ describe("HistoryListPanel", () => {
expect(screen.getByText("No request history")).toBeInTheDocument();
});
it("renders the History title with count for unpinned entries", () => {
it("hides the History header when there are no pinned entries", () => {
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
expect(screen.getByText("History (3)")).toBeInTheDocument();
// With no pinned section to distinguish it from, the header is dropped...
expect(screen.queryByText("History (3)")).toBeNull();
// ...but the entries themselves are still shown.
expect(screen.getByText("resources/read")).toBeInTheDocument();
});
it("renders the Pinned title with count when entries are pinned", () => {
@ -135,12 +138,13 @@ describe("HistoryListPanel", () => {
expect(header).toHaveAttribute("aria-expanded", "true");
});
it("renders a lone section as a plain (non-collapsible) header with its entries shown", () => {
// Only the unpinned section → no accordion toggle, entries always visible.
it("renders a lone unpinned section headerless, with its entries shown", () => {
// Only the unpinned section → no header at all, no accordion toggle,
// entries always visible.
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
expect(screen.getByText("History (3)")).toBeInTheDocument();
expect(screen.queryByText("History (3)")).toBeNull();
expect(
screen.queryByRole("button", { name: "History (3)" }),
).not.toBeInTheDocument();
@ -246,8 +250,9 @@ describe("HistoryListPanel", () => {
visibleDirections={{ client: true, server: false }}
/>,
);
// The server-origin entry is filtered out, leaving one.
expect(screen.getByText("History (1)")).toBeInTheDocument();
// The server-origin entry is filtered out, leaving the client one.
expect(screen.getByText("tools/call")).toBeInTheDocument();
expect(screen.queryByText("resources/read")).toBeNull();
});
it("filters entries by searchText (case-insensitive)", () => {
@ -258,7 +263,10 @@ describe("HistoryListPanel", () => {
searchText="config.json"
/>,
);
expect(screen.getByText("History (1)")).toBeInTheDocument();
// Only the resources/read entry references config.json.
expect(screen.getByText("resources/read")).toBeInTheDocument();
expect(screen.queryByText("tools/call")).toBeNull();
expect(screen.queryByText("tools/list")).toBeNull();
});
it("filters entries by methodFilter", () => {
@ -269,7 +277,9 @@ describe("HistoryListPanel", () => {
methodFilter="tools/list"
/>,
);
expect(screen.getByText("History (1)")).toBeInTheDocument();
expect(screen.getByText("tools/list")).toBeInTheDocument();
expect(screen.queryByText("tools/call")).toBeNull();
expect(screen.queryByText("resources/read")).toBeNull();
});
it("invokes onExport when Export is clicked", async () => {

View file

@ -4,7 +4,6 @@ import {
Collapse,
Group,
Paper,
ScrollArea,
Stack,
Text,
Title,
@ -21,6 +20,8 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { extractMethod } from "../historyUtils.js";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
@ -43,6 +44,10 @@ export interface HistoryListPanelProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
/** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
onPin?: () => void;
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
embedded?: boolean;
}
const PanelContainer = Paper.withProps({
@ -123,10 +128,14 @@ function SectionActions({
// is a `listItem` toggle — with an optional actions slot on the right — over a
// `Collapse` of the entries. When it's the only section, the accordion makes no
// sense: the header is a plain title and the entries always show (so a stale
// collapsed state from when both sections were present can't hide them).
// collapsed state from when both sections were present can't hide them) —
// unless `hideHeaderWhenAlone`, in which case the lone section drops its title
// entirely (the "History" label is redundant when there's nothing to
// distinguish it from).
function CollapsibleSection({
title,
collapsible,
hideHeaderWhenAlone = false,
open,
onToggle,
actions,
@ -134,6 +143,7 @@ function CollapsibleSection({
}: {
title: string;
collapsible: boolean;
hideHeaderWhenAlone?: boolean;
open: boolean;
onToggle: () => void;
actions?: ReactNode;
@ -142,7 +152,7 @@ function CollapsibleSection({
if (!collapsible) {
return (
<Stack gap="md">
<Title order={5}>{title}</Title>
{hideHeaderWhenAlone ? null : <Title order={5}>{title}</Title>}
<Stack gap="md">{children}</Stack>
</Stack>
);
@ -168,13 +178,18 @@ function matchesFilters(
entry: MessageEntry,
searchText: string,
visibleDirections: Record<MessageOrigin, boolean>,
methodFilter?: MessageMethod,
methodFilter: MessageMethod | undefined,
// The embedded column exposes only the search box (no direction/method
// controls), so it applies the text filter but skips those (#1616).
ignoreDirectionAndMethod: boolean,
): boolean {
// Hide a direction when its toggle is off. Entries with no recorded origin
// (legacy / pre-origin logs) are never filtered out by direction.
if (entry.origin && !visibleDirections[entry.origin]) return false;
const method = extractMethod(entry);
if (methodFilter && method !== methodFilter) return false;
if (!ignoreDirectionAndMethod) {
// Hide a direction when its toggle is off. Entries with no recorded origin
// (legacy / pre-origin logs) are never filtered out by direction.
if (entry.origin && !visibleDirections[entry.origin]) return false;
if (methodFilter && method !== methodFilter) return false;
}
if (searchText) {
const term = searchText.toLowerCase();
const responseText = entry.response ? JSON.stringify(entry.response) : "";
@ -201,6 +216,8 @@ export function HistoryListPanel({
onSortChange,
compact,
onToggleCompact,
onPin,
embedded = false,
}: HistoryListPanelProps) {
const viewportRef = useScrollMemory("history-list");
// Per-section expand/collapse, like the LogControls level toggles. Both start
@ -208,15 +225,30 @@ export function HistoryListPanel({
const [pinnedOpen, setPinnedOpen] = useState(true);
const [historyOpen, setHistoryOpen] = useState(true);
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
// Embedded column filters by text only (its direction/method controls live
// in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns
// a fresh array, so sorting in-place is safe.
const sorted = entries
.filter((e) =>
matchesFilters(e, searchText, visibleDirections, methodFilter),
matchesFilters(
e,
searchText,
visibleDirections,
methodFilter,
embedded,
),
)
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, searchText, visibleDirections, methodFilter, sortDirection]);
}, [
entries,
searchText,
visibleDirections,
methodFilter,
sortDirection,
embedded,
]);
const pinnedEntries = useMemo(
() => filteredEntries.filter((e) => pinnedIds.has(e.id)),
@ -238,9 +270,6 @@ export function HistoryListPanel({
<Group justify="space-between" mb="sm">
<Title order={4}>Requests</Title>
<Group gap="xs">
{hasResults && (
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
<SortToggle
value={sortDirection}
onChange={onSortChange}
@ -256,18 +285,17 @@ export function HistoryListPanel({
<Button variant="default" onClick={onExport} disabled={!hasResults}>
Export
</Button>
{hasResults && (
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
{onPin ? <PinColumnButton onPin={onPin} /> : null}
</Group>
</Group>
{!hasResults ? (
<EmptyState>No request history</EmptyState>
) : (
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
type="scroll"
offsetScrollbars
>
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="md">
{pinnedEntries.length > 0 && (
<CollapsibleSection
@ -290,6 +318,7 @@ export function HistoryListPanel({
entry={entry}
isPinned={true}
isListExpanded={!compact}
embedded={embedded}
onReplay={() => onReplay(entry.id)}
onTogglePin={() => onTogglePin(entry.id)}
/>
@ -301,6 +330,9 @@ export function HistoryListPanel({
<CollapsibleSection
title={formatHistoryTitle(unpinnedEntries.length)}
collapsible={bothSections}
// With no pinned section to distinguish it from, the lone
// "History (N)" header is redundant — drop it.
hideHeaderWhenAlone
open={historyOpen}
onToggle={() => setHistoryOpen((v) => !v)}
actions={
@ -318,6 +350,7 @@ export function HistoryListPanel({
entry={entry}
isPinned={false}
isListExpanded={!compact}
embedded={embedded}
onReplay={() => onReplay(entry.id)}
onTogglePin={() => onTogglePin(entry.id)}
/>
@ -325,7 +358,7 @@ export function HistoryListPanel({
</CollapsibleSection>
)}
</Stack>
</ScrollArea.Autosize>
</EmbeddableScrollArea>
)}
</PanelContainer>
);

View file

@ -1,13 +1,5 @@
import { useMemo } from "react";
import {
Button,
Group,
Paper,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
import { LogEntry } from "../../elements/LogEntry/LogEntry";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
@ -15,6 +7,8 @@ import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface LogStreamPanelProps {
@ -25,6 +19,18 @@ export interface LogStreamPanelProps {
onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/**
* When set, renders a "pin as column" button in the toolbar that opens this
* screen in the monitoring column (#1616). Omitted when the panel is already
* embedded in that column (or when pinning isn't available).
*/
onPin?: () => void;
/**
* True when this panel is rendered inside the monitoring column. Switches the
* scroll region from the viewport-height calc to filling its flex parent, so
* it fits below the column's controls row without viewport math.
*/
embedded?: boolean;
}
const PanelContainer = Paper.withProps({
@ -50,8 +56,11 @@ function matchesFilters(
entry: LogEntryData,
filterText: string,
visibleLevels: Record<LoggingLevel, boolean>,
// The embedded column exposes only the search box (no level toggles), so it
// applies the text filter but skips the level filter (#1616).
ignoreLevels: boolean,
): boolean {
if (!visibleLevels[entry.params.level]) return false;
if (!ignoreLevels && !visibleLevels[entry.params.level]) return false;
if (filterText) {
const term = filterText.toLowerCase();
const searchable =
@ -69,16 +78,20 @@ export function LogStreamPanel({
onExport,
sortDirection,
onSortChange,
onPin,
embedded = false,
}: LogStreamPanelProps) {
const viewportRef = useScrollMemory("logs-stream");
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
// The embedded column has only the search box (its level toggles live in the
// full-size sidebar), so it filters by text but ignores the level filter
// (#1616). `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
.filter((e) => matchesFilters(e, filterText, visibleLevels))
.filter((e) => matchesFilters(e, filterText, visibleLevels, embedded))
.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, filterText, visibleLevels, sortDirection]);
}, [entries, filterText, visibleLevels, sortDirection, embedded]);
return (
<PanelContainer>
@ -104,21 +117,17 @@ export function LogStreamPanel({
>
Export
</Button>
{onPin ? <PinColumnButton onPin={onPin} /> : null}
</Group>
</Group>
{filteredEntries.length > 0 ? (
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
type="scroll"
offsetScrollbars
>
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="xs">
{filteredEntries.map((entry, index) => (
<LogEntry key={index} entry={entry} />
))}
</Stack>
</ScrollArea.Autosize>
</EmbeddableScrollArea>
) : (
<EmptyCenter>
<Text c="dimmed">No log entries</Text>

View file

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { MonitoringControls } from "./MonitoringControls";
const meta: Meta<typeof MonitoringControls> = {
title: "Groups/MonitoringControls",
component: MonitoringControls,
args: {
tabs: ["Logs", "History", "Network"],
value: "Logs",
onChange: fn(),
searchValue: "",
onSearchChange: fn(),
onClose: fn(),
},
};
export default meta;
type Story = StoryObj<typeof MonitoringControls>;
export const AllTabs: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
expect(
canvas.getByRole("button", { name: "Close monitoring column" }),
).toBeInTheDocument();
},
};
export const StdioServer: Story = {
args: { tabs: ["Logs", "History"], value: "History" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();
},
};

View file

@ -0,0 +1,103 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
MonitoringControls,
type MonitoringControlsProps,
} from "./MonitoringControls";
const TABS = ["Logs", "History", "Network"];
function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
const props: MonitoringControlsProps = {
tabs: TABS,
value: "Logs",
onChange: vi.fn(),
searchValue: "",
onSearchChange: vi.fn(),
onClose: vi.fn(),
...overrides,
};
renderWithMantine(<MonitoringControls {...props} />);
return props;
}
describe("MonitoringControls", () => {
it("renders a radio option for each available tab", () => {
renderControls();
for (const tab of TABS) {
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
}
});
it("renders only the tabs it is given", () => {
renderControls({ tabs: ["Logs", "History"] });
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
});
it("gives the tab switcher an accessible name", () => {
const { container } = renderWithMantine(
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={vi.fn()}
searchValue=""
onSearchChange={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(
container.querySelector('[aria-label="Monitoring screen"]'),
).not.toBeNull();
});
it("marks the active tab as selected", () => {
renderControls({ value: "History" });
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
});
it("calls onChange when a different tab is chosen", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderControls({ onChange });
await user.click(screen.getByRole("radio", { name: "Network" }));
expect(onChange).toHaveBeenCalledWith("Network");
});
it("renders the search box with the current value", () => {
renderControls({ searchValue: "auth" });
expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue("auth");
});
it("calls onSearchChange as the user types", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderControls({ onSearchChange });
await user.type(screen.getByRole("textbox", { name: "Search" }), "x");
expect(onSearchChange).toHaveBeenCalledWith("x");
});
it("clears the search via the clear button", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderControls({ searchValue: "term", onSearchChange });
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("shows no clear button when the search is empty", () => {
renderControls({ searchValue: "" });
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderControls({ onClose });
await user.click(
screen.getByRole("button", { name: "Close monitoring column" }),
);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

Some files were not shown because too many files have changed in this diff Show more