feat(web): pin Logging/History/Network as a resizable column (#1616)

Add the ability to "pin" the Logging, History, and Network screens into a
second column on the right of the InspectorView, so a primary screen and a
monitor screen are visible at once (e.g. watch Logs while calling a Tool).

- A "pin as column" button on the Logs/History/Network panel toolbars opens
  the column (shown only when wide + connected).
- The column hosts a MonitoringControls tab row (available monitor tabs +
  close) over the selected embedded screen. Pinning removes the monitor group
  from the header tab bar; closing (x) returns it.
- The divider between the primary area and the column is draggable (pointer +
  keyboard); width is clamped and persisted.
- The split collapses to one column below ~1040px (the ServerListScreen
  single-card breakpoint) and while disconnected, without discarding the pin
  preference — it reopens when wide + connected again.

State (pinned/tab/width) is view-local and persisted via the existing
useLocalStorage pattern, so no new props thread through App and ViewHeader is
unchanged (it just receives a filtered tab list). Screens/panels gain an
`embedded` mode (fills the parent height, drops the 340px filter sidebar) via a
shared EmbeddableScrollArea element.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-07-08 18:52:41 -04:00
commit 081fd789f1
9 changed files with 1728 additions and 241 deletions

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({
@ -201,6 +206,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
@ -238,6 +245,7 @@ export function HistoryListPanel({
<Group justify="space-between" mb="sm">
<Title order={4}>Requests</Title>
<Group gap="xs">
{onPin ? <PinColumnButton onPin={onPin} /> : null}
{hasResults && (
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
@ -262,12 +270,7 @@ export function HistoryListPanel({
{!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
@ -325,7 +328,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({
@ -69,6 +75,8 @@ export function LogStreamPanel({
onExport,
sortDirection,
onSortChange,
onPin,
embedded = false,
}: LogStreamPanelProps) {
const viewportRef = useScrollMemory("logs-stream");
const filteredEntries = useMemo(() => {
@ -85,6 +93,7 @@ export function LogStreamPanel({
<Group justify="space-between" mb="sm">
<Title order={4}>Log Stream</Title>
<Group>
{onPin ? <PinColumnButton onPin={onPin} /> : null}
<SortToggle
value={sortDirection}
onChange={onSortChange}
@ -107,18 +116,13 @@ export function LogStreamPanel({
</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,35 @@
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(),
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,79 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { MonitoringControls } from "./MonitoringControls";
const TABS = ["Logs", "History", "Network"];
describe("MonitoringControls", () => {
it("renders a radio option for each available tab", () => {
renderWithMantine(
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={vi.fn()}
onClose={vi.fn()}
/>,
);
for (const tab of TABS) {
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
}
});
it("renders only the tabs it is given", () => {
renderWithMantine(
<MonitoringControls
tabs={["Logs", "History"]}
value="Logs"
onChange={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
});
it("marks the active tab as selected", () => {
renderWithMantine(
<MonitoringControls
tabs={TABS}
value="History"
onChange={vi.fn()}
onClose={vi.fn()}
/>,
);
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();
renderWithMantine(
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={onChange}
onClose={vi.fn()}
/>,
);
await user.click(screen.getByRole("radio", { name: "Network" }));
expect(onChange).toHaveBeenCalledWith("Network");
});
it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={vi.fn()}
onClose={onClose}
/>,
);
await user.click(
screen.getByRole("button", { name: "Close monitoring column" }),
);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,46 @@
import { CloseButton, Group, SegmentedControl } from "@mantine/core";
export interface MonitoringControlsProps {
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
tabs: string[];
/** The active monitor tab shown in the column below. */
value: string;
onChange: (tab: string) => void;
/** Close the monitoring column, returning its screens to the header menu. */
onClose: () => void;
}
// The controls row that sits at the top of the pinned monitoring column: a
// segmented tab switcher on the left, a close button on the right that unpins
// the whole column.
const ControlsBar = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "sm",
p: "sm",
});
/**
* Tab row + close control for the pinned monitoring column (#1616). Renders only
* the currently-available monitor tabs (the caller filters by capability), so it
* never shows an empty switcher. Selecting a tab swaps the screen below; the
* close button unpins the column so its screens rejoin the header tab menu.
*/
export function MonitoringControls({
tabs,
value,
onChange,
onClose,
}: MonitoringControlsProps) {
return (
<ControlsBar>
<SegmentedControl
size="sm"
value={value}
onChange={onChange}
data={tabs}
/>
<CloseButton aria-label="Close monitoring column" onClick={onClose} />
</ControlsBar>
);
}

View file

@ -0,0 +1,59 @@
import { Box, Text } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { MonitoringScreen } from "./MonitoringScreen";
const demoScreens = {
Logs: (
<Box p="md">
<Text>Log stream</Text>
</Box>
),
History: (
<Box p="md">
<Text>Request history</Text>
</Box>
),
Network: (
<Box p="md">
<Text>Network requests</Text>
</Box>
),
};
const meta: Meta<typeof MonitoringScreen> = {
title: "Groups/MonitoringScreen",
component: MonitoringScreen,
decorators: [
(Story) => (
<Box h={320}>
<Story />
</Box>
),
],
args: {
tabs: ["Logs", "History", "Network"],
value: "Logs",
onChange: fn(),
onClose: fn(),
screens: demoScreens,
},
};
export default meta;
type Story = StoryObj<typeof MonitoringScreen>;
export const Logs: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Log stream")).toBeInTheDocument();
},
};
export const History: Story = {
args: { value: "History" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Request history")).toBeInTheDocument();
},
};

View file

@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { MonitoringScreen } from "./MonitoringScreen";
const TABS = ["Logs", "History", "Network"];
function screens() {
return {
Logs: <div>logs-body</div>,
History: <div>history-body</div>,
Network: <div>network-body</div>,
};
}
describe("MonitoringScreen", () => {
it("renders the screen for the active tab", () => {
renderWithMantine(
<MonitoringScreen
tabs={TABS}
value="History"
onChange={vi.fn()}
onClose={vi.fn()}
screens={screens()}
/>,
);
expect(screen.getByText("history-body")).toBeInTheDocument();
expect(screen.queryByText("logs-body")).toBeNull();
expect(screen.queryByText("network-body")).toBeNull();
});
it("renders the controls tab row", () => {
renderWithMantine(
<MonitoringScreen
tabs={TABS}
value="Logs"
onChange={vi.fn()}
onClose={vi.fn()}
screens={screens()}
/>,
);
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Close monitoring column" }),
).toBeInTheDocument();
});
it("forwards tab changes and close from the controls", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onClose = vi.fn();
renderWithMantine(
<MonitoringScreen
tabs={TABS}
value="Logs"
onChange={onChange}
onClose={onClose}
screens={screens()}
/>,
);
await user.click(screen.getByRole("radio", { name: "Network" }));
expect(onChange).toHaveBeenCalledWith("Network");
await user.click(
screen.getByRole("button", { name: "Close monitoring column" }),
);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("renders nothing in the slot when the active tab has no screen", () => {
renderWithMantine(
<MonitoringScreen
tabs={TABS}
value="Network"
onChange={vi.fn()}
onClose={vi.fn()}
screens={{ Logs: <div>logs-body</div> }}
/>,
);
expect(screen.queryByText("logs-body")).toBeNull();
});
});

View file

@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { Divider, Stack } from "@mantine/core";
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
export interface MonitoringScreenProps {
/** Available monitor tabs (Logs/History/Network, filtered by capability). */
tabs: string[];
/** Active monitor tab; keys into `screens` to pick what renders below. */
value: string;
onChange: (tab: string) => void;
onClose: () => void;
/**
* The embedded screen node for each tab, keyed by tab label. The caller builds
* these (with `embedded` set) so this component stays a pure layout shell.
*/
screens: Record<string, ReactNode>;
}
// Fills the pinned column: a fixed controls row on top and the selected screen
// filling the remaining height below (mih:0 lets the inner ScrollArea bound).
const ColumnLayout = Stack.withProps({
h: "100%",
gap: 0,
});
const ScreenSlot = Stack.withProps({
flex: 1,
mih: 0,
gap: 0,
});
/**
* 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.
*/
export function MonitoringScreen({
tabs,
value,
onChange,
onClose,
screens,
}: MonitoringScreenProps) {
return (
<ColumnLayout>
<MonitoringControls
tabs={tabs}
value={value}
onChange={onChange}
onClose={onClose}
/>
<Divider />
<ScreenSlot>{screens[value]}</ScreenSlot>
</ColumnLayout>
);
}

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 {
FetchRequestCategory,
FetchRequestEntry,
@ -18,6 +10,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 NetworkStreamPanelProps {
@ -30,6 +24,10 @@ export interface NetworkStreamPanelProps {
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({
@ -95,6 +93,8 @@ export function NetworkStreamPanel({
onSortChange,
compact,
onToggleCompact,
onPin,
embedded = false,
}: NetworkStreamPanelProps) {
const viewportRef = useScrollMemory("network-stream");
const filteredEntries = useMemo(() => {
@ -114,6 +114,7 @@ export function NetworkStreamPanel({
<Group justify="space-between" mb="sm">
<Title order={4}>{formatTitle(filteredEntries.length)}</Title>
<Group gap="xs">
{onPin ? <PinColumnButton onPin={onPin} /> : null}
{hasResults && (
<ListToggle compact={compact} onToggle={onToggleCompact} />
)}
@ -134,12 +135,7 @@ export function NetworkStreamPanel({
{!hasResults ? (
<EmptyState>No network requests</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">
{filteredEntries.map((entry) => (
<NetworkEntry
@ -149,7 +145,7 @@ export function NetworkStreamPanel({
/>
))}
</Stack>
</ScrollArea.Autosize>
</EmbeddableScrollArea>
)}
</PanelContainer>
);