feat(web): History — Pin & Replay, plus two history-status fixes (#1438) (#1441)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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<string> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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<MessageOrigin, boolean>),
  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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-07 20:21:52 -04:00 • committed by GitHub
commit 121f875a0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1634 additions and 67 deletions

View file

@ -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<string>;
}) => (
<div>
<span data-testid="tool-status">
@ -347,6 +363,16 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
<span data-testid="pinned-history">
{Array.from(props.pinnedHistoryIds ?? []).join(",")}
</span>
<button onClick={() => props.onTogglePinHistory("hist-1")}>
toggle-pin
</button>
<button onClick={() => props.onReplayHistory("hist-1")}>
replay-history
</button>
<button onClick={() => props.onClearHistory()}>clear-history</button>
</div>
),
}));
@ -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(<App />);
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(<App />);
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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<typeof vi.fn>;
};
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(<App />);
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" }),
),
);
});
});

View file

@ -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<string, unknown> | undefined,
tools: Tool[],
): Promise<string | null> {
// 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<string, JsonValue>,
);
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<string, JsonValue>,
);
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<Set<string>>(
() => 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<string, unknown> | 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}

View file

@ -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<typeof ExpandToggle> = {
title: "Elements/ExpandToggle",
component: ExpandToggle,
args: { onToggle: fn() },
};
export default meta;
type Story = StoryObj<typeof ExpandToggle>;
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();
},
};

View file

@ -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(<ExpandToggle expanded={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument();
});
it("labels itself Collapse when expanded", () => {
renderWithMantine(<ExpandToggle expanded={true} onToggle={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Collapse" }),
).toBeInTheDocument();
});
it("invokes onToggle when clicked", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(<ExpandToggle expanded={false} onToggle={onToggle} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});

View file

@ -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 (
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={label}
onClick={onToggle}
>
<Icon size={16} />
</ActionIcon>
);
}

View file

@ -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<typeof MessageDirectionBadge> = {
title: "Elements/MessageDirectionBadge",
component: MessageDirectionBadge,
};
export default meta;
type Story = StoryObj<typeof MessageDirectionBadge>;
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();
},
};

View file

@ -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(<MessageDirectionBadge direction="outgoing" />);
expect(screen.getByText("client → server")).toBeInTheDocument();
});
it("renders client ← server for incoming", () => {
renderWithMantine(<MessageDirectionBadge direction="incoming" />);
expect(screen.getByText("client ← server")).toBeInTheDocument();
});
});

View file

@ -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<MessageDirectionBadgeProps["direction"], string> = {
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 (
<Badge
color={direction === "outgoing" ? "green" : "violet"}
variant="light"
>
{LABEL[direction]}
</Badge>
);
}

View file

@ -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<typeof PinToggle> = {
title: "Elements/PinToggle",
component: PinToggle,
args: { onToggle: fn() },
};
export default meta;
type Story = StoryObj<typeof PinToggle>;
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();
},
};

View file

@ -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(<PinToggle pinned={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument();
});
it("labels itself Unpin when pinned", () => {
renderWithMantine(<PinToggle pinned={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button", { name: "Unpin" })).toBeInTheDocument();
});
it("invokes onToggle when clicked", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(<PinToggle pinned={false} onToggle={onToggle} />);
await user.click(screen.getByRole("button", { name: "Pin" }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});

View file

@ -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 (
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={label}
onClick={onToggle}
>
<Icon size={18} />
</ActionIcon>
);
}

View file

@ -20,8 +20,11 @@ const meta: Meta<typeof HistoryControls> = {
args: {
searchText: "",
availableMethods: SAMPLE_METHODS,
visibleDirections: { client: true, server: true },
onSearchChange: fn(),
onMethodFilterChange: fn(),
onToggleDirection: fn(),
onToggleAllDirections: fn(),
},
};

View file

@ -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(<HistoryControls {...baseProps} />);
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(
<HistoryControls {...baseProps} onToggleDirection={onToggleDirection} />,
);
// 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(
<HistoryControls
{...baseProps}
onToggleAllDirections={onToggleAllDirections}
/>,
);
// Both visible → the control reads "Deselect All".
await user.click(screen.getByRole("button", { name: "Deselect All" }));
expect(onToggleAllDirections).toHaveBeenCalledTimes(1);
});
});

View file

@ -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<MessageOrigin, boolean>;
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 (
<Stack gap="md">
@ -35,6 +45,12 @@ export function HistoryControls({
}
clearable
/>
<MessageDirectionFilter
visibleDirections={visibleDirections}
onToggleDirection={onToggleDirection}
onToggleAllDirections={onToggleAllDirections}
/>
</Stack>
);
}

View file

@ -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,

View file

