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
2 changed files with 1728 additions and 241 deletions

View file

@ -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",
),
);
});
});
});

View file

@ -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>
);