feat(web): monitoring column search, close-to-fullscreen, and open/close animation (#1616)

- Add a search box to MonitoringControls (right of the tabs). It's a single
  shared search kept across column tab switches, filtering whichever screen is
  shown. Embedded panels apply this text but still ignore the sidebar-only
  filters (levels/categories/directions/method), which have no control there.
- Close button now uses the "collapse sidebar" icon (TbLayoutSidebarRightCollapse),
  the mirror of the pin button's expand icon.
- Closing the column carries its selection to the primary view: the screen the
  user was watching becomes the active header tab (rather than snapping back to
  the pre-pin selection).
- Animate the column open/close: the handle + column slide in from the right and
  fade (reversing on close) via a Mantine Transition.
- HistoryListPanel: drop the redundant "History (N)" header when there are no
  pinned requests (lone unpinned section renders headerless).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-07-08 20:16:15 -04:00
commit c5e064ff71
2 changed files with 448 additions and 169 deletions

View file

@ -1388,15 +1388,38 @@ describe("InspectorView", () => {
await screen.findByRole("button", { name: "Close monitoring column" }),
);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull();
// The column plays its slide-out animation before unmounting.
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull(),
);
const header = screen.getByRole("banner");
expect(
within(header).getByRole("radio", { name: "Logs" }),
).toBeInTheDocument();
});
it("carries the column's selection to the primary view on close", async () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
const user = await gotoTab("Logs");
await user.click(
await screen.findByRole("button", { name: "Pin as column" }),
);
// Switch the column to History, then close it.
await user.click(await screen.findByRole("radio", { name: "History" }));
await user.click(
await screen.findByRole("button", { name: "Close monitoring column" }),
);
// The primary header now has History selected (not the pre-pin Logs).
const header = screen.getByRole("banner");
expect(
within(header).getByRole("radio", { name: "History" }),
).toBeChecked();
});
it("persists the pin preference and reopens the column when wide", () => {
// Stored preference from a prior wide session.
window.localStorage.setItem("inspector.monitor.pinned", "true");
@ -1431,7 +1454,7 @@ describe("InspectorView", () => {
).toBeNull();
});
it("hides the column on disconnect but keeps the pin preference", () => {
it("hides the column on disconnect but keeps the pin preference", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
const { rerender } = renderWithMantine(
@ -1450,9 +1473,12 @@ describe("InspectorView", () => {
})}
/>,
);
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull();
// The column plays its slide-out animation before unmounting.
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull(),
);
// Preference is untouched — only the column's close button clears it.
expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe(
"true",
@ -1539,6 +1565,46 @@ describe("InspectorView", () => {
);
});
it("keeps the column search across tabs and filters each screen", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true;
const user = userEvent.setup();
renderWithMantine(
<StatefulInspectorViewHost
{...connectedHttp({
logs: [
{
receivedAt: new Date(),
params: { level: "info", data: "loghello" },
},
],
history: [
{
id: "h1",
timestamp: new Date(),
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
},
],
})}
/>,
);
// Column defaults to Logs; the log entry shows.
expect(await screen.findByText("loghello")).toBeInTheDocument();
// A search matching nothing filters the Logs stream...
const searchBox = screen.getByRole("textbox", { name: "Search" });
await user.type(searchBox, "zzz");
expect(screen.queryByText("loghello")).toBeNull();
// ...and the same term carries over to History (still filtered → empty).
await user.click(screen.getByRole("radio", { name: "History" }));
expect(screen.getByText("No request history")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue(
"zzz",
);
});
it("resizes and persists the column width from the keyboard", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true");
window.localStorage.setItem("inspector.monitor.width", "420");

View file

@ -1,5 +1,13 @@
import { useMemo, useState, type ReactNode, type Ref } from "react";
import { AppShell, Box, Flex, Stack, Transition } from "@mantine/core";
import {
AppShell,
Box,
Flex,
Group,
Stack,
Transition,
type MantineTransition,
} from "@mantine/core";
import { useLocalStorage, useMediaQuery } from "@mantine/hooks";
import type {
InitializeResult,
@ -244,6 +252,27 @@ const MonitoringColumn = Stack.withProps({
miw: 0,
});
// Column open/close animation (#1616): the handle + column slide in from the
// right edge and fade as they mount, reversing on close, so pinning reads as the
// screen moving into a side column rather than snapping in. `AppShell.Main`'s
// `overflow: hidden` clips the off-screen portion during the slide.
const COLUMN_ANIM_MS = 300;
const slideInFromRight: MantineTransition = {
in: { opacity: 1, transform: "translateX(0)" },
out: { opacity: 0, transform: "translateX(100%)" },
common: { transformOrigin: "right center" },
transitionProperty: "transform, opacity",
};
// Flex-row wrapper holding the resize handle + column so the whole unit animates
// as one (the Transition interpolation is applied to it via `style`).
const MonitorColumnGroup = Group.withProps({
wrap: "nowrap",
gap: 0,
h: "100%",
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,
@ -744,11 +773,25 @@ export function InspectorView({
/* v8 ignore next */
if (isMonitorTab(tab)) setMonitorTab(tab);
}
// Closing the column carries its selection back to the primary area: the
// screen the user was watching in the column becomes the active header tab,
// rather than snapping back to whatever was selected before they pinned.
function closeMonitorColumn() {
onActiveTabChange(effectiveMonitorTab);
setMonitorPinned(false);
}
function commitMonitorWidth(next: number) {
setMonitorWidth(next);
setDragWidth(null);
}
// A single search shared across the column's tabs (kept as you move between
// Logs/History/Network so it filters each one), distinct from the full-size
// screens' own searches. The embedded panels apply only this text; their other
// filters (levels / categories / directions / method) have no control in the
// column and are bypassed there.
const [monitorSearch, setMonitorSearch] = useState("");
// Merge the parent's `serversInput` (static config) with the runtime
// connection state owned by the parent — only the active server reflects
// the live status; the rest render as `disconnected`. Handshake errors
@ -840,11 +883,31 @@ export function InspectorView({
};
// Embedded instances for the pinned column, keyed by tab. MonitoringScreen
// renders only the active one; the rest are unmounted element values.
// renders only the active one; the rest are unmounted element values. Each
// screen's search field is overridden with the shared column search so it
// filters whichever tab is showing, and carries over as tabs change.
const monitorScreens: Record<string, ReactNode> = {
[LOGS_TAB]: <LoggingScreen {...loggingScreenProps} embedded />,
[HISTORY_TAB]: <HistoryScreen {...historyScreenProps} embedded />,
[NETWORK_TAB]: <NetworkScreen {...networkScreenProps} embedded />,
[LOGS_TAB]: (
<LoggingScreen
{...loggingScreenProps}
ui={{ ...logsUi, filterText: monitorSearch }}
embedded
/>
),
[HISTORY_TAB]: (
<HistoryScreen
{...historyScreenProps}
ui={{ ...historyUi, search: monitorSearch }}
embedded
/>
),
[NETWORK_TAB]: (
<NetworkScreen
{...networkScreenProps}
ui={{ ...networkUi, filterText: monitorSearch }}
embedded
/>
),
};
return (
@ -997,28 +1060,40 @@ export function InspectorView({
/>
</ScreenStage>
</ScreenStageContainer>
{effectivePinned ? (
<>
<ResizeHandle
value={columnWidth}
min={MONITOR_WIDTH_MIN}
max={MONITOR_WIDTH_MAX}
step={MONITOR_WIDTH_STEP}
onChange={setDragWidth}
onCommit={commitMonitorWidth}
aria-label="Resize monitoring column"
/>
<MonitoringColumn w={columnWidth}>
<MonitoringScreen
tabs={monitorAvailable}
value={effectiveMonitorTab}
onChange={handleMonitorTabChange}
onClose={() => setMonitorPinned(false)}
screens={monitorScreens}
<Transition
mounted={effectivePinned}
transition={slideInFromRight}
duration={COLUMN_ANIM_MS}
exitDuration={COLUMN_ANIM_MS}
timingFunction="ease"
>
{(styles) => (
// `style={styles}` is Mantine's runtime Transition interpolation,
// not static styling — same pattern as ScreenStage above.
<MonitorColumnGroup style={styles}>
<ResizeHandle
value={columnWidth}
min={MONITOR_WIDTH_MIN}
max={MONITOR_WIDTH_MAX}
step={MONITOR_WIDTH_STEP}
onChange={setDragWidth}
onCommit={commitMonitorWidth}
aria-label="Resize monitoring column"
/>
</MonitoringColumn>
</>
) : null}
<MonitoringColumn w={columnWidth}>
<MonitoringScreen
tabs={monitorAvailable}
value={effectiveMonitorTab}
onChange={handleMonitorTabChange}
searchValue={monitorSearch}
onSearchChange={setMonitorSearch}
onClose={closeMonitorColumn}
screens={monitorScreens}
/>
</MonitoringColumn>
</MonitorColumnGroup>
)}
</Transition>
</SplitRow>
</AppShell.Main>
</AppShell>