diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index f8c2bcd35..086174f10 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -24,6 +24,10 @@ vi.mock("@mantine/notifications", () => ({ notifications: notificationsMock, })); +// Shared spy for MessageLogState.clearMessages so a test can inspect the +// predicate the panel Clear passes (keep-pinned vs clear-all). +const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() })); + // App is a wiring component: it owns session-scoped UI state (the per-call // result panels and the optimistic log level) and resets it when the active // InspectorClient emits `disconnect`. These tests exercise that reset in @@ -52,6 +56,14 @@ vi.mock("@inspector/core/mcp/index.js", () => { .fn() .mockResolvedValue({ result: { contents: [] }, timestamp: 1 }); setLoggingLevel = vi.fn().mockResolvedValue(undefined); + listTools = vi.fn().mockResolvedValue({ tools: [] }); + listPrompts = vi.fn().mockResolvedValue({ prompts: [] }); + listResources = vi.fn().mockResolvedValue({ resources: [] }); + listResourceTemplates = vi + .fn() + .mockResolvedValue({ resourceTemplates: [] }); + listRequestorTasks = vi.fn().mockResolvedValue({ tasks: [] }); + ping = vi.fn().mockResolvedValue(undefined); getOAuthState = vi.fn().mockReturnValue(undefined); getPendingSamples = vi.fn().mockReturnValue([]); getPendingElicitations = vi.fn().mockReturnValue([]); @@ -104,7 +116,7 @@ vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({ })); vi.mock("@inspector/core/mcp/state/messageLogState.js", () => ({ MessageLogState: vi.fn(function () { - return { destroy: vi.fn() }; + return { destroy: vi.fn(), clearMessages: messageLogClear }; }), })); vi.mock("@inspector/core/mcp/state/fetchRequestLogState.js", () => ({ @@ -250,6 +262,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ onClearCompletedTasks: () => void; onRefreshTasks: () => void; onServerSettings: (id: string) => void; + onClearHistory: () => void; + onReplayHistory: (id: string) => void; + onTogglePinHistory: (id: string) => void; + pinnedHistoryIds?: Set; }) => (
@@ -347,6 +363,16 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ + + {Array.from(props.pinnedHistoryIds ?? []).join(",")} + + + +
), })); @@ -354,9 +380,13 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ import App from "./App"; import * as McpIndex from "@inspector/core/mcp/index.js"; import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; +import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js"; -import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import type { + InspectorServerSettings, + MessageEntry, +} from "@inspector/core/mcp/types.js"; // Default useInspectorClient return — capabilities empty (no task tool calls). // Individual tests override via vi.mocked(...).mockReturnValue(...). @@ -1007,3 +1037,242 @@ describe("App roots live-apply on settings-dialog close", () => { expect(client.setRoots).not.toHaveBeenCalled(); }); }); + +describe("App history pin/replay", () => { + const replayableEntry: MessageEntry = { + id: "hist-1", + timestamp: new Date("2026-06-06T22:00:00Z"), + direction: "request", + message: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "get_acts", arguments: { city: "SF" } }, + }, + }; + + beforeEach(() => { + clientInstances.length = 0; + notificationsMock.show.mockClear(); + vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT); + vi.mocked(useMessageLog).mockReturnValue({ messages: [] }); + }); + + it("toggles a pinned history id and passes the set down to the view", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + expect(screen.getByTestId("pinned-history")).toHaveTextContent(""); + await user.click(screen.getByText("toggle-pin")); + expect(screen.getByTestId("pinned-history")).toHaveTextContent("hist-1"); + await user.click(screen.getByText("toggle-pin")); + expect(screen.getByTestId("pinned-history")).toHaveTextContent(""); + }); + + it("panel Clear removes unpinned history but keeps pinned entries", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("toggle-pin")); + expect(screen.getByTestId("pinned-history")).toHaveTextContent("hist-1"); + + messageLogClear.mockClear(); + await user.click(screen.getByText("clear-history")); + + expect(messageLogClear).toHaveBeenCalledTimes(1); + const predicate = messageLogClear.mock.calls[0][0] as (m: { + id: string; + }) => boolean; + // The predicate is "should remove?" — pinned survives (false), unpinned is + // removed (true). + expect(predicate({ id: "hist-1" })).toBe(false); + expect(predicate({ id: "other" })).toBe(true); + }); + + it("replays a tools/call entry by re-issuing callTool with the recorded args", async () => { + vi.mocked(useMessageLog).mockReturnValue({ messages: [replayableEntry] }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + const client = clientInstances[0] as unknown as { + callTool: ReturnType; + }; + await waitFor(() => expect(client.callTool).toHaveBeenCalledTimes(1)); + expect(client.callTool.mock.calls[0][1]).toEqual({ city: "SF" }); + }); + + it("replays a tools/list entry via listTools, preserving the cursor", async () => { + vi.mocked(useMessageLog).mockReturnValue({ + messages: [ + { + ...replayableEntry, + message: { + jsonrpc: "2.0", + id: 6, + method: "tools/list", + params: { cursor: "page-2" }, + }, + }, + ], + }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + const client = clientInstances[0] as unknown as { + listTools: ReturnType; + }; + await waitFor(() => + expect(client.listTools).toHaveBeenCalledWith("page-2"), + ); + }); + + it("replays a tasks/list entry via listRequestorTasks", async () => { + vi.mocked(useMessageLog).mockReturnValue({ + messages: [ + { + ...replayableEntry, + message: { jsonrpc: "2.0", id: 7, method: "tasks/list" }, + }, + ], + }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + const client = clientInstances[0] as unknown as { + listRequestorTasks: ReturnType; + }; + await waitFor(() => + expect(client.listRequestorTasks).toHaveBeenCalledTimes(1), + ); + }); + + it("toasts when replaying an unsupported method", async () => { + vi.mocked(useMessageLog).mockReturnValue({ + messages: [ + { + ...replayableEntry, + message: { + jsonrpc: "2.0", + id: 2, + method: "logging/setLevel", + params: { level: "debug" }, + }, + }, + ], + }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + await waitFor(() => + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ title: "Can't replay", color: "yellow" }), + ), + ); + }); + + it("replays a prompts/get entry via getPrompt", async () => { + vi.mocked(useMessageLog).mockReturnValue({ + messages: [ + { + ...replayableEntry, + message: { + jsonrpc: "2.0", + id: 3, + method: "prompts/get", + params: { name: "greet", arguments: { who: "x" } }, + }, + }, + ], + }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + const client = clientInstances[0] as unknown as { + getPrompt: ReturnType; + }; + await waitFor(() => + expect(client.getPrompt).toHaveBeenCalledWith("greet", { who: "x" }), + ); + }); + + it("replays a resources/read entry via readResource", async () => { + vi.mocked(useMessageLog).mockReturnValue({ + messages: [ + { + ...replayableEntry, + message: { + jsonrpc: "2.0", + id: 4, + method: "resources/read", + params: { uri: "res://x" }, + }, + }, + ], + }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + const client = clientInstances[0] as unknown as { + readResource: ReturnType; + }; + await waitFor(() => + expect(client.readResource).toHaveBeenCalledWith("res://x"), + ); + }); + + it("toasts when the replayed tool is no longer available", async () => { + vi.mocked(useMessageLog).mockReturnValue({ + messages: [ + { + ...replayableEntry, + message: { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "gone", arguments: {} }, + }, + }, + ], + }); + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("replay-history")); + + await waitFor(() => + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ title: "Can't replay", color: "yellow" }), + ), + ); + }); +}); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index c8b84f80b..093d6222f 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -103,6 +103,7 @@ import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/Ser import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal"; import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal"; import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal"; +import { isReplayableHistoryMethod } from "./components/groups/historyUtils.js"; import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent"; import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal"; import { @@ -193,6 +194,77 @@ function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] { return out; } +// Re-issue the original request behind a History entry. The call goes through +// InspectorClient → tracked transport → message log, so the replayed +// request+response surface as a fresh History entry (history-local) — it +// intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a +// human-readable reason when the entry can't be replayed (unsupported method, +// or a tool that's no longer present), or null on a dispatched replay. +async function replayHistoryRequest( + client: InspectorClient, + method: string, + params: Record | undefined, + tools: Tool[], +): Promise { + // Gate on the shared replayable-method set (the same one HistoryEntry uses to + // show/hide the Replay button) so the two can't drift. + if (!isReplayableHistoryMethod(method)) { + return `Replay isn't supported for "${method}".`; + } + // Pagination cursor carried by the */list requests; replaying the same page + // reproduces the original call. + const cursor = typeof params?.cursor === "string" ? params.cursor : undefined; + switch (method) { + case "tools/call": { + const name = typeof params?.name === "string" ? params.name : undefined; + const tool = tools.find((t) => t.name === name); + if (!tool) { + return `Tool "${name ?? "?"}" is no longer available to replay.`; + } + await client.callTool( + tool, + (params?.arguments ?? {}) as Record, + ); + return null; + } + case "prompts/get": { + const name = typeof params?.name === "string" ? params.name : undefined; + if (!name) return "Prompt name is missing; cannot replay."; + await client.getPrompt( + name, + (params?.arguments ?? {}) as Record, + ); + return null; + } + case "resources/read": { + const uri = typeof params?.uri === "string" ? params.uri : undefined; + if (!uri) return "Resource URI is missing; cannot replay."; + await client.readResource(uri); + return null; + } + case "tools/list": + await client.listTools(cursor); + return null; + case "prompts/list": + await client.listPrompts(cursor); + return null; + case "resources/list": + await client.listResources(cursor); + return null; + case "resources/templates/list": + await client.listResourceTemplates(cursor); + return null; + case "tasks/list": + await client.listRequestorTasks(cursor); + return null; + case "ping": + await client.ping(); + return null; + default: + return `Replay isn't supported for "${method}".`; + } +} + // Stable empty-shell for `InspectorServerSettings`. Used both as the // initial draft for a server entry that hasn't been touched yet, and as // the fallback the settings modal renders against when it's closed @@ -482,6 +554,12 @@ function App() { const [tasksUi, setTasksUi] = useState(EMPTY_TASKS_UI); const [logsUi, setLogsUi] = useState(EMPTY_LOGS_UI); const [historyUi, setHistoryUi] = useState(EMPTY_HISTORY_UI); + // History entries the user pinned (by entry id). Session-scoped — the ids + // reference message-log entries, which clear on disconnect, so this resets + // with the rest of the per-screen state. + const [pinnedHistoryIds, setPinnedHistoryIds] = useState>( + () => new Set(), + ); const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI); // Handshake telemetry. `connectStartRef` is set at the "connecting" edge @@ -606,6 +684,7 @@ function App() { setTasksUi(EMPTY_TASKS_UI); setLogsUi(EMPTY_LOGS_UI); setHistoryUi(EMPTY_HISTORY_UI); + setPinnedHistoryIds(new Set()); setNetworkUi(EMPTY_NETWORK_UI); setProgressByTaskId({}); setCurrentLogLevel("info"); @@ -1612,9 +1691,13 @@ function App() { ); }, [messageLogState]); + // Panel-level Clear clears the (unpinned) history and keeps pinned entries — + // pinning is the way to protect an entry from Clear. This matches the button's + // `disabled={unpinnedEntries.length === 0}` gating and the per-section model, + // and leaves pinnedHistoryIds valid (the pins it references still exist). const onClearHistory = useCallback(() => { - messageLogState?.clearMessages(); - }, [messageLogState]); + messageLogState?.clearMessages((m) => !pinnedHistoryIds.has(m.id)); + }, [messageLogState, pinnedHistoryIds]); const onClearNetwork = useCallback(() => { fetchRequestLogState?.clearFetchRequests(); @@ -1636,6 +1719,88 @@ function App() { ); }, [messages, activeServerId]); + // Clear just one section: remove its entries from the log by pin membership. + // Clearing the pinned section also drops the (now-stale) pinned id set. + const onClearHistorySection = useCallback( + (section: "pinned" | "history") => { + const isPinned = section === "pinned"; + messageLogState?.clearMessages((m) => + isPinned ? pinnedHistoryIds.has(m.id) : !pinnedHistoryIds.has(m.id), + ); + if (isPinned) setPinnedHistoryIds(new Set()); + }, + [messageLogState, pinnedHistoryIds], + ); + + // Export just one section's entries (by pin membership) to a JSON file. + const onExportHistorySection = useCallback( + (section: "pinned" | "history") => { + const isPinned = section === "pinned"; + const subset = messages.filter((m) => + isPinned ? pinnedHistoryIds.has(m.id) : !pinnedHistoryIds.has(m.id), + ); + if (subset.length === 0) return; + downloadJsonFile( + buildExportFilename( + isPinned ? "history-pinned" : "history-unpinned", + activeServerId, + ), + JSON.stringify(subset, null, 2), + ); + }, + [messages, pinnedHistoryIds, activeServerId], + ); + + // Pin/unpin a history entry by id. HistoryListPanel sorts pinned entries to + // the top; the set is session-scoped (see resetSessionScopedUiState). + const onTogglePinHistory = useCallback((id: string) => { + setPinnedHistoryIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + // Replay a history entry: re-issue its original request so the fresh + // request+response appear as a new History entry (history-local). A reason + // string (unsupported method / missing tool) surfaces as a toast; a genuine + // call error already shows up as the replayed entry's Error status, so only a + // pre-flight failure (nothing logged) needs the fallback toast. + const onReplayHistory = useCallback( + (id: string) => { + if (!inspectorClient) return; + const entry = messages.find((m) => m.id === id); + if (!entry || !("method" in entry.message)) return; + const { method } = entry.message; + const params = + "params" in entry.message + ? (entry.message.params as Record | undefined) + : undefined; + void replayHistoryRequest(inspectorClient, method, params, tools) + .then((reason) => { + if (reason) { + notifications.show({ + title: "Can't replay", + message: reason, + color: "yellow", + }); + } + }) + .catch((err: unknown) => { + notifications.show({ + title: "Replay failed", + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }); + }, + [inspectorClient, messages, tools], + ); + const onExportLogs = useCallback(() => { if (logs.length === 0) return; downloadJsonFile( @@ -1994,8 +2159,11 @@ function App() { onHistoryUiChange={setHistoryUi} onClearHistory={onClearHistory} onExportHistory={onExportHistory} - onReplayHistory={todoNoop} - onTogglePinHistory={todoNoop} + onClearHistorySection={onClearHistorySection} + onExportHistorySection={onExportHistorySection} + onReplayHistory={onReplayHistory} + onTogglePinHistory={onTogglePinHistory} + pinnedHistoryIds={pinnedHistoryIds} onNetworkUiChange={setNetworkUi} onClearNetwork={onClearNetwork} onExportNetwork={onExportNetwork} diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx new file mode 100644 index 000000000..df4054c8c --- /dev/null +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { ExpandToggle } from "./ExpandToggle"; + +const meta: Meta = { + title: "Elements/ExpandToggle", + component: ExpandToggle, + args: { onToggle: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Collapsed: Story = { + args: { expanded: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Expand" })).toBeInTheDocument(); + }, +}; + +export const Expanded: Story = { + args: { expanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "Collapse" }), + ).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx new file mode 100644 index 000000000..01e6c2bc4 --- /dev/null +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ExpandToggle } from "./ExpandToggle"; + +describe("ExpandToggle", () => { + it("labels itself Expand when collapsed", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument(); + }); + + it("labels itself Collapse when expanded", () => { + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Collapse" }), + ).toBeInTheDocument(); + }); + + it("invokes onToggle when clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Expand" })); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx new file mode 100644 index 000000000..458caf6e3 --- /dev/null +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx @@ -0,0 +1,31 @@ +import { ActionIcon } from "@mantine/core"; +import { RiCollapseVerticalLine, RiExpandVerticalLine } from "react-icons/ri"; + +export interface ExpandToggleProps { + /** Whether the owning entry is currently expanded. */ + expanded: boolean; + onToggle: () => void; +} + +/** + * Icon toggle for a per-entry expand/collapse control (History, Network, and + * Task cards). Uses the same expand/collapse-vertical icons as the list-level + * ListToggle: collapsed shows the expand icon, expanded shows the collapse + * icon. The aria-label stays "Expand"/"Collapse" so it reads the same as the + * text button it replaced. + */ +export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) { + const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine; + const label = expanded ? "Collapse" : "Expand"; + return ( + + + + ); +} diff --git a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx new file mode 100644 index 000000000..51a34ff82 --- /dev/null +++ b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { MessageDirectionBadge } from "./MessageDirectionBadge"; + +const meta: Meta = { + title: "Elements/MessageDirectionBadge", + component: MessageDirectionBadge, +}; + +export default meta; +type Story = StoryObj; + +export const Outgoing: Story = { + args: { direction: "outgoing" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("client → server")).toBeInTheDocument(); + }, +}; + +export const Incoming: Story = { + args: { direction: "incoming" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("client ← server")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.test.tsx b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.test.tsx new file mode 100644 index 000000000..6c297daac --- /dev/null +++ b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MessageDirectionBadge } from "./MessageDirectionBadge"; + +describe("MessageDirectionBadge", () => { + it("renders client → server for outgoing", () => { + renderWithMantine(); + expect(screen.getByText("client → server")).toBeInTheDocument(); + }); + + it("renders client ← server for incoming", () => { + renderWithMantine(); + expect(screen.getByText("client ← server")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx new file mode 100644 index 000000000..5478296e3 --- /dev/null +++ b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx @@ -0,0 +1,34 @@ +import { Badge } from "@mantine/core"; + +export interface MessageDirectionBadgeProps { + /** + * Direction of travel for the entry: "outgoing" = the inspector sent it to + * the server (client → server); "incoming" = the server sent it to the + * inspector (client ← server). + */ + direction: "outgoing" | "incoming"; +} + +const LABEL: Record = { + outgoing: "client → server", + incoming: "client ← server", +}; + +/** + * Dual-state badge showing which way a History/Network entry traveled. Outgoing + * (client → server) is green; incoming (client ← server) is purple — not yellow, + * which (paired with green) reads as caution/ok status rather than direction. + * Shared by `HistoryEntry` and `NetworkEntry`. + */ +export function MessageDirectionBadge({ + direction, +}: MessageDirectionBadgeProps) { + return ( + + {LABEL[direction]} + + ); +} diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx new file mode 100644 index 000000000..d259eb84f --- /dev/null +++ b/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { PinToggle } from "./PinToggle"; + +const meta: Meta = { + title: "Elements/PinToggle", + component: PinToggle, + args: { onToggle: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Unpinned: Story = { + args: { pinned: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Pin" })).toBeInTheDocument(); + }, +}; + +export const Pinned: Story = { + args: { pinned: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Unpin" })).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.test.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.test.tsx new file mode 100644 index 000000000..ec557642c --- /dev/null +++ b/clients/web/src/components/elements/PinToggle/PinToggle.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { PinToggle } from "./PinToggle"; + +describe("PinToggle", () => { + it("labels itself Pin when not pinned", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument(); + }); + + it("labels itself Unpin when pinned", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Unpin" })).toBeInTheDocument(); + }); + + it("invokes onToggle when clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin" })); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.tsx new file mode 100644 index 000000000..62beaa426 --- /dev/null +++ b/clients/web/src/components/elements/PinToggle/PinToggle.tsx @@ -0,0 +1,29 @@ +import { ActionIcon } from "@mantine/core"; +import { TiPin, TiPinOutline } from "react-icons/ti"; + +export interface PinToggleProps { + /** Whether the owning entry is currently pinned. */ + pinned: boolean; + onToggle: () => void; +} + +/** + * Icon toggle for pinning an entry. Unpinned shows an outline pin; pinned shows + * a filled pin. The aria-label stays "Pin"/"Unpin" so it reads the same as the + * text button it replaces. + */ +export function PinToggle({ pinned, onToggle }: PinToggleProps) { + const Icon = pinned ? TiPin : TiPinOutline; + const label = pinned ? "Unpin" : "Pin"; + return ( + + + + ); +} diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx index 938e31d52..09d7177bb 100644 --- a/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.stories.tsx @@ -20,8 +20,11 @@ const meta: Meta = { args: { searchText: "", availableMethods: SAMPLE_METHODS, + visibleDirections: { client: true, server: true }, onSearchChange: fn(), onMethodFilterChange: fn(), + onToggleDirection: fn(), + onToggleAllDirections: fn(), }, }; diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx index 0d9beb4c2..8b26a064c 100644 --- a/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.test.tsx @@ -7,8 +7,11 @@ import { HistoryControls } from "./HistoryControls"; const baseProps = { searchText: "", availableMethods: ["tools/list", "prompts/list"] as MessageMethod[], + visibleDirections: { client: true, server: true }, onSearchChange: vi.fn(), onMethodFilterChange: vi.fn(), + onToggleDirection: vi.fn(), + onToggleAllDirections: vi.fn(), }; describe("HistoryControls", () => { @@ -58,4 +61,40 @@ describe("HistoryControls", () => { await user.click(clearButton!); expect(onMethodFilterChange).toHaveBeenCalledWith(undefined); }); + + it("renders the Filter by Message Direction section with both directions", () => { + renderWithMantine(); + expect(screen.getByText("Filter by Message Direction")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "client → server" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "client ← server" }), + ).toBeInTheDocument(); + }); + + it("toggles a direction's visibility when its button is clicked", async () => { + const user = userEvent.setup(); + const onToggleDirection = vi.fn(); + renderWithMantine( + , + ); + // Currently visible → clicking turns it off. + await user.click(screen.getByRole("button", { name: "client ← server" })); + expect(onToggleDirection).toHaveBeenCalledWith("server", false); + }); + + it("invokes onToggleAllDirections from the Deselect/Select All control", async () => { + const user = userEvent.setup(); + const onToggleAllDirections = vi.fn(); + renderWithMantine( + , + ); + // Both visible → the control reads "Deselect All". + await user.click(screen.getByRole("button", { name: "Deselect All" })); + expect(onToggleAllDirections).toHaveBeenCalledTimes(1); + }); }); diff --git a/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx index 32ba944d4..7dee30b4c 100644 --- a/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx +++ b/clients/web/src/components/groups/HistoryControls/HistoryControls.tsx @@ -1,20 +1,30 @@ import { Select, Stack, TextInput, Title } from "@mantine/core"; -import type { MessageMethod } from "@inspector/core/mcp/types.js"; +import type { + MessageMethod, + MessageOrigin, +} from "@inspector/core/mcp/types.js"; +import { MessageDirectionFilter } from "../MessageDirectionFilter/MessageDirectionFilter"; export interface HistoryControlsProps { searchText: string; methodFilter?: MessageMethod; availableMethods: MessageMethod[]; + visibleDirections: Record; onSearchChange: (text: string) => void; onMethodFilterChange: (method: MessageMethod | undefined) => void; + onToggleDirection: (direction: MessageOrigin, visible: boolean) => void; + onToggleAllDirections: () => void; } export function HistoryControls({ searchText, methodFilter, availableMethods, + visibleDirections, onSearchChange, onMethodFilterChange, + onToggleDirection, + onToggleAllDirections, }: HistoryControlsProps) { return ( @@ -35,6 +45,12 @@ export function HistoryControls({ } clearable /> + + ); } diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx index 637b4c77e..2febd1897 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.stories.tsx @@ -19,6 +19,7 @@ const toolCallEntry: MessageEntry = { id: "req-1", timestamp: new Date("2026-03-17T10:30:00Z"), direction: "request", + origin: "client", message: { jsonrpc: "2.0", id: 1, @@ -39,6 +40,7 @@ const errorEntry: MessageEntry = { id: "req-2", timestamp: new Date("2026-03-17T10:31:15Z"), direction: "request", + origin: "client", message: { jsonrpc: "2.0", id: 2, @@ -57,6 +59,7 @@ const resourceReadEntry: MessageEntry = { id: "req-3", timestamp: new Date("2026-03-17T10:33:00Z"), direction: "request", + origin: "client", message: { jsonrpc: "2.0", id: 3, @@ -77,6 +80,7 @@ const pendingEntry: MessageEntry = { id: "req-4", timestamp: new Date("2026-03-17T10:34:00Z"), direction: "request", + origin: "client", message: { jsonrpc: "2.0", id: 4, diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx index 2383052e5..6ed5e7699 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx @@ -8,6 +8,7 @@ const successEntry: MessageEntry = { id: "req-1", timestamp: new Date("2026-03-17T10:30:00Z"), direction: "request", + origin: "client", message: { jsonrpc: "2.0", id: 1, @@ -90,6 +91,22 @@ const noParamsEntry: MessageEntry = { }, }; +const notificationEntry: MessageEntry = { + id: "note-1", + timestamp: new Date("2026-03-17T10:36:00Z"), + direction: "notification", + origin: "server", + message: { + jsonrpc: "2.0", + method: "notifications/message", + params: { + level: "info", + logger: "everything-server", + data: "Roots updated: 2 root(s) received from client", + }, + }, +}; + const baseProps = { isPinned: false, isListExpanded: false, @@ -124,6 +141,30 @@ describe("HistoryEntry", () => { expect(screen.getByText("Pending")).toBeInTheDocument(); }); + it("renders no request-style status badge for a notification", () => { + renderWithMantine( + , + ); + // The method badge still labels it; there is no Pending/OK/Error badge, + // since a fire-and-forget notification has no request lifecycle. + expect(screen.getByText("notifications/message")).toBeInTheDocument(); + expect(screen.queryByText("Pending")).not.toBeInTheDocument(); + expect(screen.queryByText("OK")).not.toBeInTheDocument(); + expect(screen.queryByText("Error")).not.toBeInTheDocument(); + }); + + it("shows client → server for a client-originated entry", () => { + renderWithMantine(); + expect(screen.getByText("client → server")).toBeInTheDocument(); + }); + + it("shows client ← server for a server-originated entry", () => { + renderWithMantine( + , + ); + expect(screen.getByText("client ← server")).toBeInTheDocument(); + }); + it("renders Pin label when not pinned", () => { renderWithMantine(); expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument(); @@ -146,6 +187,27 @@ describe("HistoryEntry", () => { expect(onReplay).toHaveBeenCalledTimes(1); }); + it("hides the Replay button for a method that can't be replayed", () => { + // A notification isn't a replayable client→server request. + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Replay" }), + ).not.toBeInTheDocument(); + // Pin stays available. + expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument(); + }); + + it("orders the actions Replay, then Pin, then the expand toggle on the right", () => { + renderWithMantine(); + const names = screen + .getAllByRole("button") + .map((b) => b.getAttribute("aria-label") ?? b.textContent); + expect(names.indexOf("Replay")).toBeLessThan(names.indexOf("Pin")); + expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand")); + }); + it("invokes onTogglePin when Pin button is clicked", async () => { const user = userEvent.setup(); const onTogglePin = vi.fn(); diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx index 0d795f947..3a1599b58 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx @@ -11,7 +11,10 @@ import { } from "@mantine/core"; import type { MessageEntry } from "../../../../../../core/mcp/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -import { extractMethod } from "../historyUtils.js"; +import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; +import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; +import { PinToggle } from "../../elements/PinToggle/PinToggle"; +import { extractMethod, isReplayableHistoryMethod } from "../historyUtils.js"; export interface HistoryEntryProps { entry: MessageEntry; @@ -60,10 +63,6 @@ function formatTimestamp(date: Date): string { return date.toISOString(); } -function formatPinLabel(isPinned: boolean): string { - return isPinned ? "Unpin" : "Pin"; -} - function extractTarget(entry: MessageEntry): string | undefined { const msg = entry.message; if (!("params" in msg) || !msg.params) return undefined; @@ -73,7 +72,15 @@ function extractTarget(entry: MessageEntry): string | undefined { return undefined; } -function extractStatus(entry: MessageEntry): "success" | "error" | "pending" { +// The pending → OK/Error lifecycle only applies to requests: messageLogState +// attaches a `response` to request entries by JSON-RPC id. A notification is +// fire-and-forget (no id, no response, ever) and an unmatched standalone +// response has none either — so those carry no request-style status ("none") +// and render no badge, rather than a misleading permanent "Pending". +function extractStatus( + entry: MessageEntry, +): "success" | "error" | "pending" | "none" { + if (entry.direction !== "request") return "none"; if (!entry.response) return "pending"; if ("error" in entry.response) return "error"; return "success"; @@ -106,6 +113,7 @@ export function HistoryEntry({ const method = extractMethod(entry); const target = extractTarget(entry); const status = extractStatus(entry); + const canReplay = isReplayableHistoryMethod(method); useEffect(() => { setIsExpanded(isListExpanded); @@ -116,6 +124,11 @@ export function HistoryEntry({ + {entry.origin && ( + + )} {formatTimestamp(entry.timestamp)} {method} {target && {target}} @@ -124,18 +137,25 @@ export function HistoryEntry({ {entry.duration != null && ( {formatDuration(entry.duration)} )} - {statusLabel(status)} + {status !== "none" && ( + {statusLabel(status)} + )} - - Replay - - {formatPinLabel(isPinned)} - - setIsExpanded((v) => !v)} ml="auto"> - {isExpanded ? "Collapse" : "Expand"} - + + + {canReplay && ( + Replay + )} + + + + setIsExpanded((v) => !v)} + /> + diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx index 0e9a2aa7a..e14dc035c 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx @@ -11,6 +11,8 @@ const meta: Meta = { pinnedIds: new Set(), onClearAll: fn(), onExport: fn(), + onClearSection: fn(), + onExportSection: fn(), onReplay: fn(), onTogglePin: fn(), sortDirection: "newest-first", diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx index a96077240..05684e9fc 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx @@ -62,8 +62,11 @@ const sampleEntries: MessageEntry[] = [ const baseProps = { pinnedIds: new Set(), searchText: "", + visibleDirections: { client: true, server: true }, onClearAll: vi.fn(), onExport: vi.fn(), + onClearSection: vi.fn(), + onExportSection: vi.fn(), onReplay: vi.fn(), onTogglePin: vi.fn(), sortDirection: "newest-first" as const, @@ -114,6 +117,139 @@ describe("HistoryListPanel", () => { expect(screen.getByText("History (2)")).toBeInTheDocument(); }); + it("toggles a section's expanded state when its header is clicked", async () => { + const user = userEvent.setup(); + // Both sections present so the headers are collapsible toggles. + renderWithMantine( + , + ); + const header = screen.getByRole("button", { name: "History (2)" }); + expect(header).toHaveAttribute("aria-expanded", "true"); + await user.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + await user.click(header); + expect(header).toHaveAttribute("aria-expanded", "true"); + }); + + it("renders a lone section as a plain (non-collapsible) header with its entries shown", () => { + // Only the unpinned section → no accordion toggle, entries always visible. + renderWithMantine( + , + ); + expect(screen.getByText("History (3)")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "History (3)" }), + ).not.toBeInTheDocument(); + // An entry's method badge is visible (content shown, not collapsed). + expect(screen.getByText("resources/read")).toBeInTheDocument(); + }); + + it("shows the surviving section's entries after the other is removed, even if it was collapsed", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + // Collapse the History section while both sections are present. + await user.click(screen.getByRole("button", { name: "History (2)" })); + expect(screen.getByRole("button", { name: "History (2)" })).toHaveAttribute( + "aria-expanded", + "false", + ); + // Remove the pinned section → History is now the only section. Its entries + // must show despite the stale collapsed state, and the header is plain. + rerender(); + expect( + screen.queryByRole("button", { name: "History (3)" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("resources/read")).toBeInTheDocument(); + }); + + it("collapses the Pinned and History sections independently", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + const pinned = screen.getByRole("button", { + name: "Pinned Requests (1)", + }); + const history = screen.getByRole("button", { name: "History (2)" }); + await user.click(pinned); + expect(pinned).toHaveAttribute("aria-expanded", "false"); + // Collapsing Pinned leaves History untouched. + expect(history).toHaveAttribute("aria-expanded", "true"); + }); + + it("shows per-section Clear/Export only when both sections are present", () => { + // Only an unpinned section → just the panel-level Clear/Export. + const { unmount } = renderWithMantine( + , + ); + expect(screen.getAllByRole("button", { name: "Clear" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Export" })).toHaveLength(1); + unmount(); + + // Both sections → panel-level plus one Clear/Export per section. + renderWithMantine( + , + ); + expect(screen.getAllByRole("button", { name: "Clear" })).toHaveLength(3); + expect(screen.getAllByRole("button", { name: "Export" })).toHaveLength(3); + }); + + it("invokes onClearSection/onExportSection for the clicked section", async () => { + const user = userEvent.setup(); + const onClearSection = vi.fn(); + const onExportSection = vi.fn(); + renderWithMantine( + , + ); + // [0] = panel-level, [1] = Pinned section, [2] = History section. + const clears = screen.getAllByRole("button", { name: "Clear" }); + const exports = screen.getAllByRole("button", { name: "Export" }); + await user.click(clears[1]); + expect(onClearSection).toHaveBeenCalledWith("pinned"); + await user.click(exports[2]); + expect(onExportSection).toHaveBeenCalledWith("history"); + }); + + it("hides entries whose message direction is toggled off", () => { + const directional: MessageEntry[] = [ + { ...sampleEntries[0], id: "from-client", origin: "client" }, + { ...sampleEntries[1], id: "from-server", origin: "server" }, + ]; + renderWithMantine( + , + ); + // The server-origin entry is filtered out, leaving one. + expect(screen.getByText("History (1)")).toBeInTheDocument(); + }); + it("filters entries by searchText (case-insensitive)", () => { renderWithMantine( ; searchText: string; methodFilter?: MessageMethod; + /** Which message directions to show, keyed by entry origin. */ + visibleDirections: Record; onClearAll: () => void; onExport: () => void; + /** Clear just one section's entries (pinned vs unpinned history). */ + onClearSection: (section: HistorySectionName) => void; + /** Export just one section's entries. */ + onExportSection: (section: HistorySectionName) => void; onReplay: (id: string) => void; onTogglePin: (id: string) => void; sortDirection: SortDirection; @@ -46,6 +58,39 @@ const EmptyState = Text.withProps({ py: "xl", }); +// The section header is a single "pleat" bar (rounded, hover-highlighted, with +// the active background passed per instance via `bg`). Inside it sit the +// clickable toggle area (the title, filling the left) and the optional +// Clear/Export actions on the right — so the actions live on the pleat itself, +// not beside it. The toggle is its own button (the actions can't nest inside a +// button), `flex: 1` so it spans the bar up to the actions. +const SectionHeaderBar = Group.withProps({ + variant: "sectionHeader", + gap: "sm", + wrap: "nowrap", + p: "sm", +}); + +const SectionToggleArea = UnstyledButton.withProps({ + flex: 1, +}); + +const SectionTitle = Text.withProps({ + fw: 600, +}); + +const SectionActionGroup = Group.withProps({ + gap: "sm", + wrap: "nowrap", +}); + +// Subtle link-style button, matching the Select/Deselect All control in +// HistoryControls. +const SectionLinkButton = Button.withProps({ + variant: "subtle", + size: "xs", +}); + function formatPinnedTitle(count: number): string { return `Pinned Requests (${count})`; } @@ -54,11 +99,79 @@ function formatHistoryTitle(count: number): string { return `History (${count})`; } +type HistorySectionName = "pinned" | "history"; + +// Per-section Clear / Export links, shown to the right of a section header when +// both sections are present (so each can be cleared/exported on its own). +function SectionActions({ + onClear, + onExport, +}: { + onClear: () => void; + onExport: () => void; +}) { + return ( + + Clear + Export + + ); +} + +// A History section. When `collapsible` (both sections are on screen) the header +// is a `listItem` toggle — with an optional actions slot on the right — over a +// `Collapse` of the entries. When it's the only section, the accordion makes no +// sense: the header is a plain title and the entries always show (so a stale +// collapsed state from when both sections were present can't hide them). +function CollapsibleSection({ + title, + collapsible, + open, + onToggle, + actions, + children, +}: { + title: string; + collapsible: boolean; + open: boolean; + onToggle: () => void; + actions?: ReactNode; + children: ReactNode; +}) { + if (!collapsible) { + return ( + + {title} + {children} + + ); + } + return ( + + + + {title} + + {actions} + + + {children} + + + ); +} + function matchesFilters( entry: MessageEntry, searchText: string, + visibleDirections: Record, methodFilter?: MessageMethod, ): boolean { + // Hide a direction when its toggle is off. Entries with no recorded origin + // (legacy / pre-origin logs) are never filtered out by direction. + if (entry.origin && !visibleDirections[entry.origin]) return false; const method = extractMethod(entry); if (methodFilter && method !== methodFilter) return false; if (searchText) { @@ -76,8 +189,11 @@ export function HistoryListPanel({ pinnedIds, searchText, methodFilter, + visibleDirections, onClearAll, onExport, + onClearSection, + onExportSection, onReplay, onTogglePin, sortDirection, @@ -86,14 +202,20 @@ export function HistoryListPanel({ onToggleCompact, }: HistoryListPanelProps) { const viewportRef = useScrollMemory("history-list"); + // Per-section expand/collapse, like the LogControls level toggles. Both start + // open; collapsing hides that section's entries without affecting the other. + const [pinnedOpen, setPinnedOpen] = useState(true); + const [historyOpen, setHistoryOpen] = useState(true); const filteredEntries = useMemo(() => { // `.filter()` returns a fresh array, so sorting in-place is safe. const sorted = entries - .filter((e) => matchesFilters(e, searchText, methodFilter)) + .filter((e) => + matchesFilters(e, searchText, visibleDirections, methodFilter), + ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, searchText, methodFilter, sortDirection]); + }, [entries, searchText, visibleDirections, methodFilter, sortDirection]); const pinnedEntries = useMemo( () => filteredEntries.filter((e) => pinnedIds.has(e.id)), @@ -106,6 +228,9 @@ export function HistoryListPanel({ ); const hasResults = filteredEntries.length > 0; + // Per-section Clear/Export only make sense when both sections are on screen; + // with a single section the panel-level Clear/Export already covers it. + const bothSections = pinnedEntries.length > 0 && unpinnedEntries.length > 0; return ( @@ -142,10 +267,20 @@ export function HistoryListPanel({ > {pinnedEntries.length > 0 && ( - <> - - {formatPinnedTitle(pinnedEntries.length)} - + setPinnedOpen((v) => !v)} + actions={ + bothSections ? ( + onClearSection("pinned")} + onExport={() => onExportSection("pinned")} + /> + ) : undefined + } + > {pinnedEntries.map((entry) => ( onTogglePin(entry.id)} /> ))} - + )} {unpinnedEntries.length > 0 && ( - <> - - {formatHistoryTitle(unpinnedEntries.length)} - + setHistoryOpen((v) => !v)} + actions={ + bothSections ? ( + onClearSection("history")} + onExport={() => onExportSection("history")} + /> + ) : undefined + } + > {unpinnedEntries.map((entry) => ( onTogglePin(entry.id)} /> ))} - + )} diff --git a/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx new file mode 100644 index 000000000..362b30bd1 --- /dev/null +++ b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx @@ -0,0 +1,45 @@ +import { Stack } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { + MessageDirectionFilter, + type MessageDirectionFilterProps, +} from "./MessageDirectionFilter"; + +function Wrapped(args: MessageDirectionFilterProps) { + return ( + + + + ); +} + +const meta: Meta = { + title: "Groups/MessageDirectionFilter", + component: MessageDirectionFilter, + render: Wrapped, + args: { + onToggleDirection: fn(), + onToggleAllDirections: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllVisible: Story = { + args: { visibleDirections: { client: true, server: true } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "client → server" }), + ).toBeInTheDocument(); + expect( + canvas.getByRole("button", { name: "Deselect All" }), + ).toBeInTheDocument(); + }, +}; + +export const OnlyOutgoing: Story = { + args: { visibleDirections: { client: true, server: false } }, +}; diff --git a/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.test.tsx b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.test.tsx new file mode 100644 index 000000000..33b96770b --- /dev/null +++ b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MessageDirectionFilter } from "./MessageDirectionFilter"; + +const baseProps = { + visibleDirections: { client: true, server: true }, + onToggleDirection: vi.fn(), + onToggleAllDirections: vi.fn(), +}; + +describe("MessageDirectionFilter", () => { + it("renders the heading and both direction toggles", () => { + renderWithMantine(); + expect(screen.getByText("Filter by Message Direction")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "client → server" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "client ← server" }), + ).toBeInTheDocument(); + }); + + it("toggles a direction off when it's currently visible", async () => { + const user = userEvent.setup(); + const onToggleDirection = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "client ← server" })); + expect(onToggleDirection).toHaveBeenCalledWith("server", false); + }); + + it("shows Deselect All when all visible and invokes onToggleAllDirections", async () => { + const user = userEvent.setup(); + const onToggleAllDirections = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Deselect All" })); + expect(onToggleAllDirections).toHaveBeenCalledTimes(1); + }); + + it("shows Select All when not all directions are visible", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Select All" }), + ).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx new file mode 100644 index 000000000..5a81a086b --- /dev/null +++ b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx @@ -0,0 +1,77 @@ +import { + Button, + Group, + Stack, + Text, + Title, + UnstyledButton, +} from "@mantine/core"; +import type { MessageOrigin } from "@inspector/core/mcp/types.js"; + +const SubtleButton = Button.withProps({ + variant: "subtle", + size: "xs", +}); + +// The two message directions, in display order. Label + color mirror the +// MessageDirectionBadge: outgoing (client → server) is green, incoming +// (client ← server) is violet. +const MESSAGE_DIRECTIONS: { + origin: MessageOrigin; + label: string; + color: string; +}[] = [ + { origin: "client", label: "client → server", color: "green" }, + { origin: "server", label: "client ← server", color: "violet" }, +]; + +export interface MessageDirectionFilterProps { + visibleDirections: Record; + onToggleDirection: (direction: MessageOrigin, visible: boolean) => void; + onToggleAllDirections: () => void; +} + +/** + * "Filter by Message Direction" section — a Select/Deselect All control plus a + * listItem toggle per direction (client → server / client ← server). Used by the + * History controls. (Kept as its own component so the section is testable in + * isolation and reusable if another screen ever needs a direction filter.) + */ +export function MessageDirectionFilter({ + visibleDirections, + onToggleDirection, + onToggleAllDirections, +}: MessageDirectionFilterProps) { + return ( + <> + + Filter by Message Direction + + {Object.values(visibleDirections).every(Boolean) + ? "Deselect All" + : "Select All"} + + + + {MESSAGE_DIRECTIONS.map(({ origin, label, color }) => { + const active = visibleDirections[origin]; + return ( + onToggleDirection(origin, !active)} + > + + {label} + + + ); + })} + + + ); +} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 7cf8da595..a0a58cfb4 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -13,6 +13,7 @@ import { import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { maskSecretsInBody } from "../../../utils/maskSecrets"; export interface NetworkEntryProps { @@ -47,11 +48,6 @@ const DurationText = Text.withProps({ c: "dimmed", }); -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - // Cap is in JS string `.length` units (UTF-16 code units), not bytes — for // multi-byte content the wire size is larger, but the limit's purpose is // to keep the DOM from drowning in a single Code block so character count @@ -235,10 +231,11 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { - - setIsExpanded((v) => !v)} ml="auto"> - {isExpanded ? "Collapse" : "Expand"} - + + setIsExpanded((v) => !v)} + /> diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.tsx index 2a08a1837..1eb706181 100644 --- a/clients/web/src/components/groups/TaskCard/TaskCard.tsx +++ b/clients/web/src/components/groups/TaskCard/TaskCard.tsx @@ -13,6 +13,7 @@ import type { Task, } from "@modelcontextprotocol/sdk/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; import { TaskStatusBadge } from "../../elements/TaskStatusBadge/TaskStatusBadge"; @@ -59,11 +60,6 @@ const StatusMessageText = Text.withProps({ fs: "italic", }); -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - const CancelButton = Button.withProps({ variant: "subtle", color: "red", @@ -148,9 +144,10 @@ export function TaskCard({ )} - setIsExpanded((v) => !v)}> - {isExpanded ? "Collapse" : "Expand"} - + setIsExpanded((v) => !v)} + /> {progress && isActive ? ( diff --git a/clients/web/src/components/groups/historyUtils.ts b/clients/web/src/components/groups/historyUtils.ts index f6258b10d..e418caf19 100644 --- a/clients/web/src/components/groups/historyUtils.ts +++ b/clients/web/src/components/groups/historyUtils.ts @@ -8,3 +8,27 @@ export function extractMethod(entry: MessageEntry): MessageMethod { } return "response"; } + +/** + * Request methods the History Replay action can re-issue (client→server reads + * and calls). Server→client requests (roots/list, sampling, elicitation) and + * side-effectful methods (logging/setLevel, subscribe) are intentionally + * excluded. Single source of truth: `HistoryEntry` hides the Replay button for + * anything not listed here, and App's `replayHistoryRequest` gates dispatch on + * the same set. + */ +export const REPLAYABLE_HISTORY_METHODS: ReadonlySet = new Set([ + "tools/call", + "prompts/get", + "resources/read", + "tools/list", + "prompts/list", + "resources/list", + "resources/templates/list", + "tasks/list", + "ping", +]); + +export function isReplayableHistoryMethod(method: string): boolean { + return REPLAYABLE_HISTORY_METHODS.has(method); +} diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index bbff1767a..943c9cca7 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -27,6 +27,8 @@ const baseProps = { onUiChange: vi.fn(), onClearAll: vi.fn(), onExport: vi.fn(), + onClearSection: vi.fn(), + onExportSection: vi.fn(), onReplay: vi.fn(), onTogglePin: vi.fn(), sortDirection: "newest-first" as const, @@ -66,6 +68,30 @@ describe("HistoryScreen", () => { ); }); + it("toggles a single message direction through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "client ← server" })); + expect(onUiChange).toHaveBeenCalledWith( + expect.objectContaining({ + visibleDirections: { client: true, server: false }, + }), + ); + }); + + it("toggles all message directions off through onUiChange", async () => { + const user = userEvent.setup(); + const onUiChange = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Deselect All" })); + expect(onUiChange).toHaveBeenCalledWith( + expect.objectContaining({ + visibleDirections: { client: false, server: false }, + }), + ); + }); + it("emits the cleared method filter through onUiChange", async () => { const user = userEvent.setup(); const onUiChange = vi.fn(); diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx index 5411afc26..a91995874 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx @@ -1,6 +1,10 @@ import { useCallback, useMemo } from "react"; import { Card, Flex, Stack } from "@mantine/core"; -import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js"; +import type { + MessageEntry, + MessageMethod, + MessageOrigin, +} from "@inspector/core/mcp/types.js"; import { HistoryControls } from "../../groups/HistoryControls/HistoryControls"; import { HistoryListPanel } from "../../groups/HistoryListPanel/HistoryListPanel.js"; import { extractMethod } from "../../groups/historyUtils.js"; @@ -13,6 +17,8 @@ export interface HistoryScreenProps { onUiChange: (next: HistoryUiState) => void; onClearAll: () => void; onExport: () => void; + onClearSection: (section: "pinned" | "history") => void; + onExportSection: (section: "pinned" | "history") => void; onReplay: (id: string) => void; onTogglePin: (id: string) => void; sortDirection: SortDirection; @@ -21,11 +27,14 @@ export interface HistoryScreenProps { onToggleCompact: () => void; } -// Search text + method filter — controlled by the parent (App) as one object so -// they persist across tab navigation within a live session (#1417). +// Search text, method filter, and per-direction visibility — controlled by the +// parent (App) as one object so they persist across tab navigation within a +// live session (#1417). export interface HistoryUiState { search: string; methodFilter?: MessageMethod; + /** Which message directions are shown, keyed by entry origin. */ + visibleDirections: Record; } const ScreenLayout = Flex.withProps({ @@ -52,6 +61,8 @@ export function HistoryScreen({ onUiChange, onClearAll, onExport, + onClearSection, + onExportSection, onReplay, onTogglePin, sortDirection, @@ -59,7 +70,7 @@ export function HistoryScreen({ compact, onToggleCompact, }: HistoryScreenProps) { - const { search, methodFilter } = ui; + const { search, methodFilter, visibleDirections } = ui; const availableMethods = useMemo( () => Array.from(new Set(entries.map(extractMethod))).sort(), @@ -71,6 +82,24 @@ export function HistoryScreen({ onClearAll(); }, [ui, onUiChange, onClearAll]); + const handleToggleDirection = useCallback( + (direction: MessageOrigin, visible: boolean) => { + onUiChange({ + ...ui, + visibleDirections: { ...visibleDirections, [direction]: visible }, + }); + }, + [ui, visibleDirections, onUiChange], + ); + + const handleToggleAllDirections = useCallback(() => { + const next = !Object.values(visibleDirections).every(Boolean); + onUiChange({ + ...ui, + visibleDirections: { client: next, server: next }, + }); + }, [ui, visibleDirections, onUiChange]); + return ( @@ -79,10 +108,13 @@ export function HistoryScreen({ searchText={search} methodFilter={methodFilter} availableMethods={availableMethods} + visibleDirections={visibleDirections} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onMethodFilterChange={(value) => onUiChange({ ...ui, methodFilter: value }) } + onToggleDirection={handleToggleDirection} + onToggleAllDirections={handleToggleAllDirections} /> @@ -91,8 +123,11 @@ export function HistoryScreen({ pinnedIds={pinnedIds} searchText={search} methodFilter={methodFilter} + visibleDirections={visibleDirections} onClearAll={handleClearAll} onExport={onExport} + onClearSection={onClearSection} + onExportSection={onExportSection} onReplay={onReplay} onTogglePin={onTogglePin} sortDirection={sortDirection} diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts index eaed48783..f35d6fa74 100644 --- a/clients/web/src/components/screens/screenUiState.ts +++ b/clients/web/src/components/screens/screenUiState.ts @@ -55,6 +55,7 @@ export const EMPTY_LOGS_UI: LogsUiState = { export const EMPTY_HISTORY_UI: HistoryUiState = { search: "", methodFilter: undefined, + visibleDirections: { client: true, server: true }, }; export const EMPTY_NETWORK_UI: NetworkUiState = { diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 5ea8f667b..f437434ee 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -108,6 +108,8 @@ function makeProps( onHistoryUiChange: vi.fn(), onClearHistory: vi.fn(), onExportHistory: vi.fn(), + onClearHistorySection: vi.fn(), + onExportHistorySection: vi.fn(), onReplayHistory: vi.fn(), onTogglePinHistory: vi.fn(), onNetworkUiChange: vi.fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 7f8384269..417592d79 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -309,6 +309,8 @@ export interface InspectorViewProps { onHistoryUiChange: (next: HistoryUiState) => void; onClearHistory: () => void; onExportHistory: () => void; + onClearHistorySection: (section: "pinned" | "history") => void; + onExportHistorySection: (section: "pinned" | "history") => void; onReplayHistory: (id: string) => void; onTogglePinHistory: (id: string) => void; @@ -396,6 +398,8 @@ export function InspectorView({ onHistoryUiChange, onClearHistory, onExportHistory, + onClearHistorySection, + onExportHistorySection, onReplayHistory, onTogglePinHistory, onNetworkUiChange, @@ -620,6 +624,8 @@ export function InspectorView({ onUiChange={onHistoryUiChange} onClearAll={onClearHistory} onExport={onExportHistory} + onClearSection={onClearHistorySection} + onExportSection={onExportHistorySection} onReplay={onReplayHistory} onTogglePin={onTogglePinHistory} sortDirection={historySort} diff --git a/clients/web/src/lib/downloadFile.ts b/clients/web/src/lib/downloadFile.ts index 18f649162..77590717d 100644 --- a/clients/web/src/lib/downloadFile.ts +++ b/clients/web/src/lib/downloadFile.ts @@ -36,7 +36,12 @@ export function downloadJsonFile(filename: string, json: string): void { * `kind` to this union catches typos at call sites and documents the * stable on-disk filename prefix. */ -export type ExportKind = "history" | "logs" | "network"; +export type ExportKind = + | "history" + | "history-pinned" + | "history-unpinned" + | "logs" + | "network"; /** * Build a sortable export filename in the shape diff --git a/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts new file mode 100644 index 000000000..f065f5907 --- /dev/null +++ b/clients/web/src/test/core/mcp/messageTrackingTransport.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { MessageTrackingTransport } from "@inspector/core/mcp/messageTrackingTransport.js"; + +/** Minimal in-memory Transport so we can drive send()/onmessage directly. */ +class FakeTransport implements Transport { + sent: JSONRPCMessage[] = []; + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + async start(): Promise {} + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + } + async close(): Promise {} +} + +function makeTracked() { + const callbacks = { + trackRequest: vi.fn(), + trackResponse: vi.fn(), + trackNotification: vi.fn(), + }; + const base = new FakeTransport(); + const tracked = new MessageTrackingTransport(base, callbacks); + return { callbacks, base, tracked }; +} + +describe("MessageTrackingTransport.send", () => { + it("tracks an outgoing request", async () => { + const { callbacks, tracked } = makeTracked(); + const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const; + await tracked.send(request); + expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client"); + expect(callbacks.trackResponse).not.toHaveBeenCalled(); + }); + + it("tracks an outgoing response to a server→client request (roots/list)", async () => { + const { callbacks, tracked } = makeTracked(); + // The client answering a server's roots/list request. + const response = { + jsonrpc: "2.0", + id: 7, + result: { roots: [{ uri: "file:///work", name: "work" }] }, + } as const; + await tracked.send(response); + expect(callbacks.trackResponse).toHaveBeenCalledWith(response, "client"); + expect(callbacks.trackRequest).not.toHaveBeenCalled(); + }); + + it("tracks an outgoing error response", async () => { + const { callbacks, tracked } = makeTracked(); + const errorResponse = { + jsonrpc: "2.0", + id: 8, + error: { code: -32603, message: "boom" }, + } as const; + await tracked.send(errorResponse); + expect(callbacks.trackResponse).toHaveBeenCalledWith( + errorResponse, + "client", + ); + }); + + it("tracks an outgoing notification as a client notification", async () => { + const { callbacks, tracked } = makeTracked(); + const notification = { + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + } as const; + await tracked.send(notification); + expect(callbacks.trackNotification).toHaveBeenCalledWith( + notification, + "client", + ); + expect(callbacks.trackRequest).not.toHaveBeenCalled(); + expect(callbacks.trackResponse).not.toHaveBeenCalled(); + }); + + it("forwards the message to the base transport", async () => { + const { base, tracked } = makeTracked(); + const request = { jsonrpc: "2.0", id: 1, method: "ping" } as const; + await tracked.send(request); + expect(base.sent).toEqual([request]); + }); +}); + +describe("MessageTrackingTransport.onmessage", () => { + it("classifies incoming response / request / notification", () => { + const { callbacks, base, tracked } = makeTracked(); + const handler = vi.fn(); + tracked.onmessage = handler; + + const response = { jsonrpc: "2.0", id: 1, result: {} } as const; + const serverRequest = { + jsonrpc: "2.0", + id: 2, + method: "roots/list", + } as const; + const notification = { + jsonrpc: "2.0", + method: "notifications/ping", + } as const; + + base.onmessage?.(response); + base.onmessage?.(serverRequest); + base.onmessage?.(notification); + + expect(callbacks.trackResponse).toHaveBeenCalledWith(response, "server"); + expect(callbacks.trackRequest).toHaveBeenCalledWith( + serverRequest, + "server", + ); + expect(callbacks.trackNotification).toHaveBeenCalledWith( + notification, + "server", + ); + // The wrapped handler still receives every message. + expect(handler).toHaveBeenCalledTimes(3); + }); +}); diff --git a/clients/web/src/theme/Group.ts b/clients/web/src/theme/Group.ts new file mode 100644 index 000000000..5c63caa3c --- /dev/null +++ b/clients/web/src/theme/Group.ts @@ -0,0 +1,17 @@ +import { Group } from "@mantine/core"; + +export const ThemeGroup = Group.extend({ + // `sectionHeader` styles a Group as a collapsible-section header "pleat": + // rounded, with the same hover highlight as the listItem toggles. The active + // (open) background is passed per-instance via the `bg` prop. + classNames: (_theme, props) => { + if (props.variant === "sectionHeader") return { root: "list-item" }; + return {}; + }, + styles: (_theme, props) => { + if (props.variant === "sectionHeader") { + return { root: { borderRadius: "var(--mantine-radius-md)" } }; + } + return { root: {} }; + }, +}); diff --git a/clients/web/src/theme/index.ts b/clients/web/src/theme/index.ts index d8caf63fe..4bb47db77 100644 --- a/clients/web/src/theme/index.ts +++ b/clients/web/src/theme/index.ts @@ -7,6 +7,7 @@ export { ThemeButton } from "./Button"; export { ThemeCard } from "./Card"; export { ThemeCode } from "./Code"; export { ThemeFlex } from "./Flex"; +export { ThemeGroup } from "./Group"; export { ThemeInput } from "./Input"; export { ThemePaper } from "./Paper"; export { ThemeSelect } from "./Select"; diff --git a/clients/web/src/theme/theme.ts b/clients/web/src/theme/theme.ts index 3782914a2..40da99cf8 100644 --- a/clients/web/src/theme/theme.ts +++ b/clients/web/src/theme/theme.ts @@ -9,6 +9,7 @@ import { ThemeCard, ThemeCode, ThemeFlex, + ThemeGroup, ThemeInput, ThemePaper, ThemeSelect, @@ -64,6 +65,7 @@ export const theme = createTheme({ Card: ThemeCard, Code: ThemeCode, Flex: ThemeFlex, + Group: ThemeGroup, Input: ThemeInput, Paper: ThemePaper, Select: ThemeSelect, diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 575c3efea..81ffe7bb5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4,6 +4,7 @@ import type { StderrLogEntry, ConnectionStatus, MessageEntry, + MessageOrigin, FetchRequestEntry, FetchRequestEntryBase, InspectorServerSettings, @@ -351,31 +352,35 @@ export class InspectorClient extends InspectorClientEventTarget { private createMessageTrackingCallbacks(): MessageTrackingCallbacks { return { - trackRequest: (message: JSONRPCRequest) => { + trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "request", + origin, message, }; this.dispatchTypedEvent("message", entry); }, trackResponse: ( message: JSONRPCResultResponse | JSONRPCErrorResponse, + origin: MessageOrigin, ) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "response", + origin, message, }; this.dispatchTypedEvent("message", entry); }, - trackNotification: (message: JSONRPCNotification) => { + trackNotification: (message: JSONRPCNotification, origin: MessageOrigin) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), direction: "notification", + origin, message, }; this.dispatchTypedEvent("message", entry); diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index a0892108a..c0e98caa1 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -12,13 +12,18 @@ import type { JSONRPCResultResponse, JSONRPCErrorResponse, } from "@modelcontextprotocol/sdk/types.js"; +import type { MessageOrigin } from "./types.js"; export interface MessageTrackingCallbacks { - trackRequest?: (message: JSONRPCRequest) => void; + trackRequest?: (message: JSONRPCRequest, origin: MessageOrigin) => void; trackResponse?: ( message: JSONRPCResultResponse | JSONRPCErrorResponse, + origin: MessageOrigin, + ) => void; + trackNotification?: ( + message: JSONRPCNotification, + origin: MessageOrigin, ) => void; - trackNotification?: (message: JSONRPCNotification) => void; } // Transport wrapper that intercepts all messages for tracking @@ -42,9 +47,26 @@ export class MessageTrackingTransport implements Transport { message: JSONRPCMessage, options?: TransportSendOptions, ): Promise { - // Track outgoing requests (only requests have a method and are sent by the client) - if ("method" in message && "id" in message) { - this.callbacks.trackRequest?.(message as JSONRPCRequest); + // Track outgoing traffic symmetrically to onmessage. The client issues + // requests (client→server), answers server→client requests — roots/list, + // sampling, elicitation (responses, which messageLogState folds back into + // the originating request by id) — and emits its own notifications + // (initialized, progress, roots/list_changed). All are tagged origin + // "client". + if ("id" in message && message.id !== null && message.id !== undefined) { + if ("result" in message || "error" in message) { + this.callbacks.trackResponse?.( + message as JSONRPCResultResponse | JSONRPCErrorResponse, + "client", + ); + } else if ("method" in message) { + this.callbacks.trackRequest?.(message as JSONRPCRequest, "client"); + } + } else if ("method" in message) { + this.callbacks.trackNotification?.( + message as JSONRPCNotification, + "client", + ); } return this.baseTransport.send(message, options); } @@ -99,14 +121,18 @@ export class MessageTrackingTransport implements Transport { if ("result" in message || "error" in message) { this.callbacks.trackResponse?.( message as JSONRPCResultResponse | JSONRPCErrorResponse, + "server", ); } else if ("method" in message) { // This is a request coming from the server - this.callbacks.trackRequest?.(message as JSONRPCRequest); + this.callbacks.trackRequest?.(message as JSONRPCRequest, "server"); } } else if ("method" in message) { // Notification (no ID, has method) - this.callbacks.trackNotification?.(message as JSONRPCNotification); + this.callbacks.trackNotification?.( + message as JSONRPCNotification, + "server", + ); } // Call the original handler handler(message, extra); diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 29bf50c0b..c01080d96 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -168,10 +168,20 @@ export interface StderrLogEntry { message: string; } +/** Who sent a tracked message: the inspector ("client") or the "server". */ +export type MessageOrigin = "client" | "server"; + export interface MessageEntry { id: string; timestamp: Date; direction: "request" | "response" | "notification"; + /** + * Who sent the message — drives the History direction badge (client → server + * vs client ← server). Set at tracking time: outgoing (transport `send`) is + * "client", incoming (`onmessage`) is "server". Optional for back-compat with + * older logs and test fixtures that predate it. + */ + origin?: MessageOrigin; message: | JSONRPCRequest | JSONRPCNotification