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..cd252845 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -0,0 +1,27 @@ +import { Button } 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 button 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. Styled + * to match the panel's expand/collapse `ListToggle` (subtle icon button). + */ +export function PinColumnButton({ onPin }: PinColumnButtonProps) { + return ( + + ); +} diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx new file mode 100644 index 00000000..6391f2b4 --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { ReplayButton } from "./ReplayButton"; + +const meta: Meta = { + title: "Elements/ReplayButton", + component: ReplayButton, + args: { onReplay: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx new file mode 100644 index 00000000..e992e920 --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ReplayButton } from "./ReplayButton"; + +describe("ReplayButton", () => { + it("renders a labelled replay button", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + }); + + it("invokes onReplay when clicked", async () => { + const user = userEvent.setup(); + const onReplay = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Replay" })); + expect(onReplay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx new file mode 100644 index 00000000..6cac503e --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx @@ -0,0 +1,26 @@ +import { ActionIcon } from "@mantine/core"; +import { MdReplay } from "react-icons/md"; + +export interface ReplayButtonProps { + /** Re-send the owning history request. */ + onReplay: () => void; +} + +/** + * Icon form of the "Replay" action, used in the compact (column) HistoryEntry + * layout where the text button is replaced by a replay icon sitting next to the + * pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling. + */ +export function ReplayButton({ onReplay }: ReplayButtonProps) { + 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..5ba77b0b --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx @@ -0,0 +1,166 @@ +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("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 }); + 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..77545a5e --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -0,0 +1,119 @@ +import { + useEffect, + 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); + + // 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 + // 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/HistoryEntry/HistoryEntry.test.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx index 6ed5e769..e144e65d 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx @@ -208,6 +208,54 @@ describe("HistoryEntry", () => { expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand")); }); + it("renders the compact two-line layout with Replay as an icon when embedded", () => { + renderWithMantine( + , + ); + // Line 1 essentials plus the method are still shown. The timestamp is the + // compact time-only form (not the full ISO) to fit the narrow line. + expect(screen.getByText("10:30:00")).toBeInTheDocument(); + expect( + screen.queryByText("2026-03-17T10:30:00.000Z"), + ).not.toBeInTheDocument(); + expect(screen.getByText("client → server")).toBeInTheDocument(); + expect(screen.getByText("142ms")).toBeInTheDocument(); + expect(screen.getByText("OK")).toBeInTheDocument(); + expect(screen.getByText("tools/call")).toBeInTheDocument(); + // Replay is an icon button (aria-label), not the text button. + expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + expect(screen.queryByText("Replay")).toBeNull(); + }); + + it("keeps action order Replay, Pin, Expand in the compact layout", () => { + renderWithMantine( + , + ); + const names = screen + .getAllByRole("button") + .map((b) => b.getAttribute("aria-label") ?? b.textContent); + expect(names.indexOf("Replay")).toBeLessThan(names.indexOf("Pin")); + expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand")); + }); + + it("does not render a Replay icon for a non-replayable method when embedded", () => { + // A server→client response isn't replayable. + const responseEntry: MessageEntry = { + id: "resp-1", + timestamp: new Date("2026-03-17T10:30:00Z"), + direction: "response", + origin: "server", + message: { jsonrpc: "2.0", id: 1, result: {} }, + }; + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Replay" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument(); + }); + it("invokes onTogglePin when Pin button is clicked", async () => { const user = userEvent.setup(); const onTogglePin = vi.fn(); diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx index 3a1599b5..a76ad4f9 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx @@ -14,6 +14,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { PinToggle } from "../../elements/PinToggle/PinToggle"; +import { ReplayButton } from "../../elements/ReplayButton/ReplayButton"; import { extractMethod, isReplayableHistoryMethod } from "../historyUtils.js"; export interface HistoryEntryProps { @@ -22,6 +23,12 @@ export interface HistoryEntryProps { isListExpanded: boolean; onReplay: () => void; onTogglePin: () => void; + /** + * Compact two-line header for the narrow monitoring column (#1616): line 1 is + * time + direction + duration + status; line 2 is the method (and target) with + * the controls — Replay as an icon — on the right. + */ + embedded?: boolean; } const EntryContainer = Card.withProps({ @@ -34,6 +41,19 @@ const HeaderRow = Group.withProps({ wrap: "nowrap", }); +// Left / right clusters within a compact header line. The left cluster shrinks +// (`miw: 0`) so a long target can truncate rather than push the row wider. +const HeaderCluster = Group.withProps({ + gap: "sm", + wrap: "nowrap", + miw: 0, +}); + +const ControlsCluster = Group.withProps({ + gap: "xs", + wrap: "nowrap", +}); + const TimestampText = Text.withProps({ size: "sm", c: "dimmed", @@ -63,6 +83,12 @@ function formatTimestamp(date: Date): string { return date.toISOString(); } +// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO +// string would eat most of the narrow line-1 width (#1616). +function formatTimestampCompact(date: Date): string { + return date.toISOString().slice(11, 19); +} + function extractTarget(entry: MessageEntry): string | undefined { const msg = entry.message; if (!("params" in msg) || !msg.params) return undefined; @@ -108,6 +134,7 @@ export function HistoryEntry({ isListExpanded, onReplay, onTogglePin, + embedded = false, }: HistoryEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); const method = extractMethod(entry); @@ -119,44 +146,88 @@ export function HistoryEntry({ setIsExpanded(isListExpanded); }, [isListExpanded]); + const directionBadge = entry.origin && ( + + ); + const statusBadge = status !== "none" && ( + {statusLabel(status)} + ); + const durationText = entry.duration != null && ( + {formatDuration(entry.duration)} + ); + return ( - - - {entry.origin && ( - - )} - {formatTimestamp(entry.timestamp)} - {method} - {target && {target}} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {status !== "none" && ( - {statusLabel(status)} - )} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestampCompact(entry.timestamp)} + + {directionBadge} + + + {durationText} + {statusBadge} + + + + + {method} + {target && ( + + {target} + + )} + + + {canReplay && } + + setIsExpanded((v) => !v)} + /> + + + + ) : ( + <> + + + + {formatTimestamp(entry.timestamp)} + + {directionBadge} + {method} + {target && {target}} + + + {durationText} + {statusBadge} + + - - - {canReplay && ( - Replay - )} - - - - setIsExpanded((v) => !v)} - /> - - + + + {canReplay && ( + Replay + )} + + + + setIsExpanded((v) => !v)} + /> + + + + )} 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 cd9125fe..05133ba0 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({ @@ -123,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, @@ -134,6 +143,7 @@ function CollapsibleSection({ }: { title: string; collapsible: boolean; + hideHeaderWhenAlone?: boolean; open: boolean; onToggle: () => void; actions?: ReactNode; @@ -142,7 +152,7 @@ function CollapsibleSection({ if (!collapsible) { return ( - {title} + {hideHeaderWhenAlone ? null : {title}} {children} ); @@ -168,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) : ""; @@ -201,6 +216,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 @@ -208,15 +225,30 @@ 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 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) => - matchesFilters(e, searchText, visibleDirections, methodFilter), + matchesFilters( + e, + searchText, + visibleDirections, + methodFilter, + embedded, + ), ) .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)), @@ -238,9 +270,6 @@ export function HistoryListPanel({ Requests - {hasResults && ( - - )} Export + {hasResults && ( + + )} + {onPin ? : null} {!hasResults ? ( No request history ) : ( - + {pinnedEntries.length > 0 && ( onReplay(entry.id)} onTogglePin={() => onTogglePin(entry.id)} /> @@ -301,6 +330,9 @@ export function HistoryListPanel({ setHistoryOpen((v) => !v)} actions={ @@ -318,6 +350,7 @@ export function HistoryListPanel({ entry={entry} isPinned={false} isListExpanded={!compact} + embedded={embedded} onReplay={() => onReplay(entry.id)} onTogglePin={() => onTogglePin(entry.id)} /> @@ -325,7 +358,7 @@ export function HistoryListPanel({ )} - + )} ); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index b164f649..24fda38c 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({ @@ -50,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 = @@ -69,16 +78,20 @@ export function LogStreamPanel({ onExport, sortDirection, onSortChange, + onPin, + embedded = false, }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { - // `.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) => 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; - }, [entries, filterText, visibleLevels, sortDirection]); + }, [entries, filterText, visibleLevels, sortDirection, embedded]); return ( @@ -104,21 +117,17 @@ export function LogStreamPanel({ > Export + {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..a176508e --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -0,0 +1,37 @@ +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(), + searchValue: "", + onSearchChange: 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..8cf86503 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +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", () => { + renderControls(); + for (const tab of TABS) { + expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); + } + }); + + it("renders only the tabs it is given", () => { + renderControls({ tabs: ["Logs", "History"] }); + expect(screen.getAllByRole("radio")).toHaveLength(2); + 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", () => { + 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(); + 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(); + renderControls({ onClose }); + 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..58c79cc1 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -0,0 +1,83 @@ +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). */ + tabs: string[]; + /** 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 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({ + 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 + 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 ( + + + 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 new file mode 100644 index 00000000..8d934c82 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -0,0 +1,61 @@ +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(), + searchValue: "", + onSearchChange: 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..33f89ace --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + MonitoringScreen, + type MonitoringScreenProps, +} from "./MonitoringScreen"; + +const TABS = ["Logs", "History", "Network"]; + +function screens() { + return { + Logs:
logs-body
, + History:
history-body
, + Network:
network-body
, + }; +} + +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", () => { + 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", () => { + renderScreen(); + 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(); + renderScreen({ onChange, onClose }); + 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("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", () => { + 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 new file mode 100644 index 00000000..2d9f392d --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -0,0 +1,63 @@ +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; + /** 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 + * 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, + searchValue, + onSearchChange, + onClose, + screens, +}: MonitoringScreenProps) { + return ( + + + + {screens[value]} + + ); +} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 514e78e8..e0c09b5a 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -31,6 +31,24 @@ describe("NetworkEntry", () => { expect(screen.getByText("transport")).toBeInTheDocument(); }); + it("renders the compact two-line layout (line 1 meta, line 2 URL) when embedded", () => { + renderWithMantine( + , + ); + // Line 1: compact time-only timestamp, method, category, duration, status. + expect(screen.getByText("10:00:00")).toBeInTheDocument(); + expect( + screen.queryByText("2026-03-17T10:00:00.000Z"), + ).not.toBeInTheDocument(); + expect(screen.getByText("POST")).toBeInTheDocument(); + expect(screen.getByText("transport")).toBeInTheDocument(); + expect(screen.getByText("45ms")).toBeInTheDocument(); + expect(screen.getByText("200 OK")).toBeInTheDocument(); + // Line 2: the URL (in its scroll area) and the expand toggle. + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument(); + }); + it("shows body / header detail when expanded", async () => { const user = userEvent.setup(); renderWithMantine( diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index a0a58cfb..08bb937b 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -6,6 +6,7 @@ import { Collapse, Divider, Group, + ScrollArea, Stack, Table, Text, @@ -19,6 +20,12 @@ import { maskSecretsInBody } from "../../../utils/maskSecrets"; export interface NetworkEntryProps { entry: FetchRequestEntry; isListExpanded: boolean; + /** + * Compact two-line header for the narrow monitoring column (#1616): line 1 is + * time + method + category + duration + status; line 2 is the URL in a + * horizontal scroll area with the expand toggle on the right. + */ + embedded?: boolean; } const EntryContainer = Card.withProps({ @@ -43,6 +50,26 @@ const UrlText = Text.withProps({ truncate: "end", }); +// Compact-header URL: never wraps, so a long URL scrolls horizontally inside its +// ScrollArea instead of wrapping to many lines. +const UrlScroll = Text.withProps({ + size: "sm", + fw: 500, + variant: "nowrap", +}); + +// Left / right clusters for a compact header line (mirrors HistoryEntry). +const HeaderCluster = Group.withProps({ + gap: "sm", + wrap: "nowrap", + miw: 0, +}); + +const ControlsCluster = Group.withProps({ + gap: "sm", + wrap: "nowrap", +}); + const DurationText = Text.withProps({ size: "sm", c: "dimmed", @@ -62,6 +89,12 @@ function formatTimestamp(date: Date): string { return date.toISOString(); } +// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO +// string would eat most of the narrow line-1 width (#1616). +function formatTimestampCompact(date: Date): string { + return date.toISOString().slice(11, 19); +} + function statusColor(entry: FetchRequestEntry): string { if (entry.error) return "red"; const status = entry.responseStatus; @@ -197,7 +230,11 @@ function BodyPreview({ ); } -export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { +export function NetworkEntry({ + entry, + isListExpanded, + embedded = false, +}: NetworkEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); // The list-level Expand/Collapse toggle is authoritative: each time the @@ -210,33 +247,70 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { setIsExpanded(isListExpanded); }, [isListExpanded]); + const metaBadges = ( + <> + {entry.duration != null && ( + {formatDuration(entry.duration)} + )} + {isLongLivedStream(entry) && SSE} + {statusLabel(entry)} + + ); + const expandToggle = ( + setIsExpanded((v) => !v)} + /> + ); + return ( - - - {formatTimestamp(entry.timestamp)} - {entry.method} - - {entry.category} - - {entry.url} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {isLongLivedStream(entry) && SSE} - {statusLabel(entry)} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestampCompact(entry.timestamp)} + + {entry.method} + + {entry.category} + + + {metaBadges} + + + + {entry.url} + + {expandToggle} + + + ) : ( + <> + + + + {formatTimestamp(entry.timestamp)} + + {entry.method} + + {entry.category} + + {entry.url} + + + {metaBadges} + + - - setIsExpanded((v) => !v)} - /> - + + {expandToggle} + + + )} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 7f8b24d8..509f5a98 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({ @@ -60,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 = @@ -95,16 +96,20 @@ export function NetworkStreamPanel({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { - // `.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) => 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; - }, [entries, filterText, visibleCategories, sortDirection]); + }, [entries, filterText, visibleCategories, sortDirection, embedded]); const hasEntries = entries.length > 0; const hasResults = filteredEntries.length > 0; @@ -114,9 +119,6 @@ export function NetworkStreamPanel({ {formatTitle(filteredEntries.length)} - {hasResults && ( - - )} Export + {hasResults && ( + + )} + {onPin ? : null} {!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..f5ae4f10 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -111,4 +111,48 @@ 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(); + }); + + 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( + , + ); + expect(screen.getByText("No request history")).toBeInTheDocument(); + }); }); 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..675e028b 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -131,4 +131,62 @@ 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(); + }); + + 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" }, + }, + ]; + renderWithMantine( + , + ); + expect(screen.queryByText("hello")).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..3f67adc7 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -163,4 +163,49 @@ 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(); + }); + + 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( + , + ); + expect(screen.getByText("No network requests")).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..99197eaf 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,382 @@ 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("opens the monitoring column when a connection is established", async () => { + monitorWide.value = true; + const { rerender } = renderWithMantine( + , + ); + // Disconnected: the column is closed. + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + + // Connecting opens it (the disconnected → connected transition). + rerender(); + expect( + await screen.findByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("does not auto-open on a mount that starts already connected", () => { + // No disconnected → connected transition, and no stored preference, so the + // column stays closed (a user who closed it isn't fought on remount). + monitorWide.value = true; + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + }); + + 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" }), + ); + + // 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("keeps the current header screen (not the column's) when closed", async () => { + monitorWide.value = true; + renderWithMantine(); + const user = await gotoTab("Logs"); + await user.click( + await screen.findByRole("button", { name: "Pin as column" }), + ); + // Pinning moved the primary to the first non-Servers header tab (Tools). + const header = screen.getByRole("banner"); + expect( + within(header).getByRole("radio", { name: "Tools" }), + ).toBeChecked(); + + // 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 stays on the header's current screen (Tools), not the + // column's History, and the monitor group returns to the header. + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(), + ); + expect( + within(header).getByRole("radio", { name: "Tools" }), + ).toBeChecked(); + expect( + within(header).getByRole("radio", { name: "History" }), + ).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", async () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const { rerender } = renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + + rerender( + , + ); + // 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", + ); + }); + + 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("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"); + 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..b61f1ed3 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1,6 +1,21 @@ -import { useMemo, type ReactNode, type Ref } from "react"; -import { AppShell, Box, Stack, Transition } from "@mantine/core"; -import { useLocalStorage } from "@mantine/hooks"; +import { + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from "react"; +import { + AppShell, + Box, + Flex, + Group, + Stack, + Transition, + type MantineTransition, +} from "@mantine/core"; +import { useLocalStorage, useMediaQuery } from "@mantine/hooks"; import type { InitializeResult, LoggingLevel, @@ -64,6 +79,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 +144,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 +155,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 +234,55 @@ 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, +}); + +// 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 a +// side column sliding open rather than snapping in. The primary screen keeps its +// standard `ScreenStage` transition. Mantine plays `out → in` on enter and +// `in → out` on exit. `AppShell.Main`'s `overflow: hidden` clips the off-screen +// portion during the slide. +const COLUMN_ANIM_MS = 300; +const columnSlide: 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`). `align: +// stretch` overrides Group's default `center` so the full-height resize handle +// (which has no intrinsic height) fills the column instead of collapsing. +const MonitorColumnGroup = Group.withProps({ + wrap: "nowrap", + align: "stretch", + gap: 0, + h: "100%", + flex: "0 0 auto", }); // Wraps each screen in a Mantine Transition. With Transition's default @@ -489,6 +618,59 @@ 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, + }); + + // Open the monitoring column when a connection is established (#1616). Gated on + // the disconnected → connected *transition* (via the ref) rather than the + // "connected" state itself, so it fires on an actual connect — not on every + // render while connected, and not on a mount that starts already-connected + // (which would fight a user who closed it). The column still only *appears* + // when wide + a monitor tab is available (`effectivePinned`); this just sets + // the preference. Closing it stays closed until the next connect, since this + // effect only re-runs when `connectionStatus` changes. + const wasConnectedRef = useRef(connectionStatus === "connected"); + useEffect(() => { + const isConnected = connectionStatus === "connected"; + if (isConnected && !wasConnectedRef.current) { + setMonitorPinned(true); + } + wasConnectedRef.current = isConnected; + }, [connectionStatus, setMonitorPinned]); + const appTools = useMemo(() => { return tools.filter((tool) => { try { @@ -558,15 +740,87 @@ 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); + } + // Closing the column leaves the primary area on whatever screen is currently + // selected in the header — it does not swap in the column's screen. We commit + // the current `activeTab` to the lifted state first so unpinning can't resurrect + // the (now stale) monitor tab that was selected before pinning. + function closeMonitorColumn() { + onActiveTabChange(activeTab); + 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 @@ -616,6 +870,76 @@ 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. 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]: ( + + ), + }; + 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 +956,7 @@ export function InspectorView({ status={connectionStatus} latencyMs={latencyMs} activeTab={activeTab} - availableTabs={availableTabs} + availableTabs={headerTabs} onTabChange={onActiveTabChange} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} @@ -647,150 +971,160 @@ 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} + /> + + + + {(styles) => ( + // `style={styles}` is Mantine's runtime Transition interpolation, + // not static styling — same pattern as ScreenStage above. + + + + + + + )} + + ); diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index fa5cbde5..818ceb00 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -16,6 +16,16 @@ export const ThemeText = Text.extend({ }, }; } + // Single-line text that never wraps — used inside a horizontal ScrollArea so + // a long value (e.g. a network URL in the compact column) scrolls instead of + // wrapping to many lines (#1616). + if (props.variant === "nowrap") { + return { + root: { + whiteSpace: "nowrap", + }, + }; + } return { root: {} }; }, });