chore(web): collapse InspectorView's prop wall into domain bundles (#2130)

`<InspectorView>` took ~130 flat props, which made ~170 of App.tsx's JSX
lines a prop wall rather than a component tree. It now takes 12 domain
props and the call site is 14 lines.

Two conventions keep the change reviewable. No field is renamed — every
field inside a bundle carries the exact name it had as a flat prop, so
this is a regrouping rather than a rename. And the bundles stop at
`InspectorView`: it destructures each one back into the same locals its
body already used and passes the same individual props down, so nothing
below the view knows they exist.

Three props have no single screen and are placed deliberately:
`onCompleteArgument` / `completionsSupported` are read by both the
Prompts and Resources screens, so they sit in `connection` rather than
being duplicated; `malformedListItems` is one array three screens filter
for their own entries, so it sits in `shell`; `erroredServerId` /
`connectedServerId` are connection outcomes the Servers screen renders,
so they sit in `connection`.

Every multi-line closure declared inline in the JSX is now a named
`useCallback` above the return, including `onServerReorder`'s `.catch`
and the seven `void`-discarding wrappers, which carry their
`no-floating-promises` justification once rather than at each call site.

The `InspectorView` double in App.test.tsx is now typed with the real
`InspectorViewProps` instead of a hand-written structural mirror, so it
cannot drift from the component it stands in for. Stories keep one
constant per bundle, since Storybook merges args only at the top level.

No behavior change.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 23:20:58 -04:00
commit 0425516fa2
6 changed files with 2272 additions and 1489 deletions

View file

