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:
parent
4ee9bbce0f
commit
081fd789f1
3 changed files with 1728 additions and 241 deletions
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue