feat(web): pin Logging/History/Network as a resizable column (#1616)

Add the ability to "pin" the Logging, History, and Network screens into a
second column on the right of the InspectorView, so a primary screen and a
monitor screen are visible at once (e.g. watch Logs while calling a Tool).

- A "pin as column" button on the Logs/History/Network panel toolbars opens
  the column (shown only when wide + connected).
- The column hosts a MonitoringControls tab row (available monitor tabs +
  close) over the selected embedded screen. Pinning removes the monitor group
  from the header tab bar; closing (x) returns it.
- The divider between the primary area and the column is draggable (pointer +
  keyboard); width is clamped and persisted.
- The split collapses to one column below ~1040px (the ServerListScreen
  single-card breakpoint) and while disconnected, without discarding the pin
  preference — it reopens when wide + connected again.

State (pinned/tab/width) is view-local and persisted via the existing
useLocalStorage pattern, so no new props thread through App and ViewHeader is
unchanged (it just receives a filtered tab list). Screens/panels gain an
`embedded` mode (fills the parent height, drops the 340px filter sidebar) via a
shared EmbeddableScrollArea element.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-07-08 18:52:41 -04:00
commit 081fd789f1
9 changed files with 1728 additions and 241 deletions

View file

@ -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<typeof EmbeddableScrollArea> = {
title: "Elements/EmbeddableScrollArea",
component: EmbeddableScrollArea,
};
export default meta;
type Story = StoryObj<typeof EmbeddableScrollArea>;
const rows = Array.from({ length: 40 }, (_, i) => (
<Text key={i}>Row {i + 1}</Text>
));
export const FullSize: Story = {
render: () => (
<EmbeddableScrollArea embedded={false} viewportRef={createRef()}>
<Stack gap="xs">{rows}</Stack>
</EmbeddableScrollArea>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};
export const Embedded: Story = {
render: () => (
<Stack h={240} gap={0}>
<EmbeddableScrollArea embedded viewportRef={createRef()}>
<Stack gap="xs">{rows}</Stack>
</EmbeddableScrollArea>
</Stack>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Row 1")).toBeInTheDocument();
},
};

View file

@ -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(
<EmbeddableScrollArea embedded={false} viewportRef={createRef()}>
<div>full-size content</div>
</EmbeddableScrollArea>,
);
expect(screen.getByText("full-size content")).toBeInTheDocument();
});
it("renders its children when embedded", () => {
renderWithMantine(
<EmbeddableScrollArea embedded={true} viewportRef={createRef()}>
<div>embedded content</div>
</EmbeddableScrollArea>,
);
expect(screen.getByText("embedded content")).toBeInTheDocument();
});
it("attaches the viewport ref to the scroll viewport", () => {
const ref = createRef<HTMLDivElement>();
renderWithMantine(
<EmbeddableScrollArea embedded={false} viewportRef={ref}>
<div>content</div>
</EmbeddableScrollArea>,
);
expect(ref.current).toBeInstanceOf(HTMLElement);
});
});

View file

@ -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<HTMLDivElement>;
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 (
<Stack flex={1} mih={0} gap={0}>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="100%"
type="scroll"
offsetScrollbars
>
{children}
</ScrollArea.Autosize>
</Stack>
);
}
return (
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={FULLSIZE_MAH}
type="scroll"
offsetScrollbars
>
{children}
</ScrollArea.Autosize>
);
}

View file

@ -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<typeof PinColumnButton> = {
title: "Elements/PinColumnButton",
component: PinColumnButton,
args: { onPin: fn() },
};
export default meta;
type Story = StoryObj<typeof PinColumnButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: "Pin as column" }),
).toBeInTheDocument();
},
};

View file