@ -450,122 +450,62 @@ vi.mock("./lib/publishAppDocument", () => ({
// that invoke the App's connect / call-tool / get-prompt / read-resource /
// set-log-level handlers.
vi.mock("./components/views/InspectorView/InspectorView", () => ({
InspectorView: (props: {
toolCallState?: { status?: string };
toolsUi?: {
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
};
promptsUi?: {
selectedPromptName?: string;
argumentValues: Record<string, string>;
submittedFor?: string;
search: string;
};
logsUi?: { filterText: string; visibleLevels: Record<string, boolean> };
getPromptState?: { status?: string };
readResourceState?: { status?: string };
currentLogLevel?: string;
activeTab?: string;
activeServer?: string;
erroredServerId?: string;
initializeResult?: { serverInfo: { name: string; version: string } };
onActiveTabChange: (tab: string) => void;
onConnectionInfo: () => void;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
}) => void;
onPromptsUiChange: (next: {
selectedPromptName?: string;
argumentValues: Record<string, string>;
submittedFor?: string;
search: string;
}) => void;
onLogsUiChange: (next: {
filterText: string;
visibleLevels: Record<string, boolean>;
}) => void;
progressByTaskId?: Record<string, unknown>;
onCallTool: (
name: string,
args: Record<string, unknown>,
runAsTask?: boolean,
) => void;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onReadResource: (uri: string) => void;
onSetLogLevel: (level: string) => void;
onCancelTask: (taskId: string) => void;
onCancelToolCall: () => void;
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
onServerSettings: (id: string) => void;
onServerEdit: (id: string) => void;
onServerAdd: () => void;
highlightedServerIds?: string[];
onClearProtocol: () => void;
onReplayProtocol: (id: string) => void;
onTogglePinProtocol: (id: string) => void;
pinnedProtocolIds?: Set<string>;
onRefreshTools: () => void;
toolsPagination: {
paginated: boolean;
canLoadMore: boolean;
loadedPages: number;
onPaginatedChange: (v: boolean) => void;
onLoadMore: () => void;
};
}) => (
InspectorView: (props: InspectorViewProps) => (
<div>
<span data-testid="tool-status">
{props.toolCallState?.status ?? "none"}
{props.tools.toolCallState?.status ?? "none"}
</span>
<span data-testid="task-progress-keys">
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
{Object.keys(props.tasks.progressByTaskId ?? {}).join(",") || "none"}
</span>
<span data-testid="selected-tool">
{props.toolsUi?.selectedToolKey ?? "none"}
{props.tools.toolsUi?.selectedToolKey ?? "none"}
</span>
<span data-testid="tool-search">
{props.tools.toolsUi?.search || "none"}
</span>
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
<span data-testid="selected-prompt">
{props.promptsUi?.selectedPromptName ?? "none"}
{props.prompts.promptsUi?.selectedPromptName ?? "none"}
</span>
<span data-testid="log-filter">
{props.logs.logsUi?.filterText || "none"}
</span>
<span data-testid="log-filter">{props.logsUi?.filterText || "none"}</span>
<span data-testid="prompt-status">
{props.getPromptState?.status ?? "none"}
{props.prompts.getPromptState?.status ?? "none"}
</span>
<span data-testid="resource-status">
{props.readResourceState?.status ?? "none"}
{props.resources.readResourceState?.status ?? "none"}
</span>
<span data-testid="log-level">{props.currentLogLevel}</span>
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
<span data-testid="log-level">{props.logs.currentLogLevel}</span>
<span data-testid="active-tab">{props.shell.activeTab ?? "none"}</span>
<span data-testid="init-result">
{props.initializeResult
? `name:${props.initializeResult.serverInfo.name || "(empty)"}`
{props.connection.initializeResult
? `name:${props.connection.initializeResult.serverInfo.name || "(empty)"}`
: "none"}
</span>
<span data-testid="errored-server">
{props.erroredServerId ?? "none"}
{props.connection.erroredServerId ?? "none"}
</span>
<span data-testid="active-server">{props.activeServer ?? "none"}</span>
<button onClick={() => props.onActiveTabChange("Servers")}>
<span data-testid="active-server">
{props.connection.activeServer ?? "none"}
</span>
<button onClick={() => props.shell.onActiveTabChange("Servers")}>
switch-servers-tab
</button>
<button onClick={() => props.onToggleConnection("A")}>connect</button>
<button onClick={() => props.connection.onToggleConnection("A")}>
connect
</button>
{/* A second target, so a test can drive an A -> B -> A switch (#2095). */}
<button onClick={() => props.onToggleConnection("B")}>connect-b</button>
<button onClick={() => props.onConnectionInfo()}>
<button onClick={() => props.connection.onToggleConnection("B")}>
connect-b
</button>
<button onClick={() => props.servers.onConnectionInfo("A")}>
open-connection-info
</button>
<button
onClick={() =>
props.onToolsUiChange({
formValues: {},
search: "",
...props.toolsUi,
props.tools.onToolsUiChange({
...props.tools.toolsUi,
selectedToolKey: "0:get_acts",
})
}
@ -574,10 +514,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button
onClick={() =>
props.onToolsUiChange({
formValues: {},
search: "",
...props.toolsUi,
props.tools.onToolsUiChange({
...props.tools.toolsUi,
selectedToolKey: "1:other_tool",
})
}
@ -586,9 +524,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button
onClick={() =>
props.onToolsUiChange({
formValues: {},
...props.toolsUi,
props.tools.onToolsUiChange({
...props.tools.toolsUi,
search: "act",
})
}
@ -597,10 +534,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button
onClick={() =>
props.onPromptsUiChange({
argumentValues: {},
search: "",
...props.promptsUi,
props.prompts.onPromptsUiChange({
...props.prompts.promptsUi,
selectedPromptName: "greet",
})
}
@ -609,70 +544,94 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button
onClick={() =>
props.onLogsUiChange({
visibleLevels: {},
...props.logsUi,
props.logs.onLogsUiChange({
...props.logs.logsUi,
filterText: "err",
})
}
>
set-log-filter
</button>
<button onClick={() => props.onCallTool("get_acts", {})}>call</button>
<button onClick={() => props.onCallTool("get_acts", {}, true)}>
<button onClick={() => props.tools.onCallTool("get_acts", {})}>
call
</button>
<button onClick={() => props.tools.onCallTool("get_acts", {}, true)}>
call-as-task
</button>
<button onClick={() => props.onCancelTask("task-1")}>cancel-task</button>
<button onClick={() => props.onCancelToolCall()}>cancel-tool-call</button>
<button onClick={() => props.onClearCompletedTasks()}>
<button onClick={() => props.tasks.onCancelTask("task-1")}>
cancel-task
</button>
<button onClick={() => props.tools.onCancelToolCall?.()}>
cancel-tool-call
</button>
<button onClick={() => props.tasks.onClearCompletedTasks()}>
clear-completed
</button>
<button onClick={() => props.onRefreshTasks()}>refresh-tasks</button>
<button onClick={() => props.onGetPrompt("greet", {})}>get-prompt</button>
<button onClick={() => props.onReadResource("res://x")}>
<button onClick={() => props.tasks.onRefreshTasks()}>
refresh-tasks
</button>
<button onClick={() => props.prompts.onGetPrompt("greet", {})}>
get-prompt
</button>
<button onClick={() => props.resources.onReadResource("res://x")}>
read-resource
</button>
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
<button onClick={() => props.logs.onSetLogLevel("debug")}>
set-level
</button>
<button onClick={() => props.servers.onServerSettings("A")}>
open-settings
</button>
{/* The real server grid (and its Add / Edit controls) lives inside this
mocked view, so the config modal is only reachable through these
callbacks — and the highlight batch only observable through this prop. */}
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
<button onClick={() => props.onServerAdd()}>add-server</button>
<button onClick={() => props.servers.onServerEdit("A")}>
edit-server
</button>
<button onClick={() => props.servers.onServerAdd()}>add-server</button>
<span data-testid="highlighted-servers">
{(props.highlightedServerIds ?? []).join(",") || "none"}
{(props.servers.highlightedServerIds ?? []).join(",") || "none"}
</span>
<span data-testid="pinned-history">
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
{Array.from(props.protocol.pinnedProtocolIds ?? []).join(",")}
</span>
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
<button onClick={() => props.protocol.onTogglePinProtocol("hist-1")}>
toggle-pin
</button>
<button onClick={() => props.onReplayProtocol("hist-1")}>
<button onClick={() => props.protocol.onReplayProtocol("hist-1")}>
replay-history
</button>
<button onClick={() => props.onClearProtocol()}>clear-history</button>
<button onClick={() => props.protocol.onClearProtocol()}>
clear-history
</button>
<span data-testid="tools-paginated">
{String(props.toolsPagination.paginated)}
{String(props.tools.toolsPagination.paginated)}
</span>
<span data-testid="tools-loaded-pages">
{props.toolsPagination.loadedPages}
{props.tools.toolsPagination.loadedPages}
</span>
<button onClick={() => props.toolsPagination.onPaginatedChange(true)}>
<button
onClick={() => props.tools.toolsPagination.onPaginatedChange(true)}
>
paginated-on
</button>
<button onClick={() => props.toolsPagination.onPaginatedChange(false)}>
<button
onClick={() => props.tools.toolsPagination.onPaginatedChange(false)}
>
paginated-off
</button>
<button onClick={() => props.toolsPagination.onLoadMore()}>
<button onClick={() => props.tools.toolsPagination.onLoadMore()}>
load-more-tools
</button>
<button onClick={() => props.onRefreshTools()}>refresh-tools</button>
<button onClick={() => props.tools.onRefreshTools()}>
refresh-tools
</button>
</div>
),
}));
import App from "./App";
import type { InspectorViewProps } from "./components/views/InspectorView/InspectorView";
import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";

View file

@ -64,6 +64,20 @@ import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js";
import { refreshingPersist } from "./lib/refreshingPersist";
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import type {
AppsPanelProps,
ConnectionProps,
ConsolePanelProps,
LogsPanelProps,
NetworkPanelProps,
PromptsPanelProps,
ProtocolPanelProps,
ResourcesPanelProps,
ServerListProps,
ShellProps,
TasksPanelProps,
ToolsPanelProps,
} from "./components/views/InspectorView/types";
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
@ -1469,6 +1483,275 @@ function App() {
[pendingElicitations, inspectorClient],
);
// --- InspectorView prop bundles (#2130) -----------------------------------
//
// The view takes one prop per domain, so the JSX below reads as a component
// tree rather than a wall of ~130 props. Everything the bundles need is
// assembled here: the handlers that used to be multi-line closures declared
// inline in the JSX are named callbacks, and the `void`-discarding wrappers
// carry their justification once rather than at a call site.
const onServerAdd = useCallback(() => {
setHighlightedServerIds([]);
setConfigModal({ mode: "add" });
}, []);
const onServerImportConfig = useCallback(() => {
setHighlightedServerIds([]);
setImportConfigOpen(true);
}, []);
const onServerImportJson = useCallback(() => {
setHighlightedServerIds([]);
setImportJsonOpen(true);
}, []);
const onServerEdit = useCallback((id: string) => {
setConfigModal({ mode: "edit", targetId: id });
}, []);
const onServerClone = useCallback((id: string) => {
setHighlightedServerIds([]);
setConfigModal({ mode: "clone", targetId: id });
}, []);
const onServerRemove = useCallback(
(id: string) => {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
},
[servers],
);
const onServerReorderFromList = useCallback(
(orderedIds: string[]) => {
// reorderServers reverts the optimistic order via an internal refresh()
// and re-throws on failure (409 from a racing external edit, or a network
// error). Surface that to the user so the drag doesn't silently bounce
// back — matching the toast pattern every other mutation here uses.
reorderServers(orderedIds).catch((err: unknown) => {
notifications.show({
title: "Failed to reorder servers",
message: err instanceof Error ? err.message : String(err),
color: "red",
});
});
},
[reorderServers],
);
const openClientSettings = useCallback(() => setClientSettingsOpen(true), []);
const openConnectionInfo = useCallback(
() => setConnectionInfoModalOpen(true),
[],
);
const openServerSettings = useCallback(
(id: string) => setSettingsModalTargetId(id),
[],
);
// The seven wrappers below discard a promise the callee already owns — each
// of these handlers ends in its own `catch` that surfaces the failure as a
// toast or a panel error — and the view's props are synchronous. `void` is
// the explicit discard `@typescript-eslint/no-floating-promises` requires.
const dispatchToggleConnection = useCallback(
(id: string) => {
void onToggleConnection(id);
},
[onToggleConnection],
);
const dispatchDisconnect = useCallback(() => {
void onDisconnect();
}, [onDisconnect]);
const dispatchCallTool = useCallback(
(name: string, args: Record<string, unknown>, runAsTask?: boolean) => {
void onCallTool(name, args, runAsTask);
},
[onCallTool],
);
const dispatchGetPrompt = useCallback(
(name: string, args: Record<string, string>) => {
void onGetPrompt(name, args);
},
[onGetPrompt],
);
const dispatchReadResource = useCallback(
(uri: string) => {
void onReadResource(uri);
},
[onReadResource],
);
const dispatchCancelTask = useCallback(
(taskId: string) => {
void onCancelTask(taskId);
},
[onCancelTask],
);
const dispatchOpenApp = useCallback(
(name: string, args: Record<string, unknown>) => {
void onOpenApp(name, args);
},
[onOpenApp],
);
const shellProps: ShellProps = {
deepLink,
deepLinkStatus,
version: inspectorVersion,
malformedListItems: shownMalformedListItems,
activeTab,
onActiveTabChange: setActiveTab,
onToggleTheme,
onOpenClientSettings: openClientSettings,
};
const connectionProps: ConnectionProps = {
activeServer: activeServerId,
erroredServerId: failedServerId,
connectedServerId,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
protocolEra,
onCompleteArgument,
completionsSupported: capabilities?.completions !== undefined,
onToggleConnection: dispatchToggleConnection,
onDisconnect: dispatchDisconnect,
};
const serverListProps: ServerListProps = {
servers,
serverListWritable,
highlightedServerIds,
onClearHighlight: clearHighlight,
onServerAdd,
onServerImportConfig,
onServerImportJson,
onServerExport,
onConnectionInfo: openConnectionInfo,
onServerSettings: openServerSettings,
onServerEdit,
onServerClone,
onServerRemove,
onServerReorder: onServerReorderFromList,
};
const toolsPanelProps: ToolsPanelProps = {
tools,
excludedTools,
toolsListChanged,
toolsLoadError: toolsPagination.error,
toolsUi: ui.toolsUi,
toolCallState,
toolsPagination: toolsPaginationControls,
serverSupportsTaskToolCalls:
!!capabilities?.tasks?.requests?.tools?.call ||
(inspectorClient?.isTasksExtensionNegotiated() ?? false),
onToolsUiChange,
onCallTool: dispatchCallTool,
onCancelToolCall,
onClearToolResult,
onRefreshTools,
onReadResourceContents,
};
const promptsPanelProps: PromptsPanelProps = {
prompts,
promptsListChanged,
promptsLoadError: promptsPagination.error,
promptsUi: ui.promptsUi,
getPromptState,
promptsPagination: promptsPaginationControls,
onPromptsUiChange: setUi.setPromptsUi,
onGetPrompt: dispatchGetPrompt,
onRefreshPrompts,
};
const resourcesPanelProps: ResourcesPanelProps = {
resources,
resourceTemplates,
subscriptions,
subscriptionStreamState,
subscriptionsSupported: capabilities?.resources?.subscribe === true,
resourcesListChanged,
resourcesLoadError: resourcesPagination.error ?? resourceTemplatesLoadError,
resourcesUi: ui.resourcesUi,
readResourceState: effectiveReadResourceState,
resourcesPagination: resourcesPaginationControls,
onResourcesUiChange: setUi.setResourcesUi,
onReadResource: dispatchReadResource,
onSubscribeResource,
onUnsubscribeResource,
onRefreshResources,
};
const appsPanelProps: AppsPanelProps = {
appsUi: ui.appsUi,
sandboxPath: sandboxUrl,
bridgeFactory: sandboxBridgeFactory,
appRendererRef,
onAppsUiChange: setUi.setAppsUi,
onSelectApp,
onOpenApp: dispatchOpenApp,
onCloseApp,
onAppError,
// The Apps tab is a filtered view of the tools list, so refreshing it is
// refreshing tools.
onRefreshApps: onRefreshTools,
};
const tasksPanelProps: TasksPanelProps = {
tasks,
progressByTaskId,
tasksUi: ui.tasksUi,
onTasksUiChange: setUi.setTasksUi,
onCancelTask: dispatchCancelTask,
onClearCompletedTasks,
onRefreshTasks,
};
const logsPanelProps: LogsPanelProps = {
logs,
logsUi: ui.logsUi,
currentLogLevel,
modernLogLevel,
onSetLogLevel,
onSetModernLogLevel,
onLogsUiChange: setUi.setLogsUi,
onClearLogs,
onExportLogs,
};
const protocolPanelProps: ProtocolPanelProps = {
protocol: protocolEntries,
protocolUi: ui.protocolUi,
pinnedProtocolIds,
onProtocolUiChange: setUi.setProtocolUi,
onClearProtocol,
onExportProtocol,
onClearProtocolSection,
onExportProtocolSection,
onReplayProtocol,
onTogglePinProtocol: togglePinProtocol,
};
const networkPanelProps: NetworkPanelProps = {
network: fetchRequests,
networkUi: ui.networkUi,
onNetworkUiChange: setUi.setNetworkUi,
onClearNetwork,
onExportNetwork,
};
const consolePanelProps: ConsolePanelProps = {
stderrLogs,
consoleUi: ui.consoleUi,
onConsoleUiChange: setUi.setConsoleUi,
onClearConsole,
onExportConsole,
};
return (
<>
<Box>
@ -1484,171 +1767,18 @@ function App() {
</ReAuthBannerBar>
) : null}
<InspectorView
deepLink={deepLink}
deepLinkStatus={deepLinkStatus}
servers={servers}
serverListWritable={serverListWritable}
activeServer={activeServerId}
erroredServerId={failedServerId}
connectedServerId={connectedServerId}
version={inspectorVersion}
connectionStatus={connectionStatus}
connectErrorMessage={connectErrorMessage}
initializeResult={initializeResult}
latencyMs={latencyMs}
tools={tools}
excludedTools={excludedTools}
malformedListItems={shownMalformedListItems}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}
toolsListChanged={toolsListChanged}
promptsListChanged={promptsListChanged}
resourcesListChanged={resourcesListChanged}
toolsLoadError={toolsPagination.error}
promptsLoadError={promptsPagination.error}
resourcesLoadError={
resourcesPagination.error ?? resourceTemplatesLoadError
}
subscriptions={subscriptions}
subscriptionStreamState={subscriptionStreamState}
logs={logs}
tasks={tasks}
progressByTaskId={progressByTaskId}
protocol={protocolEntries}
protocolEra={protocolEra}
network={fetchRequests}
stderrLogs={stderrLogs}
toolCallState={toolCallState}
getPromptState={getPromptState}
readResourceState={effectiveReadResourceState}
toolsUi={ui.toolsUi}
promptsUi={ui.promptsUi}
resourcesUi={ui.resourcesUi}
appsUi={ui.appsUi}
tasksUi={ui.tasksUi}
logsUi={ui.logsUi}
protocolUi={ui.protocolUi}
networkUi={ui.networkUi}
consoleUi={ui.consoleUi}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
currentLogLevel={currentLogLevel}
sandboxPath={sandboxUrl}
bridgeFactory={sandboxBridgeFactory}
appRendererRef={appRendererRef}
onToggleTheme={onToggleTheme}
onOpenClientSettings={() => setClientSettingsOpen(true)}
onToggleConnection={(id) => {
void onToggleConnection(id);
}}
onDisconnect={() => {
void onDisconnect();
}}
onServerAdd={() => {
setHighlightedServerIds([]);
setConfigModal({ mode: "add" });
}}
onServerImportConfig={() => {
setHighlightedServerIds([]);
setImportConfigOpen(true);
}}
onServerImportJson={() => {
setHighlightedServerIds([]);
setImportJsonOpen(true);
}}
onServerExport={onServerExport}
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
onServerSettings={(id) => setSettingsModalTargetId(id)}
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
onServerClone={(id) => {
setHighlightedServerIds([]);
setConfigModal({ mode: "clone", targetId: id });
}}
onServerRemove={(id) => {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
}}
onServerReorder={(orderedIds) => {
// reorderServers reverts the optimistic order via an internal
// refresh() and re-throws on failure (409 from a racing external
// edit, or a network error). Surface that to the user so the drag
// doesn't silently bounce back — matching the toast pattern every
// other mutation here uses.
reorderServers(orderedIds).catch((err: unknown) => {
notifications.show({
title: "Failed to reorder servers",
message: err instanceof Error ? err.message : String(err),
color: "red",
});
});
}}
highlightedServerIds={highlightedServerIds}
onClearHighlight={clearHighlight}
serverSupportsTaskToolCalls={
!!capabilities?.tasks?.requests?.tools?.call ||
(inspectorClient?.isTasksExtensionNegotiated() ?? false)
}
onToolsUiChange={onToolsUiChange}
onCallTool={(name, args, runAsTask) => {
void onCallTool(name, args, runAsTask);
}}
onCancelToolCall={onCancelToolCall}
onClearToolResult={onClearToolResult}
onReadResourceContents={onReadResourceContents}
onRefreshTools={onRefreshTools}
toolsPagination={toolsPaginationControls}
promptsPagination={promptsPaginationControls}
resourcesPagination={resourcesPaginationControls}
onPromptsUiChange={setUi.setPromptsUi}
onGetPrompt={(name, args) => {
void onGetPrompt(name, args);
}}
onRefreshPrompts={onRefreshPrompts}
onResourcesUiChange={setUi.setResourcesUi}
onReadResource={(uri) => {
void onReadResource(uri);
}}
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
subscriptionsSupported={capabilities?.resources?.subscribe === true}
onTasksUiChange={setUi.setTasksUi}
onCancelTask={(taskId) => {
void onCancelTask(taskId);
}}
onClearCompletedTasks={onClearCompletedTasks}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
modernLogLevel={modernLogLevel}
onSetModernLogLevel={onSetModernLogLevel}
onLogsUiChange={setUi.setLogsUi}
onClearLogs={onClearLogs}
onExportLogs={onExportLogs}
onProtocolUiChange={setUi.setProtocolUi}
onClearProtocol={onClearProtocol}
onExportProtocol={onExportProtocol}
onClearProtocolSection={onClearProtocolSection}
onExportProtocolSection={onExportProtocolSection}
onReplayProtocol={onReplayProtocol}
onTogglePinProtocol={togglePinProtocol}
pinnedProtocolIds={pinnedProtocolIds}
onNetworkUiChange={setUi.setNetworkUi}
onClearNetwork={onClearNetwork}
onExportNetwork={onExportNetwork}
onConsoleUiChange={setUi.setConsoleUi}
onClearConsole={onClearConsole}
onExportConsole={onExportConsole}
onAppsUiChange={setUi.setAppsUi}
onSelectApp={onSelectApp}
onOpenApp={(name, args) => {
void onOpenApp(name, args);
}}
onCloseApp={onCloseApp}
onAppError={onAppError}
onRefreshApps={onRefreshTools}
shell={shellProps}
connection={connectionProps}
servers={serverListProps}
tools={toolsPanelProps}
prompts={promptsPanelProps}
resources={resourcesPanelProps}
apps={appsPanelProps}
tasks={tasksPanelProps}
logs={logsPanelProps}
protocol={protocolPanelProps}
network={networkPanelProps}
console={consolePanelProps}
/>
</Box>
<AppElicitationHost

View file

@ -18,6 +18,20 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { expect, fn } from "storybook/test";
import { InspectorView } from "./InspectorView";
import type {
AppsPanelProps,
ConnectionProps,
ConsolePanelProps,
LogsPanelProps,
NetworkPanelProps,
PromptsPanelProps,
ProtocolPanelProps,
ResourcesPanelProps,
ServerListProps,
ShellProps,
TasksPanelProps,
ToolsPanelProps,
} from "./types";
import { noopPagination } from "../../../test/fixtures/pagination";
import {
EMPTY_TOOLS_UI,
@ -324,116 +338,164 @@ const demoInitializeResult: InitializeResult = {
serverInfo: { name: "Local Dev Server", version: "1.2.0" },
};
// One constant per domain bundle, so a story can override a single field
// without replacing its whole bundle — Storybook merges args only at the top
// level, and each of these IS one top-level arg now (#2130).
const shellArgs: ShellProps = {
activeTab: "Servers",
onActiveTabChange: fn(),
onToggleTheme: fn(),
onOpenClientSettings: fn(),
};
// Stories default to "disconnected"; per-story overrides drive the connected /
// error narratives.
const connectionArgs: ConnectionProps = {
activeServer: undefined,
connectionStatus: "disconnected",
initializeResult: undefined,
latencyMs: undefined,
onToggleConnection: fn(),
onDisconnect: fn(),
};
// Callbacks are wired to storybook spies so play functions can assert on
// dispatch. Real wiring routes these to InspectorClient methods (the app shell
// at clients/web/src/App.tsx).
const serversArgs: ServerListProps = {
servers: demoServers,
onServerAdd: fn(),
onServerImportConfig: fn(),
onServerImportJson: fn(),
onServerExport: fn(),
onConnectionInfo: fn(),
onServerSettings: fn(),
onServerEdit: fn(),
onServerClone: fn(),
onServerRemove: fn(),
onServerReorder: fn(),
};
const toolsArgs: ToolsPanelProps = {
tools: demoTools,
toolsListChanged: false,
toolsUi: EMPTY_TOOLS_UI,
toolsPagination: noopPagination,
serverSupportsTaskToolCalls: false,
onToolsUiChange: fn(),
onCallTool: fn(),
onRefreshTools: fn(),
};
const promptsArgs: PromptsPanelProps = {
prompts: demoPrompts,
promptsListChanged: false,
promptsUi: EMPTY_PROMPTS_UI,
promptsPagination: noopPagination,
onPromptsUiChange: fn(),
onGetPrompt: fn(),
onRefreshPrompts: fn(),
};
const resourcesArgs: ResourcesPanelProps = {
resources: demoResources,
resourceTemplates: demoResourceTemplates,
subscriptions: demoSubscriptions,
resourcesListChanged: false,
resourcesUi: EMPTY_RESOURCES_UI,
resourcesPagination: noopPagination,
onResourcesUiChange: fn(),
onReadResource: fn(),
onSubscribeResource: fn(),
onUnsubscribeResource: fn(),
onRefreshResources: fn(),
};
const appsArgs: AppsPanelProps = {
appsUi: EMPTY_APPS_UI,
sandboxPath: "about:blank",
bridgeFactory: noopBridgeFactory,
appRendererRef: { current: null },
onAppsUiChange: fn(),
onSelectApp: fn(),
onOpenApp: fn(),
onCloseApp: fn(),
onAppError: fn(),
onRefreshApps: fn(),
};
const tasksArgs: TasksPanelProps = {
tasks: demoTasks,
progressByTaskId: demoProgressByTaskId,
tasksUi: EMPTY_TASKS_UI,
onTasksUiChange: fn(),
onCancelTask: fn(),
onClearCompletedTasks: fn(),
onRefreshTasks: fn(),
};
const logsArgs: LogsPanelProps = {
logs: demoLogs,
logsUi: EMPTY_LOGS_UI,
currentLogLevel: "info",
onSetLogLevel: fn(),
onLogsUiChange: fn(),
onClearLogs: fn(),
onExportLogs: fn(),
};
const protocolArgs: ProtocolPanelProps = {
protocol: demoHistory,
protocolUi: EMPTY_PROTOCOL_UI,
onProtocolUiChange: fn(),
onClearProtocol: fn(),
onExportProtocol: fn(),
onClearProtocolSection: fn(),
onExportProtocolSection: fn(),
onReplayProtocol: fn(),
onTogglePinProtocol: fn(),
};
const networkArgs: NetworkPanelProps = {
network: demoNetwork,
networkUi: EMPTY_NETWORK_UI,
onNetworkUiChange: fn(),
onClearNetwork: fn(),
onExportNetwork: fn(),
};
const consoleArgs: ConsolePanelProps = {
stderrLogs: demoStderr,
consoleUi: EMPTY_CONSOLE_UI,
onConsoleUiChange: fn(),
onClearConsole: fn(),
onExportConsole: fn(),
};
const meta: Meta<typeof InspectorView> = {
title: "Views/InspectorView",
component: InspectorView,
parameters: { layout: "fullscreen" },
args: {
// Data
servers: demoServers,
tools: demoTools,
prompts: demoPrompts,
resources: demoResources,
resourceTemplates: demoResourceTemplates,
subscriptions: demoSubscriptions,
logs: demoLogs,
tasks: demoTasks,
progressByTaskId: demoProgressByTaskId,
protocol: demoHistory,
network: demoNetwork,
stderrLogs: demoStderr,
// Connection state — stories default to "disconnected"; per-story
// overrides drive the connected / error narratives.
activeServer: undefined,
connectionStatus: "disconnected",
initializeResult: undefined,
latencyMs: undefined,
// Misc state
currentLogLevel: "info",
sandboxPath: "about:blank",
bridgeFactory: noopBridgeFactory,
appRendererRef: { current: null },
// Per-screen UI state (search / filter / selection), one object per screen.
toolsUi: EMPTY_TOOLS_UI,
promptsUi: EMPTY_PROMPTS_UI,
resourcesUi: EMPTY_RESOURCES_UI,
appsUi: EMPTY_APPS_UI,
tasksUi: EMPTY_TASKS_UI,
logsUi: EMPTY_LOGS_UI,
protocolUi: EMPTY_PROTOCOL_UI,
networkUi: EMPTY_NETWORK_UI,
consoleUi: EMPTY_CONSOLE_UI,
// Callbacks — all wired to storybook spies so play functions can assert
// on dispatch. Real wiring routes these to InspectorClient methods (the
// app shell at clients/web/src/App.tsx).
onToggleTheme: fn(),
onOpenClientSettings: fn(),
onToggleConnection: fn(),
onDisconnect: fn(),
onServerAdd: fn(),
onServerImportConfig: fn(),
onServerImportJson: fn(),
onServerExport: fn(),
onConnectionInfo: fn(),
onServerSettings: fn(),
onServerEdit: fn(),
onServerClone: fn(),
onServerRemove: fn(),
onServerReorder: fn(),
serverSupportsTaskToolCalls: false,
onToolsUiChange: fn(),
onCallTool: fn(),
onRefreshTools: fn(),
toolsPagination: noopPagination,
promptsPagination: noopPagination,
resourcesPagination: noopPagination,
onPromptsUiChange: fn(),
onGetPrompt: fn(),
onRefreshPrompts: fn(),
onResourcesUiChange: fn(),
onReadResource: fn(),
onSubscribeResource: fn(),
onUnsubscribeResource: fn(),
onRefreshResources: fn(),
onTasksUiChange: fn(),
onCancelTask: fn(),
onClearCompletedTasks: fn(),
onRefreshTasks: fn(),
onSetLogLevel: fn(),
onLogsUiChange: fn(),
onClearLogs: fn(),
onExportLogs: fn(),
onProtocolUiChange: fn(),
onClearProtocol: fn(),
onExportProtocol: fn(),
onReplayProtocol: fn(),
onTogglePinProtocol: fn(),
onNetworkUiChange: fn(),
onClearNetwork: fn(),
onExportNetwork: fn(),
onConsoleUiChange: fn(),
onClearConsole: fn(),
onExportConsole: fn(),
onAppsUiChange: fn(),
onSelectApp: fn(),
onOpenApp: fn(),
onCloseApp: fn(),
onAppError: fn(),
onRefreshApps: fn(),
activeTab: "Servers",
onActiveTabChange: fn(),
shell: shellArgs,
connection: connectionArgs,
servers: serversArgs,
tools: toolsArgs,
prompts: promptsArgs,
resources: resourcesArgs,
apps: appsArgs,
tasks: tasksArgs,
logs: logsArgs,
protocol: protocolArgs,
network: networkArgs,
console: consoleArgs,
},
render: (args) => {
const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers");
const [activeTab, setActiveTab] = useState(args.shell.activeTab);
return (
<InspectorView
{...args}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
shell={{ ...args.shell, activeTab, onActiveTabChange: setActiveTab }}
/>
);
},
@ -463,7 +525,7 @@ export const Default: Story = {
export const NoServers: Story = {
args: {
servers: [],
servers: { ...serversArgs, servers: [] },
},
};
@ -480,7 +542,7 @@ const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({
export const ManyServers: Story = {
args: {
servers: manyServers,
servers: { ...serversArgs, servers: manyServers },
},
play: async ({ canvasElement }) => {
// Even under enough content to overflow, the shell stays viewport-clamped
@ -509,10 +571,13 @@ export const ManyServers: Story = {
// regression / storybook play function coverage.
export const Connected: Story = {
args: {
activeServer: demoServers[0]!.id,
connectionStatus: "connected",
initializeResult: demoInitializeResult,
latencyMs: 142,
connection: {
...connectionArgs,
activeServer: demoServers[0]!.id,
connectionStatus: "connected",
initializeResult: demoInitializeResult,
latencyMs: 142,
},
},
};
@ -524,10 +589,13 @@ export const ConnectionError: Story = {
// connect-attempt-failure signal that gates the failure column; `network: []`
// (and empty Protocol) keep it a pure stdio failure so only Console is offered.
args: {
activeServer: demoServers[0]!.id,
erroredServerId: demoServers[0]!.id,
connectionStatus: "error",
network: [],
stderrLogs: demoStderr,
connection: {
...connectionArgs,
activeServer: demoServers[0]!.id,
erroredServerId: demoServers[0]!.id,
connectionStatus: "error",
},
network: { ...networkArgs, network: [] },
console: { ...consoleArgs, stderrLogs: demoStderr },
},
};

View file

@ -1,14 +1,4 @@
import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type Ref,
} from "react";
import type { MalformedListItem } from "@inspector/core/mcp";
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
AppShell,
Flex,
@ -18,81 +8,38 @@ import {
type MantineTransition,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import type {
Implementation,
InitializeResult,
LoggingLevel,
Prompt,
ProtocolEra,
ReadResourceResult,
Resource,
ResourceTemplateType as ResourceTemplate,
Task,
Tool,
} from "@modelcontextprotocol/client";
import type {
ConnectionStatus,
ExcludedTool,
FetchRequestEntry,
InspectorResourceSubscription,
MessageEntry,
ResourceSubscriptionStreamState,
ServerEntry,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
import type { Implementation, Tool } from "@modelcontextprotocol/client";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
import { isAppTool } from "@inspector/core/mcp/apps.js";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
import {
ToolsScreen,
type ToolCallState,
type ToolsUiState,
} from "../../screens/ToolsScreen/ToolsScreen";
import {
AppsScreen,
type AppsUiState,
} from "../../screens/AppsScreen/AppsScreen";
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
import { ProtocolScreen } from "../../screens/ProtocolScreen/ProtocolScreen";
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
import { ConsoleScreen } from "../../screens/ConsoleScreen/ConsoleScreen";
import type {
AppRendererHandle,
BridgeFactory,
} from "../../elements/AppRenderer/AppRenderer";
import {
PromptsScreen,
type GetPromptState,
type PromptsUiState,
} from "../../screens/PromptsScreen/PromptsScreen";
import {
ResourcesScreen,
type ReadResourceState,
type ResourcesUiState,
} from "../../screens/ResourcesScreen/ResourcesScreen";
import {
LoggingScreen,
type LogsUiState,
} from "../../screens/LoggingScreen/LoggingScreen";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
import {
TasksScreen,
type TasksUiState,
} from "../../screens/TasksScreen/TasksScreen";
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
import {
ProtocolScreen,
type ProtocolUiState,
} from "../../screens/ProtocolScreen/ProtocolScreen";
import {
NetworkScreen,
type NetworkUiState,
} from "../../screens/NetworkScreen/NetworkScreen";
import {
ConsoleScreen,
type ConsoleUiState,
} from "../../screens/ConsoleScreen/ConsoleScreen";
AppsPanelProps,
ConnectionProps,
ConsolePanelProps,
LogsPanelProps,
NetworkPanelProps,
PromptsPanelProps,
ProtocolPanelProps,
ResourcesPanelProps,
ServerListProps,
ShellProps,
TasksPanelProps,
ToolsPanelProps,
} from "./types";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
@ -360,402 +307,193 @@ const MonitorColumnGroup = Group.withProps({
flex: "0 0 auto",
});
/**
* `InspectorView` takes one prop per domain rather than a wall of ~130 flat
* ones (#2130). Each bundle's shape lives in `./types`; the fields inside keep
* the names they had as flat props, so this is a regrouping rather than a
* rename.
*
* The bundles stop here: the function below destructures each one back into
* the same locals it already used, and the screens beneath receive the same
* individual props they always did.
*/
export interface InspectorViewProps {
/**
* Validated deep-link parameters from the page URL. When present and
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
* app (with `appArgs` as the form values) once the connection is up and the
* app list contains it. The connect itself is driven by the parent.
*/
deepLink?: DeepLink;
/**
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
* on the `connection-status` testid. Distinguishes "no deep link" from
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
* idle, so an automated driver otherwise cannot tell them apart.
*/
deepLinkStatus?: DeepLinkParseStatus;
// Server list (static config; runtime connection state comes from the
// separate fields below and is merged into each card by this component).
servers: ServerEntry[];
/**
* Whether the server list is writable (catalog) or read-only (a `--config`
* session file / ad-hoc launch). When false, the Servers screen hides all
* catalog mutation controls. Defaults to true.
*/
serverListWritable?: boolean;
// Connection state — driven by the parent via `useInspectorClient`.
activeServer?: string;
/**
* Id of the server whose last connection attempt failed (#1621). Its card in
* the Servers screen draws a red border until another server is connected or
* a new connection is attempted. Independent of `activeServer`, which the
* parent clears on the failure's `disconnect` event.
*
* It is also the sole signal that opens the monitoring sidebar onto the
* failure's diagnostics (#2108). That used to be gated on the `"error"`
* connection status, which an OAuth failure never reaches — the parent drives
* that leg and tears the client down itself, settling the session at
* `"disconnected"`. So the parent must set this for *every* connect-attempt
* failure, auth legs included, and clear it as each new attempt starts.
*/
erroredServerId?: string;
/**
* Id of the server that just connected successfully (#1682). Its card draws
* the green highlight border and scrolls into view once the monitoring
* sidebar has opened — the success mirror of `erroredServerId`.
*/
connectedServerId?: string;
/**
* The Inspector build version (root `package.json`), shown at the left of the
* footer row (#1682). Absent on a legacy backend that omits it — the version
* label then renders nothing.
*/
version?: string;
connectionStatus: ConnectionStatus;
/**
* Last connection-level error message (handshake failure, OAuth start
* failure, deep-link automation failure). Surfaced as `data-error-message`
* on the header's `connection-status` testid so an automated driver can read
* *why* a connect failed without scraping a transient toast.
*/
connectErrorMessage?: string;
initializeResult?: InitializeResult;
latencyMs?: number;
// Primitive lists, log streams, task state — all sourced from the
// per-primitive `useManaged*` / `useMessageLog` hooks in the parent.
tools: Tool[];
/** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
* (SEP-2243); shown in the Tools sidebar with the reason (#1632). */
excludedTools?: ExcludedTool[];
/**
* Entries dropped from a list result as malformed, across every list method.
* Each list panel filters for its own and warns about them (#1909).
*/
malformedListItems?: MalformedListItem[];
prompts: Prompt[];
resources: Resource[];
resourceTemplates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
/**
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
* legacy era.
*/
subscriptionStreamState?: ResourceSubscriptionStreamState;
// "List changed since last refresh" flags, sourced from the managed-state
// layer (#1402). They light the per-screen list-changed indicator. Apps is a
// filtered view of tools, so it shares the tools flag.
toolsListChanged: boolean;
promptsListChanged: boolean;
resourcesListChanged: boolean;
// Last list-load failure per screen, sourced from the same managed-state
// layer. Rendered above the sidebar list so a failed load (including the
// connect-time one) can't read as "this server has none" (#1953).
toolsLoadError?: Error | null;
promptsLoadError?: Error | null;
/**
* The Resources sidebar lists resources AND templates behind a single
* Refresh, so App passes whichever of the two loads failed.
*/
resourcesLoadError?: Error | null;
logs: LogEntryData[];
tasks: Task[];
progressByTaskId?: Record<string, TaskProgress>;
protocol: MessageEntry[];
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
protocolEra?: ProtocolEra;
network: FetchRequestEntry[];
/** Captured stdio stderr (the Console screen). Empty for HTTP servers. (#1621) */
stderrLogs: StderrLogEntry[];
// Per-screen "operation in flight" states (panel-level; optional because
// the underlying screens accept them as optional).
toolCallState?: ToolCallState;
getPromptState?: GetPromptState;
readResourceState?: ReadResourceState;
// Per-screen selection / search / filter state, one object per screen. Owned
// by the parent (App) so it persists across tab navigation within a live
// session — the screens unmount on tab switch, so screen-local state would be
// lost (#1417). Each is paired with an `on{Screen}UiChange` setter below.
toolsUi: ToolsUiState;
promptsUi: PromptsUiState;
resourcesUi: ResourcesUiState;
appsUi: AppsUiState;
tasksUi: TasksUiState;
logsUi: LogsUiState;
protocolUi: ProtocolUiState;
networkUi: NetworkUiState;
consoleUi: ConsoleUiState;
/** Active inspector tab (lifted to App for OAuth resume). */
activeTab: string;
onActiveTabChange: (tab: string) => void;
// Logging level. The MCP `logging/setLevel` request has no echo
// notification, so the parent keeps the optimistic current value.
currentLogLevel: LoggingLevel;
// MCP Apps sandbox. The parent's web environment provides the sandbox iframe
// URL (undefined when the sandbox controller is unavailable), the per-app
// bridge factory, and the renderer handle the parent uses to push tool
// input/result into the running app and tear it down.
sandboxPath?: string;
bridgeFactory: BridgeFactory;
appRendererRef: Ref<AppRendererHandle>;
// Protocol pinning. Optional because pin state isn't persisted yet (#1244
// is single-PR; persistence is a separate concern).
pinnedProtocolIds?: Set<string>;
// Theme toggle (lives in the parent so the color scheme can also flow
// into other top-level UI later).
onToggleTheme: () => void;
/** Open install-level client settings (client.json / EMA IdP). */
onOpenClientSettings: () => void;
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
onToggleConnection: (id: string) => void;
onDisconnect: () => void;
// Server list actions.
onServerAdd: () => void;
onServerImportConfig: () => void;
onServerImportJson: () => void;
/** Download the current server list as a canonical `mcp.json` file. */
onServerExport: () => void;
onConnectionInfo: (id: string) => void;
onServerSettings: (id: string) => void;
onServerEdit: (id: string) => void;
onServerClone: (id: string) => void;
onServerRemove: (id: string) => void;
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
onServerReorder: (orderedIds: string[]) => void;
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
highlightedServerIds?: string[];
/** Clears the freshly-added highlight for a server (on click of its card). */
onClearHighlight?: (id: string) => void;
// Per-primitive actions (route to `inspectorClient` methods / hook refresh).
// Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417).
/** Whether the connected server advertises task-augmented tool calls. */
serverSupportsTaskToolCalls: boolean;
onToolsUiChange: (next: ToolsUiState) => void;
onCallTool: (
name: string,
args: Record<string, unknown>,
runAsTask?: boolean,
) => void;
onCancelToolCall?: () => void;
onClearToolResult?: () => void;
onRefreshTools: () => void;
/** Pagination controls for the Tools list (#1721). */
toolsPagination: ListPaginationControlsProps;
/** Pagination controls for the Prompts list (#1721). */
promptsPagination: ListPaginationControlsProps;
/** Pagination controls for the Resources list (#1721). */
resourcesPagination: ListPaginationControlsProps;
/**
* Read-on-demand handler for `resource_link` blocks in a tool result.
* Returns the linked resource's contents so the result panel can inline them.
*/
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
onPromptsUiChange: (next: PromptsUiState) => void;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onCopyPromptMessages?: () => void;
onRefreshPrompts: () => void;
onResourcesUiChange: (next: ResourcesUiState) => void;
onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onRefreshResources: () => void;
onCompleteArgument?: (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
completionsSupported?: boolean;
/**
* Whether the connected server advertises the `resources.subscribe`
* capability. When false, the Resources screen hides the Subscribe/
* Unsubscribe button and the Subscriptions accordion section.
*/
subscriptionsSupported?: boolean;
onTasksUiChange: (next: TasksUiState) => void;
onCancelTask: (taskId: string) => void;
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
onSetLogLevel: (level: LoggingLevel) => void;
/**
* Modern-era per-request log level currently stamped, or `null` when opted
* out (#1629). On modern connections the Logs sidebar shows a per-request
* opt-in control instead of the legacy `logging/setLevel` selector.
*/
modernLogLevel?: LoggingLevel | null;
/** Set (or clear, with `null`) the modern per-request log level. */
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
onLogsUiChange: (next: LogsUiState) => void;
onClearLogs: () => void;
onExportLogs: () => void;
onProtocolUiChange: (next: ProtocolUiState) => void;
onClearProtocol: () => void;
onExportProtocol: () => void;
onClearProtocolSection: (section: "pinned" | "history") => void;
onExportProtocolSection: (section: "pinned" | "history") => void;
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
onTogglePinProtocol: (id: string) => void;
onNetworkUiChange: (next: NetworkUiState) => void;
onClearNetwork: () => void;
onExportNetwork: () => void;
onConsoleUiChange: (next: ConsoleUiState) => void;
onClearConsole: () => void;
onExportConsole: () => void;
onAppsUiChange: (next: AppsUiState) => void;
onSelectApp: (name: string) => void;
onOpenApp: (name: string, args: Record<string, unknown>) => void;
onCloseApp: () => void;
onAppError: (err: Error) => void;
onRefreshApps: () => void;
/** App chrome: active tab, theme/settings entry points, footer, deep link. */
shell: ShellProps;
/** Live connection state plus the capability facts several screens read. */
connection: ConnectionProps;
/** The server catalog and every action on it. */
servers: ServerListProps;
tools: ToolsPanelProps;
prompts: PromptsPanelProps;
resources: ResourcesPanelProps;
apps: AppsPanelProps;
tasks: TasksPanelProps;
logs: LogsPanelProps;
protocol: ProtocolPanelProps;
network: NetworkPanelProps;
console: ConsolePanelProps;
}
export function InspectorView({
deepLink,
deepLinkStatus,
servers: serversInput,
serverListWritable = true,
activeServer,
erroredServerId,
connectedServerId,
version,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
tools,
excludedTools = [],
malformedListItems = [],
prompts,
resources,
resourceTemplates,
toolsListChanged,
promptsListChanged,
resourcesListChanged,
toolsLoadError,
promptsLoadError,
resourcesLoadError,
subscriptions,
subscriptionStreamState,
logs,
tasks,
progressByTaskId,
protocol,
protocolEra,
network,
stderrLogs,
toolCallState,
getPromptState,
readResourceState,
toolsUi,
promptsUi,
resourcesUi,
appsUi,
tasksUi,
logsUi,
protocolUi,
networkUi,
consoleUi,
currentLogLevel,
sandboxPath,
bridgeFactory,
appRendererRef,
pinnedProtocolIds,
onToggleTheme,
onOpenClientSettings,
onToggleConnection,
onDisconnect,
onServerAdd,
onServerImportConfig,
onServerImportJson,
onServerExport,
onConnectionInfo,
onServerSettings,
onServerEdit,
onServerClone,
onServerRemove,
onServerReorder,
highlightedServerIds,
onClearHighlight,
serverSupportsTaskToolCalls,
onToolsUiChange,
onCallTool,
onCancelToolCall,
onClearToolResult,
onReadResourceContents,
onRefreshTools,
toolsPagination,
promptsPagination,
resourcesPagination,
onPromptsUiChange,
onGetPrompt,
onCopyPromptMessages,
onRefreshPrompts,
onResourcesUiChange,
onReadResource,
onSubscribeResource,
onUnsubscribeResource,
onRefreshResources,
onCompleteArgument,
completionsSupported,
subscriptionsSupported,
onTasksUiChange,
onCancelTask,
onClearCompletedTasks,
onRefreshTasks,
onSetLogLevel,
modernLogLevel = null,
onSetModernLogLevel,
onLogsUiChange,
onClearLogs,
onExportLogs,
onProtocolUiChange,
onClearProtocol,
onExportProtocol,
onClearProtocolSection,
onExportProtocolSection,
onReplayProtocol,
onTogglePinProtocol,
onNetworkUiChange,
onClearNetwork,
onExportNetwork,
onConsoleUiChange,
onClearConsole,
onExportConsole,
onAppsUiChange,
onSelectApp,
onOpenApp,
onCloseApp,
onAppError,
onRefreshApps,
activeTab: activeTabProp,
onActiveTabChange,
shell,
connection,
servers: serverList,
tools: toolsPanel,
prompts: promptsPanel,
resources: resourcesPanel,
apps: appsPanel,
tasks: tasksPanel,
logs: logsPanel,
protocol: protocolPanel,
network: networkPanel,
console: consolePanel,
}: InspectorViewProps) {
// Unpack the bundles back into the locals the body already uses, so the
// grouping is confined to the prop boundary.
const {
deepLink,
deepLinkStatus,
version,
malformedListItems = [],
activeTab: activeTabProp,
onActiveTabChange,
onToggleTheme,
onOpenClientSettings,
} = shell;
const {
activeServer,
erroredServerId,
connectedServerId,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
protocolEra,
onCompleteArgument,
completionsSupported,
onToggleConnection,
onDisconnect,
} = connection;
const {
servers: serversInput,
serverListWritable = true,
highlightedServerIds,
onClearHighlight,
onServerAdd,
onServerImportConfig,
onServerImportJson,
onServerExport,
onConnectionInfo,
onServerSettings,
onServerEdit,
onServerClone,
onServerRemove,
onServerReorder,
} = serverList;
const {
tools,
excludedTools = [],
toolsListChanged,
toolsLoadError,
toolsUi,
toolCallState,
toolsPagination,
serverSupportsTaskToolCalls,
onToolsUiChange,
onCallTool,
onCancelToolCall,
onClearToolResult,
onRefreshTools,
onReadResourceContents,
} = toolsPanel;
const {
prompts,
promptsListChanged,
promptsLoadError,
promptsUi,
getPromptState,
promptsPagination,
onPromptsUiChange,
onGetPrompt,
onCopyPromptMessages,
onRefreshPrompts,
} = promptsPanel;
const {
resources,
resourceTemplates,
subscriptions,
subscriptionStreamState,
subscriptionsSupported,
resourcesListChanged,
resourcesLoadError,
resourcesUi,
readResourceState,
resourcesPagination,
onResourcesUiChange,
onReadResource,
onSubscribeResource,
onUnsubscribeResource,
onRefreshResources,
} = resourcesPanel;
const {
appsUi,
sandboxPath,
bridgeFactory,
appRendererRef,
onAppsUiChange,
onSelectApp,
onOpenApp,
onCloseApp,
onAppError,
onRefreshApps,
} = appsPanel;
const {
tasks,
progressByTaskId,
tasksUi,
onTasksUiChange,
onCancelTask,
onClearCompletedTasks,
onRefreshTasks,
} = tasksPanel;
const {
logs,
logsUi,
currentLogLevel,
modernLogLevel = null,
onSetLogLevel,
onSetModernLogLevel,
onLogsUiChange,
onClearLogs,
onExportLogs,
} = logsPanel;
const {
protocol,
protocolUi,
pinnedProtocolIds,
onProtocolUiChange,
onClearProtocol,
onExportProtocol,
onClearProtocolSection,
onExportProtocolSection,
onReplayProtocol,
onTogglePinProtocol,
} = protocolPanel;
const {
network,
networkUi,
onNetworkUiChange,
onClearNetwork,
onExportNetwork,
} = networkPanel;
const {
stderrLogs,
consoleUi,
onConsoleUiChange,
onClearConsole,
onExportConsole,
} = consolePanel;
// UI-only state. Connection state, primitive lists, and all action
// dispatching live in the parent; this component only owns view-local
// toggles (sort direction, list compact). Tab selection is lifted (#1417).

View file

@ -0,0 +1,379 @@
// Domain prop bundles for `InspectorView` (#2130).
//
// The view used to take ~130 flat props, which made App.tsx's JSX a prop wall
// rather than a component tree. Each bundle below groups one domain's data,
// UI state, and actions into a single object, so the call site reads as the
// tree it is and a hook can hand over exactly one bundle.
//
// Two conventions keep the change reviewable and keep it from rippling
// downward:
//
// - **Field names are unchanged.** Every field carries the exact name it had
// as a flat prop, so the bundles are a regrouping, not a rename.
// - **The bundles stop here.** `InspectorView` destructures them back into
// the same locals and passes them down to the screens as before, so
// nothing below the view knows they exist.
import type { Ref } from "react";
import type {
InitializeResult,
LoggingLevel,
Prompt,
ProtocolEra,
ReadResourceResult,
Resource,
ResourceTemplateType as ResourceTemplate,
Task,
Tool,
} from "@modelcontextprotocol/client";
import type { MalformedListItem } from "@inspector/core/mcp";
import type {
ConnectionStatus,
ExcludedTool,
FetchRequestEntry,
InspectorResourceSubscription,
MessageEntry,
ResourceSubscriptionStreamState,
ServerEntry,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
import type {
AppRendererHandle,
BridgeFactory,
} from "../../elements/AppRenderer/AppRenderer";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
import type {
ToolCallState,
ToolsUiState,
} from "../../screens/ToolsScreen/ToolsScreen";
import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen";
import type {
GetPromptState,
PromptsUiState,
} from "../../screens/PromptsScreen/PromptsScreen";
import type {
ReadResourceState,
ResourcesUiState,
} from "../../screens/ResourcesScreen/ResourcesScreen";
import type { LogsUiState } from "../../screens/LoggingScreen/LoggingScreen";
import type { TasksUiState } from "../../screens/TasksScreen/TasksScreen";
import type { ProtocolUiState } from "../../screens/ProtocolScreen/ProtocolScreen";
import type { NetworkUiState } from "../../screens/NetworkScreen/NetworkScreen";
import type { ConsoleUiState } from "../../screens/ConsoleScreen/ConsoleScreen";
/**
* App chrome and the few things that belong to no single screen: the active
* tab, the theme/client-settings entry points, the footer version, the
* deep-link parameters, and the malformed-list-item log that three separate
* screens each filter for their own entries.
*/
export interface ShellProps {
/**
* Validated deep-link parameters from the page URL. When present and
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
* app (with `appArgs` as the form values) once the connection is up and the
* app list contains it. The connect itself is driven by the parent.
*/
deepLink?: DeepLink;
/**
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
* on the `connection-status` testid. Distinguishes "no deep link" from
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
* idle, so an automated driver otherwise cannot tell them apart.
*/
deepLinkStatus?: DeepLinkParseStatus;
/**
* The Inspector build version (root `package.json`), shown at the left of the
* footer row (#1682). Absent on a legacy backend that omits it — the version
* label then renders nothing.
*/
version?: string;
/**
* Entries dropped from a list result as malformed, across every list method.
* Each list panel filters for its own and warns about them (#1909). It spans
* three screens, so it has no single domain owner.
*/
malformedListItems?: MalformedListItem[];
/** Active inspector tab (lifted to App for OAuth resume). */
activeTab: string;
onActiveTabChange: (tab: string) => void;
/**
* Theme toggle (lives in the parent so the color scheme can also flow into
* other top-level UI later).
*/
onToggleTheme: () => void;
/** Open install-level client settings (client.json / EMA IdP). */
onOpenClientSettings: () => void;
}
/**
* Live connection state and the capability facts more than one screen reads.
* Driven by the parent via `useInspectorClient`.
*/
export interface ConnectionProps {
activeServer?: string;
/**
* Id of the server whose last connection attempt failed (#1621). Its card in
* the Servers screen draws a red border until another server is connected or
* a new connection is attempted. Independent of `activeServer`, which the
* parent clears on the failure's `disconnect` event.
*
* It is also the sole signal that opens the monitoring sidebar onto the
* failure's diagnostics (#2108). That used to be gated on the `"error"`
* connection status, which an OAuth failure never reaches — the parent drives
* that leg and tears the client down itself, settling the session at
* `"disconnected"`. So the parent must set this for *every* connect-attempt
* failure, auth legs included, and clear it as each new attempt starts.
*/
erroredServerId?: string;
/**
* Id of the server that just connected successfully (#1682). Its card draws
* the green highlight border and scrolls into view once the monitoring
* sidebar has opened — the success mirror of `erroredServerId`.
*/
connectedServerId?: string;
connectionStatus: ConnectionStatus;
/**
* Last connection-level error message (handshake failure, OAuth start
* failure, deep-link automation failure). Surfaced as `data-error-message`
* on the header's `connection-status` testid so an automated driver can read
* *why* a connect failed without scraping a transient toast.
*/
connectErrorMessage?: string;
initializeResult?: InitializeResult;
latencyMs?: number;
/** Negotiated protocol era (SEP §7.8), for the Protocol view's era badge. */
protocolEra?: ProtocolEra;
/**
* Argument completion, shared by the Prompts and Resources screens — hence
* here rather than in either one.
*/
onCompleteArgument?: (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
completionsSupported?: boolean;
// Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`).
onToggleConnection: (id: string) => void;
onDisconnect: () => void;
}
/**
* The server catalog and every action on it. Static config only — the runtime
* connection state comes from {@link ConnectionProps} and is merged into each
* card by the view.
*/
export interface ServerListProps {
servers: ServerEntry[];
/**
* Whether the server list is writable (catalog) or read-only (a `--config`
* session file / ad-hoc launch). When false, the Servers screen hides all
* catalog mutation controls. Defaults to true.
*/
serverListWritable?: boolean;
/** Ids of freshly-added servers to highlight on the list (first is scrolled to). */
highlightedServerIds?: string[];
/** Clears the freshly-added highlight for a server (on click of its card). */
onClearHighlight?: (id: string) => void;
onServerAdd: () => void;
onServerImportConfig: () => void;
onServerImportJson: () => void;
/** Download the current server list as a canonical `mcp.json` file. */
onServerExport: () => void;
onConnectionInfo: (id: string) => void;
onServerSettings: (id: string) => void;
onServerEdit: (id: string) => void;
onServerClone: (id: string) => void;
onServerRemove: (id: string) => void;
/** Persist a new server ordering (drag-and-drop / keyboard reorder). */
onServerReorder: (orderedIds: string[]) => void;
}
/** The Tools screen: its list, lifted UI state, and actions. */
export interface ToolsPanelProps {
tools: Tool[];
/**
* Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
* (SEP-2243); shown in the Tools sidebar with the reason (#1632).
*/
excludedTools?: ExcludedTool[];
/** "List changed since last refresh", from the managed-state layer (#1402). */
toolsListChanged: boolean;
/**
* Last list-load failure, rendered above the sidebar list so a failed load
* (including the connect-time one) can't read as "this server has none"
* (#1953).
*/
toolsLoadError?: Error | null;
/**
* Lifted selection / search / filter state. Owned by the parent so it
* persists across tab navigation within a live session — the screens unmount
* on tab switch, so screen-local state would be lost (#1417).
*/
toolsUi: ToolsUiState;
/** Panel-level "operation in flight" state. */
toolCallState?: ToolCallState;
/** Pagination controls for the Tools list (#1721). */
toolsPagination: ListPaginationControlsProps;
/** Whether the connected server advertises task-augmented tool calls. */
serverSupportsTaskToolCalls: boolean;
onToolsUiChange: (next: ToolsUiState) => void;
onCallTool: (
name: string,
args: Record<string, unknown>,
runAsTask?: boolean,
) => void;
onCancelToolCall?: () => void;
onClearToolResult?: () => void;
onRefreshTools: () => void;
/**
* Read-on-demand handler for `resource_link` blocks in a tool result.
* Returns the linked resource's contents so the result panel can inline them.
*/
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
}
/** The Prompts screen: its list, lifted UI state, and actions. */
export interface PromptsPanelProps {
prompts: Prompt[];
promptsListChanged: boolean;
promptsLoadError?: Error | null;
promptsUi: PromptsUiState;
getPromptState?: GetPromptState;
/** Pagination controls for the Prompts list (#1721). */
promptsPagination: ListPaginationControlsProps;
onPromptsUiChange: (next: PromptsUiState) => void;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onCopyPromptMessages?: () => void;
onRefreshPrompts: () => void;
}
/** The Resources screen: resources, templates, subscriptions, and actions. */
export interface ResourcesPanelProps {
resources: Resource[];
resourceTemplates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
/**
* Modern-era `subscriptions/listen` stream state (#1630). Drives the
* Resources screen's stream badge/dot; `active: false` (or omitted) on the
* legacy era.
*/
subscriptionStreamState?: ResourceSubscriptionStreamState;
/**
* Whether the connected server advertises the `resources.subscribe`
* capability. When false, the Resources screen hides the Subscribe/
* Unsubscribe button and the Subscriptions accordion section.
*/
subscriptionsSupported?: boolean;
resourcesListChanged: boolean;
/**
* The Resources sidebar lists resources AND templates behind a single
* Refresh, so App passes whichever of the two loads failed.
*/
resourcesLoadError?: Error | null;
resourcesUi: ResourcesUiState;
readResourceState?: ReadResourceState;
/** Pagination controls for the Resources list (#1721). */
resourcesPagination: ListPaginationControlsProps;
onResourcesUiChange: (next: ResourcesUiState) => void;
onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onRefreshResources: () => void;
}
/**
* The Apps screen and its sandbox wiring. The parent's web environment
* provides the sandbox iframe URL (undefined when the sandbox controller is
* unavailable), the per-app bridge factory, and the renderer handle the parent
* uses to push tool input/result into the running app and tear it down.
*/
export interface AppsPanelProps {
appsUi: AppsUiState;
sandboxPath?: string;
bridgeFactory: BridgeFactory;
appRendererRef: Ref<AppRendererHandle>;
onAppsUiChange: (next: AppsUiState) => void;
onSelectApp: (name: string) => void;
onOpenApp: (name: string, args: Record<string, unknown>) => void;
onCloseApp: () => void;
onAppError: (err: Error) => void;
onRefreshApps: () => void;
}
/** The Tasks monitor: the task list, its progress map, and actions. */
export interface TasksPanelProps {
tasks: Task[];
progressByTaskId?: Record<string, TaskProgress>;
tasksUi: TasksUiState;
onTasksUiChange: (next: TasksUiState) => void;
onCancelTask: (taskId: string) => void;
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
}
/** The Logs monitor, including both eras' log-level controls. */
export interface LogsPanelProps {
logs: LogEntryData[];
logsUi: LogsUiState;
/**
* The MCP `logging/setLevel` request has no echo notification, so the parent
* keeps the optimistic current value.
*/
currentLogLevel: LoggingLevel;
/**
* Modern-era per-request log level currently stamped, or `null` when opted
* out (#1629). On modern connections the Logs sidebar shows a per-request
* opt-in control instead of the legacy `logging/setLevel` selector.
*/
modernLogLevel?: LoggingLevel | null;
onSetLogLevel: (level: LoggingLevel) => void;
/** Set (or clear, with `null`) the modern per-request log level. */
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
onLogsUiChange: (next: LogsUiState) => void;
onClearLogs: () => void;
onExportLogs: () => void;
}
/** The Protocol monitor: the message log, pinning, replay, and exports. */
export interface ProtocolPanelProps {
protocol: MessageEntry[];
protocolUi: ProtocolUiState;
/**
* Pinned message ids. Optional because pin state isn't persisted yet (#1244
* is single-PR; persistence is a separate concern).
*/
pinnedProtocolIds?: Set<string>;
onProtocolUiChange: (next: ProtocolUiState) => void;
onClearProtocol: () => void;
onExportProtocol: () => void;
onClearProtocolSection: (section: "pinned" | "history") => void;
onExportProtocolSection: (section: "pinned" | "history") => void;
onReplayProtocol: (id: string, overrideParams?: ReplayParamsOverride) => void;
onTogglePinProtocol: (id: string) => void;
}
/** The Network monitor: captured HTTP traffic and its actions. */
export interface NetworkPanelProps {
network: FetchRequestEntry[];
networkUi: NetworkUiState;
onNetworkUiChange: (next: NetworkUiState) => void;
onClearNetwork: () => void;
onExportNetwork: () => void;
}
/** The Console monitor: captured stdio stderr. Empty for HTTP servers (#1621). */
export interface ConsolePanelProps {
stderrLogs: StderrLogEntry[];
consoleUi: ConsoleUiState;
onConsoleUiChange: (next: ConsoleUiState) => void;
onClearConsole: () => void;
onExportConsole: () => void;
}