@ -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(
<HistoryEntry {...baseProps} entry={notificationEntry} />,
);
// 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(<HistoryEntry {...baseProps} entry={successEntry} />);
expect(screen.getByText("client → server")).toBeInTheDocument();
});
it("shows client ← server for a server-originated entry", () => {
renderWithMantine(
<HistoryEntry {...baseProps} entry={notificationEntry} />,
);
expect(screen.getByText("client ← server")).toBeInTheDocument();
});
it("renders Pin label when not pinned", () => {
renderWithMantine(<HistoryEntry {...baseProps} entry={successEntry} />);
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(
<HistoryEntry {...baseProps} entry={notificationEntry} />,
);
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(<HistoryEntry {...baseProps} entry={successEntry} />);
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();

View file

@ -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({
<Stack gap="sm">
<HeaderRow>
<Group gap="sm">
{entry.origin && (
<MessageDirectionBadge
direction={entry.origin === "client" ? "outgoing" : "incoming"}
/>
)}
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
<Badge color="dark">{method}</Badge>
{target && <TargetText>{target}</TargetText>}
@ -124,18 +137,25 @@ export function HistoryEntry({
{entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
)}
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
{status !== "none" && (
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
)}
</Group>
</HeaderRow>
<Group gap="xs">
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
<SubtleButton onClick={onTogglePin}>
{formatPinLabel(isPinned)}
</SubtleButton>
<SubtleButton onClick={() => setIsExpanded((v) => !v)} ml="auto">
{isExpanded ? "Collapse" : "Expand"}
</SubtleButton>
<Group gap="xs" justify="space-between">
<Group gap="xs">
{canReplay && (
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
)}
</Group>
<Group gap="xs">
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</Group>
</Group>
<Collapse in={isExpanded}>

View file

@ -11,6 +11,8 @@ const meta: Meta<typeof HistoryListPanel> = {
pinnedIds: new Set<string>(),
onClearAll: fn(),
onExport: fn(),
onClearSection: fn(),
onExportSection: fn(),
onReplay: fn(),
onTogglePin: fn(),
sortDirection: "newest-first",

View file

@ -62,8 +62,11 @@ const sampleEntries: MessageEntry[] = [
const baseProps = {
pinnedIds: new Set<string>(),
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(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
pinnedIds={new Set(["req-1"])}
/>,
);
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(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
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(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
pinnedIds={new Set(["req-1"])}
/>,
);
// 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(<HistoryListPanel {...baseProps} entries={sampleEntries} />);
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(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
pinnedIds={new Set(["req-1"])}
/>,
);
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(
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
);
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(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
pinnedIds={new Set(["req-1"])}
/>,
);
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(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
pinnedIds={new Set(["req-1"])}
onClearSection={onClearSection}
onExportSection={onExportSection}
/>,
);
// [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(
<HistoryListPanel
{...baseProps}
entries={directional}
visibleDirections={{ client: true, server: false }}
/>,
);
// The server-origin entry is filtered out, leaving one.
expect(screen.getByText("History (1)")).toBeInTheDocument();
});
it("filters entries by searchText (case-insensitive)", () => {
renderWithMantine(
<HistoryListPanel

View file

@ -1,14 +1,20 @@
import { useMemo } from "react";
import { useMemo, useState, type ReactNode } from "react";
import {
Button,
Collapse,
Group,
Paper,
ScrollArea,
Stack,
Text,
Title,
UnstyledButton,
} 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 { HistoryEntry } from "../HistoryEntry/HistoryEntry";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
@ -23,8 +29,14 @@ export interface HistoryListPanelProps {
pinnedIds: Set<string>;
searchText: string;
methodFilter?: MessageMethod;
/** Which message directions to show, keyed by entry origin. */
visibleDirections: Record<MessageOrigin, boolean>;
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 (
<SectionActionGroup>
<SectionLinkButton onClick={onClear}>Clear</SectionLinkButton>
<SectionLinkButton onClick={onExport}>Export</SectionLinkButton>
</SectionActionGroup>
);
}
// 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 (
<Stack gap="md">
<Title order={5}>{title}</Title>
<Stack gap="md">{children}</Stack>
</Stack>
);
}
return (
<Stack gap="md">
<SectionHeaderBar
bg={open ? "var(--mantine-primary-color-light)" : undefined}
>
<SectionToggleArea aria-expanded={open} onClick={onToggle}>
<SectionTitle>{title}</SectionTitle>
</SectionToggleArea>
{actions}
</SectionHeaderBar>
<Collapse in={open}>
<Stack gap="md">{children}</Stack>
</Collapse>
</Stack>
);
}
function matchesFilters(
entry: MessageEntry,
searchText: string,
visibleDirections: Record<MessageOrigin, boolean>,
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 (
<PanelContainer>
@ -142,10 +267,20 @@ export function HistoryListPanel({
>
<Stack gap="md">
{pinnedEntries.length > 0 && (
<>
<Title order={5}>
{formatPinnedTitle(pinnedEntries.length)}
</Title>
<CollapsibleSection
title={formatPinnedTitle(pinnedEntries.length)}
collapsible={bothSections}
open={pinnedOpen}
onToggle={() => setPinnedOpen((v) => !v)}
actions={
bothSections ? (
<SectionActions
onClear={() => onClearSection("pinned")}
onExport={() => onExportSection("pinned")}
/>
) : undefined
}
>
{pinnedEntries.map((entry) => (
<HistoryEntry
key={entry.id}
@ -156,14 +291,24 @@ export function HistoryListPanel({
onTogglePin={() => onTogglePin(entry.id)}
/>
))}
</>
</CollapsibleSection>
)}
{unpinnedEntries.length > 0 && (
<>
<Title order={5}>
{formatHistoryTitle(unpinnedEntries.length)}
</Title>
<CollapsibleSection
title={formatHistoryTitle(unpinnedEntries.length)}
collapsible={bothSections}
open={historyOpen}
onToggle={() => setHistoryOpen((v) => !v)}
actions={
bothSections ? (
<SectionActions
onClear={() => onClearSection("history")}
onExport={() => onExportSection("history")}
/>
) : undefined
}
>
{unpinnedEntries.map((entry) => (
<HistoryEntry
key={entry.id}
@ -174,7 +319,7 @@ export function HistoryListPanel({
onTogglePin={() => onTogglePin(entry.id)}
/>
))}
</>
</CollapsibleSection>
)}
</Stack>
</ScrollArea.Autosize>

Some files were not shown because too many files have changed in this diff Show more