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
15 changed files with 448 additions and 169 deletions

View file

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

View file

@ -128,10 +128,14 @@ function SectionActions({
// is a `listItem` toggle — with an optional actions slot on the right — over a // 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 // `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 // 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({ function CollapsibleSection({
title, title,
collapsible, collapsible,
hideHeaderWhenAlone = false,
open, open,
onToggle, onToggle,
actions, actions,
@ -139,6 +143,7 @@ function CollapsibleSection({
}: { }: {
title: string; title: string;
collapsible: boolean; collapsible: boolean;
hideHeaderWhenAlone?: boolean;
open: boolean; open: boolean;
onToggle: () => void; onToggle: () => void;
actions?: ReactNode; actions?: ReactNode;
@ -147,7 +152,7 @@ function CollapsibleSection({
if (!collapsible) { if (!collapsible) {
return ( return (
<Stack gap="md"> <Stack gap="md">
<Title order={5}>{title}</Title> {hideHeaderWhenAlone ? null : <Title order={5}>{title}</Title>}
<Stack gap="md">{children}</Stack> <Stack gap="md">{children}</Stack>
</Stack> </Stack>
); );
@ -173,13 +178,18 @@ function matchesFilters(
entry: MessageEntry, entry: MessageEntry,
searchText: string, searchText: string,
visibleDirections: Record<MessageOrigin, boolean>, 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 { ): 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); 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) { if (searchText) {
const term = searchText.toLowerCase(); const term = searchText.toLowerCase();
const responseText = entry.response ? JSON.stringify(entry.response) : ""; const responseText = entry.response ? JSON.stringify(entry.response) : "";
@ -215,14 +225,18 @@ export function HistoryListPanel({
const [pinnedOpen, setPinnedOpen] = useState(true); const [pinnedOpen, setPinnedOpen] = useState(true);
const [historyOpen, setHistoryOpen] = useState(true); const [historyOpen, setHistoryOpen] = useState(true);
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
// Embedded column: show the full stream (no filter sidebar to explain a // Embedded column filters by text only (its direction/method controls live
// mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh // in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns
// array, so sorting in-place is safe. // a fresh array, so sorting in-place is safe.
const sorted = entries const sorted = entries
.filter( .filter((e) =>
(e) => matchesFilters(
embedded || e,
matchesFilters(e, searchText, visibleDirections, methodFilter), searchText,
visibleDirections,
methodFilter,
embedded,
),
) )
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
if (sortDirection === "newest-first") sorted.reverse(); if (sortDirection === "newest-first") sorted.reverse();
@ -315,6 +329,9 @@ export function HistoryListPanel({
<CollapsibleSection <CollapsibleSection
title={formatHistoryTitle(unpinnedEntries.length)} title={formatHistoryTitle(unpinnedEntries.length)}
collapsible={bothSections} collapsible={bothSections}
// With no pinned section to distinguish it from, the lone
// "History (N)" header is redundant — drop it.
hideHeaderWhenAlone
open={historyOpen} open={historyOpen}
onToggle={() => setHistoryOpen((v) => !v)} onToggle={() => setHistoryOpen((v) => !v)}
actions={ actions={

View file

@ -56,8 +56,11 @@ function matchesFilters(
entry: LogEntryData, entry: LogEntryData,
filterText: string, filterText: string,
visibleLevels: Record<LoggingLevel, boolean>, 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 { ): boolean {
if (!visibleLevels[entry.params.level]) return false; if (!ignoreLevels && !visibleLevels[entry.params.level]) return false;
if (filterText) { if (filterText) {
const term = filterText.toLowerCase(); const term = filterText.toLowerCase();
const searchable = const searchable =
@ -80,12 +83,11 @@ export function LogStreamPanel({
}: LogStreamPanelProps) { }: LogStreamPanelProps) {
const viewportRef = useScrollMemory("logs-stream"); const viewportRef = useScrollMemory("logs-stream");
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
// The embedded column has no filter sidebar, so it shows the full stream // The embedded column has only the search box (its level toggles live in the
// rather than mirroring the full-size screen's live filter with no visible // full-size sidebar), so it filters by text but ignores the level filter
// control to explain it (#1616). `.filter()` returns a fresh array, so // (#1616). `.filter()` returns a fresh array, so sorting in-place is safe.
// sorting in-place is safe.
const sorted = entries const sorted = entries
.filter((e) => embedded || matchesFilters(e, filterText, visibleLevels)) .filter((e) => matchesFilters(e, filterText, visibleLevels, embedded))
.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
if (sortDirection === "newest-first") sorted.reverse(); if (sortDirection === "newest-first") sorted.reverse();
return sorted; return sorted;

View file

@ -9,6 +9,8 @@ const meta: Meta<typeof MonitoringControls> = {
tabs: ["Logs", "History", "Network"], tabs: ["Logs", "History", "Network"],
value: "Logs", value: "Logs",
onChange: fn(), onChange: fn(),
searchValue: "",
onSearchChange: fn(),
onClose: fn(), onClose: fn(),
}, },
}; };

View file

@ -1,34 +1,37 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { MonitoringControls } from "./MonitoringControls"; import {
MonitoringControls,
type MonitoringControlsProps,
} from "./MonitoringControls";
const TABS = ["Logs", "History", "Network"]; 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", () => { describe("MonitoringControls", () => {
it("renders a radio option for each available tab", () => { it("renders a radio option for each available tab", () => {
renderWithMantine( renderControls();
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={vi.fn()}
onClose={vi.fn()}
/>,
);
for (const tab of TABS) { for (const tab of TABS) {
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
} }
}); });
it("renders only the tabs it is given", () => { it("renders only the tabs it is given", () => {
renderWithMantine( renderControls({ tabs: ["Logs", "History"] });
<MonitoringControls
tabs={["Logs", "History"]}
value="Logs"
onChange={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(screen.getAllByRole("radio")).toHaveLength(2); expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
}); });
@ -39,6 +42,8 @@ describe("MonitoringControls", () => {
tabs={TABS} tabs={TABS}
value="Logs" value="Logs"
onChange={vi.fn()} onChange={vi.fn()}
searchValue=""
onSearchChange={vi.fn()}
onClose={vi.fn()} onClose={vi.fn()}
/>, />,
); );
@ -48,43 +53,48 @@ describe("MonitoringControls", () => {
}); });
it("marks the active tab as selected", () => { it("marks the active tab as selected", () => {
renderWithMantine( renderControls({ value: "History" });
<MonitoringControls
tabs={TABS}
value="History"
onChange={vi.fn()}
onClose={vi.fn()}
/>,
);
expect(screen.getByRole("radio", { name: "History" })).toBeChecked(); expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
}); });
it("calls onChange when a different tab is chosen", async () => { it("calls onChange when a different tab is chosen", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); const onChange = vi.fn();
renderWithMantine( renderControls({ onChange });
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={onChange}
onClose={vi.fn()}
/>,
);
await user.click(screen.getByRole("radio", { name: "Network" })); await user.click(screen.getByRole("radio", { name: "Network" }));
expect(onChange).toHaveBeenCalledWith("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 () => { it("calls onClose when the close button is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onClose = vi.fn(); const onClose = vi.fn();
renderWithMantine( renderControls({ onClose });
<MonitoringControls
tabs={TABS}
value="Logs"
onChange={vi.fn()}
onClose={onClose}
/>,
);
await user.click( await user.click(
screen.getByRole("button", { name: "Close monitoring column" }), screen.getByRole("button", { name: "Close monitoring column" }),
); );

View file

@ -1,4 +1,6 @@
import { CloseButton, Group, SegmentedControl } from "@mantine/core"; import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core";
import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
export interface MonitoringControlsProps { export interface MonitoringControlsProps {
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */ /** The monitor screens currently available to pin (e.g. Logs/History/Network). */
@ -6,30 +8,46 @@ export interface MonitoringControlsProps {
/** The active monitor tab shown in the column below. */ /** The active monitor tab shown in the column below. */
value: string; value: string;
onChange: (tab: string) => void; onChange: (tab: string) => void;
/** Search text for the active screen (shares the screen's own filter state). */
searchValue: string;
onSearchChange: (next: string) => void;
/** Close the monitoring column, returning its screens to the header menu. */ /** Close the monitoring column, returning its screens to the header menu. */
onClose: () => void; onClose: () => void;
} }
// The controls row that sits at the top of the pinned monitoring column: a // The controls row at the top of the pinned monitoring column: a segmented tab
// segmented tab switcher on the left, a close button on the right that unpins // switcher on the left, a search box filling the middle, and a close button on
// the whole column. // the right that unpins the whole column.
const ControlsBar = Group.withProps({ const ControlsBar = Group.withProps({
justify: "space-between",
wrap: "nowrap", wrap: "nowrap",
gap: "sm", gap: "sm",
p: "sm", p: "sm",
}); });
// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0`
// so it fills the space between the tabs and the close button.
const SearchInput = TextInput.withProps({
placeholder: "Search...",
size: "sm",
flex: 1,
miw: 0,
"aria-label": "Search",
});
/** /**
* Tab row + close control for the pinned monitoring column (#1616). Renders only * Tab row + search + close control for the pinned monitoring column (#1616).
* the currently-available monitor tabs (the caller filters by capability), so it * Renders only the currently-available monitor tabs (the caller filters by
* never shows an empty switcher. Selecting a tab swaps the screen below; the * capability), so it never shows an empty switcher. Selecting a tab swaps the
* close button unpins the column so its screens rejoin the header tab menu. * screen below; the search box filters the shown screen (wired by the caller to
* that screen's own filter state); the close button unpins the column so its
* screens rejoin the header tab menu.
*/ */
export function MonitoringControls({ export function MonitoringControls({
tabs, tabs,
value, value,
onChange, onChange,
searchValue,
onSearchChange,
onClose, onClose,
}: MonitoringControlsProps) { }: MonitoringControlsProps) {
return ( return (
@ -41,7 +59,25 @@ export function MonitoringControls({
data={tabs} data={tabs}
aria-label="Monitoring screen" aria-label="Monitoring screen"
/> />
<CloseButton aria-label="Close monitoring column" onClick={onClose} /> <SearchInput
value={searchValue}
onChange={(e) => onSearchChange(e.currentTarget.value)}
rightSectionPointerEvents="auto"
rightSection={
searchValue ? (
<ClearButton onClick={() => onSearchChange("")} />
) : null
}
/>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label="Close monitoring column"
onClick={onClose}
>
<TbLayoutSidebarRightCollapse size={20} />
</ActionIcon>
</ControlsBar> </ControlsBar>
); );
} }

View file

@ -35,6 +35,8 @@ const meta: Meta<typeof MonitoringScreen> = {
tabs: ["Logs", "History", "Network"], tabs: ["Logs", "History", "Network"],
value: "Logs", value: "Logs",
onChange: fn(), onChange: fn(),
searchValue: "",
onSearchChange: fn(),
onClose: fn(), onClose: fn(),
screens: demoScreens, screens: demoScreens,
}, },

View file

@ -1,7 +1,10 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { MonitoringScreen } from "./MonitoringScreen"; import {
MonitoringScreen,
type MonitoringScreenProps,
} from "./MonitoringScreen";
const TABS = ["Logs", "History", "Network"]; const TABS = ["Logs", "History", "Network"];
@ -13,32 +16,31 @@ function screens() {
}; };
} }
function renderScreen(overrides: Partial<MonitoringScreenProps> = {}) {
const props: MonitoringScreenProps = {
tabs: TABS,
value: "Logs",
onChange: vi.fn(),
searchValue: "",
onSearchChange: vi.fn(),
onClose: vi.fn(),
screens: screens(),
...overrides,
};
renderWithMantine(<MonitoringScreen {...props} />);
return props;
}
describe("MonitoringScreen", () => { describe("MonitoringScreen", () => {
it("renders the screen for the active tab", () => { it("renders the screen for the active tab", () => {
renderWithMantine( renderScreen({ value: "History" });
<MonitoringScreen
tabs={TABS}
value="History"
onChange={vi.fn()}
onClose={vi.fn()}
screens={screens()}
/>,
);
expect(screen.getByText("history-body")).toBeInTheDocument(); expect(screen.getByText("history-body")).toBeInTheDocument();
expect(screen.queryByText("logs-body")).toBeNull(); expect(screen.queryByText("logs-body")).toBeNull();
expect(screen.queryByText("network-body")).toBeNull(); expect(screen.queryByText("network-body")).toBeNull();
}); });
it("renders the controls tab row", () => { it("renders the controls tab row", () => {
renderWithMantine( renderScreen();
<MonitoringScreen
tabs={TABS}
value="Logs"
onChange={vi.fn()}
onClose={vi.fn()}
screens={screens()}
/>,
);
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "Close monitoring column" }), screen.getByRole("button", { name: "Close monitoring column" }),
@ -49,15 +51,7 @@ describe("MonitoringScreen", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn(); const onChange = vi.fn();
const onClose = vi.fn(); const onClose = vi.fn();
renderWithMantine( renderScreen({ onChange, onClose });
<MonitoringScreen
tabs={TABS}
value="Logs"
onChange={onChange}
onClose={onClose}
screens={screens()}
/>,
);
await user.click(screen.getByRole("radio", { name: "Network" })); await user.click(screen.getByRole("radio", { name: "Network" }));
expect(onChange).toHaveBeenCalledWith("Network"); expect(onChange).toHaveBeenCalledWith("Network");
await user.click( await user.click(
@ -66,16 +60,18 @@ describe("MonitoringScreen", () => {
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
it("forwards the search value and changes from the controls", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderScreen({ searchValue: "hi", onSearchChange });
const box = screen.getByRole("textbox", { name: "Search" });
expect(box).toHaveValue("hi");
await user.type(box, "!");
expect(onSearchChange).toHaveBeenCalledWith("hi!");
});
it("renders nothing in the slot when the active tab has no screen", () => { it("renders nothing in the slot when the active tab has no screen", () => {
renderWithMantine( renderScreen({ value: "Network", screens: { Logs: <div>logs-body</div> } });
<MonitoringScreen
tabs={TABS}
value="Network"
onChange={vi.fn()}
onClose={vi.fn()}
screens={{ Logs: <div>logs-body</div> }}
/>,
);
expect(screen.queryByText("logs-body")).toBeNull(); expect(screen.queryByText("logs-body")).toBeNull();
}); });
}); });

View file

@ -8,6 +8,9 @@ export interface MonitoringScreenProps {
/** Active monitor tab; keys into `screens` to pick what renders below. */ /** Active monitor tab; keys into `screens` to pick what renders below. */
value: string; value: string;
onChange: (tab: string) => void; onChange: (tab: string) => void;
/** Search text for the active screen; wired by the caller to its filter state. */
searchValue: string;
onSearchChange: (next: string) => void;
onClose: () => void; onClose: () => void;
/** /**
* The embedded screen node for each tab, keyed by tab label. The caller builds * The embedded screen node for each tab, keyed by tab label. The caller builds
@ -38,6 +41,8 @@ export function MonitoringScreen({
tabs, tabs,
value, value,
onChange, onChange,
searchValue,
onSearchChange,
onClose, onClose,
screens, screens,
}: MonitoringScreenProps) { }: MonitoringScreenProps) {
@ -47,6 +52,8 @@ export function MonitoringScreen({
tabs={tabs} tabs={tabs}
value={value} value={value}
onChange={onChange} onChange={onChange}
searchValue={searchValue}
onSearchChange={onSearchChange}
onClose={onClose} onClose={onClose}
/> />
<Divider /> <Divider />

View file

@ -58,8 +58,11 @@ function matchesFilters(
entry: FetchRequestEntry, entry: FetchRequestEntry,
filterText: string, filterText: string,
visibleCategories: Record<FetchRequestCategory, boolean>, visibleCategories: Record<FetchRequestCategory, boolean>,
// The embedded column exposes only the search box (no category toggles), so it
// applies the text filter but skips the category filter (#1616).
ignoreCategories: boolean,
): boolean { ): boolean {
if (!visibleCategories[entry.category]) return false; if (!ignoreCategories && !visibleCategories[entry.category]) return false;
if (filterText) { if (filterText) {
const term = filterText.toLowerCase(); const term = filterText.toLowerCase();
const status = const status =
@ -98,13 +101,11 @@ export function NetworkStreamPanel({
}: NetworkStreamPanelProps) { }: NetworkStreamPanelProps) {
const viewportRef = useScrollMemory("network-stream"); const viewportRef = useScrollMemory("network-stream");
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
// Embedded column: show the full stream (no filter sidebar to explain a // Embedded column filters by text only (its category toggles live in the
// mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh // full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns a
// array, so sorting in-place is safe. // fresh array, so sorting in-place is safe.
const sorted = entries const sorted = entries
.filter( .filter((e) => matchesFilters(e, filterText, visibleCategories, embedded))
(e) => embedded || matchesFilters(e, filterText, visibleCategories),
)
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
if (sortDirection === "newest-first") sorted.reverse(); if (sortDirection === "newest-first") sorted.reverse();
return sorted; return sorted;

View file

@ -127,8 +127,25 @@ describe("HistoryScreen", () => {
expect(screen.queryByPlaceholderText("Search...")).toBeNull(); expect(screen.queryByPlaceholderText("Search...")).toBeNull();
}); });
it("shows the full stream when embedded, ignoring the live filter", () => { it("applies the search text but ignores the method filter when embedded", () => {
// A search that matches nothing would empty the full-size screen... renderWithMantine(
<HistoryScreen
{...baseProps}
ui={{
...EMPTY_HISTORY_UI,
// Method filter would exclude the tools/list entries on the full-size
// screen...
methodFilter: "resources/list",
// ...but the column search matches them.
search: "tools",
}}
embedded
/>,
);
expect(screen.queryByText("No request history")).toBeNull();
});
it("hides entries not matching the column search when embedded", () => {
renderWithMantine( renderWithMantine(
<HistoryScreen <HistoryScreen
{...baseProps} {...baseProps}
@ -136,7 +153,6 @@ describe("HistoryScreen", () => {
embedded embedded
/>, />,
); );
// ...but the embedded column shows the full stream (not the empty state). expect(screen.getByText("No request history")).toBeInTheDocument();
expect(screen.queryByText("No request history")).toBeNull();
}); });
}); });

View file

@ -147,14 +147,38 @@ describe("LoggingScreen", () => {
expect(screen.queryByRole("button", { name: "Set" })).toBeNull(); expect(screen.queryByRole("button", { name: "Set" })).toBeNull();
}); });
it("shows the full stream when embedded, ignoring the live filter", () => { it("applies the search text but ignores the level filter when embedded", () => {
const entries = [
{
receivedAt: new Date(),
params: { level: "info" as const, data: "hello" },
},
];
renderWithMantine(
<LoggingScreen
{...baseProps}
entries={entries}
ui={{
...EMPTY_LOGS_UI,
// The level filter would hide this entry on the full-size screen...
visibleLevels: { ...EMPTY_LOGS_UI.visibleLevels, info: false },
// ...but the column search matches it.
filterText: "hello",
}}
embedded
/>,
);
// Search matches + level filter is ignored → the entry shows.
expect(screen.getByText("hello")).toBeInTheDocument();
});
it("hides entries not matching the column search when embedded", () => {
const entries = [ const entries = [
{ {
receivedAt: new Date(), receivedAt: new Date(),
params: { level: "info" as const, data: "hello" }, params: { level: "info" as const, data: "hello" },
}, },
]; ];
// A filter that would hide the entry on the full-size screen...
renderWithMantine( renderWithMantine(
<LoggingScreen <LoggingScreen
{...baseProps} {...baseProps}
@ -163,7 +187,6 @@ describe("LoggingScreen", () => {
embedded embedded
/>, />,
); );
// ...is ignored in the embedded, sidebar-less column. expect(screen.queryByText("hello")).toBeNull();
expect(screen.getByText("hello")).toBeInTheDocument();
}); });
}); });

View file

@ -180,8 +180,25 @@ describe("NetworkScreen", () => {
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
}); });
it("shows the full stream when embedded, ignoring the live filter", () => { it("applies the search text but ignores the category filter when embedded", () => {
// A filter that matches nothing would empty the full-size screen... renderWithMantine(
<NetworkScreen
{...baseProps}
ui={{
...EMPTY_NETWORK_UI,
// Category filter would hide the transport entry on the full-size
// screen...
visibleCategories: { auth: true, transport: false },
// ...but the column search matches it.
filterText: "example.com",
}}
embedded
/>,
);
expect(screen.queryByText("No network requests")).toBeNull();
});
it("hides entries not matching the column search when embedded", () => {
renderWithMantine( renderWithMantine(
<NetworkScreen <NetworkScreen
{...baseProps} {...baseProps}
@ -189,7 +206,6 @@ describe("NetworkScreen", () => {
embedded embedded
/>, />,
); );
// ...but the embedded column shows the full stream (not the empty state). expect(screen.getByText("No network requests")).toBeInTheDocument();
expect(screen.queryByText("No network requests")).toBeNull();
}); });
}); });

View file

@ -1388,15 +1388,38 @@ describe("InspectorView", () => {
await screen.findByRole("button", { name: "Close monitoring column" }), await screen.findByRole("button", { name: "Close monitoring column" }),
); );
expect( // The column plays its slide-out animation before unmounting.
screen.queryByRole("button", { name: "Close monitoring column" }), await waitFor(() =>
).toBeNull(); expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull(),
);
const header = screen.getByRole("banner"); const header = screen.getByRole("banner");
expect( expect(
within(header).getByRole("radio", { name: "Logs" }), within(header).getByRole("radio", { name: "Logs" }),
).toBeInTheDocument(); ).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", () => { it("persists the pin preference and reopens the column when wide", () => {
// Stored preference from a prior wide session. // Stored preference from a prior wide session.
window.localStorage.setItem("inspector.monitor.pinned", "true"); window.localStorage.setItem("inspector.monitor.pinned", "true");
@ -1431,7 +1454,7 @@ describe("InspectorView", () => {
).toBeNull(); ).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"); window.localStorage.setItem("inspector.monitor.pinned", "true");
monitorWide.value = true; monitorWide.value = true;
const { rerender } = renderWithMantine( const { rerender } = renderWithMantine(
@ -1450,9 +1473,12 @@ describe("InspectorView", () => {
})} })}
/>, />,
); );
expect( // The column plays its slide-out animation before unmounting.
screen.queryByRole("button", { name: "Close monitoring column" }), await waitFor(() =>
).toBeNull(); expect(
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull(),
);
// Preference is untouched — only the column's close button clears it. // Preference is untouched — only the column's close button clears it.
expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe( expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe(
"true", "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 () => { it("resizes and persists the column width from the keyboard", async () => {
window.localStorage.setItem("inspector.monitor.pinned", "true"); window.localStorage.setItem("inspector.monitor.pinned", "true");
window.localStorage.setItem("inspector.monitor.width", "420"); window.localStorage.setItem("inspector.monitor.width", "420");

View file

@ -1,5 +1,13 @@
import { useMemo, useState, type ReactNode, type Ref } from "react"; 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 { useLocalStorage, useMediaQuery } from "@mantine/hooks";
import type { import type {
InitializeResult, InitializeResult,
@ -244,6 +252,27 @@ const MonitoringColumn = Stack.withProps({
miw: 0, 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 // Wraps each screen in a Mantine Transition. With Transition's default
// (`keepMounted={false}`), the outgoing screen unmounts after its exit // (`keepMounted={false}`), the outgoing screen unmounts after its exit
// animation — any local screen state (search filters, scroll position, // animation — any local screen state (search filters, scroll position,
@ -744,11 +773,25 @@ export function InspectorView({
/* v8 ignore next */ /* v8 ignore next */
if (isMonitorTab(tab)) setMonitorTab(tab); 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) { function commitMonitorWidth(next: number) {
setMonitorWidth(next); setMonitorWidth(next);
setDragWidth(null); 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 // Merge the parent's `serversInput` (static config) with the runtime
// connection state owned by the parent — only the active server reflects // connection state owned by the parent — only the active server reflects
// the live status; the rest render as `disconnected`. Handshake errors // 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 // 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> = { const monitorScreens: Record<string, ReactNode> = {
[LOGS_TAB]: <LoggingScreen {...loggingScreenProps} embedded />, [LOGS_TAB]: (
[HISTORY_TAB]: <HistoryScreen {...historyScreenProps} embedded />, <LoggingScreen
[NETWORK_TAB]: <NetworkScreen {...networkScreenProps} embedded />, {...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 ( return (
@ -997,28 +1060,40 @@ export function InspectorView({
/> />
</ScreenStage> </ScreenStage>
</ScreenStageContainer> </ScreenStageContainer>
{effectivePinned ? ( <Transition
<> mounted={effectivePinned}
<ResizeHandle transition={slideInFromRight}
value={columnWidth} duration={COLUMN_ANIM_MS}
min={MONITOR_WIDTH_MIN} exitDuration={COLUMN_ANIM_MS}
max={MONITOR_WIDTH_MAX} timingFunction="ease"
step={MONITOR_WIDTH_STEP} >
onChange={setDragWidth} {(styles) => (
onCommit={commitMonitorWidth} // `style={styles}` is Mantine's runtime Transition interpolation,
aria-label="Resize monitoring column" // not static styling — same pattern as ScreenStage above.
/> <MonitorColumnGroup style={styles}>
<MonitoringColumn w={columnWidth}> <ResizeHandle
<MonitoringScreen value={columnWidth}
tabs={monitorAvailable} min={MONITOR_WIDTH_MIN}
value={effectiveMonitorTab} max={MONITOR_WIDTH_MAX}
onChange={handleMonitorTabChange} step={MONITOR_WIDTH_STEP}
onClose={() => setMonitorPinned(false)} onChange={setDragWidth}
screens={monitorScreens} onCommit={commitMonitorWidth}
aria-label="Resize monitoring column"
/> />
</MonitoringColumn> <MonitoringColumn w={columnWidth}>
</> <MonitoringScreen
) : null} tabs={monitorAvailable}
value={effectiveMonitorTab}
onChange={handleMonitorTabChange}
searchValue={monitorSearch}
onSearchChange={setMonitorSearch}
onClose={closeMonitorColumn}
screens={monitorScreens}
/>
</MonitoringColumn>
</MonitorColumnGroup>
)}
</Transition>
</SplitRow> </SplitRow>
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>