From 9b76272ad1fa871e280fc867ded88e6bf948cd95 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 19:41:00 -0400 Subject: [PATCH] fix(web): address review feedback on the monitoring column (#1616) - ResizeHandle: clear the `resizing-col` body class on unmount so a drag interrupted by the handle unmounting (narrow past the breakpoint / disconnect) can't leave page-wide user-select:none / col-resize applied. - Embedded monitor column now shows the full unfiltered stream instead of silently mirroring the full-size screen's live filter (the column has no filter control to explain an empty/partial list). - MonitoringControls: give the SegmentedControl an aria-label ("Monitoring screen") so the tab group has an accessible name. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResizeHandle/ResizeHandle.test.tsx | 22 +++++++++++++++++++ .../elements/ResizeHandle/ResizeHandle.tsx | 14 +++++++++++- .../HistoryListPanel/HistoryListPanel.tsx | 19 ++++++++++++---- .../groups/LogStreamPanel/LogStreamPanel.tsx | 9 +++++--- .../MonitoringControls.test.tsx | 14 ++++++++++++ .../MonitoringControls/MonitoringControls.tsx | 1 + .../NetworkStreamPanel/NetworkStreamPanel.tsx | 10 ++++++--- .../HistoryScreen/HistoryScreen.test.tsx | 13 +++++++++++ .../LoggingScreen/LoggingScreen.test.tsx | 20 +++++++++++++++++ .../NetworkScreen/NetworkScreen.test.tsx | 13 +++++++++++ 10 files changed, 124 insertions(+), 11 deletions(-) diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx index 0cac37d2..5ba77b0b 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx @@ -86,6 +86,28 @@ describe("ResizeHandle", () => { expect(document.body.classList.contains("resizing-col")).toBe(false); }); + it("clears the drag body class if it unmounts mid-drag", () => { + const onChange = vi.fn(); + const onCommit = vi.fn(); + const { unmount } = renderWithMantine( + , + ); + fireEvent.pointerDown(screen.getByRole("separator"), { + clientX: 500, + pointerId: 1, + }); + expect(document.body.classList.contains("resizing-col")).toBe(true); + // No pointerup/cancel — the handle is removed from the tree first. + unmount(); + expect(document.body.classList.contains("resizing-col")).toBe(false); + }); + it("ignores pointer moves when no drag is active", () => { const { handle, onChange, onCommit } = setup(); fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 }); diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx index a181e367..77545a5e 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -1,4 +1,9 @@ -import { useRef, type KeyboardEvent, type PointerEvent } from "react"; +import { + useEffect, + useRef, + type KeyboardEvent, + type PointerEvent, +} from "react"; import { Box } from "@mantine/core"; export interface ResizeHandleProps { @@ -45,6 +50,13 @@ export function ResizeHandle({ // Gesture origin, captured on pointer down. `null` while idle. const drag = useRef<{ startX: number; startWidth: number } | null>(null); + // If the handle unmounts mid-drag — the window narrows past the split + // breakpoint or the server disconnects, either of which removes the handle + // from the tree before a pointerup/cancel fires — the drag body class would + // otherwise linger and leave `user-select: none` + `col-resize` applied + // page-wide. Clear it on unmount so the class is self-healing. + useEffect(() => () => document.body.classList.remove("resizing-col"), []); + function handlePointerDown(e: PointerEvent) { drag.current = { startX: e.clientX, startWidth: value }; // Optional chaining: happy-dom (unit tests) doesn't implement pointer diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index be7840c3..9aaa8187 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -215,15 +215,26 @@ export function HistoryListPanel({ const [pinnedOpen, setPinnedOpen] = useState(true); const [historyOpen, setHistoryOpen] = useState(true); const filteredEntries = useMemo(() => { - // `.filter()` returns a fresh array, so sorting in-place is safe. + // 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. const sorted = entries - .filter((e) => - matchesFilters(e, searchText, visibleDirections, methodFilter), + .filter( + (e) => + embedded || + matchesFilters(e, searchText, visibleDirections, methodFilter), ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, searchText, visibleDirections, methodFilter, sortDirection]); + }, [ + entries, + searchText, + visibleDirections, + methodFilter, + sortDirection, + embedded, + ]); const pinnedEntries = useMemo( () => filteredEntries.filter((e) => pinnedIds.has(e.id)), diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index 784faafe..a2ddd421 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -80,13 +80,16 @@ export function LogStreamPanel({ }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { - // `.filter()` returns a fresh array, so sorting in-place is safe. + // 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. const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleLevels)) + .filter((e) => embedded || matchesFilters(e, filterText, visibleLevels)) .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, filterText, visibleLevels, sortDirection]); + }, [entries, filterText, visibleLevels, sortDirection, embedded]); return ( diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx index 458d1cf4..8d6a0bca 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -33,6 +33,20 @@ describe("MonitoringControls", () => { expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); }); + it("gives the tab switcher an accessible name", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[aria-label="Monitoring screen"]'), + ).not.toBeNull(); + }); + it("marks the active tab as selected", () => { renderWithMantine( diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 9d66367d..b1f58bba 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -98,13 +98,17 @@ export function NetworkStreamPanel({ }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { - // `.filter()` returns a fresh array, so sorting in-place is safe. + // 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. const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleCategories)) + .filter( + (e) => embedded || matchesFilters(e, filterText, visibleCategories), + ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, filterText, visibleCategories, sortDirection]); + }, [entries, filterText, visibleCategories, sortDirection, embedded]); const hasEntries = entries.length > 0; const hasResults = filteredEntries.length > 0; diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index aad3f5da..c4e23a02 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -126,4 +126,17 @@ describe("HistoryScreen", () => { // The sidebar (HistoryControls, with its Search box) is not rendered. 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... + renderWithMantine( + , + ); + // ...but the embedded column shows the full stream (not the empty state). + expect(screen.queryByText("No request history")).toBeNull(); + }); }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index 11e1e84a..b5df10ab 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -146,4 +146,24 @@ describe("LoggingScreen", () => { // The sidebar (LogControls, with its Set button) is not rendered. expect(screen.queryByRole("button", { name: "Set" })).toBeNull(); }); + + it("shows the full stream when embedded, ignoring the live filter", () => { + 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( + , + ); + // ...is ignored in the embedded, sidebar-less column. + expect(screen.getByText("hello")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index a093d39b..0059a794 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -179,4 +179,17 @@ describe("NetworkScreen", () => { // The stream toolbar (Export) is still present. 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... + renderWithMantine( + , + ); + // ...but the embedded column shows the full stream (not the empty state). + expect(screen.queryByText("No network requests")).toBeNull(); + }); });