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}
+
+
+
+
+ )}
+