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
3 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>
);
}