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:
parent
85153c6232
commit
219298b24d
6 changed files with 643 additions and 22 deletions
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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()} />);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue