feat(web): add Console (stderr) tab; show it on stdio connect failure (#1621)

A failed stdio launch had no useful diagnostic surface: the message log
(History) is cleared on the error transition and there are no HTTP
requests (Network), so the auto-opened monitoring column was empty. The
process's stderr — which explains the failure (e.g. "No module named
mcp_server_time") — was captured in StderrLogState but never displayed.

- New ConsoleScreen renders the captured stderr stream (search / sort /
  clear / export; embeddable in the monitoring column), wired through App
  via the existing useStderrLog hook + StderrLogState.
- Console is a pinnable monitor tab like Logs/History/Network, gated to
  stdio servers (HTTP has no child process). It lives in the header menu
  when the column isn't pinned, and moves into the column when it is.
- The failure column now picks its tabs by which diagnostic actually
  captured content (stderr → Console, fetch requests → Network), not by
  the active server's transport: a connect failure fires the client
  `disconnect` event, which clears `activeServer`, so transport can't be
  read back there. The memo re-runs as stderr/fetch entries stream in, so
  the diagnostic tab appears as soon as the failing process prints.

Verified live against a failing `python -m mcp_server_time` stdio server:
the column auto-opens to Console showing the real ModuleNotFoundError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-09 14:06:13 -04:00
commit 219298b24d
6 changed files with 643 additions and 22 deletions

View file

@ -0,0 +1,87 @@
import { useState } from "react";
import type { ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type { StderrLogEntry } from "../../../../../../core/mcp/types.js";
import { ConsoleScreen } from "./ConsoleScreen";
import { EMPTY_CONSOLE_UI } from "../screenUiState";
// ConsoleScreen is controlled (the search text lives in the parent as `ui` —
// see #1417). This wrapper holds that state so the play-driven search actually
// filters entries, mirroring how App owns the state.
function StatefulConsoleScreen(args: ComponentProps<typeof ConsoleScreen>) {
const [ui, setUi] = useState({ ...EMPTY_CONSOLE_UI, ...args.ui });
return <ConsoleScreen {...args} ui={ui} onUiChange={setUi} />;
}
const meta: Meta<typeof ConsoleScreen> = {
title: "Screens/ConsoleScreen",
component: ConsoleScreen,
parameters: { layout: "fullscreen" },
args: {
onClear: fn(),
onExport: fn(),
ui: EMPTY_CONSOLE_UI,
onUiChange: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
},
render: (args) => <StatefulConsoleScreen {...args} />,
};
export default meta;
type Story = StoryObj<typeof ConsoleScreen>;
const sampleEntries: StderrLogEntry[] = [
{
timestamp: new Date("2026-03-17T10:00:00Z"),
message: "Starting MCP time server…",
},
{
timestamp: new Date("2026-03-17T10:00:01Z"),
message:
'Traceback (most recent call last):\n File "<frozen runpy>", line 198, in _run_module_as_main',
},
{
timestamp: new Date("2026-03-17T10:00:01Z"),
message: "ModuleNotFoundError: No module named 'mcp_server_time'",
},
];
export const WithEntries: Story = {
args: {
entries: sampleEntries,
},
};
export const Empty: Story = {
args: {
entries: [],
},
};
export const FilterBySearch: Story = {
args: {
entries: sampleEntries,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Both the startup line and the error are visible initially.
await expect(
canvas.getByText("Starting MCP time server…"),
).toBeInTheDocument();
// Searching for the error text hides the unrelated startup line.
await userEvent.type(
canvas.getByPlaceholderText("Search..."),
"ModuleNotFound",
);
await expect(
canvas.queryByText("Starting MCP time server…"),
).not.toBeInTheDocument();
await expect(
canvas.getByText(
"ModuleNotFoundError: No module named 'mcp_server_time'",
),
).toBeInTheDocument();
},
};

View file

@ -0,0 +1,119 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { StderrLogEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ConsoleScreen, type ConsoleScreenProps } from "./ConsoleScreen";
import { EMPTY_CONSOLE_UI } from "../screenUiState";
const entries: StderrLogEntry[] = [
{ timestamp: new Date("2026-03-17T10:00:00Z"), message: "first line" },
{ timestamp: new Date("2026-03-17T10:00:01Z"), message: "second BOOM line" },
];
function makeProps(
overrides: Partial<ConsoleScreenProps> = {},
): ConsoleScreenProps {
return {
entries,
ui: EMPTY_CONSOLE_UI,
onUiChange: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
sortDirection: "newest-first",
onSortChange: vi.fn(),
...overrides,
};
}
describe("ConsoleScreen", () => {
it("renders each stderr entry's message", () => {
renderWithMantine(<ConsoleScreen {...makeProps()} />);
expect(screen.getByText("first line")).toBeInTheDocument();
expect(screen.getByText("second BOOM line")).toBeInTheDocument();
});
it("shows the empty state when there are no entries", () => {
renderWithMantine(<ConsoleScreen {...makeProps({ entries: [] })} />);
expect(screen.getByText("No console output")).toBeInTheDocument();
});
it("filters entries by the search text in ui", () => {
renderWithMantine(
<ConsoleScreen {...makeProps({ ui: { filterText: "boom" } })} />,
);
expect(screen.queryByText("first line")).toBeNull();
expect(screen.getByText("second BOOM line")).toBeInTheDocument();
});
it("dispatches onUiChange when typing in the search box", async () => {
const onUiChange = vi.fn();
const user = userEvent.setup();
renderWithMantine(<ConsoleScreen {...makeProps({ onUiChange })} />);
await user.type(screen.getByPlaceholderText("Search..."), "x");
expect(onUiChange).toHaveBeenCalledWith({ filterText: "x" });
});
it("clears the search text via the clear button", async () => {
const onUiChange = vi.fn();
const user = userEvent.setup();
renderWithMantine(
<ConsoleScreen
{...makeProps({ ui: { filterText: "boom" }, onUiChange })}
/>,
);
// Two controls share the accessible name "Clear": the search-box clear
// ActionIcon (rendered first, in the sidebar) and the panel's Clear button.
// The sidebar one is the search reset.
const [clearSearch] = screen.getAllByRole("button", { name: "Clear" });
await user.click(clearSearch);
expect(onUiChange).toHaveBeenCalledWith({ filterText: "" });
});
it("orders entries oldest-first when requested", () => {
renderWithMantine(
<ConsoleScreen {...makeProps({ sortDirection: "oldest-first" })} />,
);
const rendered = screen.getAllByText(/line$/);
expect(rendered[0]).toHaveTextContent("first line");
});
it("orders entries newest-first by default", () => {
renderWithMantine(<ConsoleScreen {...makeProps()} />);
const rendered = screen.getAllByText(/line$/);
expect(rendered[0]).toHaveTextContent("second BOOM line");
});
it("disables Clear and Export when there are no entries", () => {
renderWithMantine(<ConsoleScreen {...makeProps({ entries: [] })} />);
expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
});
it("invokes onClear and onExport", async () => {
const onClear = vi.fn();
const onExport = vi.fn();
const user = userEvent.setup();
renderWithMantine(<ConsoleScreen {...makeProps({ onClear, onExport })} />);
await user.click(screen.getByRole("button", { name: "Clear" }));
await user.click(screen.getByRole("button", { name: "Export" }));
expect(onClear).toHaveBeenCalledOnce();
expect(onExport).toHaveBeenCalledOnce();
});
it("shows a pin button only when onPin is provided", () => {
const { rerender } = renderWithMantine(<ConsoleScreen {...makeProps()} />);
expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
rerender(<ConsoleScreen {...makeProps({ onPin: vi.fn() })} />);
expect(
screen.getByRole("button", { name: "Pin as column" }),
).toBeInTheDocument();
});
it("drops the search sidebar when embedded", () => {
renderWithMantine(<ConsoleScreen {...makeProps({ embedded: true })} />);
// The embedded column has no in-screen search box (the column supplies it).
expect(screen.queryByPlaceholderText("Search...")).toBeNull();
// Entries still render.
expect(screen.getByText("first line")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,203 @@
import { useMemo } from "react";
import {
Button,
Card,
Flex,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { StderrLogEntry } from "@inspector/core/mcp/types.js";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface ConsoleScreenProps {
entries: StderrLogEntry[];
ui: ConsoleUiState;
onUiChange: (next: ConsoleUiState) => void;
onClear: () => void;
onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/** See LoggingScreen: shows a "pin as column" button when set (#1616). */
onPin?: () => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
// Just the search text — stderr has no levels/categories to filter, so this is
// the whole of the Console screen's lifted UI state. Controlled by the parent
// (App) so it survives tab navigation within a live session (#1417).
export interface ConsoleUiState {
filterText: string;
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
const Sidebar = Stack.withProps({
w: 340,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
});
const PanelContainer = Paper.withProps({
withBorder: true,
p: "lg",
flex: 1,
variant: "panel",
});
const EmptyCenter = Stack.withProps({
flex: 1,
align: "center",
justify: "center",
});
// A single stderr line: a dimmed monospace timestamp beside the process's raw
// output. `align: flex-start` keeps the timestamp on the first wrapped row.
const EntryRow = Group.withProps({
gap: "sm",
wrap: "nowrap",
align: "flex-start",
});
const TimestampText = Text.withProps({
size: "sm",
ff: "monospace",
c: "dimmed",
flex: "0 0 auto",
});
// `consoleLine` (theme variant) preserves the process's own newlines/whitespace
// while wrapping over-long lines inside the narrow column.
const MessageText = Text.withProps({
size: "sm",
ff: "monospace",
variant: "consoleLine",
flex: 1,
});
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString();
}
function matchesFilter(entry: StderrLogEntry, filterText: string): boolean {
if (!filterText) return true;
return entry.message.toLowerCase().includes(filterText.toLowerCase());
}
export function ConsoleScreen({
entries,
ui,
onUiChange,
onClear,
onExport,
sortDirection,
onSortChange,
onPin,
embedded = false,
}: ConsoleScreenProps) {
const { filterText } = ui;
const viewportRef = useScrollMemory("console-stream");
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const sorted = entries
.filter((e) => matchesFilter(e, filterText))
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, filterText, sortDirection]);
return (
<ScreenLayout h={embedded ? "100%" : undefined}>
{embedded ? null : (
<Sidebar>
<SidebarCard>
<Stack gap="md">
<Title order={4}>Console</Title>
<TextInput
placeholder="Search..."
value={filterText}
onChange={(e) =>
onUiChange({ filterText: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
filterText ? (
<ClearButton
onClick={() => onUiChange({ filterText: "" })}
/>
) : null
}
/>
</Stack>
</SidebarCard>
</Sidebar>
)}
<PanelContainer>
<Group justify="space-between" mb="sm">
<Title order={4}>Server Console</Title>
<Group>
<SortToggle
value={sortDirection}
onChange={onSortChange}
aria-label="Console sort direction"
/>
<Button
variant="default"
onClick={onClear}
disabled={entries.length === 0}
>
Clear
</Button>
<Button
variant="default"
onClick={onExport}
disabled={entries.length === 0}
>
Export
</Button>
{onPin ? <PinColumnButton onPin={onPin} /> : null}
</Group>
</Group>
{filteredEntries.length > 0 ? (
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
<Stack gap="xs">
{filteredEntries.map((entry, index) => (
<EntryRow key={index}>
<TimestampText>
{formatTimestamp(entry.timestamp)}
</TimestampText>
<MessageText>{entry.message}</MessageText>
</EntryRow>
))}
</Stack>
</EmbeddableScrollArea>
) : (
<EmptyCenter>
<Text c="dimmed">No console output</Text>
</EmptyCenter>
)}
</PanelContainer>
</ScreenLayout>
);
}

View file

@ -11,6 +11,7 @@ import type { TasksUiState } from "./TasksScreen/TasksScreen";
import type { LogsUiState } from "./LoggingScreen/LoggingScreen";
import type { HistoryUiState } from "./HistoryScreen/HistoryScreen";
import type { NetworkUiState } from "./NetworkScreen/NetworkScreen";
import type { ConsoleUiState } from "./ConsoleScreen/ConsoleScreen";
import { ALL_LEVELS_VISIBLE } from "./LoggingScreen/logLevels";
import { ALL_CATEGORIES_VISIBLE } from "./NetworkScreen/fetchCategories";
@ -63,6 +64,10 @@ export const EMPTY_NETWORK_UI: NetworkUiState = {
visibleCategories: ALL_CATEGORIES_VISIBLE,
};
export const EMPTY_CONSOLE_UI: ConsoleUiState = {
filterText: "",
};
/** Registry for OAuth resume snapshot restore (data-only; setters live in App). */
export const TAB_UI_REGISTRY = {
Apps: { empty: EMPTY_APPS_UI },

View file

@ -44,6 +44,7 @@ import {
EMPTY_LOGS_UI,
EMPTY_HISTORY_UI,
EMPTY_NETWORK_UI,
EMPTY_CONSOLE_UI,
} from "../../screens/screenUiState";
// Stub bridge factory — AppsScreen mounts the inner iframe and invokes
@ -84,6 +85,7 @@ function makeProps(
tasks: [],
history: [],
network: [],
stderrLogs: [],
currentLogLevel: "info",
sandboxPath: "about:blank",
bridgeFactory: noopBridgeFactory,
@ -96,6 +98,7 @@ function makeProps(
logsUi: EMPTY_LOGS_UI,
historyUi: EMPTY_HISTORY_UI,
networkUi: EMPTY_NETWORK_UI,
consoleUi: EMPTY_CONSOLE_UI,
onToggleTheme: vi.fn(),
onOpenClientSettings: vi.fn(),
onToggleConnection: vi.fn(),
@ -140,6 +143,9 @@ function makeProps(
onNetworkUiChange: vi.fn(),
onClearNetwork: vi.fn(),
onExportNetwork: vi.fn(),
onConsoleUiChange: vi.fn(),
onClearConsole: vi.fn(),
onExportConsole: vi.fn(),
onAppsUiChange: vi.fn(),
onSelectApp: vi.fn(),
onOpenApp: vi.fn(),
@ -1404,22 +1410,41 @@ describe("InspectorView", () => {
screen.queryByRole("button", { name: "Close monitoring column" }),
).toBeNull();
// A connection failure (→ error) opens it to surface the diagnostics.
rerender(<StatefulInspectorViewHost {...failedHttp()} />);
// A connection failure (→ error) opens it to surface the diagnostics. The
// failed HTTP request is what captured the failure, so Network is present.
rerender(
<StatefulInspectorViewHost
{...failedHttp({
network: [
{
id: "f1",
timestamp: new Date(),
method: "POST",
url: "http://localhost:3000/mcp",
requestHeaders: {},
error: "fetch failed: ECONNREFUSED",
category: "transport",
},
],
})}
/>,
);
expect(
await screen.findByRole("button", { name: "Close monitoring column" }),
).toBeInTheDocument();
// The failure column offers the History + Network diagnostics, but not
// Logs (a failed connect never negotiated the logging capability).
// The failure column offers History + Network (the captured request), but
// not Logs (a failed connect never negotiated the logging capability) nor
// Console (an HTTP transport has no child-process stderr).
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
expect(
screen.getByRole("radio", { name: "Network" }),
).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Logs" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Console" })).toBeNull();
});
it("omits Network from the failure column for a stdio server (#1621)", async () => {
it("surfaces Console (stderr), not Network, in the failure column for a stdio server (#1621)", async () => {
monitorWide.value = true;
const stdioErr: ServerEntry = {
id: "beta",
@ -1437,13 +1462,30 @@ describe("InspectorView", () => {
/>,
);
rerender(
<StatefulInspectorViewHost {...failedHttp({ servers: [stdioErr] })} />,
<StatefulInspectorViewHost
{...failedHttp({
servers: [stdioErr],
// A connect failure fires the client `disconnect` event, which
// clears activeServer — so the failure column must key off captured
// stderr, NOT the (now-undefined) active server's transport.
activeServer: undefined,
stderrLogs: [
{ timestamp: new Date(), message: "ModuleNotFoundError: boom" },
],
})}
/>,
);
// stdio has no HTTP traffic, so the failure column shows History only.
// The captured stderr means it was a stdio launch: the column surfaces
// Console (the process's stderr) and defaults to it — that's where the
// spawn error is — with no Network (no HTTP traffic). The stderr renders.
expect(
await screen.findByRole("radio", { name: "History" }),
await screen.findByRole("radio", { name: "Console" }),
).toBeChecked();
expect(
screen.getByRole("radio", { name: "History" }),
).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
expect(screen.getByText("ModuleNotFoundError: boom")).toBeInTheDocument();
});
it("does not auto-open on a mount that starts already errored (#1621)", () => {
@ -1456,6 +1498,63 @@ describe("InspectorView", () => {
).toBeNull();
});
const stdioServer: ServerEntry = {
id: "gamma",
name: "Gamma",
config: { type: "stdio", command: "echo" },
connection: { status: "connected" },
};
it("offers Console (not Network) as a monitor tab for a connected stdio server (#1621)", async () => {
monitorWide.value = true;
window.localStorage.setItem("inspector.monitor.pinned", "true");
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [stdioServer],
activeServer: "gamma",
connectionStatus: "connected",
initializeResult: httpInit,
stderrLogs: [{ timestamp: new Date(), message: "server booting" }],
})}
/>,
);
// Pinned column for a stdio server hosts Logs/History/Console — never
// Network (no HTTP traffic to show).
expect(
await screen.findByRole("radio", { name: "Console" }),
).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: "History" }),
).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
});
it("lists Console in the header tab menu for a connected stdio server when unpinned (#1621)", async () => {
// Column not pinned, so the monitor group (incl. Console) lives in the
// header menu — the user can reach the stderr stream during a live
// session, not only on failure.
monitorWide.value = true;
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [stdioServer],
activeServer: "gamma",
connectionStatus: "connected",
initializeResult: httpInit,
})}
/>,
);
const header = screen.getByRole("banner");
expect(
within(header).getByRole("radio", { name: "Console" }),
).toBeInTheDocument();
// stdio: Network is not in the header menu.
expect(
within(header).queryByRole("radio", { name: "Network" }),
).toBeNull();
});
it("pins the monitor group into the column and removes it from the header", async () => {
monitorWide.value = true;
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);

View file

@ -32,6 +32,7 @@ import type {
InspectorResourceSubscription,
MessageEntry,
ServerEntry,
StderrLogEntry,
} from "@inspector/core/mcp/types.js";
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
import { isAppTool } from "@inspector/core/mcp/apps.js";
@ -78,6 +79,10 @@ import {
NetworkScreen,
type NetworkUiState,
} from "../../screens/NetworkScreen/NetworkScreen";
import {
ConsoleScreen,
type ConsoleUiState,
} from "../../screens/ConsoleScreen/ConsoleScreen";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
@ -120,7 +125,7 @@ function serializeListCompact(value: boolean): string {
// SPA only, no SSR — so the persisted value lands without a one-frame
// flicker through the default. The `inspector.<kind>.<scope>` namespace
// keeps related preferences grouped and easy to clear in bulk.
function useSortDirection(scope: "logs" | "history" | "network") {
function useSortDirection(scope: "logs" | "history" | "network" | "console") {
return useLocalStorage<SortDirection>({
key: `inspector.sortDirection.${scope}`,
defaultValue: SORT_DEFAULT,
@ -147,6 +152,7 @@ const SERVERS_TAB = INSPECTOR_SERVERS_TAB;
const LOGS_TAB = "Logs";
const HISTORY_TAB = "History";
const NETWORK_TAB = "Network";
const CONSOLE_TAB = "Console";
const ALL_TABS: string[] = [
SERVERS_TAB,
@ -158,16 +164,29 @@ const ALL_TABS: string[] = [
LOGS_TAB,
HISTORY_TAB,
NETWORK_TAB,
CONSOLE_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];
// the header and hosts them in the column instead. Console (#1621) is the
// stdio server's stderr stream — mutually exclusive with Network (Console shows
// for stdio, Network for HTTP), but both live in the monitor group.
type MonitorTab = "Logs" | "History" | "Network" | "Console";
const MONITOR_TABS: string[] = [
LOGS_TAB,
HISTORY_TAB,
NETWORK_TAB,
CONSOLE_TAB,
];
function isMonitorTab(tab: string): tab is MonitorTab {
return tab === LOGS_TAB || tab === HISTORY_TAB || tab === NETWORK_TAB;
return (
tab === LOGS_TAB ||
tab === HISTORY_TAB ||
tab === NETWORK_TAB ||
tab === CONSOLE_TAB
);
}
// The viewport width below which the split collapses to a single column: matches
@ -351,6 +370,8 @@ export interface InspectorViewProps {
progressByTaskId?: Record<string, TaskProgress>;
history: MessageEntry[];
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).
@ -370,6 +391,7 @@ export interface InspectorViewProps {
logsUi: LogsUiState;
historyUi: HistoryUiState;
networkUi: NetworkUiState;
consoleUi: ConsoleUiState;
/** Active inspector tab (lifted to App for OAuth resume). */
activeTab: string;
@ -486,6 +508,10 @@ export interface InspectorViewProps {
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;
@ -514,6 +540,7 @@ export function InspectorView({
progressByTaskId,
history,
network,
stderrLogs,
toolCallState,
getPromptState,
readResourceState,
@ -525,6 +552,7 @@ export function InspectorView({
logsUi,
historyUi,
networkUi,
consoleUi,
currentLogLevel,
sandboxPath,
bridgeFactory,
@ -583,6 +611,9 @@ export function InspectorView({
onNetworkUiChange,
onClearNetwork,
onExportNetwork,
onConsoleUiChange,
onClearConsole,
onExportConsole,
onAppsUiChange,
onSelectApp,
onOpenApp,
@ -599,6 +630,7 @@ export function InspectorView({
const [logsSort, setLogsSort] = useSortDirection("logs");
const [historySort, setHistorySort] = useSortDirection("history");
const [networkSort, setNetworkSort] = useSortDirection("network");
const [consoleSort, setConsoleSort] = useSortDirection("console");
// Servers and Resources default to expanded (collapsed=false) so new
// users see content on first paint; History/Network default to
@ -729,6 +761,9 @@ export function InspectorView({
const hasLogging = capabilities?.logging !== undefined;
return ALL_TABS.filter((t) => {
if (t === NETWORK_TAB && isStdio) return false;
// Console is the stdio process's stderr stream — shown only for stdio
// servers (HTTP transports have no child process to capture). (#1621)
if (t === CONSOLE_TAB && !isStdio) return false;
if (t === "Tools" && !hasTools) return false;
if (t === "Apps" && !hasApps) return false;
if (t === "Prompts" && !hasPrompts) return false;
@ -761,16 +796,27 @@ export function InspectorView({
if (connected) return availableTabs.filter((t) => MONITOR_TABS.includes(t));
if (failed) {
// A failed connect never negotiated capabilities, so Logs (gated on the
// server's `logging` capability) isn't meaningful. History (the local
// client-side message log) always captured the attempt, and Network (HTTP
// requests) did too for non-stdio transports — those are what explain the
// failure, so offer exactly them.
const active = serversInput.find((s) => s.id === activeServer);
const isStdio = active ? getServerType(active.config) === "stdio" : false;
return isStdio ? [HISTORY_TAB] : [HISTORY_TAB, NETWORK_TAB];
// server's `logging` capability) isn't meaningful. What explains the
// failure is whichever diagnostic actually captured something:
// • stdio → the process's stderr (Console): the spawn/startup error the
// child printed before dying.
// • HTTP → the failed requests (Network).
// We key off *captured content*, not the server's declared transport,
// because a connect failure fires the client `disconnect` event, which
// clears `activeServer` — so transport can't be read back reliably here.
// History is always offered, though a pre-handshake failure usually
// leaves it empty. Console leads (it's the stdio failure's story); Network
// trails History (matching the connected order). This memo re-runs as
// stderr/fetch entries stream in, so the diagnostic tab appears as soon as
// the failing process/request produces output.
const tabs: string[] = [];
if (stderrLogs.length > 0) tabs.push(CONSOLE_TAB);
tabs.push(HISTORY_TAB);
if (network.length > 0) tabs.push(NETWORK_TAB);
return tabs;
}
return [];
}, [connected, failed, availableTabs, serversInput, activeServer]);
}, [connected, failed, availableTabs, stderrLogs, network]);
const effectivePinned =
monitorPinned &&
!!isWide &&
@ -933,6 +979,15 @@ export function InspectorView({
compact: networkCompact,
onToggleCompact: () => setNetworkCompact((c) => !c),
};
const consoleScreenProps = {
entries: stderrLogs,
ui: consoleUi,
onUiChange: onConsoleUiChange,
onClear: onClearConsole,
onExport: onExportConsole,
sortDirection: consoleSort,
onSortChange: setConsoleSort,
};
// Embedded instances for the pinned column, keyed by tab. MonitoringScreen
// renders only the active one; the rest are unmounted element values. Each
@ -960,6 +1015,13 @@ export function InspectorView({
embedded
/>
),
[CONSOLE_TAB]: (
<ConsoleScreen
{...consoleScreenProps}
ui={{ filterText: monitorSearch }}
embedded
/>
),
};
return (
@ -1111,6 +1173,12 @@ export function InspectorView({
onPin={canPin ? () => pinMonitor(NETWORK_TAB) : undefined}
/>
</ScreenStage>
<ScreenStage active={activeTab === CONSOLE_TAB}>
<ConsoleScreen
{...consoleScreenProps}
onPin={canPin ? () => pinMonitor(CONSOLE_TAB) : undefined}
/>
</ScreenStage>
</ScreenStageContainer>
<Transition
mounted={effectivePinned}