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,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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue