From 081fd789f1e2717b52eb6c7c5997145df8d4b75c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 18:52:41 -0400 Subject: [PATCH] feat(web): pin Logging/History/Network as a resizable column (#1616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ability to "pin" the Logging, History, and Network screens into a second column on the right of the InspectorView, so a primary screen and a monitor screen are visible at once (e.g. watch Logs while calling a Tool). - A "pin as column" button on the Logs/History/Network panel toolbars opens the column (shown only when wide + connected). - The column hosts a MonitoringControls tab row (available monitor tabs + close) over the selected embedded screen. Pinning removes the monitor group from the header tab bar; closing (x) returns it. - The divider between the primary area and the column is draggable (pointer + keyboard); width is clamped and persisted. - The split collapses to one column below ~1040px (the ServerListScreen single-card breakpoint) and while disconnected, without discarding the pin preference — it reopens when wide + connected again. State (pinned/tab/width) is view-local and persisted via the existing useLocalStorage pattern, so no new props thread through App and ViewHeader is unchanged (it just receives a filtered tab list). Screens/panels gain an `embedded` mode (fills the parent height, drops the 340px filter sidebar) via a shared EmbeddableScrollArea element. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.css | 27 + .../EmbeddableScrollArea.stories.tsx | 43 ++ .../EmbeddableScrollArea.test.tsx | 34 ++ .../EmbeddableScrollArea.tsx | 56 ++ .../PinColumnButton.stories.tsx | 21 + .../PinColumnButton/PinColumnButton.test.tsx | 21 + .../PinColumnButton/PinColumnButton.tsx | 26 + .../ResizeHandle/ResizeHandle.stories.tsx | 51 ++ .../ResizeHandle/ResizeHandle.test.tsx | 144 +++++ .../elements/ResizeHandle/ResizeHandle.tsx | 107 ++++ .../HistoryListPanel/HistoryListPanel.tsx | 19 +- .../groups/LogStreamPanel/LogStreamPanel.tsx | 36 +- .../MonitoringControls.stories.tsx | 35 ++ .../MonitoringControls.test.tsx | 79 +++ .../MonitoringControls/MonitoringControls.tsx | 46 ++ .../MonitoringScreen.stories.tsx | 59 ++ .../MonitoringScreen.test.tsx | 81 +++ .../MonitoringScreen/MonitoringScreen.tsx | 56 ++ .../NetworkStreamPanel/NetworkStreamPanel.tsx | 28 +- .../HistoryScreen/HistoryScreen.test.tsx | 15 + .../screens/HistoryScreen/HistoryScreen.tsx | 44 +- .../LoggingScreen/LoggingScreen.test.tsx | 15 + .../screens/LoggingScreen/LoggingScreen.tsx | 47 +- .../NetworkScreen/NetworkScreen.test.tsx | 16 + .../screens/NetworkScreen/NetworkScreen.tsx | 36 +- .../InspectorView/InspectorView.test.tsx | 284 ++++++++- .../views/InspectorView/InspectorView.tsx | 543 +++++++++++++----- 27 files changed, 1728 insertions(+), 241 deletions(-) create mode 100644 clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx create mode 100644 clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx create mode 100644 clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx create mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx create mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx create mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx create mode 100644 clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx create mode 100644 clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx create mode 100644 clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx create mode 100644 clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx create mode 100644 clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx create mode 100644 clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx create mode 100644 clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx create mode 100644 clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx create mode 100644 clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx diff --git a/clients/web/src/App.css b/clients/web/src/App.css index fc431065..2d35f4b6 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -422,3 +422,30 @@ z-index: 200; box-shadow: var(--mantine-shadow-sm); } + +/* + * Draggable vertical divider between the primary screen area and the pinned + * monitoring column (#1616). The col-resize cursor, the hover/active accent, + * and the drag-time user-select suppression are all pseudo-selector / cursor / + * cross-element concerns that can't be expressed as Mantine props, so the whole + * unit lives here rather than split across the theme. + */ +.resize-handle { + flex: 0 0 auto; + width: 6px; + cursor: col-resize; + background-color: var(--inspector-border-default); + transition: background-color 120ms ease; + touch-action: none; +} + +.resize-handle:hover, +.resize-handle:focus-visible { + background-color: var(--mantine-primary-color-filled); +} + +/* Suppress text selection anywhere on the page while a drag is in progress. */ +body.resizing-col { + user-select: none; + cursor: col-resize; +} diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx new file mode 100644 index 00000000..e85f9b65 --- /dev/null +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx @@ -0,0 +1,43 @@ +import { createRef } from "react"; +import { Stack, Text } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { EmbeddableScrollArea } from "./EmbeddableScrollArea"; + +const meta: Meta = { + title: "Elements/EmbeddableScrollArea", + component: EmbeddableScrollArea, +}; + +export default meta; +type Story = StoryObj; + +const rows = Array.from({ length: 40 }, (_, i) => ( + Row {i + 1} +)); + +export const FullSize: Story = { + render: () => ( + + {rows} + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Row 1")).toBeInTheDocument(); + }, +}; + +export const Embedded: Story = { + render: () => ( + + + {rows} + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Row 1")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx new file mode 100644 index 00000000..55c78e53 --- /dev/null +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx @@ -0,0 +1,34 @@ +import { createRef } from "react"; +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { EmbeddableScrollArea } from "./EmbeddableScrollArea"; + +describe("EmbeddableScrollArea", () => { + it("renders its children full-size", () => { + renderWithMantine( + +
full-size content
+
, + ); + expect(screen.getByText("full-size content")).toBeInTheDocument(); + }); + + it("renders its children when embedded", () => { + renderWithMantine( + +
embedded content
+
, + ); + expect(screen.getByText("embedded content")).toBeInTheDocument(); + }); + + it("attaches the viewport ref to the scroll viewport", () => { + const ref = createRef(); + renderWithMantine( + +
content
+
, + ); + expect(ref.current).toBeInstanceOf(HTMLElement); + }); +}); diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx new file mode 100644 index 00000000..34dac26b --- /dev/null +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -0,0 +1,56 @@ +import type { ReactNode, Ref } from "react"; +import { ScrollArea, Stack } from "@mantine/core"; + +// Full-size, the monitor stream panels bound their scroll to the viewport +// minus the header and the panel's own chrome. +const FULLSIZE_MAH = + "calc(100vh - var(--app-shell-header-height, 0px) - 150px)"; + +export interface EmbeddableScrollAreaProps { + /** + * True when rendered inside the pinned monitoring column (#1616): the scroll + * region fills its flex parent instead of using the viewport calc. A + * `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining + * below the column's controls (via `mah:100%`), so no viewport math is needed + * and the final rows never clip. + */ + embedded: boolean; + viewportRef: Ref; + children: ReactNode; +} + +/** + * The scroll region shared by the Logs / History / Network stream panels, which + * render both full-size (their own tab) and embedded (the monitoring column). + * Centralizes the one layout difference between those two hosts. + */ +export function EmbeddableScrollArea({ + embedded, + viewportRef, + children, +}: EmbeddableScrollAreaProps) { + if (embedded) { + return ( + + + {children} + + + ); + } + return ( + + {children} + + ); +} diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx new file mode 100644 index 00000000..8d6b7fc3 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { PinColumnButton } from "./PinColumnButton"; + +const meta: Meta = { + title: "Elements/PinColumnButton", + component: PinColumnButton, + args: { onPin: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx new file mode 100644 index 00000000..50ce1b62 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { PinColumnButton } from "./PinColumnButton"; + +describe("PinColumnButton", () => { + it("renders a labelled pin-as-column button", () => { + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }); + + it("invokes onPin when clicked", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx new file mode 100644 index 00000000..86467db9 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -0,0 +1,26 @@ +import { ActionIcon } from "@mantine/core"; +import { TbLayoutSidebarRightExpand } from "react-icons/tb"; + +export interface PinColumnButtonProps { + /** Pin this screen into the monitoring column to the right. */ + onPin: () => void; +} + +/** + * Toolbar icon that pins the owning monitor screen (Logs / History / Network) + * into the resizable column on the right of the InspectorView (#1616). Distinct + * from `PinToggle` (which pins individual history entries) — this one opens a + * side column, so it uses a right-sidebar glyph and an "as column" label. + */ +export function PinColumnButton({ onPin }: PinColumnButtonProps) { + return ( + + + + ); +} diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx new file mode 100644 index 00000000..4fb1c122 --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { Box, Group, Text } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { ResizeHandle } from "./ResizeHandle"; + +const meta: Meta = { + title: "Elements/ResizeHandle", + component: ResizeHandle, +}; + +export default meta; +type Story = StoryObj; + +// A minimal split so the handle has a panel to resize in the docs view. The +// panel sits to the right; dragging the handle left widens it. +function ResizeDemo() { + const [width, setWidth] = useState(320); + const [dragWidth, setDragWidth] = useState(null); + return ( + + + Primary + + { + setWidth(next); + setDragWidth(null); + }} + aria-label="Resize demo panel" + /> + + Column ({dragWidth ?? width}px) + + + ); +} + +export const Default: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("separator", { name: "Resize demo panel" }), + ).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx new file mode 100644 index 00000000..0cac37d2 --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + renderWithMantine, + screen, + fireEvent, +} from "../../../test/renderWithMantine"; +import { ResizeHandle } from "./ResizeHandle"; + +// The panel sits to the RIGHT of the handle, so moving the pointer LEFT +// (decreasing clientX) widens it: next = startWidth + (startX - clientX). +function setup(overrides: Partial[0]> = {}) { + const onChange = vi.fn(); + const onCommit = vi.fn(); + renderWithMantine( + , + ); + return { handle: screen.getByRole("separator"), onChange, onCommit }; +} + +describe("ResizeHandle", () => { + afterEach(() => { + document.body.classList.remove("resizing-col"); + }); + + it("exposes the separator role with live aria values", () => { + const { handle } = setup(); + expect(handle).toHaveAttribute("aria-orientation", "vertical"); + expect(handle).toHaveAttribute("aria-valuenow", "400"); + expect(handle).toHaveAttribute("aria-valuemin", "320"); + expect(handle).toHaveAttribute("aria-valuemax", "720"); + expect(handle).toHaveAccessibleName("Resize panel"); + }); + + it("accepts a custom aria-label", () => { + setup({ "aria-label": "Resize monitoring column" }); + expect( + screen.getByRole("separator", { name: "Resize monitoring column" }), + ).toBeInTheDocument(); + }); + + it("reports the widened value while dragging left and commits on release", () => { + const { handle, onChange, onCommit } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 }); + // startWidth 400 + (500 - 460) = 440 + expect(onChange).toHaveBeenLastCalledWith(440); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 }); + expect(onCommit).toHaveBeenCalledWith(440); + }); + + it("clamps to max when dragged past the upper bound", () => { + const { handle, onChange } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: 0, pointerId: 1 }); + expect(onChange).toHaveBeenLastCalledWith(720); + }); + + it("clamps to min when dragged past the lower bound", () => { + const { handle, onChange } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: 900, pointerId: 1 }); + expect(onChange).toHaveBeenLastCalledWith(320); + }); + + it("toggles the drag body class across the gesture", () => { + const { handle } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + expect(document.body.classList.contains("resizing-col")).toBe(true); + fireEvent.pointerUp(handle, { clientX: 500, pointerId: 1 }); + expect(document.body.classList.contains("resizing-col")).toBe(false); + }); + + it("ends the drag on pointer cancel", () => { + const { handle, onCommit } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerCancel(handle, { clientX: 480, pointerId: 1 }); + expect(onCommit).toHaveBeenCalledWith(420); + 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 }); + fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 }); + expect(onChange).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("uses pointer capture when the platform supports it", () => { + const setCapture = vi.fn(); + const releaseCapture = vi.fn(); + const proto = HTMLElement.prototype as unknown as { + setPointerCapture?: (id: number) => void; + releasePointerCapture?: (id: number) => void; + }; + proto.setPointerCapture = setCapture; + proto.releasePointerCapture = releaseCapture; + try { + const { handle } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 7 }); + fireEvent.pointerUp(handle, { clientX: 500, pointerId: 7 }); + expect(setCapture).toHaveBeenCalledWith(7); + expect(releaseCapture).toHaveBeenCalledWith(7); + } finally { + delete proto.setPointerCapture; + delete proto.releasePointerCapture; + } + }); + + it("widens by one step on ArrowLeft and commits", () => { + const { handle, onChange, onCommit } = setup({ step: 20 }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenCalledWith(420); + expect(onCommit).toHaveBeenCalledWith(420); + }); + + it("narrows by one step on ArrowRight", () => { + const { handle, onChange } = setup(); + fireEvent.keyDown(handle, { key: "ArrowRight" }); + // default step 16: 400 - 16 = 384 + expect(onChange).toHaveBeenCalledWith(384); + }); + + it("clamps keyboard steps to the bounds", () => { + const { handle, onChange } = setup({ value: 712 }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenCalledWith(720); + }); + + it("ignores non-arrow keys", () => { + const { handle, onChange, onCommit } = setup(); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx new file mode 100644 index 00000000..a181e367 --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -0,0 +1,107 @@ +import { useRef, type KeyboardEvent, type PointerEvent } from "react"; +import { Box } from "@mantine/core"; + +export interface ResizeHandleProps { + /** Current width (px) of the panel this handle resizes. */ + value: number; + /** Lower / upper clamp bounds (px). */ + min: number; + max: number; + /** Keyboard arrow step in px (default 16). */ + step?: number; + /** + * Fired continuously while dragging and on each keyboard step, with the + * clamped next width. The parent renders from this for live feedback. + */ + onChange: (next: number) => void; + /** + * Fired once when the gesture ends (pointer up / key up) with the final + * clamped width, so the parent can persist it in a single write. + */ + onCommit: (next: number) => void; + "aria-label"?: string; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * A thin draggable vertical divider that resizes a panel sitting to its RIGHT + * (so dragging left widens the panel). Pointer-capture keeps the gesture alive + * even when the pointer crosses an iframe (the Apps screen renders one), which + * would otherwise swallow the move stream and make the drag stick. Exposes the + * ARIA `separator` role with live value so it's operable by keyboard too. + */ +export function ResizeHandle({ + value, + min, + max, + step = 16, + onChange, + onCommit, + "aria-label": ariaLabel = "Resize panel", +}: ResizeHandleProps) { + // Gesture origin, captured on pointer down. `null` while idle. + const drag = useRef<{ startX: number; startWidth: number } | null>(null); + + function handlePointerDown(e: PointerEvent) { + drag.current = { startX: e.clientX, startWidth: value }; + // Optional chaining: happy-dom (unit tests) doesn't implement pointer + // capture, and it's a progressive enhancement, not required for correctness. + e.currentTarget.setPointerCapture?.(e.pointerId); + document.body.classList.add("resizing-col"); + } + + function handlePointerMove(e: PointerEvent) { + if (!drag.current) return; + const next = clamp( + drag.current.startWidth + (drag.current.startX - e.clientX), + min, + max, + ); + onChange(next); + } + + function endDrag(e: PointerEvent) { + if (!drag.current) return; + const next = clamp( + drag.current.startWidth + (drag.current.startX - e.clientX), + min, + max, + ); + drag.current = null; + e.currentTarget.releasePointerCapture?.(e.pointerId); + document.body.classList.remove("resizing-col"); + onCommit(next); + } + + function handleKeyDown(e: KeyboardEvent) { + // Left widens (panel is on the right), Right narrows — mirrors the drag. + const delta = + e.key === "ArrowLeft" ? step : e.key === "ArrowRight" ? -step : 0; + if (delta === 0) return; + e.preventDefault(); + const next = clamp(value + delta, min, max); + onChange(next); + onCommit(next); + } + + return ( + + ); +} diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index cd9125fe..af777001 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -4,7 +4,6 @@ import { Collapse, Group, Paper, - ScrollArea, Stack, Text, Title, @@ -21,6 +20,8 @@ import { SortToggle, type SortDirection, } from "../../elements/SortToggle/SortToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; +import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; import { extractMethod } from "../historyUtils.js"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; @@ -43,6 +44,10 @@ export interface HistoryListPanelProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LogStreamPanel: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -201,6 +206,8 @@ export function HistoryListPanel({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: HistoryListPanelProps) { const viewportRef = useScrollMemory("history-list"); // Per-section expand/collapse, like the LogControls level toggles. Both start @@ -238,6 +245,7 @@ export function HistoryListPanel({ Requests + {onPin ? : null} {hasResults && ( )} @@ -262,12 +270,7 @@ export function HistoryListPanel({ {!hasResults ? ( No request history ) : ( - + {pinnedEntries.length > 0 && ( )} - + )} ); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index b164f649..bc21b721 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -1,13 +1,5 @@ import { useMemo } from "react"; -import { - Button, - Group, - Paper, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; +import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import { LogEntry } from "../../elements/LogEntry/LogEntry"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; @@ -15,6 +7,8 @@ import { SortToggle, type SortDirection, } from "../../elements/SortToggle/SortToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; +import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; export interface LogStreamPanelProps { @@ -25,6 +19,18 @@ export interface LogStreamPanelProps { onExport: () => void; sortDirection: SortDirection; onSortChange: (next: SortDirection) => void; + /** + * When set, renders a "pin as column" button in the toolbar that opens this + * screen in the monitoring column (#1616). Omitted when the panel is already + * embedded in that column (or when pinning isn't available). + */ + onPin?: () => void; + /** + * True when this panel is rendered inside the monitoring column. Switches the + * scroll region from the viewport-height calc to filling its flex parent, so + * it fits below the column's controls row without viewport math. + */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -69,6 +75,8 @@ export function LogStreamPanel({ onExport, sortDirection, onSortChange, + onPin, + embedded = false, }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { @@ -85,6 +93,7 @@ export function LogStreamPanel({ Log Stream + {onPin ? : null} {filteredEntries.length > 0 ? ( - + {filteredEntries.map((entry, index) => ( ))} - + ) : ( No log entries diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx new file mode 100644 index 00000000..3815d80a --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { MonitoringControls } from "./MonitoringControls"; + +const meta: Meta = { + title: "Groups/MonitoringControls", + component: MonitoringControls, + args: { + tabs: ["Logs", "History", "Network"], + value: "Logs", + onChange: fn(), + onClose: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllTabs: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked(); + expect( + canvas.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }, +}; + +export const StdioServer: Story = { + args: { tabs: ["Logs", "History"], value: "History" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull(); + }, +}; diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx new file mode 100644 index 00000000..458d1cf4 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MonitoringControls } from "./MonitoringControls"; + +const TABS = ["Logs", "History", "Network"]; + +describe("MonitoringControls", () => { + it("renders a radio option for each available tab", () => { + renderWithMantine( + , + ); + for (const tab of TABS) { + expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); + } + }); + + it("renders only the tabs it is given", () => { + renderWithMantine( + , + ); + expect(screen.getAllByRole("radio")).toHaveLength(2); + expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); + }); + + it("marks the active tab as selected", () => { + renderWithMantine( + , + ); + 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( + , + ); + await user.click(screen.getByRole("radio", { name: "Network" })); + expect(onChange).toHaveBeenCalledWith("Network"); + }); + + it("calls onClose when the close button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { name: "Close monitoring column" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx new file mode 100644 index 00000000..028801d2 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -0,0 +1,46 @@ +import { CloseButton, Group, SegmentedControl } from "@mantine/core"; + +export interface MonitoringControlsProps { + /** The monitor screens currently available to pin (e.g. Logs/History/Network). */ + tabs: string[]; + /** The active monitor tab shown in the column below. */ + value: string; + onChange: (tab: 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. +const ControlsBar = Group.withProps({ + justify: "space-between", + wrap: "nowrap", + gap: "sm", + p: "sm", +}); + +/** + * 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. + */ +export function MonitoringControls({ + tabs, + value, + onChange, + onClose, +}: MonitoringControlsProps) { + return ( + + + + + ); +} diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx new file mode 100644 index 00000000..6c23380a --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -0,0 +1,59 @@ +import { Box, Text } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { MonitoringScreen } from "./MonitoringScreen"; + +const demoScreens = { + Logs: ( + + Log stream + + ), + History: ( + + Request history + + ), + Network: ( + + Network requests + + ), +}; + +const meta: Meta = { + title: "Groups/MonitoringScreen", + component: MonitoringScreen, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + tabs: ["Logs", "History", "Network"], + value: "Logs", + onChange: fn(), + onClose: fn(), + screens: demoScreens, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Logs: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Log stream")).toBeInTheDocument(); + }, +}; + +export const History: Story = { + args: { value: "History" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Request history")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx new file mode 100644 index 00000000..a0c8cbc2 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MonitoringScreen } from "./MonitoringScreen"; + +const TABS = ["Logs", "History", "Network"]; + +function screens() { + return { + Logs:
logs-body
, + History:
history-body
, + Network:
network-body
, + }; +} + +describe("MonitoringScreen", () => { + it("renders the screen for the active tab", () => { + renderWithMantine( + , + ); + 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( + , + ); + expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("forwards tab changes and close from the controls", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onClose = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("radio", { name: "Network" })); + expect(onChange).toHaveBeenCalledWith("Network"); + await user.click( + screen.getByRole("button", { name: "Close monitoring column" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders nothing in the slot when the active tab has no screen", () => { + renderWithMantine( + 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 new file mode 100644 index 00000000..d6dde2fd --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { Divider, Stack } from "@mantine/core"; +import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; + +export interface MonitoringScreenProps { + /** Available monitor tabs (Logs/History/Network, filtered by capability). */ + tabs: string[]; + /** Active monitor tab; keys into `screens` to pick what renders below. */ + value: string; + onChange: (tab: string) => void; + onClose: () => void; + /** + * The embedded screen node for each tab, keyed by tab label. The caller builds + * these (with `embedded` set) so this component stays a pure layout shell. + */ + screens: Record; +} + +// Fills the pinned column: a fixed controls row on top and the selected screen +// filling the remaining height below (mih:0 lets the inner ScrollArea bound). +const ColumnLayout = Stack.withProps({ + h: "100%", + gap: 0, +}); + +const ScreenSlot = Stack.withProps({ + flex: 1, + mih: 0, + gap: 0, +}); + +/** + * The pinned monitoring column's content (#1616): a `MonitoringControls` tab row + * over the currently-selected monitor screen. Layout-only — it renders whichever + * embedded screen node the caller supplies for the active tab. + */ +export function MonitoringScreen({ + tabs, + value, + onChange, + onClose, + screens, +}: MonitoringScreenProps) { + return ( + + + + {screens[value]} + + ); +} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 7f8b24d8..727a34be 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -1,13 +1,5 @@ import { useMemo } from "react"; -import { - Button, - Group, - Paper, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; +import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; import type { FetchRequestCategory, FetchRequestEntry, @@ -18,6 +10,8 @@ import { SortToggle, type SortDirection, } from "../../elements/SortToggle/SortToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; +import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; export interface NetworkStreamPanelProps { @@ -30,6 +24,10 @@ export interface NetworkStreamPanelProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LogStreamPanel: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -95,6 +93,8 @@ export function NetworkStreamPanel({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { @@ -114,6 +114,7 @@ export function NetworkStreamPanel({ {formatTitle(filteredEntries.length)} + {onPin ? : null} {hasResults && ( )} @@ -134,12 +135,7 @@ export function NetworkStreamPanel({ {!hasResults ? ( No network requests ) : ( - + {filteredEntries.map((entry) => ( ))} - + )} ); diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index 943c9cca..aad3f5da 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -111,4 +111,19 @@ describe("HistoryScreen", () => { expect.objectContaining({ methodFilter: undefined }), ); }); + + it("renders a pin-as-column button when onPin is provided and invokes it", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); + + it("drops the filter sidebar when embedded, keeping the request list", () => { + renderWithMantine(); + expect(screen.getByText("Requests")).toBeInTheDocument(); + // The sidebar (HistoryControls, with its Search box) is not rendered. + expect(screen.queryByPlaceholderText("Search...")).toBeNull(); + }); }); diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx index 65fd4407..5f269b5b 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx @@ -25,6 +25,10 @@ export interface HistoryScreenProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LoggingScreen: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ + embedded?: boolean; } // Search text, method filter, and per-direction visibility — controlled by the @@ -69,6 +73,8 @@ export function HistoryScreen({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: HistoryScreenProps) { const { search, methodFilter, visibleDirections } = ui; @@ -101,23 +107,25 @@ export function HistoryScreen({ }, [ui, visibleDirections, onUiChange]); return ( - - - - onUiChange({ ...ui, search: value })} - onMethodFilterChange={(value) => - onUiChange({ ...ui, methodFilter: value }) - } - onToggleDirection={handleToggleDirection} - onToggleAllDirections={handleToggleAllDirections} - /> - - + + {embedded ? null : ( + + + onUiChange({ ...ui, search: value })} + onMethodFilterChange={(value) => + onUiChange({ ...ui, methodFilter: value }) + } + onToggleDirection={handleToggleDirection} + onToggleAllDirections={handleToggleAllDirections} + /> + + + )} ); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index 19f7c50b..11e1e84a 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -131,4 +131,19 @@ describe("LoggingScreen", () => { await user.click(screen.getByRole("button", { name: "Set" })); expect(onSetLevel).toHaveBeenCalledWith("info"); }); + + it("renders a pin-as-column button when onPin is provided and invokes it", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); + + it("drops the filter sidebar when embedded, keeping the stream", () => { + renderWithMantine(); + expect(screen.getByText("Log Stream")).toBeInTheDocument(); + // The sidebar (LogControls, with its Set button) is not rendered. + expect(screen.queryByRole("button", { name: "Set" })).toBeNull(); + }); }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index 60754e3f..924f9b11 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -16,6 +16,17 @@ export interface LoggingScreenProps { onExport: () => void; sortDirection: SortDirection; onSortChange: (next: SortDirection) => void; + /** + * When set, the stream panel shows a "pin as column" button (#1616). Omitted + * when already embedded, or when pinning isn't currently available. + */ + onPin?: () => void; + /** + * True when rendered inside the monitoring column: the screen fills its + * parent's height (instead of the viewport calc) and drops the filter + * sidebar so the narrow column is stream-only. + */ + embedded?: boolean; } // Filter text + visible-level set — controlled by the parent (App) as one @@ -52,6 +63,8 @@ export function LoggingScreen({ onExport, sortDirection, onSortChange, + onPin, + embedded = false, }: LoggingScreenProps) { const { filterText, visibleLevels } = ui; @@ -71,20 +84,24 @@ export function LoggingScreen({ } return ( - - - - onUiChange({ ...ui, filterText: value })} - onToggleLevel={handleToggleLevel} - onToggleAllLevels={handleToggleAllLevels} - /> - - + + {embedded ? null : ( + + + + onUiChange({ ...ui, filterText: value }) + } + onToggleLevel={handleToggleLevel} + onToggleAllLevels={handleToggleAllLevels} + /> + + + )} ); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index 9d182290..a093d39b 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -163,4 +163,20 @@ describe("NetworkScreen", () => { expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); }); + + it("renders a pin-as-column button when onPin is provided and invokes it", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); + + it("drops the filter sidebar when embedded, keeping the request list", () => { + renderWithMantine(); + // The sidebar (NetworkControls, with its Search box) is not rendered. + expect(screen.queryByPlaceholderText("Search...")).toBeNull(); + // The stream toolbar (Export) is still present. + expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx index 65b88df2..4e66700f 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -21,6 +21,10 @@ export interface NetworkScreenProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LoggingScreen: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ + embedded?: boolean; } // Filter text + visible-category set — controlled by the parent (App) as one @@ -57,6 +61,8 @@ export function NetworkScreen({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: NetworkScreenProps) { const { filterText, visibleCategories } = ui; @@ -81,18 +87,22 @@ export function NetworkScreen({ } return ( - - - - onUiChange({ ...ui, filterText: value })} - onToggleCategory={handleToggleCategory} - onToggleAllCategories={handleToggleAllCategories} - /> - - + + {embedded ? null : ( + + + + onUiChange({ ...ui, filterText: value }) + } + onToggleCategory={handleToggleCategory} + onToggleAllCategories={handleToggleAllCategories} + /> + + + )} ); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 72882b0d..907e8c54 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InitializeResult, @@ -15,8 +15,25 @@ import { renderWithMantine, screen, waitFor, + within, + fireEvent, } from "../../../test/renderWithMantine"; import { InspectorView, type InspectorViewProps } from "./InspectorView"; + +// The monitoring column (#1616) is gated on a 1040px viewport media query. +// happy-dom's viewport is 1024px, so that query is really false; make just that +// gate controllable per test. ViewHeader's own 992/768 queries stay "wide" (as +// they are in the real 1024px happy-dom viewport), so header rendering — and +// every existing test below — is unaffected. +const monitorWide = vi.hoisted(() => ({ value: false })); +vi.mock("@mantine/hooks", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMediaQuery: (query: string): boolean => + query === "(min-width: 1040px)" ? monitorWide.value : true, + }; +}); import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; import { EMPTY_TOOLS_UI, @@ -1274,4 +1291,269 @@ describe("InspectorView", () => { expect(screen.queryByText("List updated")).not.toBeInTheDocument(); }); }); + + describe("pinned monitoring column (#1616)", () => { + const httpServer: ServerEntry = { + id: "beta", + name: "Beta", + config: { type: "streamable-http", url: "http://localhost:3000/mcp" }, + connection: { status: "connected" }, + }; + const httpInit = initWithCapabilities(allCapabilities); + + beforeEach(() => { + // Default narrow so a test only pins when it opts in. + monitorWide.value = false; + }); + + function connectedHttp(overrides: Partial = {}) { + return makeProps({ + servers: [httpServer], + activeServer: "beta", + connectionStatus: "connected", + initializeResult: httpInit, + ...overrides, + }); + } + + async function gotoTab(tab: string) { + const user = userEvent.setup(); + const tabSelect = await screen.findByDisplayValue("Servers"); + await user.click(tabSelect); + await user.click(await screen.findByText(tab)); + return user; + } + + it("shows the Pin as column button on a monitor screen only when wide", async () => { + monitorWide.value = false; + const { unmount } = renderWithMantine( + , + ); + await gotoTab("Logs"); + expect( + screen.queryByRole("button", { name: "Pin as column" }), + ).not.toBeInTheDocument(); + unmount(); + + monitorWide.value = true; + renderWithMantine(); + await gotoTab("Logs"); + expect( + await screen.findByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }); + + it("pins the monitor group into the column and removes it from the header", async () => { + monitorWide.value = true; + renderWithMantine(); + const user = await gotoTab("Logs"); + await user.click( + await screen.findByRole("button", { name: "Pin as column" }), + ); + + // Column is open (its close control is present). + expect( + await screen.findByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + + // The monitor group is gone from the header tab bar... + const header = screen.getByRole("banner"); + expect(within(header).queryByRole("radio", { name: "Logs" })).toBeNull(); + expect( + within(header).queryByRole("radio", { name: "History" }), + ).toBeNull(); + expect( + within(header).queryByRole("radio", { name: "Network" }), + ).toBeNull(); + // ...and a non-monitor tab still sits in the header. + expect( + within(header).getByRole("radio", { name: "Tools" }), + ).toBeInTheDocument(); + + // The column hosts the monitor tabs, defaulting to the pinned one. + expect(screen.getByRole("radio", { name: "Logs" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "Network" }), + ).toBeInTheDocument(); + }); + + it("returns the monitor group to the header when the column is closed", async () => { + monitorWide.value = true; + renderWithMantine(); + const user = await gotoTab("Logs"); + await user.click( + await screen.findByRole("button", { name: "Pin as column" }), + ); + await user.click( + await screen.findByRole("button", { name: "Close monitoring column" }), + ); + + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + const header = screen.getByRole("banner"); + expect( + within(header).getByRole("radio", { name: "Logs" }), + ).toBeInTheDocument(); + }); + + 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"); + + // Narrow: the column stays closed and the group is in the header. + monitorWide.value = false; + const { unmount } = renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + expect( + within(screen.getByRole("banner")).getByRole("radio", { name: "Logs" }), + ).toBeInTheDocument(); + unmount(); + + // Wide: the preserved preference reopens the column without re-pinning. + monitorWide.value = true; + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("keeps the column closed when the stored preference is explicitly false", () => { + window.localStorage.setItem("inspector.monitor.pinned", "false"); + monitorWide.value = true; + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + }); + + it("hides the column on disconnect but keeps the pin preference", () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const { rerender } = renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + + rerender( + , + ); + 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", + ); + }); + + it("drops Network from the column tabs for a stdio server", () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + renderWithMantine( + , + ); + // Column open, but Network is unavailable over stdio. + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); + expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); + }); + + it("falls back to an available tab when the stored monitor tab is unavailable", () => { + // Stored tab is Network, but a stdio + logging-only server can't offer it. + window.localStorage.setItem("inspector.monitor.pinned", "true"); + window.localStorage.setItem("inspector.monitor.tab", "Network"); + monitorWide.value = true; + renderWithMantine( + , + ); + // Column active tab clamps to the first available monitor tab (Logs). + expect(screen.getByRole("radio", { name: "Logs" })).toBeChecked(); + expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); + }); + + it("clamps the primary tab to Servers when the header has no other tab", () => { + // stdio + logging only ⇒ availableTabs = [Servers, Logs, History]; pinning + // moves both monitor tabs out, leaving [Servers] as the only header tab. + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + renderWithMantine( + , + ); + const header = screen.getByRole("banner"); + expect( + within(header) + .getAllByRole("radio") + .map((r) => r.getAttribute("value")), + ).toEqual(["Servers"]); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("persists the selected column tab", async () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const user = userEvent.setup(); + renderWithMantine(); + await user.click(await screen.findByRole("radio", { name: "History" })); + await waitFor(() => + expect(window.localStorage.getItem("inspector.monitor.tab")).toBe( + "History", + ), + ); + }); + + 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"); + monitorWide.value = true; + renderWithMantine(); + const handle = await screen.findByRole("separator", { + name: "Resize monitoring column", + }); + // ArrowLeft widens by the 16px step (panel is on the right). + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + await waitFor(() => + expect(window.localStorage.getItem("inspector.monitor.width")).toBe( + "436", + ), + ); + }); + }); }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 0e7a1eb5..104dd9d0 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1,6 +1,6 @@ -import { useMemo, type ReactNode, type Ref } from "react"; -import { AppShell, Box, Stack, Transition } from "@mantine/core"; -import { useLocalStorage } from "@mantine/hooks"; +import { useMemo, useState, type ReactNode, type Ref } from "react"; +import { AppShell, Box, Flex, Stack, Transition } from "@mantine/core"; +import { useLocalStorage, useMediaQuery } from "@mantine/hooks"; import type { InitializeResult, LoggingLevel, @@ -64,6 +64,8 @@ import { type NetworkUiState, } from "../../screens/NetworkScreen/NetworkScreen"; import type { SortDirection } from "../../elements/SortToggle/SortToggle"; +import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen"; +import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle"; import { getServerType } from "@inspector/core/mcp/config.js"; import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs"; @@ -127,6 +129,8 @@ function useListCompact( } const SERVERS_TAB = INSPECTOR_SERVERS_TAB; +const LOGS_TAB = "Logs"; +const HISTORY_TAB = "History"; const NETWORK_TAB = "Network"; const ALL_TABS: string[] = [ @@ -136,11 +140,72 @@ const ALL_TABS: string[] = [ "Prompts", "Resources", "Tasks", - "Logs", - "History", + LOGS_TAB, + HISTORY_TAB, NETWORK_TAB, ]; +// The screens that can be pinned into the monitoring column (#1616). Pinning is +// a group action: opening the column removes all *available* monitor tabs from +// the header and hosts them in the column instead. +type MonitorTab = "Logs" | "History" | "Network"; +const MONITOR_TABS: string[] = [LOGS_TAB, HISTORY_TAB, NETWORK_TAB]; + +function isMonitorTab(tab: string): tab is MonitorTab { + return tab === LOGS_TAB || tab === HISTORY_TAB || tab === NETWORK_TAB; +} + +// The viewport width below which the split collapses to a single column: matches +// the point where ServerListScreen drops to one card, so the primary area always +// has room for at least one full-width card beside the column. +const MONITOR_WIDE_QUERY = "(min-width: 1040px)"; + +// Monitoring column width bounds (px). MIN keeps the stream readable; MAX stops +// the column from crowding out the primary area. +const MONITOR_WIDTH_MIN = 320; +const MONITOR_WIDTH_MAX = 720; +const MONITOR_WIDTH_DEFAULT = 420; +const MONITOR_WIDTH_STEP = 16; + +function clampMonitorWidth(value: number): number { + return Math.min(MONITOR_WIDTH_MAX, Math.max(MONITOR_WIDTH_MIN, value)); +} + +// localStorage adapters for the monitoring-column preferences, matching the +// human-readable / clamp-on-read shape of the sort + compact adapters above. +const MONITOR_PINNED_DEFAULT = false; + +function deserializeMonitorPinned(raw: string | undefined): boolean { + if (raw === "true") return true; + if (raw === "false") return false; + return MONITOR_PINNED_DEFAULT; +} + +function serializeMonitorPinned(value: boolean): string { + return value ? "true" : "false"; +} + +const MONITOR_TAB_DEFAULT: MonitorTab = LOGS_TAB; + +function deserializeMonitorTab(raw: string | undefined): MonitorTab { + return raw !== undefined && isMonitorTab(raw) ? raw : MONITOR_TAB_DEFAULT; +} + +function serializeMonitorTab(value: MonitorTab): string { + return value; +} + +function deserializeMonitorWidth(raw: string | undefined): number { + const parsed = raw !== undefined ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(parsed) + ? clampMonitorWidth(parsed) + : MONITOR_WIDTH_DEFAULT; +} + +function serializeMonitorWidth(value: number): string { + return String(value); +} + const SCREEN_ENTER_MS = 350; const SCREEN_EXIT_MS = 250; @@ -154,6 +219,29 @@ const ScreenStageContainer = Stack.withProps({ gap: 0, flex: 1, mih: "100%", + // Let the pane shrink below its content's intrinsic width when the monitoring + // column opens — without this the Servers grid / SegmentedControl refuse to + // shrink and squeeze the column (or force a page scrollbar). (#1616) + miw: 0, +}); + +// Row wrapper turning AppShell.Main into a [primary | handle | column] split. +// `h/w: 100%` inherit the header-offset height AppShell.Main provides, matching +// the screens' own `calc(100dvh - header)`. (#1616) +const SplitRow = Flex.withProps({ + direction: "row", + gap: 0, + h: "100%", + w: "100%", +}); + +// The pinned monitoring column. Fixed-basis (its width is driven live via the +// `w` style prop at the call site); `miw: 0` so its inner ScrollArea can bound. +const MonitoringColumn = Stack.withProps({ + flex: "0 0 auto", + h: "100%", + gap: 0, + miw: 0, }); // Wraps each screen in a Mantine Transition. With Transition's default @@ -489,6 +577,42 @@ export function InspectorView({ false, ); + // Monitoring-column state (#1616): whether Logs/History/Network are pinned to + // the right column, which one is active there, and the column width. All + // persisted (view-layout preferences), same shape as the sort/compact hooks. + const [monitorPinned, setMonitorPinned] = useLocalStorage({ + key: "inspector.monitor.pinned", + defaultValue: MONITOR_PINNED_DEFAULT, + deserialize: deserializeMonitorPinned, + serialize: serializeMonitorPinned, + getInitialValueInEffect: false, + }); + const [monitorTab, setMonitorTab] = useLocalStorage({ + key: "inspector.monitor.tab", + defaultValue: MONITOR_TAB_DEFAULT, + deserialize: deserializeMonitorTab, + serialize: serializeMonitorTab, + getInitialValueInEffect: false, + }); + const [monitorWidth, setMonitorWidth] = useLocalStorage({ + key: "inspector.monitor.width", + defaultValue: MONITOR_WIDTH_DEFAULT, + deserialize: deserializeMonitorWidth, + serialize: serializeMonitorWidth, + getInitialValueInEffect: false, + }); + // Transient width during a drag; committed to `monitorWidth` on release so + // localStorage takes a single write per drag rather than one per pointer move. + const [dragWidth, setDragWidth] = useState(null); + const columnWidth = dragWidth ?? monitorWidth; + + // The split only exists with enough horizontal room. `true` initial value so + // the first synchronous paint assumes wide (the common desktop case) rather + // than flashing the collapsed layout. + const isWide = useMediaQuery(MONITOR_WIDE_QUERY, true, { + getInitialValueInEffect: false, + }); + const appTools = useMemo(() => { return tools.filter((tool) => { try { @@ -558,15 +682,72 @@ export function InspectorView({ appTools, ]); - // Clamp the rendered tab to whatever's currently available. If the user - // had "Tools" selected and the connection drops, `availableTabs` becomes + // Monitoring column, derived (#1616). The monitor group is pinned into the + // right column only when: the user asked for it, the viewport is wide enough, + // a server is connected, and at least one monitor tab is actually available + // (capability/stdio aware). Narrowing, disconnecting, or losing the last + // monitor capability flips `effectivePinned` false and closes the column — + // WITHOUT clearing `monitorPinned`, so it re-opens when the condition returns. + // Only the column's close button writes `monitorPinned = false`. + const connected = connectionStatus === "connected"; + const monitorAvailable = useMemo( + () => availableTabs.filter((t) => MONITOR_TABS.includes(t)), + [availableTabs], + ); + const effectivePinned = + monitorPinned && !!isWide && connected && monitorAvailable.length > 0; + + // The header loses the monitor group while the column is open (its screens + // live in the column instead); otherwise it shows every available tab. + const headerTabs = useMemo( + () => + effectivePinned + ? availableTabs.filter((t) => !MONITOR_TABS.includes(t)) + : availableTabs, + [effectivePinned, availableTabs], + ); + + // Clamp the rendered primary tab to whatever the header currently shows. If + // the user had "Tools" selected and the connection drops, `headerTabs` becomes // `[Servers]` and the view renders Servers without us having to imperatively - // reset the state (and trip the `set-state-in-effect` lint). When the - // connection comes back, the previous selection pops in again because - // the parent's `activeTab` is preserved. - const activeTab = availableTabs.includes(activeTabProp) + // reset the state (and trip the `set-state-in-effect` lint). When pinning + // moves a monitor tab out of the header, the primary falls back to the first + // non-Servers tab; the lifted `activeTabProp` is left intact so closing the + // column restores the user's prior selection. + const firstNonServersTab = + headerTabs.find((t) => t !== SERVERS_TAB) ?? SERVERS_TAB; + const activeTab = headerTabs.includes(activeTabProp) ? activeTabProp - : SERVERS_TAB; + : effectivePinned + ? firstNonServersTab + : SERVERS_TAB; + + // The monitor tab shown in the column, clamped to what's available (e.g. a + // switch to a stdio server drops Network). `?? LOGS_TAB` is a types-only + // fallback: the column only renders while `monitorAvailable.length > 0`. + const effectiveMonitorTab: MonitorTab = + monitorAvailable.includes(monitorTab) && isMonitorTab(monitorTab) + ? monitorTab + : (monitorAvailable.find(isMonitorTab) ?? LOGS_TAB); + + // Pinning is a group action: remember which tab was clicked and open the + // column. Gated by `canPin` so the pin affordance only appears when a click + // would actually take effect (wide + connected). + const canPin = !!isWide && connected; + function pinMonitor(tab: MonitorTab) { + setMonitorTab(tab); + setMonitorPinned(true); + } + function handleMonitorTabChange(tab: string) { + // The SegmentedControl's data only holds valid monitor tabs, so the guard's + // false arm is unreachable through the UI. + /* v8 ignore next */ + if (isMonitorTab(tab)) setMonitorTab(tab); + } + function commitMonitorWidth(next: number) { + setMonitorWidth(next); + setDragWidth(null); + } // Merge the parent's `serversInput` (static config) with the runtime // connection state owned by the parent — only the active server reflects @@ -616,6 +797,56 @@ export function InspectorView({ ? undefined : activeServer; + // Shared props for each monitor screen, spread into both its primary + // (header-tab) instance and its embedded (column) instance so the two can't + // drift. The primary adds `onPin`; the embedded adds `embedded`. (#1616) + const loggingScreenProps = { + entries: logs, + currentLevel: currentLogLevel, + ui: logsUi, + onUiChange: onLogsUiChange, + onSetLevel: onSetLogLevel, + onClear: onClearLogs, + onExport: onExportLogs, + sortDirection: logsSort, + onSortChange: setLogsSort, + }; + const historyScreenProps = { + entries: history, + pinnedIds: pinnedHistoryIds ?? new Set(), + ui: historyUi, + onUiChange: onHistoryUiChange, + onClearAll: onClearHistory, + onExport: onExportHistory, + onClearSection: onClearHistorySection, + onExportSection: onExportHistorySection, + onReplay: onReplayHistory, + onTogglePin: onTogglePinHistory, + sortDirection: historySort, + onSortChange: setHistorySort, + compact: historyCompact, + onToggleCompact: () => setHistoryCompact((c) => !c), + }; + const networkScreenProps = { + entries: network, + ui: networkUi, + onUiChange: onNetworkUiChange, + onClear: onClearNetwork, + onExport: onExportNetwork, + sortDirection: networkSort, + onSortChange: setNetworkSort, + compact: networkCompact, + onToggleCompact: () => setNetworkCompact((c) => !c), + }; + + // Embedded instances for the pinned column, keyed by tab. MonitoringScreen + // renders only the active one; the rest are unmounted element values. + const monitorScreens: Record = { + [LOGS_TAB]: , + [HISTORY_TAB]: , + [NETWORK_TAB]: , + }; + return ( // padding={0}: each screen fills `calc(100dvh - header)` and supplies its // own `xl` padding, so Main must contribute only the fixed-header offset. @@ -632,7 +863,7 @@ export function InspectorView({ status={connectionStatus} latencyMs={latencyMs} activeTab={activeTab} - availableTabs={availableTabs} + availableTabs={headerTabs} onTabChange={onActiveTabChange} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} @@ -647,150 +878,148 @@ export function InspectorView({ )} - - - setServersCompact((c) => !c)} - /> - - - - - - - - - - - - - - - - - - - - - setHistoryCompact((c) => !c)} - /> - - - setNetworkCompact((c) => !c)} - /> - - + + + + setServersCompact((c) => !c)} + /> + + + + + + + + + + + + + + + + + + pinMonitor(LOGS_TAB) : undefined} + /> + + + pinMonitor(HISTORY_TAB) : undefined} + /> + + + pinMonitor(NETWORK_TAB) : undefined} + /> + + + {effectivePinned ? ( + <> + + + setMonitorPinned(false)} + screens={monitorScreens} + /> + + + ) : null} + );