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
27 changed files with 1728 additions and 241 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import {
|
|||
Collapse,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
|
|
@ -21,6 +20,8 @@ import {
|
|||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { extractMethod } from "../historyUtils.js";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
|
|
@ -43,6 +44,10 @@ export interface HistoryListPanelProps {
|
|||
onSortChange: (next: SortDirection) => void;
|
||||
compact: boolean;
|
||||
onToggleCompact: () => void;
|
||||
/** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
|
||||
onPin?: () => void;
|
||||
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
|
|
@ -201,6 +206,8 @@ export function HistoryListPanel({
|
|||
onSortChange,
|
||||
compact,
|
||||
onToggleCompact,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: HistoryListPanelProps) {
|
||||
const viewportRef = useScrollMemory("history-list");
|
||||
// Per-section expand/collapse, like the LogControls level toggles. Both start
|
||||
|
|
@ -238,6 +245,7 @@ export function HistoryListPanel({
|
|||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>Requests</Title>
|
||||
<Group gap="xs">
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
|
|
@ -262,12 +270,7 @@ export function HistoryListPanel({
|
|||
{!hasResults ? (
|
||||
<EmptyState>No request history</EmptyState>
|
||||
) : (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
type="scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="md">
|
||||
{pinnedEntries.length > 0 && (
|
||||
<CollapsibleSection
|
||||
|
|
@ -325,7 +328,7 @@ export function HistoryListPanel({
|
|||
</CollapsibleSection>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { LogEntry } from "../../elements/LogEntry/LogEntry";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
|
|
@ -15,6 +7,8 @@ import {
|
|||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
export interface LogStreamPanelProps {
|
||||
|
|
@ -25,6 +19,18 @@ export interface LogStreamPanelProps {
|
|||
onExport: () => void;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (next: SortDirection) => void;
|
||||
/**
|
||||
* When set, renders a "pin as column" button in the toolbar that opens this
|
||||
* screen in the monitoring column (#1616). Omitted when the panel is already
|
||||
* embedded in that column (or when pinning isn't available).
|
||||
*/
|
||||
onPin?: () => void;
|
||||
/**
|
||||
* True when this panel is rendered inside the monitoring column. Switches the
|
||||
* scroll region from the viewport-height calc to filling its flex parent, so
|
||||
* it fits below the column's controls row without viewport math.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
|
|
@ -69,6 +75,8 @@ export function LogStreamPanel({
|
|||
onExport,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: LogStreamPanelProps) {
|
||||
const viewportRef = useScrollMemory("logs-stream");
|
||||
const filteredEntries = useMemo(() => {
|
||||
|
|
@ -85,6 +93,7 @@ export function LogStreamPanel({
|
|||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>Log Stream</Title>
|
||||
<Group>
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
<SortToggle
|
||||
value={sortDirection}
|
||||
onChange={onSortChange}
|
||||
|
|
@ -107,18 +116,13 @@ export function LogStreamPanel({
|
|||
</Group>
|
||||
</Group>
|
||||
{filteredEntries.length > 0 ? (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
type="scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="xs">
|
||||
{filteredEntries.map((entry, index) => (
|
||||
<LogEntry key={index} entry={entry} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
) : (
|
||||
<EmptyCenter>
|
||||
<Text c="dimmed">No log entries</Text>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { MonitoringControls } from "./MonitoringControls";
|
||||
|
||||
const meta: Meta<typeof MonitoringControls> = {
|
||||
title: "Groups/MonitoringControls",
|
||||
component: MonitoringControls,
|
||||
args: {
|
||||
tabs: ["Logs", "History", "Network"],
|
||||
value: "Logs",
|
||||
onChange: fn(),
|
||||
onClose: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MonitoringControls>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { MonitoringControls } from "./MonitoringControls";
|
||||
|
||||
const TABS = ["Logs", "History", "Network"];
|
||||
|
||||
describe("MonitoringControls", () => {
|
||||
it("renders a radio option for each available tab", () => {
|
||||
renderWithMantine(
|
||||
<MonitoringControls
|
||||
tabs={TABS}
|
||||
value="Logs"
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
for (const tab of TABS) {
|
||||
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders only the tabs it is given", () => {
|
||||
renderWithMantine(
|
||||
<MonitoringControls
|
||||
tabs={["Logs", "History"]}
|
||||
value="Logs"
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
||||
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the active tab as selected", () => {
|
||||
renderWithMantine(
|
||||
<MonitoringControls
|
||||
tabs={TABS}
|
||||
value="History"
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("calls onChange when a different tab is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<MonitoringControls
|
||||
tabs={TABS}
|
||||
value="Logs"
|
||||
onChange={onChange}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("radio", { name: "Network" }));
|
||||
expect(onChange).toHaveBeenCalledWith("Network");
|
||||
});
|
||||
|
||||
it("calls onClose when the close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<MonitoringControls
|
||||
tabs={TABS}
|
||||
value="Logs"
|
||||
onChange={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { CloseButton, Group, SegmentedControl } from "@mantine/core";
|
||||
|
||||
export interface MonitoringControlsProps {
|
||||
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
|
||||
tabs: string[];
|
||||
/** The active monitor tab shown in the column below. */
|
||||
value: string;
|
||||
onChange: (tab: string) => void;
|
||||
/** Close the monitoring column, returning its screens to the header menu. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// The controls row that sits at the top of the pinned monitoring column: a
|
||||
// segmented tab switcher on the left, a close button on the right that unpins
|
||||
// the whole column.
|
||||
const ControlsBar = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
gap: "sm",
|
||||
p: "sm",
|
||||
});
|
||||
|
||||
/**
|
||||
* Tab row + close control for the pinned monitoring column (#1616). Renders only
|
||||
* the currently-available monitor tabs (the caller filters by capability), so it
|
||||
* never shows an empty switcher. Selecting a tab swaps the screen below; the
|
||||
* close button unpins the column so its screens rejoin the header tab menu.
|
||||
*/
|
||||
export function MonitoringControls({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
}: MonitoringControlsProps) {
|
||||
return (
|
||||
<ControlsBar>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data={tabs}
|
||||
/>
|
||||
<CloseButton aria-label="Close monitoring column" onClick={onClose} />
|
||||
</ControlsBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Box, Text } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { MonitoringScreen } from "./MonitoringScreen";
|
||||
|
||||
const demoScreens = {
|
||||
Logs: (
|
||||
<Box p="md">
|
||||
<Text>Log stream</Text>
|
||||
</Box>
|
||||
),
|
||||
History: (
|
||||
<Box p="md">
|
||||
<Text>Request history</Text>
|
||||
</Box>
|
||||
),
|
||||
Network: (
|
||||
<Box p="md">
|
||||
<Text>Network requests</Text>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof MonitoringScreen> = {
|
||||
title: "Groups/MonitoringScreen",
|
||||
component: MonitoringScreen,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box h={320}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
tabs: ["Logs", "History", "Network"],
|
||||
value: "Logs",
|
||||
onChange: fn(),
|
||||
onClose: fn(),
|
||||
screens: demoScreens,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MonitoringScreen>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { MonitoringScreen } from "./MonitoringScreen";
|
||||
|
||||
const TABS = ["Logs", "History", "Network"];
|
||||
|
||||
function screens() {
|
||||
return {
|
||||
Logs: <div>logs-body</div>,
|
||||
History: <div>history-body</div>,
|
||||
Network: <div>network-body</div>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MonitoringScreen", () => {
|
||||
it("renders the screen for the active tab", () => {
|
||||
renderWithMantine(
|
||||
<MonitoringScreen
|
||||
tabs={TABS}
|
||||
value="History"
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
screens={screens()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("history-body")).toBeInTheDocument();
|
||||
expect(screen.queryByText("logs-body")).toBeNull();
|
||||
expect(screen.queryByText("network-body")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the controls tab row", () => {
|
||||
renderWithMantine(
|
||||
<MonitoringScreen
|
||||
tabs={TABS}
|
||||
value="Logs"
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
screens={screens()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards tab changes and close from the controls", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<MonitoringScreen
|
||||
tabs={TABS}
|
||||
value="Logs"
|
||||
onChange={onChange}
|
||||
onClose={onClose}
|
||||
screens={screens()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("radio", { name: "Network" }));
|
||||
expect(onChange).toHaveBeenCalledWith("Network");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing in the slot when the active tab has no screen", () => {
|
||||
renderWithMantine(
|
||||
<MonitoringScreen
|
||||
tabs={TABS}
|
||||
value="Network"
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
screens={{ Logs: <div>logs-body</div> }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("logs-body")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Divider, Stack } from "@mantine/core";
|
||||
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
|
||||
|
||||
export interface MonitoringScreenProps {
|
||||
/** Available monitor tabs (Logs/History/Network, filtered by capability). */
|
||||
tabs: string[];
|
||||
/** Active monitor tab; keys into `screens` to pick what renders below. */
|
||||
value: string;
|
||||
onChange: (tab: string) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* The embedded screen node for each tab, keyed by tab label. The caller builds
|
||||
* these (with `embedded` set) so this component stays a pure layout shell.
|
||||
*/
|
||||
screens: Record<string, ReactNode>;
|
||||
}
|
||||
|
||||
// Fills the pinned column: a fixed controls row on top and the selected screen
|
||||
// filling the remaining height below (mih:0 lets the inner ScrollArea bound).
|
||||
const ColumnLayout = Stack.withProps({
|
||||
h: "100%",
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
const ScreenSlot = Stack.withProps({
|
||||
flex: 1,
|
||||
mih: 0,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
|
||||
* over the currently-selected monitor screen. Layout-only — it renders whichever
|
||||
* embedded screen node the caller supplies for the active tab.
|
||||
*/
|
||||
export function MonitoringScreen({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
screens,
|
||||
}: MonitoringScreenProps) {
|
||||
return (
|
||||
<ColumnLayout>
|
||||
<MonitoringControls
|
||||
tabs={tabs}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Divider />
|
||||
<ScreenSlot>{screens[value]}</ScreenSlot>
|
||||
</ColumnLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type {
|
||||
FetchRequestCategory,
|
||||
FetchRequestEntry,
|
||||
|
|
@ -18,6 +10,8 @@ import {
|
|||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
export interface NetworkStreamPanelProps {
|
||||
|
|
@ -30,6 +24,10 @@ export interface NetworkStreamPanelProps {
|
|||
onSortChange: (next: SortDirection) => void;
|
||||
compact: boolean;
|
||||
onToggleCompact: () => void;
|
||||
/** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
|
||||
onPin?: () => void;
|
||||
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
|
|
@ -95,6 +93,8 @@ export function NetworkStreamPanel({
|
|||
onSortChange,
|
||||
compact,
|
||||
onToggleCompact,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: NetworkStreamPanelProps) {
|
||||
const viewportRef = useScrollMemory("network-stream");
|
||||
const filteredEntries = useMemo(() => {
|
||||
|
|
@ -114,6 +114,7 @@ export function NetworkStreamPanel({
|
|||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>{formatTitle(filteredEntries.length)}</Title>
|
||||
<Group gap="xs">
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
|
|
@ -134,12 +135,7 @@ export function NetworkStreamPanel({
|
|||
{!hasResults ? (
|
||||
<EmptyState>No network requests</EmptyState>
|
||||
) : (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
type="scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="md">
|
||||
{filteredEntries.map((entry) => (
|
||||
<NetworkEntry
|
||||
|
|
@ -149,7 +145,7 @@ export function NetworkStreamPanel({
|
|||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -111,4 +111,19 @@ describe("HistoryScreen", () => {
|
|||
expect.objectContaining({ methodFilter: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPin = vi.fn();
|
||||
renderWithMantine(<HistoryScreen {...baseProps} onPin={onPin} />);
|
||||
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(<HistoryScreen {...baseProps} embedded />);
|
||||
expect(screen.getByText("Requests")).toBeInTheDocument();
|
||||
// The sidebar (HistoryControls, with its Search box) is not rendered.
|
||||
expect(screen.queryByPlaceholderText("Search...")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue