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:
parent
9b76272ad1
commit
c5e064ff71
2 changed files with 448 additions and 169 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue