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
2 changed files with 1728 additions and 241 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type {
|
||||
InitializeResult,
|
||||
|
|
@ -15,8 +15,25 @@ import {
|
|||
renderWithMantine,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
fireEvent,
|
||||
} from "../../../test/renderWithMantine";
|
||||
import { InspectorView, type InspectorViewProps } from "./InspectorView";
|
||||
|
||||
// The monitoring column (#1616) is gated on a 1040px viewport media query.
|
||||
// happy-dom's viewport is 1024px, so that query is really false; make just that
|
||||
// gate controllable per test. ViewHeader's own 992/768 queries stay "wide" (as
|
||||
// they are in the real 1024px happy-dom viewport), so header rendering — and
|
||||
// every existing test below — is unaffected.
|
||||
const monitorWide = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock("@mantine/hooks", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@mantine/hooks")>();
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: (query: string): boolean =>
|
||||
query === "(min-width: 1040px)" ? monitorWide.value : true,
|
||||
};
|
||||
});
|
||||
import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer";
|
||||
import {
|
||||
EMPTY_TOOLS_UI,
|
||||
|
|
@ -1274,4 +1291,269 @@ describe("InspectorView", () => {
|
|||
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinned monitoring column (#1616)", () => {
|
||||
const httpServer: ServerEntry = {
|
||||
id: "beta",
|
||||
name: "Beta",
|
||||
config: { type: "streamable-http", url: "http://localhost:3000/mcp" },
|
||||
connection: { status: "connected" },
|
||||
};
|
||||
const httpInit = initWithCapabilities(allCapabilities);
|
||||
|
||||
beforeEach(() => {
|
||||
// Default narrow so a test only pins when it opts in.
|
||||
monitorWide.value = false;
|
||||
});
|
||||
|
||||
function connectedHttp(overrides: Partial<InspectorViewProps> = {}) {
|
||||
return makeProps({
|
||||
servers: [httpServer],
|
||||
activeServer: "beta",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: httpInit,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoTab(tab: string) {
|
||||
const user = userEvent.setup();
|
||||
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||
await user.click(tabSelect);
|
||||
await user.click(await screen.findByText(tab));
|
||||
return user;
|
||||
}
|
||||
|
||||
it("shows the Pin as column button on a monitor screen only when wide", async () => {
|
||||
monitorWide.value = false;
|
||||
const { unmount } = renderWithMantine(
|
||||
<StatefulInspectorViewHost {...connectedHttp()} />,
|
||||
);
|
||||
await gotoTab("Logs");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Pin as column" }),
|
||||
).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
await gotoTab("Logs");
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Pin as column" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the monitor group into the column and removes it from the header", async () => {
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
const user = await gotoTab("Logs");
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Pin as column" }),
|
||||
);
|
||||
|
||||
// Column is open (its close control is present).
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// The monitor group is gone from the header tab bar...
|
||||
const header = screen.getByRole("banner");
|
||||
expect(within(header).queryByRole("radio", { name: "Logs" })).toBeNull();
|
||||
expect(
|
||||
within(header).queryByRole("radio", { name: "History" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(header).queryByRole("radio", { name: "Network" }),
|
||||
).toBeNull();
|
||||
// ...and a non-monitor tab still sits in the header.
|
||||
expect(
|
||||
within(header).getByRole("radio", { name: "Tools" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// The column hosts the monitor tabs, defaulting to the pinned one.
|
||||
expect(screen.getByRole("radio", { name: "Logs" })).toBeChecked();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: "Network" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns the monitor group to the header when the column is closed", async () => {
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
const user = await gotoTab("Logs");
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Pin as column" }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Close monitoring column" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeNull();
|
||||
const header = screen.getByRole("banner");
|
||||
expect(
|
||||
within(header).getByRole("radio", { name: "Logs" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists the pin preference and reopens the column when wide", () => {
|
||||
// Stored preference from a prior wide session.
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
|
||||
// Narrow: the column stays closed and the group is in the header.
|
||||
monitorWide.value = false;
|
||||
const { unmount } = renderWithMantine(
|
||||
<StatefulInspectorViewHost {...connectedHttp()} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("banner")).getByRole("radio", { name: "Logs" }),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
// Wide: the preserved preference reopens the column without re-pinning.
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the column closed when the stored preference is explicitly false", () => {
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "false");
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the column on disconnect but keeps the pin preference", () => {
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
monitorWide.value = true;
|
||||
const { rerender } = renderWithMantine(
|
||||
<StatefulInspectorViewHost {...connectedHttp()} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<StatefulInspectorViewHost
|
||||
{...makeProps({
|
||||
servers: [httpServer],
|
||||
activeServer: undefined,
|
||||
connectionStatus: "disconnected",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeNull();
|
||||
// Preference is untouched — only the column's close button clears it.
|
||||
expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops Network from the column tabs for a stdio server", () => {
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(
|
||||
<StatefulInspectorViewHost
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: initWithCapabilities(allCapabilities),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// Column open, but Network is unavailable over stdio.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to an available tab when the stored monitor tab is unavailable", () => {
|
||||
// Stored tab is Network, but a stdio + logging-only server can't offer it.
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
window.localStorage.setItem("inspector.monitor.tab", "Network");
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(
|
||||
<StatefulInspectorViewHost
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: initWithCapabilities({ logging: {} }),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// Column active tab clamps to the first available monitor tab (Logs).
|
||||
expect(screen.getByRole("radio", { name: "Logs" })).toBeChecked();
|
||||
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps the primary tab to Servers when the header has no other tab", () => {
|
||||
// stdio + logging only ⇒ availableTabs = [Servers, Logs, History]; pinning
|
||||
// moves both monitor tabs out, leaving [Servers] as the only header tab.
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(
|
||||
<StatefulInspectorViewHost
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: initWithCapabilities({ logging: {} }),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const header = screen.getByRole("banner");
|
||||
expect(
|
||||
within(header)
|
||||
.getAllByRole("radio")
|
||||
.map((r) => r.getAttribute("value")),
|
||||
).toEqual(["Servers"]);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists the selected column tab", async () => {
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
monitorWide.value = true;
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
await user.click(await screen.findByRole("radio", { name: "History" }));
|
||||
await waitFor(() =>
|
||||
expect(window.localStorage.getItem("inspector.monitor.tab")).toBe(
|
||||
"History",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("resizes and persists the column width from the keyboard", async () => {
|
||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||
window.localStorage.setItem("inspector.monitor.width", "420");
|
||||
monitorWide.value = true;
|
||||
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||
const handle = await screen.findByRole("separator", {
|
||||
name: "Resize monitoring column",
|
||||
});
|
||||
// ArrowLeft widens by the 16px step (panel is on the right).
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
await waitFor(() =>
|
||||
expect(window.localStorage.getItem("inspector.monitor.width")).toBe(
|
||||
"436",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo, type ReactNode, type Ref } from "react";
|
||||
import { AppShell, Box, Stack, Transition } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { useMemo, useState, type ReactNode, type Ref } from "react";
|
||||
import { AppShell, Box, Flex, Stack, Transition } from "@mantine/core";
|
||||
import { useLocalStorage, useMediaQuery } from "@mantine/hooks";
|
||||
import type {
|
||||
InitializeResult,
|
||||
LoggingLevel,
|
||||
|
|
@ -64,6 +64,8 @@ import {
|
|||
type NetworkUiState,
|
||||
} from "../../screens/NetworkScreen/NetworkScreen";
|
||||
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
|
||||
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
|
||||
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
|
||||
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||
import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs";
|
||||
|
||||
|
|
@ -127,6 +129,8 @@ function useListCompact(
|
|||
}
|
||||
|
||||
const SERVERS_TAB = INSPECTOR_SERVERS_TAB;
|
||||
const LOGS_TAB = "Logs";
|
||||
const HISTORY_TAB = "History";
|
||||
const NETWORK_TAB = "Network";
|
||||
|
||||
const ALL_TABS: string[] = [
|
||||
|
|
@ -136,11 +140,72 @@ const ALL_TABS: string[] = [
|
|||
"Prompts",
|
||||
"Resources",
|
||||
"Tasks",
|
||||
"Logs",
|
||||
"History",
|
||||
LOGS_TAB,
|
||||
HISTORY_TAB,
|
||||
NETWORK_TAB,
|
||||
];
|
||||
|
||||
// The screens that can be pinned into the monitoring column (#1616). Pinning is
|
||||
// a group action: opening the column removes all *available* monitor tabs from
|
||||
// the header and hosts them in the column instead.
|
||||
type MonitorTab = "Logs" | "History" | "Network";
|
||||
const MONITOR_TABS: string[] = [LOGS_TAB, HISTORY_TAB, NETWORK_TAB];
|
||||
|
||||
function isMonitorTab(tab: string): tab is MonitorTab {
|
||||
return tab === LOGS_TAB || tab === HISTORY_TAB || tab === NETWORK_TAB;
|
||||
}
|
||||
|
||||
// The viewport width below which the split collapses to a single column: matches
|
||||
// the point where ServerListScreen drops to one card, so the primary area always
|
||||
// has room for at least one full-width card beside the column.
|
||||
const MONITOR_WIDE_QUERY = "(min-width: 1040px)";
|
||||
|
||||
// Monitoring column width bounds (px). MIN keeps the stream readable; MAX stops
|
||||
// the column from crowding out the primary area.
|
||||
const MONITOR_WIDTH_MIN = 320;
|
||||
const MONITOR_WIDTH_MAX = 720;
|
||||
const MONITOR_WIDTH_DEFAULT = 420;
|
||||
const MONITOR_WIDTH_STEP = 16;
|
||||
|
||||
function clampMonitorWidth(value: number): number {
|
||||
return Math.min(MONITOR_WIDTH_MAX, Math.max(MONITOR_WIDTH_MIN, value));
|
||||
}
|
||||
|
||||
// localStorage adapters for the monitoring-column preferences, matching the
|
||||
// human-readable / clamp-on-read shape of the sort + compact adapters above.
|
||||
const MONITOR_PINNED_DEFAULT = false;
|
||||
|
||||
function deserializeMonitorPinned(raw: string | undefined): boolean {
|
||||
if (raw === "true") return true;
|
||||
if (raw === "false") return false;
|
||||
return MONITOR_PINNED_DEFAULT;
|
||||
}
|
||||
|
||||
function serializeMonitorPinned(value: boolean): string {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
const MONITOR_TAB_DEFAULT: MonitorTab = LOGS_TAB;
|
||||
|
||||
function deserializeMonitorTab(raw: string | undefined): MonitorTab {
|
||||
return raw !== undefined && isMonitorTab(raw) ? raw : MONITOR_TAB_DEFAULT;
|
||||
}
|
||||
|
||||
function serializeMonitorTab(value: MonitorTab): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function deserializeMonitorWidth(raw: string | undefined): number {
|
||||
const parsed = raw !== undefined ? Number.parseInt(raw, 10) : Number.NaN;
|
||||
return Number.isFinite(parsed)
|
||||
? clampMonitorWidth(parsed)
|
||||
: MONITOR_WIDTH_DEFAULT;
|
||||
}
|
||||
|
||||
function serializeMonitorWidth(value: number): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const SCREEN_ENTER_MS = 350;
|
||||
const SCREEN_EXIT_MS = 250;
|
||||
|
||||
|
|
@ -154,6 +219,29 @@ const ScreenStageContainer = Stack.withProps({
|
|||
gap: 0,
|
||||
flex: 1,
|
||||
mih: "100%",
|
||||
// Let the pane shrink below its content's intrinsic width when the monitoring
|
||||
// column opens — without this the Servers grid / SegmentedControl refuse to
|
||||
// shrink and squeeze the column (or force a page scrollbar). (#1616)
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
// Row wrapper turning AppShell.Main into a [primary | handle | column] split.
|
||||
// `h/w: 100%` inherit the header-offset height AppShell.Main provides, matching
|
||||
// the screens' own `calc(100dvh - header)`. (#1616)
|
||||
const SplitRow = Flex.withProps({
|
||||
direction: "row",
|
||||
gap: 0,
|
||||
h: "100%",
|
||||
w: "100%",
|
||||
});
|
||||
|
||||
// The pinned monitoring column. Fixed-basis (its width is driven live via the
|
||||
// `w` style prop at the call site); `miw: 0` so its inner ScrollArea can bound.
|
||||
const MonitoringColumn = Stack.withProps({
|
||||
flex: "0 0 auto",
|
||||
h: "100%",
|
||||
gap: 0,
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
// Wraps each screen in a Mantine Transition. With Transition's default
|
||||
|
|
@ -489,6 +577,42 @@ export function InspectorView({
|
|||
false,
|
||||
);
|
||||
|
||||
// Monitoring-column state (#1616): whether Logs/History/Network are pinned to
|
||||
// the right column, which one is active there, and the column width. All
|
||||
// persisted (view-layout preferences), same shape as the sort/compact hooks.
|
||||
const [monitorPinned, setMonitorPinned] = useLocalStorage<boolean>({
|
||||
key: "inspector.monitor.pinned",
|
||||
defaultValue: MONITOR_PINNED_DEFAULT,
|
||||
deserialize: deserializeMonitorPinned,
|
||||
serialize: serializeMonitorPinned,
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
const [monitorTab, setMonitorTab] = useLocalStorage<MonitorTab>({
|
||||
key: "inspector.monitor.tab",
|
||||
defaultValue: MONITOR_TAB_DEFAULT,
|
||||
deserialize: deserializeMonitorTab,
|
||||
serialize: serializeMonitorTab,
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
const [monitorWidth, setMonitorWidth] = useLocalStorage<number>({
|
||||
key: "inspector.monitor.width",
|
||||
defaultValue: MONITOR_WIDTH_DEFAULT,
|
||||
deserialize: deserializeMonitorWidth,
|
||||
serialize: serializeMonitorWidth,
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
// Transient width during a drag; committed to `monitorWidth` on release so
|
||||
// localStorage takes a single write per drag rather than one per pointer move.
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null);
|
||||
const columnWidth = dragWidth ?? monitorWidth;
|
||||
|
||||
// The split only exists with enough horizontal room. `true` initial value so
|
||||
// the first synchronous paint assumes wide (the common desktop case) rather
|
||||
// than flashing the collapsed layout.
|
||||
const isWide = useMediaQuery(MONITOR_WIDE_QUERY, true, {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
|
||||
const appTools = useMemo<Tool[]>(() => {
|
||||
return tools.filter((tool) => {
|
||||
try {
|
||||
|
|
@ -558,15 +682,72 @@ export function InspectorView({
|
|||
appTools,
|
||||
]);
|
||||
|
||||
// Clamp the rendered tab to whatever's currently available. If the user
|
||||
// had "Tools" selected and the connection drops, `availableTabs` becomes
|
||||
// Monitoring column, derived (#1616). The monitor group is pinned into the
|
||||
// right column only when: the user asked for it, the viewport is wide enough,
|
||||
// a server is connected, and at least one monitor tab is actually available
|
||||
// (capability/stdio aware). Narrowing, disconnecting, or losing the last
|
||||
// monitor capability flips `effectivePinned` false and closes the column —
|
||||
// WITHOUT clearing `monitorPinned`, so it re-opens when the condition returns.
|
||||
// Only the column's close button writes `monitorPinned = false`.
|
||||
const connected = connectionStatus === "connected";
|
||||
const monitorAvailable = useMemo<string[]>(
|
||||
() => availableTabs.filter((t) => MONITOR_TABS.includes(t)),
|
||||
[availableTabs],
|
||||
);
|
||||
const effectivePinned =
|
||||
monitorPinned && !!isWide && connected && monitorAvailable.length > 0;
|
||||
|
||||
// The header loses the monitor group while the column is open (its screens
|
||||
// live in the column instead); otherwise it shows every available tab.
|
||||
const headerTabs = useMemo<string[]>(
|
||||
() =>
|
||||
effectivePinned
|
||||
? availableTabs.filter((t) => !MONITOR_TABS.includes(t))
|
||||
: availableTabs,
|
||||
[effectivePinned, availableTabs],
|
||||
);
|
||||
|
||||
// Clamp the rendered primary tab to whatever the header currently shows. If
|
||||
// the user had "Tools" selected and the connection drops, `headerTabs` becomes
|
||||
// `[Servers]` and the view renders Servers without us having to imperatively
|
||||
// reset the state (and trip the `set-state-in-effect` lint). When the
|
||||
// connection comes back, the previous selection pops in again because
|
||||
// the parent's `activeTab` is preserved.
|
||||
const activeTab = availableTabs.includes(activeTabProp)
|
||||
// reset the state (and trip the `set-state-in-effect` lint). When pinning
|
||||
// moves a monitor tab out of the header, the primary falls back to the first
|
||||
// non-Servers tab; the lifted `activeTabProp` is left intact so closing the
|
||||
// column restores the user's prior selection.
|
||||
const firstNonServersTab =
|
||||
headerTabs.find((t) => t !== SERVERS_TAB) ?? SERVERS_TAB;
|
||||
const activeTab = headerTabs.includes(activeTabProp)
|
||||
? activeTabProp
|
||||
: SERVERS_TAB;
|
||||
: effectivePinned
|
||||
? firstNonServersTab
|
||||
: SERVERS_TAB;
|
||||
|
||||
// The monitor tab shown in the column, clamped to what's available (e.g. a
|
||||
// switch to a stdio server drops Network). `?? LOGS_TAB` is a types-only
|
||||
// fallback: the column only renders while `monitorAvailable.length > 0`.
|
||||
const effectiveMonitorTab: MonitorTab =
|
||||
monitorAvailable.includes(monitorTab) && isMonitorTab(monitorTab)
|
||||
? monitorTab
|
||||
: (monitorAvailable.find(isMonitorTab) ?? LOGS_TAB);
|
||||
|
||||
// Pinning is a group action: remember which tab was clicked and open the
|
||||
// column. Gated by `canPin` so the pin affordance only appears when a click
|
||||
// would actually take effect (wide + connected).
|
||||
const canPin = !!isWide && connected;
|
||||
function pinMonitor(tab: MonitorTab) {
|
||||
setMonitorTab(tab);
|
||||
setMonitorPinned(true);
|
||||
}
|
||||
function handleMonitorTabChange(tab: string) {
|
||||
// The SegmentedControl's data only holds valid monitor tabs, so the guard's
|
||||
// false arm is unreachable through the UI.
|
||||
/* v8 ignore next */
|
||||
if (isMonitorTab(tab)) setMonitorTab(tab);
|
||||
}
|
||||
function commitMonitorWidth(next: number) {
|
||||
setMonitorWidth(next);
|
||||
setDragWidth(null);
|
||||
}
|
||||
|
||||
// Merge the parent's `serversInput` (static config) with the runtime
|
||||
// connection state owned by the parent — only the active server reflects
|
||||
|
|
@ -616,6 +797,56 @@ export function InspectorView({
|
|||
? undefined
|
||||
: activeServer;
|
||||
|
||||
// Shared props for each monitor screen, spread into both its primary
|
||||
// (header-tab) instance and its embedded (column) instance so the two can't
|
||||
// drift. The primary adds `onPin`; the embedded adds `embedded`. (#1616)
|
||||
const loggingScreenProps = {
|
||||
entries: logs,
|
||||
currentLevel: currentLogLevel,
|
||||
ui: logsUi,
|
||||
onUiChange: onLogsUiChange,
|
||||
onSetLevel: onSetLogLevel,
|
||||
onClear: onClearLogs,
|
||||
onExport: onExportLogs,
|
||||
sortDirection: logsSort,
|
||||
onSortChange: setLogsSort,
|
||||
};
|
||||
const historyScreenProps = {
|
||||
entries: history,
|
||||
pinnedIds: pinnedHistoryIds ?? new Set<string>(),
|
||||
ui: historyUi,
|
||||
onUiChange: onHistoryUiChange,
|
||||
onClearAll: onClearHistory,
|
||||
onExport: onExportHistory,
|
||||
onClearSection: onClearHistorySection,
|
||||
onExportSection: onExportHistorySection,
|
||||
onReplay: onReplayHistory,
|
||||
onTogglePin: onTogglePinHistory,
|
||||
sortDirection: historySort,
|
||||
onSortChange: setHistorySort,
|
||||
compact: historyCompact,
|
||||
onToggleCompact: () => setHistoryCompact((c) => !c),
|
||||
};
|
||||
const networkScreenProps = {
|
||||
entries: network,
|
||||
ui: networkUi,
|
||||
onUiChange: onNetworkUiChange,
|
||||
onClear: onClearNetwork,
|
||||
onExport: onExportNetwork,
|
||||
sortDirection: networkSort,
|
||||
onSortChange: setNetworkSort,
|
||||
compact: networkCompact,
|
||||
onToggleCompact: () => setNetworkCompact((c) => !c),
|
||||
};
|
||||
|
||||
// Embedded instances for the pinned column, keyed by tab. MonitoringScreen
|
||||
// renders only the active one; the rest are unmounted element values.
|
||||
const monitorScreens: Record<string, ReactNode> = {
|
||||
[LOGS_TAB]: <LoggingScreen {...loggingScreenProps} embedded />,
|
||||
[HISTORY_TAB]: <HistoryScreen {...historyScreenProps} embedded />,
|
||||
[NETWORK_TAB]: <NetworkScreen {...networkScreenProps} embedded />,
|
||||
};
|
||||
|
||||
return (
|
||||
// padding={0}: each screen fills `calc(100dvh - header)` and supplies its
|
||||
// own `xl` padding, so Main must contribute only the fixed-header offset.
|
||||
|
|
@ -632,7 +863,7 @@ export function InspectorView({
|
|||
status={connectionStatus}
|
||||
latencyMs={latencyMs}
|
||||
activeTab={activeTab}
|
||||
availableTabs={availableTabs}
|
||||
availableTabs={headerTabs}
|
||||
onTabChange={onActiveTabChange}
|
||||
onDisconnect={onDisconnect}
|
||||
onToggleTheme={onToggleTheme}
|
||||
|
|
@ -647,150 +878,148 @@ export function InspectorView({
|
|||
)}
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
<ScreenStageContainer>
|
||||
<ScreenStage active={activeTab === SERVERS_TAB}>
|
||||
<ServerListScreen
|
||||
servers={servers}
|
||||
writable={serverListWritable}
|
||||
activeServer={dimCardsAgainst}
|
||||
onAddManually={onServerAdd}
|
||||
onImportConfig={onServerImportConfig}
|
||||
onImportServerJson={onServerImportJson}
|
||||
onExport={onServerExport}
|
||||
onToggleConnection={onToggleConnection}
|
||||
onConnectionInfo={onConnectionInfo}
|
||||
onSettings={onServerSettings}
|
||||
onEdit={onServerEdit}
|
||||
onClone={onServerClone}
|
||||
onRemove={onServerRemove}
|
||||
onReorder={onServerReorder}
|
||||
highlightedServerIds={highlightedServerIds}
|
||||
onClearHighlight={onClearHighlight}
|
||||
compact={serversCompact}
|
||||
onToggleCompact={() => setServersCompact((c) => !c)}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Tools"}>
|
||||
<ToolsScreen
|
||||
tools={tools}
|
||||
callState={toolCallState}
|
||||
ui={toolsUi}
|
||||
listChanged={toolsListChanged}
|
||||
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
|
||||
onUiChange={onToolsUiChange}
|
||||
onRefreshList={onRefreshTools}
|
||||
onCallTool={onCallTool}
|
||||
onCancelCall={onCancelToolCall}
|
||||
onClearResult={onClearToolResult}
|
||||
onReadResource={onReadResourceContents}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Apps"}>
|
||||
<AppsScreen
|
||||
tools={appTools}
|
||||
listChanged={toolsListChanged}
|
||||
sandboxPath={sandboxPath}
|
||||
bridgeFactory={bridgeFactory}
|
||||
rendererRef={appRendererRef}
|
||||
ui={appsUi}
|
||||
onUiChange={onAppsUiChange}
|
||||
onRefreshList={onRefreshApps}
|
||||
onSelectApp={onSelectApp}
|
||||
onOpenApp={onOpenApp}
|
||||
onCloseApp={onCloseApp}
|
||||
onError={onAppError}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Prompts"}>
|
||||
<PromptsScreen
|
||||
prompts={prompts}
|
||||
getPromptState={getPromptState}
|
||||
ui={promptsUi}
|
||||
listChanged={promptsListChanged}
|
||||
completionsSupported={completionsSupported}
|
||||
onUiChange={onPromptsUiChange}
|
||||
onRefreshList={onRefreshPrompts}
|
||||
onGetPrompt={onGetPrompt}
|
||||
onCopyMessages={onCopyPromptMessages}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Resources"}>
|
||||
<ResourcesScreen
|
||||
resources={resources}
|
||||
templates={resourceTemplates}
|
||||
subscriptions={subscriptions}
|
||||
readState={readResourceState}
|
||||
ui={resourcesUi}
|
||||
listChanged={resourcesListChanged}
|
||||
completionsSupported={completionsSupported}
|
||||
subscriptionsSupported={subscriptionsSupported}
|
||||
onUiChange={onResourcesUiChange}
|
||||
onRefreshList={onRefreshResources}
|
||||
onReadResource={onReadResource}
|
||||
onSubscribeResource={onSubscribeResource}
|
||||
onUnsubscribeResource={onUnsubscribeResource}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
compact={resourcesCompact}
|
||||
onCompactChange={setResourcesCompact}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Tasks"}>
|
||||
<TasksScreen
|
||||
tasks={tasks}
|
||||
progressByTaskId={progressByTaskId}
|
||||
ui={tasksUi}
|
||||
onUiChange={onTasksUiChange}
|
||||
onRefresh={onRefreshTasks}
|
||||
onClearCompleted={onClearCompletedTasks}
|
||||
onCancel={onCancelTask}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Logs"}>
|
||||
<LoggingScreen
|
||||
entries={logs}
|
||||
currentLevel={currentLogLevel}
|
||||
ui={logsUi}
|
||||
onUiChange={onLogsUiChange}
|
||||
onSetLevel={onSetLogLevel}
|
||||
onClear={onClearLogs}
|
||||
onExport={onExportLogs}
|
||||
sortDirection={logsSort}
|
||||
onSortChange={setLogsSort}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "History"}>
|
||||
<HistoryScreen
|
||||
entries={history}
|
||||
pinnedIds={pinnedHistoryIds ?? new Set()}
|
||||
ui={historyUi}
|
||||
onUiChange={onHistoryUiChange}
|
||||
onClearAll={onClearHistory}
|
||||
onExport={onExportHistory}
|
||||
onClearSection={onClearHistorySection}
|
||||
onExportSection={onExportHistorySection}
|
||||
onReplay={onReplayHistory}
|
||||
onTogglePin={onTogglePinHistory}
|
||||
sortDirection={historySort}
|
||||
onSortChange={setHistorySort}
|
||||
compact={historyCompact}
|
||||
onToggleCompact={() => setHistoryCompact((c) => !c)}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Network"}>
|
||||
<NetworkScreen
|
||||
entries={network}
|
||||
ui={networkUi}
|
||||
onUiChange={onNetworkUiChange}
|
||||
onClear={onClearNetwork}
|
||||
onExport={onExportNetwork}
|
||||
sortDirection={networkSort}
|
||||
onSortChange={setNetworkSort}
|
||||
compact={networkCompact}
|
||||
onToggleCompact={() => setNetworkCompact((c) => !c)}
|
||||
/>
|
||||
</ScreenStage>
|
||||
</ScreenStageContainer>
|
||||
<SplitRow>
|
||||
<ScreenStageContainer>
|
||||
<ScreenStage active={activeTab === SERVERS_TAB}>
|
||||
<ServerListScreen
|
||||
servers={servers}
|
||||
writable={serverListWritable}
|
||||
activeServer={dimCardsAgainst}
|
||||
onAddManually={onServerAdd}
|
||||
onImportConfig={onServerImportConfig}
|
||||
onImportServerJson={onServerImportJson}
|
||||
onExport={onServerExport}
|
||||
onToggleConnection={onToggleConnection}
|
||||
onConnectionInfo={onConnectionInfo}
|
||||
onSettings={onServerSettings}
|
||||
onEdit={onServerEdit}
|
||||
onClone={onServerClone}
|
||||
onRemove={onServerRemove}
|
||||
onReorder={onServerReorder}
|
||||
highlightedServerIds={highlightedServerIds}
|
||||
onClearHighlight={onClearHighlight}
|
||||
compact={serversCompact}
|
||||
onToggleCompact={() => setServersCompact((c) => !c)}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Tools"}>
|
||||
<ToolsScreen
|
||||
tools={tools}
|
||||
callState={toolCallState}
|
||||
ui={toolsUi}
|
||||
listChanged={toolsListChanged}
|
||||
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
|
||||
onUiChange={onToolsUiChange}
|
||||
onRefreshList={onRefreshTools}
|
||||
onCallTool={onCallTool}
|
||||
onCancelCall={onCancelToolCall}
|
||||
onClearResult={onClearToolResult}
|
||||
onReadResource={onReadResourceContents}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Apps"}>
|
||||
<AppsScreen
|
||||
tools={appTools}
|
||||
listChanged={toolsListChanged}
|
||||
sandboxPath={sandboxPath}
|
||||
bridgeFactory={bridgeFactory}
|
||||
rendererRef={appRendererRef}
|
||||
ui={appsUi}
|
||||
onUiChange={onAppsUiChange}
|
||||
onRefreshList={onRefreshApps}
|
||||
onSelectApp={onSelectApp}
|
||||
onOpenApp={onOpenApp}
|
||||
onCloseApp={onCloseApp}
|
||||
onError={onAppError}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Prompts"}>
|
||||
<PromptsScreen
|
||||
prompts={prompts}
|
||||
getPromptState={getPromptState}
|
||||
ui={promptsUi}
|
||||
listChanged={promptsListChanged}
|
||||
completionsSupported={completionsSupported}
|
||||
onUiChange={onPromptsUiChange}
|
||||
onRefreshList={onRefreshPrompts}
|
||||
onGetPrompt={onGetPrompt}
|
||||
onCopyMessages={onCopyPromptMessages}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Resources"}>
|
||||
<ResourcesScreen
|
||||
resources={resources}
|
||||
templates={resourceTemplates}
|
||||
subscriptions={subscriptions}
|
||||
readState={readResourceState}
|
||||
ui={resourcesUi}
|
||||
listChanged={resourcesListChanged}
|
||||
completionsSupported={completionsSupported}
|
||||
subscriptionsSupported={subscriptionsSupported}
|
||||
onUiChange={onResourcesUiChange}
|
||||
onRefreshList={onRefreshResources}
|
||||
onReadResource={onReadResource}
|
||||
onSubscribeResource={onSubscribeResource}
|
||||
onUnsubscribeResource={onUnsubscribeResource}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
compact={resourcesCompact}
|
||||
onCompactChange={setResourcesCompact}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Tasks"}>
|
||||
<TasksScreen
|
||||
tasks={tasks}
|
||||
progressByTaskId={progressByTaskId}
|
||||
ui={tasksUi}
|
||||
onUiChange={onTasksUiChange}
|
||||
onRefresh={onRefreshTasks}
|
||||
onClearCompleted={onClearCompletedTasks}
|
||||
onCancel={onCancelTask}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === LOGS_TAB}>
|
||||
<LoggingScreen
|
||||
{...loggingScreenProps}
|
||||
onPin={canPin ? () => pinMonitor(LOGS_TAB) : undefined}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === HISTORY_TAB}>
|
||||
<HistoryScreen
|
||||
{...historyScreenProps}
|
||||
onPin={canPin ? () => pinMonitor(HISTORY_TAB) : undefined}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === NETWORK_TAB}>
|
||||
<NetworkScreen
|
||||
{...networkScreenProps}
|
||||
onPin={canPin ? () => pinMonitor(NETWORK_TAB) : undefined}
|
||||
/>
|
||||
</ScreenStage>
|
||||
</ScreenStageContainer>
|
||||
{effectivePinned ? (
|
||||
<>
|
||||
<ResizeHandle
|
||||
value={columnWidth}
|
||||
min={MONITOR_WIDTH_MIN}
|
||||
max={MONITOR_WIDTH_MAX}
|
||||
step={MONITOR_WIDTH_STEP}
|
||||
onChange={setDragWidth}
|
||||
onCommit={commitMonitorWidth}
|
||||
aria-label="Resize monitoring column"
|
||||
/>
|
||||
<MonitoringColumn w={columnWidth}>
|
||||
<MonitoringScreen
|
||||
tabs={monitorAvailable}
|
||||
value={effectiveMonitorTab}
|
||||
onChange={handleMonitorTabChange}
|
||||
onClose={() => setMonitorPinned(false)}
|
||||
screens={monitorScreens}
|
||||
/>
|
||||
</MonitoringColumn>
|
||||
</>
|
||||
) : null}
|
||||
</SplitRow>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue