From c5e064ff71e84902a81da8ccb0b02b69b949454e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 20:16:15 -0400 Subject: [PATCH] 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) --- .../HistoryListPanel.test.tsx | 28 ++-- .../HistoryListPanel/HistoryListPanel.tsx | 45 +++++-- .../groups/LogStreamPanel/LogStreamPanel.tsx | 14 +- .../MonitoringControls.stories.tsx | 2 + .../MonitoringControls.test.tsx | 92 +++++++------ .../MonitoringControls/MonitoringControls.tsx | 56 ++++++-- .../MonitoringScreen.stories.tsx | 2 + .../MonitoringScreen.test.tsx | 70 +++++----- .../MonitoringScreen/MonitoringScreen.tsx | 7 + .../NetworkStreamPanel/NetworkStreamPanel.tsx | 15 ++- .../HistoryScreen/HistoryScreen.test.tsx | 24 +++- .../LoggingScreen/LoggingScreen.test.tsx | 31 ++++- .../NetworkScreen/NetworkScreen.test.tsx | 24 +++- .../InspectorView/InspectorView.test.tsx | 80 ++++++++++- .../views/InspectorView/InspectorView.tsx | 127 ++++++++++++++---- 15 files changed, 448 insertions(+), 169 deletions(-) diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx index 05684e9f..4469d6e1 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx @@ -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( , ); - 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( , ); - 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 () => { diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index 9aaa8187..a6a7514b 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -128,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, @@ -139,6 +143,7 @@ function CollapsibleSection({ }: { title: string; collapsible: boolean; + hideHeaderWhenAlone?: boolean; open: boolean; onToggle: () => void; actions?: ReactNode; @@ -147,7 +152,7 @@ function CollapsibleSection({ if (!collapsible) { return ( - {title} + {hideHeaderWhenAlone ? null : {title}} {children} ); @@ -173,13 +178,18 @@ function matchesFilters( entry: MessageEntry, searchText: string, visibleDirections: Record, - 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) : ""; @@ -215,14 +225,18 @@ export function HistoryListPanel({ const [pinnedOpen, setPinnedOpen] = useState(true); const [historyOpen, setHistoryOpen] = useState(true); const filteredEntries = useMemo(() => { - // Embedded column: show the full stream (no filter sidebar to explain a - // mirrored filter). See LogStreamPanel (#1616). `.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) => - embedded || - matchesFilters(e, searchText, visibleDirections, methodFilter), + .filter((e) => + matchesFilters( + e, + searchText, + visibleDirections, + methodFilter, + embedded, + ), ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); @@ -315,6 +329,9 @@ export function HistoryListPanel({ setHistoryOpen((v) => !v)} actions={ diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index a2ddd421..24fda38c 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -56,8 +56,11 @@ function matchesFilters( entry: LogEntryData, filterText: string, visibleLevels: Record, + // 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 = @@ -80,12 +83,11 @@ export function LogStreamPanel({ }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { - // The embedded column has no filter sidebar, so it shows the full stream - // rather than mirroring the full-size screen's live filter with no visible - // control to explain it (#1616). `.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) => embedded || 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; diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx index 3815d80a..a176508e 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -9,6 +9,8 @@ const meta: Meta = { tabs: ["Logs", "History", "Network"], value: "Logs", onChange: fn(), + searchValue: "", + onSearchChange: fn(), onClose: fn(), }, }; diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx index 8d6a0bca..8cf86503 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -1,34 +1,37 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { MonitoringControls } from "./MonitoringControls"; +import { + MonitoringControls, + type MonitoringControlsProps, +} from "./MonitoringControls"; const TABS = ["Logs", "History", "Network"]; +function renderControls(overrides: Partial = {}) { + const props: MonitoringControlsProps = { + tabs: TABS, + value: "Logs", + onChange: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + onClose: vi.fn(), + ...overrides, + }; + renderWithMantine(); + return props; +} + describe("MonitoringControls", () => { it("renders a radio option for each available tab", () => { - renderWithMantine( - , - ); + renderControls(); for (const tab of TABS) { expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); } }); it("renders only the tabs it is given", () => { - renderWithMantine( - , - ); + renderControls({ tabs: ["Logs", "History"] }); expect(screen.getAllByRole("radio")).toHaveLength(2); expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); }); @@ -39,6 +42,8 @@ describe("MonitoringControls", () => { tabs={TABS} value="Logs" onChange={vi.fn()} + searchValue="" + onSearchChange={vi.fn()} onClose={vi.fn()} />, ); @@ -48,43 +53,48 @@ describe("MonitoringControls", () => { }); it("marks the active tab as selected", () => { - renderWithMantine( - , - ); + 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(); - renderWithMantine( - , - ); + 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(); - renderWithMantine( - , - ); + renderControls({ onClose }); await user.click( screen.getByRole("button", { name: "Close monitoring column" }), ); diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index bc5cb356..58c79cc1 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -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 { /** 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. */ value: string; 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. */ 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. +// The controls row at the top of the pinned monitoring column: a segmented tab +// switcher on the left, a search box filling the middle, and a close button on +// the right that unpins the whole column. const ControlsBar = Group.withProps({ - justify: "space-between", wrap: "nowrap", gap: "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 - * 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. + * Tab row + search + 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 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({ tabs, value, onChange, + searchValue, + onSearchChange, onClose, }: MonitoringControlsProps) { return ( @@ -41,7 +59,25 @@ export function MonitoringControls({ data={tabs} aria-label="Monitoring screen" /> - + onSearchChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + searchValue ? ( + onSearchChange("")} /> + ) : null + } + /> + + + ); } diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx index 6c23380a..8d934c82 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -35,6 +35,8 @@ const meta: Meta = { tabs: ["Logs", "History", "Network"], value: "Logs", onChange: fn(), + searchValue: "", + onSearchChange: fn(), onClose: fn(), screens: demoScreens, }, diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx index a0c8cbc2..33f89ace 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx @@ -1,7 +1,10 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { MonitoringScreen } from "./MonitoringScreen"; +import { + MonitoringScreen, + type MonitoringScreenProps, +} from "./MonitoringScreen"; const TABS = ["Logs", "History", "Network"]; @@ -13,32 +16,31 @@ function screens() { }; } +function renderScreen(overrides: Partial = {}) { + const props: MonitoringScreenProps = { + tabs: TABS, + value: "Logs", + onChange: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + onClose: vi.fn(), + screens: screens(), + ...overrides, + }; + renderWithMantine(); + return props; +} + describe("MonitoringScreen", () => { it("renders the screen for the active tab", () => { - renderWithMantine( - , - ); + renderScreen({ value: "History" }); 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( - , - ); + renderScreen(); expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Close monitoring column" }), @@ -49,15 +51,7 @@ describe("MonitoringScreen", () => { const user = userEvent.setup(); const onChange = vi.fn(); const onClose = vi.fn(); - renderWithMantine( - , - ); + renderScreen({ onChange, onClose }); await user.click(screen.getByRole("radio", { name: "Network" })); expect(onChange).toHaveBeenCalledWith("Network"); await user.click( @@ -66,16 +60,18 @@ describe("MonitoringScreen", () => { 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", () => { - renderWithMantine( - logs-body }} - />, - ); + renderScreen({ value: "Network", screens: { Logs:
logs-body
} }); expect(screen.queryByText("logs-body")).toBeNull(); }); }); diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index d6dde2fd..2d9f392d 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -8,6 +8,9 @@ export interface MonitoringScreenProps { /** Active monitor tab; keys into `screens` to pick what renders below. */ value: string; 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; /** * The embedded screen node for each tab, keyed by tab label. The caller builds @@ -38,6 +41,8 @@ export function MonitoringScreen({ tabs, value, onChange, + searchValue, + onSearchChange, onClose, screens, }: MonitoringScreenProps) { @@ -47,6 +52,8 @@ export function MonitoringScreen({ tabs={tabs} value={value} onChange={onChange} + searchValue={searchValue} + onSearchChange={onSearchChange} onClose={onClose} /> diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index b1f58bba..168c2aa0 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -58,8 +58,11 @@ function matchesFilters( entry: FetchRequestEntry, filterText: string, visibleCategories: Record, + // 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 { - if (!visibleCategories[entry.category]) return false; + if (!ignoreCategories && !visibleCategories[entry.category]) return false; if (filterText) { const term = filterText.toLowerCase(); const status = @@ -98,13 +101,11 @@ export function NetworkStreamPanel({ }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { - // Embedded column: show the full stream (no filter sidebar to explain a - // mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh - // array, so sorting in-place is safe. + // Embedded column filters by text only (its category toggles 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) => embedded || matchesFilters(e, filterText, visibleCategories), - ) + .filter((e) => matchesFilters(e, filterText, visibleCategories, embedded)) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index c4e23a02..f5ae4f10 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -127,8 +127,25 @@ describe("HistoryScreen", () => { expect(screen.queryByPlaceholderText("Search...")).toBeNull(); }); - it("shows the full stream when embedded, ignoring the live filter", () => { - // A search that matches nothing would empty the full-size screen... + it("applies the search text but ignores the method filter when embedded", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("No request history")).toBeNull(); + }); + + it("hides entries not matching the column search when embedded", () => { renderWithMantine( { embedded />, ); - // ...but the embedded column shows the full stream (not the empty state). - expect(screen.queryByText("No request history")).toBeNull(); + expect(screen.getByText("No request history")).toBeInTheDocument(); }); }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index b5df10ab..675e028b 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -147,14 +147,38 @@ describe("LoggingScreen", () => { 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( + , + ); + // 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 = [ { receivedAt: new Date(), params: { level: "info" as const, data: "hello" }, }, ]; - // A filter that would hide the entry on the full-size screen... renderWithMantine( { embedded />, ); - // ...is ignored in the embedded, sidebar-less column. - expect(screen.getByText("hello")).toBeInTheDocument(); + expect(screen.queryByText("hello")).toBeNull(); }); }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index 0059a794..3f67adc7 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -180,8 +180,25 @@ describe("NetworkScreen", () => { expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); }); - it("shows the full stream when embedded, ignoring the live filter", () => { - // A filter that matches nothing would empty the full-size screen... + it("applies the search text but ignores the category filter when embedded", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("No network requests")).toBeNull(); + }); + + it("hides entries not matching the column search when embedded", () => { renderWithMantine( { embedded />, ); - // ...but the embedded column shows the full stream (not the empty state). - expect(screen.queryByText("No network requests")).toBeNull(); + expect(screen.getByText("No network requests")).toBeInTheDocument(); }); }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 907e8c54..1c93bbd6 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -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(); + 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( + , + ); + // 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"); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 104dd9d0..afd36d3a 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -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 = { - [LOGS_TAB]: , - [HISTORY_TAB]: , - [NETWORK_TAB]: , + [LOGS_TAB]: ( + + ), + [HISTORY_TAB]: ( + + ), + [NETWORK_TAB]: ( + + ), }; return ( @@ -997,28 +1060,40 @@ export function InspectorView({ /> - {effectivePinned ? ( - <> - - - setMonitorPinned(false)} - screens={monitorScreens} + + {(styles) => ( + // `style={styles}` is Mantine's runtime Transition interpolation, + // not static styling — same pattern as ScreenStage above. + + - - - ) : null} + + + + + )} +