@ -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(<PinColumnButton onPin={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Pin as column" }),
).toBeInTheDocument();
});
it("invokes onPin when clicked", async () => {
const user = userEvent.setup();
const onPin = vi.fn();
renderWithMantine(<PinColumnButton onPin={onPin} />);
await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,26 @@
import { ActionIcon } from "@mantine/core";
import { TbLayoutSidebarRightExpand } from "react-icons/tb";
export interface PinColumnButtonProps {
/** Pin this screen into the monitoring column to the right. */
onPin: () => void;
}
/**
* Toolbar icon that pins the owning monitor screen (Logs / History / Network)
* into the resizable column on the right of the InspectorView (#1616). Distinct
* from `PinToggle` (which pins individual history entries) — this one opens a
* side column, so it uses a right-sidebar glyph and an "as column" label.
*/
export function PinColumnButton({ onPin }: PinColumnButtonProps) {
return (
<ActionIcon
variant="default"
size="lg"
aria-label="Pin as column"
onClick={onPin}
>
<TbLayoutSidebarRightExpand size={18} />
</ActionIcon>
);
}

View file

@ -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<typeof ResizeHandle> = {
title: "Elements/ResizeHandle",
component: ResizeHandle,
};
export default meta;
type Story = StoryObj<typeof ResizeHandle>;
// 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<number | null>(null);
return (
<Group h={200} gap={0} wrap="nowrap">
<Box flex={1} bg="var(--inspector-surface-subtle)" p="md">
<Text>Primary</Text>
</Box>
<ResizeHandle
value={dragWidth ?? width}
min={200}
max={520}
onChange={setDragWidth}
onCommit={(next) => {
setWidth(next);
setDragWidth(null);
}}
aria-label="Resize demo panel"
/>
<Box w={dragWidth ?? width} p="md" bg="var(--inspector-surface-card)">
<Text>Column ({dragWidth ?? width}px)</Text>
</Box>
</Group>
);
}
export const Default: Story = {
render: () => <ResizeDemo />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("separator", { name: "Resize demo panel" }),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,144 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
renderWithMantine,
screen,
fireEvent,
} from "../../../test/renderWithMantine";
import { ResizeHandle } from "./ResizeHandle";
// The panel sits to the RIGHT of the handle, so moving the pointer LEFT
// (decreasing clientX) widens it: next = startWidth + (startX - clientX).
function setup(overrides: Partial<Parameters<typeof ResizeHandle>[0]> = {}) {
const onChange = vi.fn();
const onCommit = vi.fn();
renderWithMantine(
<ResizeHandle
value={400}
min={320}
max={720}
onChange={onChange}
onCommit={onCommit}
{...overrides}
/>,
);
return { handle: screen.getByRole("separator"), onChange, onCommit };
}
describe("ResizeHandle", () => {
afterEach(() => {
document.body.classList.remove("resizing-col");
});
it("exposes the separator role with live aria values", () => {
const { handle } = setup();
expect(handle).toHaveAttribute("aria-orientation", "vertical");
expect(handle).toHaveAttribute("aria-valuenow", "400");
expect(handle).toHaveAttribute("aria-valuemin", "320");
expect(handle).toHaveAttribute("aria-valuemax", "720");
expect(handle).toHaveAccessibleName("Resize panel");
});
it("accepts a custom aria-label", () => {
setup({ "aria-label": "Resize monitoring column" });
expect(
screen.getByRole("separator", { name: "Resize monitoring column" }),
).toBeInTheDocument();
});
it("reports the widened value while dragging left and commits on release", () => {
const { handle, onChange, onCommit } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 });
// startWidth 400 + (500 - 460) = 440
expect(onChange).toHaveBeenLastCalledWith(440);
expect(onCommit).not.toHaveBeenCalled();
fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 });
expect(onCommit).toHaveBeenCalledWith(440);
});
it("clamps to max when dragged past the upper bound", () => {
const { handle, onChange } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerMove(handle, { clientX: 0, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(720);
});
it("clamps to min when dragged past the lower bound", () => {
const { handle, onChange } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerMove(handle, { clientX: 900, pointerId: 1 });
expect(onChange).toHaveBeenLastCalledWith(320);
});
it("toggles the drag body class across the gesture", () => {
const { handle } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
expect(document.body.classList.contains("resizing-col")).toBe(true);
fireEvent.pointerUp(handle, { clientX: 500, pointerId: 1 });
expect(document.body.classList.contains("resizing-col")).toBe(false);
});
it("ends the drag on pointer cancel", () => {
const { handle, onCommit } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 });
fireEvent.pointerCancel(handle, { clientX: 480, pointerId: 1 });
expect(onCommit).toHaveBeenCalledWith(420);
expect(document.body.classList.contains("resizing-col")).toBe(false);
});
it("ignores pointer moves when no drag is active", () => {
const { handle, onChange, onCommit } = setup();
fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 });
fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
});
it("uses pointer capture when the platform supports it", () => {
const setCapture = vi.fn();
const releaseCapture = vi.fn();
const proto = HTMLElement.prototype as unknown as {
setPointerCapture?: (id: number) => void;
releasePointerCapture?: (id: number) => void;
};
proto.setPointerCapture = setCapture;
proto.releasePointerCapture = releaseCapture;
try {
const { handle } = setup();
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 7 });
fireEvent.pointerUp(handle, { clientX: 500, pointerId: 7 });
expect(setCapture).toHaveBeenCalledWith(7);
expect(releaseCapture).toHaveBeenCalledWith(7);
} finally {
delete proto.setPointerCapture;
delete proto.releasePointerCapture;
}
});
it("widens by one step on ArrowLeft and commits", () => {
const { handle, onChange, onCommit } = setup({ step: 20 });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith(420);
expect(onCommit).toHaveBeenCalledWith(420);
});
it("narrows by one step on ArrowRight", () => {
const { handle, onChange } = setup();
fireEvent.keyDown(handle, { key: "ArrowRight" });
// default step 16: 400 - 16 = 384
expect(onChange).toHaveBeenCalledWith(384);
});
it("clamps keyboard steps to the bounds", () => {
const { handle, onChange } = setup({ value: 712 });
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(onChange).toHaveBeenCalledWith(720);
});
it("ignores non-arrow keys", () => {
const { handle, onChange, onCommit } = setup();
fireEvent.keyDown(handle, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,107 @@
import { useRef, type KeyboardEvent, type PointerEvent } from "react";
import { Box } from "@mantine/core";
export interface ResizeHandleProps {
/** Current width (px) of the panel this handle resizes. */
value: number;
/** Lower / upper clamp bounds (px). */
min: number;
max: number;
/** Keyboard arrow step in px (default 16). */
step?: number;
/**
* Fired continuously while dragging and on each keyboard step, with the
* clamped next width. The parent renders from this for live feedback.
*/
onChange: (next: number) => void;
/**
* Fired once when the gesture ends (pointer up / key up) with the final
* clamped width, so the parent can persist it in a single write.
*/
onCommit: (next: number) => void;
"aria-label"?: string;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/**
* A thin draggable vertical divider that resizes a panel sitting to its RIGHT
* (so dragging left widens the panel). Pointer-capture keeps the gesture alive
* even when the pointer crosses an iframe (the Apps screen renders one), which
* would otherwise swallow the move stream and make the drag stick. Exposes the
* ARIA `separator` role with live value so it's operable by keyboard too.
*/
export function ResizeHandle({
value,
min,
max,
step = 16,
onChange,
onCommit,
"aria-label": ariaLabel = "Resize panel",
}: ResizeHandleProps) {
// Gesture origin, captured on pointer down. `null` while idle.
const drag = useRef<{ startX: number; startWidth: number } | null>(null);
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
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<HTMLDivElement>) {
if (!drag.current) return;
const next = clamp(
drag.current.startWidth + (drag.current.startX - e.clientX),
min,
max,
);
onChange(next);
}
function endDrag(e: PointerEvent<HTMLDivElement>) {
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<HTMLDivElement>) {
// 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 (
<Box
role="separator"
aria-orientation="vertical"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-label={ariaLabel}
tabIndex={0}
className="resize-handle"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={handleKeyDown}
/>
);
}