From 121f875a0e0b986099154fc9d77323348b89e2d1 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Sun, 7 Jun 2026 20:21:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20History=20=E2=80=94=20Pin=20&=20Re?= =?UTF-8?q?play,=20plus=20two=20history-status=20fixes=20(#1438)=20(#1441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): don't show a permanent PENDING badge on history notifications (#1439) `extractStatus` derived the history status badge purely from whether a `response` was attached, so a fire-and-forget notification (no id, no response ever) — and any unmatched standalone response — was labelled "Pending" forever. The pending → OK/Error lifecycle only applies to request entries (messageLogState attaches the response by JSON-RPC id), so scope the status to `direction === "request"` and render no request-style badge for notifications/responses. The method badge already labels them. Part of the History-screen work on #1438. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(core): track outgoing responses so server→client requests resolve in history (#1440) MessageTrackingTransport.send only tracked outgoing requests, so the client's response to a server→client request (roots/list, sampling, elicitation) was never recorded. The inbound request entry never got its response folded in, leaving it stuck "Pending" with no response body. Make send symmetric with onmessage: track outgoing responses (by id + result/error) as well as outgoing requests. The response now correlates to its request entry, which resolves to OK/Error with the body. Outgoing notifications remain untracked (separate gap). Part of the History-screen work on #1438. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): wire up History Pin and Replay (#1438) Both HistoryEntry actions were rendered but stubbed to todoNoop in App. - Pin: App now owns a session-scoped Set of pinned entry ids, toggled by onTogglePinHistory and passed down as pinnedHistoryIds. HistoryListPanel already sorts pinned entries to the top. The set resets with the rest of the per-screen state on disconnect/server-switch. - Replay: onReplayHistory re-issues the entry's original request by method (tools/call → callTool, prompts/get → getPrompt, resources/read → readResource). The call flows through InspectorClient → tracked transport → message log, so the fresh request+response surface as a new History entry (history-local) without touching the Tools/Prompts/ Resources panels. Unsupported methods / a removed tool surface a toast. Tests: pin toggle propagation, replay dispatch for each supported method, unsupported-method and missing-tool toasts. Closes #1438. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): make history Replay support list/discovery requests Replaying a tools/list entry reported "Replay isn't supported". Extend replayHistoryRequest beyond the call-style requests to the read/discovery methods, re-issuing each via its InspectorClient method (preserving the pagination cursor): - tools/list → listTools, prompts/list → listPrompts, resources/list → listResources, resources/templates/list → listResourceTemplates, ping → ping. Tests: tools/list replay (cursor preserved) and an unsupported-method toast (logging/setLevel). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): replay tasks/list; reorder + conditionally show History buttons - Add tasks/list to the replayable set (re-issued via listRequestorTasks), completing the client-issued */list methods. - Reverse the HistoryEntry actions to Pin then Replay, and hide Replay for entries whose method can't be replayed. - Introduce a single source of truth — REPLAYABLE_HISTORY_METHODS / isReplayableHistoryMethod in historyUtils — used by HistoryEntry to show/hide the button and by App's replayHistoryRequest to gate dispatch, so the two can't drift. Tests: tasks/list replay, Replay hidden for a non-replayable method, and Pin-before-Replay ordering. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): add client↔server direction badge to History and Network (#1437) A dual-state badge at the top-left of each entry shows which way it traveled: "client → server" (green) for inspector-originated, "client ← server" (yellow) for server-originated. - New shared element MessageDirectionBadge (Elements). - History: MessageEntry gains an `origin` ("client" | "server") set at tracking time — MessageTrackingTransport tags `send` as client and `onmessage` as server, threaded through the track callbacks. HistoryEntry maps origin → badge. (direction stays the request/response/notification message-type used for response correlation; origin is the flow.) - Network: fetches are always inspector-originated, so NetworkEntry renders the badge as outgoing. Tests + stories for the badge; HistoryEntry direction tests; transport test asserts the origin argument. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): use purple (not yellow) for the client ← server direction badge Yellow + green read as caution/ok status; purple vs green reads as direction. Incoming (server → client) is now violet; outgoing stays green. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): make History Pinned/History sections collapsible toggles The "Pinned Requests (N)" and "History (N)" section headers are now clickable toggle buttons that expand/collapse their entries — styled like the LogControls level toggles (UnstyledButton listItem variant with the active background) and wrapping the entries in a Collapse. Both default open and collapse independently. Tests assert the per-section aria-expanded toggling and independence. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): replace Expand/Collapse text button with an icon toggle History, Network, and Task cards now use a shared ExpandToggle ActionIcon instead of the "Expand"/"Collapse" text button: LuArrowDownToLine when collapsed (expand), LuArrowUpFromLine when expanded (collapse). The aria-label stays "Expand"/"Collapse" so the control reads the same. - New shared element ExpandToggle (+ test + stories). - HistoryEntry: actions split into a left group (Pin/Replay) with the toggle pushed right via space-between. - NetworkEntry / TaskCard: drop their now-unused SubtleButton const. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): pin icon toggle on HistoryEntry, moved to the right Replace the Pin/Unpin text button with a shared PinToggle ActionIcon (TiPinOutline when unpinned, TiPin when pinned; aria-label stays Pin/Unpin). Move it to the right action group, just left of the expand/collapse toggle — Replay stays on the left. - New shared element PinToggle (+ test + stories). - HistoryEntry action row: Replay left; Pin + expand toggle right. - Drop the now-unused formatPinLabel helper. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): per-section Clear/Export on the Pinned and History headers Each section header now shows Clear and Export links on the right, but only when both sections are present (a single section is already covered by the panel-level Clear/Export). They act on just that section's entries: - Clear: removes that section's entries by pin membership (clearing the Pinned section also drops the now-stale pinned id set). - Export: downloads just that section's entries (inspector-history-pinned/-unpinned-...json). Threaded onClearSection/onExportSection App → InspectorView → HistoryScreen → HistoryListPanel; App computes each section from messages + pinned ids. Extracted a CollapsibleSection subcomponent and a SectionActions links group. Extended ExportKind with history-pinned/history-unpinned. Tests cover both-sections visibility gating and per-section dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): Filter by Message Direction on the History controls Add a "Filter by Message Direction" section to HistoryControls, modeled on LogControls' "Filter by Level": a Select/Deselect All control plus two listItem toggles — "client → server" and "client ← server" — that show or hide entries by origin. - HistoryUiState gains visibleDirections (Record), defaulting to both visible; persists with the other per-screen state. - HistoryScreen wires the toggles (single + all) through onUiChange and passes the filter to HistoryListPanel, where matchesFilters drops entries whose direction is off (entries without a recorded origin are never filtered out). Tests cover the controls (render, single toggle, select/deselect all), the HistoryScreen onUiChange wiring, and the list-level direction filtering. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): section Clear/Export as subtle buttons, not link anchors Match the Select/Deselect All control in HistoryControls: the per-section Clear/Export on the History/Pinned headers now use a subtle Button (variant subtle, size xs) instead of an Anchor link. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): color the direction filter labels to match the badge The "client → server" / "client ← server" toggles in HistoryControls now color their labels green / violet, matching the MessageDirectionBadge. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): track outgoing client notifications in history too Review follow-up (#1441): MessageTrackingTransport.send now also tracks the client's own notifications (initialized, progress, roots/list_changed) as origin "client", making send fully symmetric with onmessage. Previously only id-bearing outgoing messages (requests, responses) were tracked, so client notifications never reached History while incoming server notifications did. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): Filter by Message Direction on the Network screen Add the same "Filter by Message Direction" section to NetworkControls as on HistoryControls. Extracted the shared section into a MessageDirectionFilter group component (used by both HistoryControls and NetworkControls) so they stay identical — a Select/Deselect All control plus green/violet client → server / client ← server toggles. - NetworkUiState gains visibleDirections (default both visible), persisted with the rest of the per-screen state; NetworkScreen wires the toggles and NetworkStreamPanel filters on them. - Network fetches are always inspector-originated, so all entries are client → server: the client toggle gates them all and the server toggle matches nothing (documented in NetworkStreamPanel). The section mirrors History for parity. Tests cover the shared filter, both controls' direction sections, the Network list filtering, and the NetworkScreen wiring; ambiguous "Deselect All" selectors (now two sections) disambiguated by order. Co-Authored-By: Claude Opus 4.8 (1M context) * revert(web): drop message direction from the Network tab Network traffic is always client → server — even SSE is a client-initiated stream tagged outgoing — so a direction badge/filter there is meaningless. Remove the MessageDirectionBadge from NetworkEntry and the Filter by Message Direction section (and its NetworkUiState.visibleDirections wiring) from the Network screen. History keeps its direction badge + filter; the shared MessageDirectionBadge and MessageDirectionFilter components stay (still used by History). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): a lone History section isn't a collapsible accordion When only one of Pinned / History is present, its header is now a plain title and its entries always show — instead of a `listItem` accordion toggle that could hide them based on a stale collapsed state from when both sections were on screen. The accordion (and per-section Clear/Export) only applies when both sections are present. Example fixed: collapse History while pins exist, then unpin everything — History becomes the sole section and shows its entries under a plain header rather than staying collapsed. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): put section Clear/Export on the header pleat itself The per-section Clear/Export sat to the right of the collapsible header bar, on the panel background. Make the whole header row the "pleat": a new Group `sectionHeader` theme variant (rounded + hover, active bg passed per instance) wraps a flex-1 toggle button (the title) on the left and the Clear/Export buttons on the right — so the actions live on the pleat. The toggle is a separate button since the action buttons can't nest inside it. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): entry expand toggle uses the same icons as ListToggle Swap ExpandToggle (History / Network / Task cards) from the LuArrow icons to RiExpandVerticalLine / RiCollapseVerticalLine — the same expand/collapse icons the list-level ListToggle uses. Same ActionIcon size, so it matches the other small buttons on the cards; aria-labels unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): panel Clear keeps pinned entries (history-only) Review follow-up (#1441): the panel Clear button is gated on the unpinned count (implying history-only), but onClearHistory wiped everything (pinned included) and left pinnedHistoryIds stale. Make Clear history-only — clearMessages((m) => !pinnedHistoryIds.has(m.id)) — so pinning protects an entry, matching the gating and the per-section model; the pin set stays valid since its entries survive. Also drop the stale "and Network" mention from the MessageDirectionFilter docstring (Network direction filter was reverted). Test: panel Clear's predicate keeps pinned ids and removes unpinned. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/src/App.test.tsx | 273 +++++++++++++++++- clients/web/src/App.tsx | 176 ++++++++++- .../ExpandToggle/ExpandToggle.stories.tsx | 30 ++ .../ExpandToggle/ExpandToggle.test.tsx | 26 ++ .../elements/ExpandToggle/ExpandToggle.tsx | 31 ++ .../MessageDirectionBadge.stories.tsx | 27 ++ .../MessageDirectionBadge.test.tsx | 15 + .../MessageDirectionBadge.tsx | 34 +++ .../elements/PinToggle/PinToggle.stories.tsx | 28 ++ .../elements/PinToggle/PinToggle.test.tsx | 24 ++ .../elements/PinToggle/PinToggle.tsx | 29 ++ .../HistoryControls.stories.tsx | 3 + .../HistoryControls/HistoryControls.test.tsx | 39 +++ .../HistoryControls/HistoryControls.tsx | 18 +- .../HistoryEntry/HistoryEntry.stories.tsx | 4 + .../groups/HistoryEntry/HistoryEntry.test.tsx | 62 ++++ .../groups/HistoryEntry/HistoryEntry.tsx | 50 +++- .../HistoryListPanel.stories.tsx | 2 + .../HistoryListPanel.test.tsx | 136 +++++++++ .../HistoryListPanel/HistoryListPanel.tsx | 173 ++++++++++- .../MessageDirectionFilter.stories.tsx | 45 +++ .../MessageDirectionFilter.test.tsx | 61 ++++ .../MessageDirectionFilter.tsx | 77 +++++ .../groups/NetworkEntry/NetworkEntry.tsx | 15 +- .../components/groups/TaskCard/TaskCard.tsx | 13 +- .../web/src/components/groups/historyUtils.ts | 24 ++ .../HistoryScreen/HistoryScreen.test.tsx | 26 ++ .../screens/HistoryScreen/HistoryScreen.tsx | 43 ++- .../src/components/screens/screenUiState.ts | 1 + .../InspectorView/InspectorView.test.tsx | 2 + .../views/InspectorView/InspectorView.tsx | 6 + clients/web/src/lib/downloadFile.ts | 7 +- .../core/mcp/messageTrackingTransport.test.ts | 122 ++++++++ clients/web/src/theme/Group.ts | 17 ++ clients/web/src/theme/index.ts | 1 + clients/web/src/theme/theme.ts | 2 + core/mcp/inspectorClient.ts | 9 +- core/mcp/messageTrackingTransport.ts | 40 ++- core/mcp/types.ts | 10 + 39 files changed, 1634 insertions(+), 67 deletions(-) create mode 100644 clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx create mode 100644 clients/web/src/components/elements/ExpandToggle/ExpandToggle.test.tsx create mode 100644 clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx create mode 100644 clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx create mode 100644 clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.test.tsx create mode 100644 clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx create mode 100644 clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx create mode 100644 clients/web/src/components/elements/PinToggle/PinToggle.test.tsx create mode 100644 clients/web/src/components/elements/PinToggle/PinToggle.tsx create mode 100644 clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx create mode 100644 clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.test.tsx create mode 100644 clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.tsx create mode 100644 clients/web/src/test/core/mcp/messageTrackingTransport.test.ts create mode 100644 clients/web/src/theme/Group.ts diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index f8c2bcd3..086174f1 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 c8b84f80..093d6222 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 00000000..df4054c8 --- /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 00000000..01e6c2bc --- /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 00000000..458caf6e --- /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 00000000..51a34ff8 --- /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 00000000..6c297daa --- /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 00000000..5478296e --- /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 00000000..d259eb84 --- /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 00000000..ec557642 --- /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 00000000..62beaa42 --- /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 938e31d5..09d7177b 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 0d9beb4c..8b26a064 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 32ba944d..7dee30b4 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 637b4c77..2febd189 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 2383052e..6ed5e769 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 0d795f94..3a1599b5 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 0e9a2aa7..e14dc035 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 a9607724..05684e9f 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 00000000..362b30bd --- /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 00000000..33b96770 --- /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 00000000..5a81a086 --- /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 7cf8da59..a0a58cfb 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 2a08a183..1eb70618 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 f6258b10..e418caf1 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 bbff1767..943c9cca 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 5411afc2..a9199587 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 eaed4878..f35d6fa7 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 5ea8f667..f437434e 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 7f838426..417592d7 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 18f64916..77590717 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 00000000..f065f590 --- /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 00000000..5c63caa3 --- /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 d8caf63f..4bb47db7 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 3782914a..40da99cf 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 575c3efe..81ffe7bb 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 a0892108..c0e98caa 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 29bf50c0..c01080d9 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