From 219298b24d19fd150d74c8154876814390c86370 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 9 Jul 2026 14:06:13 -0400 Subject: [PATCH] feat(web): add Console (stderr) tab; show it on stdio connect failure (#1621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/src/App.test.tsx | 3 + clients/web/src/App.tsx | 25 +++ .../ConsoleScreen/ConsoleScreen.stories.tsx | 87 ++++++++ .../ConsoleScreen/ConsoleScreen.test.tsx | 119 ++++++++++ .../screens/ConsoleScreen/ConsoleScreen.tsx | 203 ++++++++++++++++++ .../src/components/screens/screenUiState.ts | 5 + .../InspectorView/InspectorView.test.tsx | 115 +++++++++- .../views/InspectorView/InspectorView.tsx | 94 ++++++-- clients/web/src/lib/downloadFile.ts | 3 +- clients/web/src/theme/Text.ts | 11 + 10 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx create mode 100644 clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx create mode 100644 clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 7d7e2aba..6257844d 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -227,6 +227,9 @@ vi.mock("@inspector/core/react/useMessageLog.js", () => ({ vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({ useFetchRequestLog: vi.fn(() => ({ fetchRequests: [] })), })); +vi.mock("@inspector/core/react/useStderrLog.js", () => ({ + useStderrLog: vi.fn(() => ({ stderrLogs: [] })), +})); vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({ useSettingsDraft: vi.fn(() => ({ // `draft` is widened so tests can override the return with a populated diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index feb17352..9e8f9a26 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -95,6 +95,7 @@ import { useManagedRequestorTasks } from "@inspector/core/react/useManagedReques import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"; +import { useStderrLog } from "@inspector/core/react/useStderrLog.js"; import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js"; import { useServerListWritable } from "@inspector/core/react/useServerListWritable.js"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; @@ -115,6 +116,7 @@ import { EMPTY_LOGS_UI, EMPTY_HISTORY_UI, EMPTY_NETWORK_UI, + EMPTY_CONSOLE_UI, } from "./components/screens/screenUiState"; import { clearScrollMemory } from "./hooks/useScrollMemory"; import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer"; @@ -751,6 +753,7 @@ function App() { () => new Set(), ); const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI); + const [consoleUi, setConsoleUi] = useState(EMPTY_CONSOLE_UI); const [activeTab, setActiveTab] = useState(INSPECTOR_SERVERS_TAB); const [pendingStepUp, setPendingStepUp] = useState<{ challenge: AuthChallenge; @@ -887,6 +890,7 @@ function App() { ); const { messages } = useMessageLog(messageLogState); const { fetchRequests } = useFetchRequestLog(fetchRequestLogState); + const { stderrLogs } = useStderrLog(stderrLogState); // Surface the otherwise-invisible "response body dropped after rotation" case // (#1390) as a deduped toast that links to this server's Network Log Size @@ -1003,6 +1007,10 @@ function App() { setHistoryUi(EMPTY_HISTORY_UI); setPinnedHistoryIds(new Set()); setNetworkUi(EMPTY_NETWORK_UI); + // Only the search filter resets here; the stderr entries themselves live in + // StderrLogState, which deliberately survives connect/disconnect so a failed + // launch's output stays visible for diagnosis (#1621). + setConsoleUi(EMPTY_CONSOLE_UI); setProgressByTaskId({}); setCurrentLogLevel("info"); setPendingStepUp(null); @@ -3091,6 +3099,18 @@ function App() { ); }, [logs, activeServerId]); + const onClearConsole = useCallback(() => { + stderrLogState?.clearStderrLogs(); + }, [stderrLogState]); + + const onExportConsole = useCallback(() => { + if (stderrLogs.length === 0) return; + downloadJsonFile( + buildExportFilename("console", activeServerId), + JSON.stringify(stderrLogs, null, 2), + ); + }, [stderrLogs, activeServerId]); + // Download the current server list as a canonical mcp.json file. Uses the // in-memory `servers` list (kept in sync with disk by useServers' refresh- // after-mutate flow) so there's no extra HTTP roundtrip. Serialization @@ -3653,6 +3673,7 @@ function App() { progressByTaskId={progressByTaskId} history={messages} network={fetchRequests} + stderrLogs={stderrLogs} toolCallState={toolCallState} getPromptState={getPromptState} readResourceState={effectiveReadResourceState} @@ -3664,6 +3685,7 @@ function App() { logsUi={logsUi} historyUi={historyUi} networkUi={networkUi} + consoleUi={consoleUi} activeTab={activeTab} onActiveTabChange={setActiveTab} currentLogLevel={currentLogLevel} @@ -3765,6 +3787,9 @@ function App() { onNetworkUiChange={setNetworkUi} onClearNetwork={onClearNetwork} onExportNetwork={onExportNetwork} + onConsoleUiChange={setConsoleUi} + onClearConsole={onClearConsole} + onExportConsole={onExportConsole} onAppsUiChange={setAppsUi} onSelectApp={onSelectApp} onOpenApp={(name, args) => { diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx new file mode 100644 index 00000000..85dd5fdb --- /dev/null +++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.stories.tsx @@ -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) { + const [ui, setUi] = useState({ ...EMPTY_CONSOLE_UI, ...args.ui }); + return ; +} + +const meta: Meta = { + 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) => , +}; + +export default meta; +type Story = StoryObj; + +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 "", 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(); + }, +}; diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx new file mode 100644 index 00000000..d31eb4bf --- /dev/null +++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.test.tsx @@ -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 { + 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(); + expect(screen.getByText("first line")).toBeInTheDocument(); + expect(screen.getByText("second BOOM line")).toBeInTheDocument(); + }); + + it("shows the empty state when there are no entries", () => { + renderWithMantine(); + expect(screen.getByText("No console output")).toBeInTheDocument(); + }); + + it("filters entries by the search text in ui", () => { + renderWithMantine( + , + ); + 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(); + 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( + , + ); + // 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( + , + ); + const rendered = screen.getAllByText(/line$/); + expect(rendered[0]).toHaveTextContent("first line"); + }); + + it("orders entries newest-first by default", () => { + renderWithMantine(); + const rendered = screen.getAllByText(/line$/); + expect(rendered[0]).toHaveTextContent("second BOOM line"); + }); + + it("disables Clear and Export when there are no entries", () => { + renderWithMantine(); + 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(); + 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(); + expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull(); + rerender(); + expect( + screen.getByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }); + + it("drops the search sidebar when embedded", () => { + renderWithMantine(); + // 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(); + }); +}); diff --git a/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx new file mode 100644 index 00000000..f29dfe1c --- /dev/null +++ b/clients/web/src/components/screens/ConsoleScreen/ConsoleScreen.tsx @@ -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 ( + + {embedded ? null : ( + + + + Console + + onUiChange({ filterText: e.currentTarget.value }) + } + rightSectionPointerEvents="auto" + rightSection={ + filterText ? ( + onUiChange({ filterText: "" })} + /> + ) : null + } + /> + + + + )} + + + Server Console + + + + + {onPin ? : null} + + + {filteredEntries.length > 0 ? ( + + + {filteredEntries.map((entry, index) => ( + + + {formatTimestamp(entry.timestamp)} + + {entry.message} + + ))} + + + ) : ( + + No console output + + )} + + + ); +} diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts index a0a85f23..abdfaeb7 100644 --- a/clients/web/src/components/screens/screenUiState.ts +++ b/clients/web/src/components/screens/screenUiState.ts @@ -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 }, diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 9879a5c5..02a9fa2a 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -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(); + // A connection failure (→ error) opens it to surface the diagnostics. The + // failed HTTP request is what captured the failure, so Network is present. + rerender( + , + ); 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( - , + , ); - // 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( + , + ); + // 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( + , + ); + 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(); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 74dddfad..07228bd3 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -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..` 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({ 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; 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) => 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]: ( + + ), }; return ( @@ -1111,6 +1173,12 @@ export function InspectorView({ onPin={canPin ? () => pinMonitor(NETWORK_TAB) : undefined} /> + + pinMonitor(CONSOLE_TAB) : undefined} + /> +