refactor(web): rename "History" tab/feature to "Protocol" (#1623)

Rename the web client's History tab and its supporting code to Protocol
so the feature reads as the MCP "Protocol" view.

- Tab renders as "Protocol"; INSPECTOR_TAB_IDS, InspectorView tab
  constants/MonitorTab, TAB_UI_REGISTRY key, and navigation.ts tab id all
  updated to Protocol.
- Rename components + folders: HistoryScreen -> ProtocolScreen,
  HistoryListPanel -> ProtocolListPanel, HistoryEntry -> ProtocolEntry,
  HistoryControls -> ProtocolControls, historyUtils -> protocolUtils
  (plus their tests and stories, and exported types).
- Rename App/InspectorView plumbing: historyUi, pinnedHistoryIds,
  on*History* handlers/props, replayHistoryRequest,
  isReplayableHistoryMethod, sort/compact scopes, etc. -> Protocol names.
- Export filenames use the "protocol" slug (buildExportFilename +
  ExportKind).

Preserved (per issue #1623 decisions):
- The in-panel section discriminator keeps its "pinned" | "history"
  values, and the sub-sections stay labeled Pinned / History.
- window.history and core message-log/replay concepts are untouched.

npm run ci passes (format, lint, build, unit tests, >=90 per-file
coverage gate, smoke, Storybook).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-09 19:04:54 -04:00
commit ace4250d31
46 changed files with 413 additions and 405 deletions

View file

@ -309,10 +309,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>;
onClearProtocol: () => void;
onReplayProtocol: (id: string) => void;
onTogglePinProtocol: (id: string) => void;
pinnedProtocolIds?: Set<string>;
}) => (
<div>
<span data-testid="tool-status">
@ -419,15 +419,15 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
<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(",")}
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
</span>
<button onClick={() => props.onTogglePinHistory("hist-1")}>
<button onClick={() => props.onTogglePinProtocol("hist-1")}>
toggle-pin
</button>
<button onClick={() => props.onReplayHistory("hist-1")}>
<button onClick={() => props.onReplayProtocol("hist-1")}>
replay-history
</button>
<button onClick={() => props.onClearHistory()}>clear-history</button>
<button onClick={() => props.onClearProtocol()}>clear-history</button>
</div>
),
}));

View file

@ -114,7 +114,7 @@ import {
EMPTY_APPS_UI,
EMPTY_TASKS_UI,
EMPTY_LOGS_UI,
EMPTY_HISTORY_UI,
EMPTY_PROTOCOL_UI,
EMPTY_NETWORK_UI,
EMPTY_CONSOLE_UI,
} from "./components/screens/screenUiState";
@ -140,7 +140,7 @@ import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/Con
import { oauthDetailsFromConnectionState } from "./components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
import { isReplayableHistoryMethod } from "./components/groups/historyUtils.js";
import { isReplayableProtocolMethod } from "./components/groups/protocolUtils.js";
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import { StepUpAuthModal } from "./components/groups/StepUpAuthModal/StepUpAuthModal";
@ -267,21 +267,21 @@ function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] {
return out;
}
// Re-issue the original request behind a History entry. The call goes through
// Re-issue the original request behind a Protocol entry. The call goes through
// InspectorClient → tracked transport → message log, so the replayed
// request+response surface as a fresh History entry (history-local) — it
// request+response surface as a fresh Protocol entry (protocol-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(
async function replayProtocolRequest(
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
// Gate on the shared replayable-method set (the same one ProtocolEntry uses to
// show/hide the Replay button) so the two can't drift.
if (!isReplayableHistoryMethod(method)) {
if (!isReplayableProtocolMethod(method)) {
return `Replay isn't supported for "${method}".`;
}
// Pagination cursor carried by the */list requests; replaying the same page
@ -755,11 +755,11 @@ function App() {
const [appsUi, setAppsUi] = useState(EMPTY_APPS_UI);
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
const [protocolUi, setProtocolUi] = useState(EMPTY_PROTOCOL_UI);
// Protocol 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>>(
const [pinnedProtocolIds, setPinnedProtocolIds] = useState<Set<string>>(
() => new Set(),
);
const [networkUi, setNetworkUi] = useState(EMPTY_NETWORK_UI);
@ -1014,8 +1014,8 @@ function App() {
setAppsUi(EMPTY_APPS_UI);
setTasksUi(EMPTY_TASKS_UI);
setLogsUi(EMPTY_LOGS_UI);
setHistoryUi(EMPTY_HISTORY_UI);
setPinnedHistoryIds(new Set());
setProtocolUi(EMPTY_PROTOCOL_UI);
setPinnedProtocolIds(new Set());
setNetworkUi(EMPTY_NETWORK_UI);
// Only the search filter resets here; the stderr entries themselves live in
// StderrLogState, which deliberately survives connect/disconnect so a failed
@ -1059,7 +1059,7 @@ function App() {
// Surface incoming `notifications/progress` as toasts so the user can watch a
// long-running tool's progress while staying on the tool view — the v2
// replacement for v1's always-visible "Server Notifications" shelf (#1414).
// The full notification history still lives in the History tab; these toasts
// The full notification history still lives in the Protocol tab; these toasts
// are the at-a-glance, in-context signal. Toasts are keyed by progress stream
// (see `progressToastId`) and replaced per tick so a chatty server updates one
// toast rather than stacking one per tick.
@ -1171,7 +1171,7 @@ function App() {
// with `requestorTaskUpdated` so we skip it to avoid double-firing. One toast per
// taskId, replaced per tick, dismissed on terminal status (which also prunes the
// task's progress entry) and on client teardown. The full status history still
// lives in the History view.
// lives in the Protocol view.
useEffect(() => {
if (!inspectorClient) return;
const liveToastIds = taskToastIdsRef.current;
@ -1560,7 +1560,7 @@ function App() {
appsUi,
tasksUi,
logsUi,
historyUi,
protocolUi,
networkUi,
}),
...(remoteSessionId && { remoteSessionId }),
@ -1578,7 +1578,7 @@ function App() {
appsUi,
tasksUi,
logsUi,
historyUi,
protocolUi,
networkUi,
onBeforeOAuthRedirect,
],
@ -2189,7 +2189,7 @@ function App() {
setAppsUi,
setTasksUi,
setLogsUi,
setHistoryUi,
setProtocolUi,
setNetworkUi,
setActiveTab,
clearToolCallState: () => setToolCallState(undefined),
@ -2987,7 +2987,7 @@ function App() {
const onClearLogs = useCallback(() => {
if (!messageLogState) return;
// Clear only the log notifications, not the entire request/response
// history (which the History screen renders from the same source).
// history (which the Protocol screen renders from the same source).
messageLogState.clearMessages(
(m) =>
m.direction === "notification" &&
@ -2999,10 +2999,10 @@ function App() {
// 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((m) => !pinnedHistoryIds.has(m.id));
}, [messageLogState, pinnedHistoryIds]);
// and leaves pinnedProtocolIds valid (the pins it references still exist).
const onClearProtocol = useCallback(() => {
messageLogState?.clearMessages((m) => !pinnedProtocolIds.has(m.id));
}, [messageLogState, pinnedProtocolIds]);
const onClearNetwork = useCallback(() => {
fetchRequestLogState?.clearFetchRequests();
@ -3016,50 +3016,50 @@ function App() {
);
}, [fetchRequests, activeServerId]);
const onExportHistory = useCallback(() => {
const onExportProtocol = useCallback(() => {
if (messages.length === 0) return;
downloadJsonFile(
buildExportFilename("history", activeServerId),
buildExportFilename("protocol", activeServerId),
JSON.stringify(messages, null, 2),
);
}, [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(
const onClearProtocolSection = useCallback(
(section: "pinned" | "history") => {
const isPinned = section === "pinned";
messageLogState?.clearMessages((m) =>
isPinned ? pinnedHistoryIds.has(m.id) : !pinnedHistoryIds.has(m.id),
isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id),
);
if (isPinned) setPinnedHistoryIds(new Set());
if (isPinned) setPinnedProtocolIds(new Set());
},
[messageLogState, pinnedHistoryIds],
[messageLogState, pinnedProtocolIds],
);
// Export just one section's entries (by pin membership) to a JSON file.
const onExportHistorySection = useCallback(
const onExportProtocolSection = useCallback(
(section: "pinned" | "history") => {
const isPinned = section === "pinned";
const subset = messages.filter((m) =>
isPinned ? pinnedHistoryIds.has(m.id) : !pinnedHistoryIds.has(m.id),
isPinned ? pinnedProtocolIds.has(m.id) : !pinnedProtocolIds.has(m.id),
);
if (subset.length === 0) return;
downloadJsonFile(
buildExportFilename(
isPinned ? "history-pinned" : "history-unpinned",
isPinned ? "protocol-pinned" : "protocol-unpinned",
activeServerId,
),
JSON.stringify(subset, null, 2),
);
},
[messages, pinnedHistoryIds, activeServerId],
[messages, pinnedProtocolIds, activeServerId],
);
// Pin/unpin a history entry by id. HistoryListPanel sorts pinned entries to
// Pin/unpin a Protocol entry by id. ProtocolListPanel sorts pinned entries to
// the top; the set is session-scoped (see resetSessionScopedUiState).
const onTogglePinHistory = useCallback((id: string) => {
setPinnedHistoryIds((prev) => {
const onTogglePinProtocol = useCallback((id: string) => {
setPinnedProtocolIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
@ -3070,12 +3070,12 @@ function App() {
});
}, []);
// Replay a history entry: re-issue its original request so the fresh
// request+response appear as a new History entry (history-local). A reason
// Replay a Protocol entry: re-issue its original request so the fresh
// request+response appear as a new Protocol entry (protocol-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(
const onReplayProtocol = useCallback(
(id: string) => {
if (!inspectorClient) return;
const entry = messages.find((m) => m.id === id);
@ -3085,7 +3085,7 @@ function App() {
"params" in entry.message
? (entry.message.params as Record<string, unknown> | undefined)
: undefined;
void replayHistoryRequest(inspectorClient, method, params, tools)
void replayProtocolRequest(inspectorClient, method, params, tools)
.then((reason) => {
if (reason) {
notifications.show({
@ -3687,7 +3687,7 @@ function App() {
logs={logs}
tasks={tasks}
progressByTaskId={progressByTaskId}
history={messages}
protocol={messages}
network={fetchRequests}
stderrLogs={stderrLogs}
toolCallState={toolCallState}
@ -3699,7 +3699,7 @@ function App() {
appsUi={appsUi}
tasksUi={tasksUi}
logsUi={logsUi}
historyUi={historyUi}
protocolUi={protocolUi}
networkUi={networkUi}
consoleUi={consoleUi}
activeTab={activeTab}
@ -3792,14 +3792,14 @@ function App() {
onLogsUiChange={setLogsUi}
onClearLogs={onClearLogs}
onExportLogs={onExportLogs}
onHistoryUiChange={setHistoryUi}
onClearHistory={onClearHistory}
onExportHistory={onExportHistory}
onClearHistorySection={onClearHistorySection}
onExportHistorySection={onExportHistorySection}
onReplayHistory={onReplayHistory}
onTogglePinHistory={onTogglePinHistory}
pinnedHistoryIds={pinnedHistoryIds}
onProtocolUiChange={setProtocolUi}
onClearProtocol={onClearProtocol}
onExportProtocol={onExportProtocol}
onClearProtocolSection={onClearProtocolSection}
onExportProtocolSection={onExportProtocolSection}
onReplayProtocol={onReplayProtocol}
onTogglePinProtocol={onTogglePinProtocol}
pinnedProtocolIds={pinnedProtocolIds}
onNetworkUiChange={setNetworkUi}
onClearNetwork={onClearNetwork}
onExportNetwork={onExportNetwork}

View file

@ -20,7 +20,7 @@ export interface EmbeddableScrollAreaProps {
}
/**
* The scroll region shared by the Logs / History / Network stream panels, which
* The scroll region shared by the Logs / Protocol / Network stream panels, which
* render both full-size (their own tab) and embedded (the monitoring column).
* Centralizes the one layout difference between those two hosts.
*/

View file

@ -8,7 +8,7 @@ export interface ExpandToggleProps {
}
/**
* Icon toggle for a per-entry expand/collapse control (History, Network, and
* Icon toggle for a per-entry expand/collapse control (Protocol, 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

View file

@ -17,7 +17,7 @@ const ToggleLabel = Text.withProps({
});
/**
* A single full-width filter toggle used by the Logging, History, and Network
* A single full-width filter toggle used by the Logging, Protocol, and Network
* controls. The `filterToggle` theme variant + `.filter-toggle` rules own the
* styling: hover shows a thin border, the active (`aria-pressed`) state shows a
* filled background. Keeping hover as a border (not a fill) means toggling a

View file

@ -15,10 +15,10 @@ const LABEL: Record<MessageDirectionBadgeProps["direction"], string> = {
};
/**
* Dual-state badge showing which way a History/Network entry traveled. Outgoing
* Dual-state badge showing which way a Protocol/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`.
* Shared by `ProtocolEntry` and `NetworkEntry`.
*/
export function MessageDirectionBadge({
direction,

View file

@ -7,7 +7,7 @@ export interface PinColumnButtonProps {
}
/**
* Toolbar button that pins the owning monitor screen (Logs / History / Network)
* Toolbar button that pins the owning monitor screen (Logs / Protocol / Network)
* into the resizable column on the right of the InspectorView (#1616). Distinct
* from `PinToggle` (which pins individual history entries) — this one opens a
* side column, so it uses a right-sidebar glyph and an "as column" label. Styled

View file

@ -7,7 +7,7 @@ export interface ReplayButtonProps {
}
/**
* Icon form of the "Replay" action, used in the compact (column) HistoryEntry
* Icon form of the "Replay" action, used in the compact (column) ProtocolEntry
* layout where the text button is replaced by a replay icon sitting next to the
* pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling.
*/

View file

@ -28,7 +28,7 @@ export interface MessageDirectionFilterProps {
/**
* "Filter by Message Direction" section — a Select/Deselect All control plus a
* FilterToggleButton per direction (client → server / client ← server). Used by
* the History controls. (Kept as its own component so the section is testable in
* the Protocol 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({

View file

@ -6,7 +6,7 @@ const meta: Meta<typeof MonitoringControls> = {
title: "Groups/MonitoringControls",
component: MonitoringControls,
args: {
tabs: ["Logs", "History", "Network"],
tabs: ["Logs", "Protocol", "Network"],
value: "Logs",
onChange: fn(),
searchValue: "",
@ -29,7 +29,7 @@ export const AllTabs: Story = {
};
export const StdioServer: Story = {
args: { tabs: ["Logs", "History"], value: "History" },
args: { tabs: ["Logs", "Protocol"], value: "Protocol" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();

View file

@ -6,7 +6,7 @@ import {
type MonitoringControlsProps,
} from "./MonitoringControls";
const TABS = ["Logs", "History", "Network"];
const TABS = ["Logs", "Protocol", "Network"];
function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
const props: MonitoringControlsProps = {
@ -31,7 +31,7 @@ describe("MonitoringControls", () => {
});
it("renders only the tabs it is given", () => {
renderControls({ tabs: ["Logs", "History"] });
renderControls({ tabs: ["Logs", "Protocol"] });
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
});
@ -53,8 +53,8 @@ describe("MonitoringControls", () => {
});
it("marks the active tab as selected", () => {
renderControls({ value: "History" });
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
renderControls({ value: "Protocol" });
expect(screen.getByRole("radio", { name: "Protocol" })).toBeChecked();
});
it("calls onChange when a different tab is chosen", async () => {

View file

@ -3,7 +3,7 @@ import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
export interface MonitoringControlsProps {
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
/** The monitor screens currently available to pin (e.g. Logs/Protocol/Network). */
tabs: string[];
/** The active monitor tab shown in the column below. */
value: string;

View file

@ -9,7 +9,7 @@ const demoScreens = {
<Text>Log stream</Text>
</Box>
),
History: (
Protocol: (
<Box p="md">
<Text>Request history</Text>
</Box>
@ -32,7 +32,7 @@ const meta: Meta<typeof MonitoringScreen> = {
),
],
args: {
tabs: ["Logs", "History", "Network"],
tabs: ["Logs", "Protocol", "Network"],
value: "Logs",
onChange: fn(),
searchValue: "",
@ -52,8 +52,8 @@ export const Logs: Story = {
},
};
export const History: Story = {
args: { value: "History" },
export const Protocol: Story = {
args: { value: "Protocol" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Request history")).toBeInTheDocument();

View file

@ -6,12 +6,12 @@ import {
type MonitoringScreenProps,
} from "./MonitoringScreen";
const TABS = ["Logs", "History", "Network"];
const TABS = ["Logs", "Protocol", "Network"];
function screens() {
return {
Logs: <div>logs-body</div>,
History: <div>history-body</div>,
Protocol: <div>history-body</div>,
Network: <div>network-body</div>,
};
}
@ -33,7 +33,7 @@ function renderScreen(overrides: Partial<MonitoringScreenProps> = {}) {
describe("MonitoringScreen", () => {
it("renders the screen for the active tab", () => {
renderScreen({ value: "History" });
renderScreen({ value: "Protocol" });
expect(screen.getByText("history-body")).toBeInTheDocument();
expect(screen.queryByText("logs-body")).toBeNull();
expect(screen.queryByText("network-body")).toBeNull();

View file

@ -3,7 +3,7 @@ import { Divider, Stack } from "@mantine/core";
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
export interface MonitoringScreenProps {
/** Available monitor tabs (Logs/History/Network, filtered by capability). */
/** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */
tabs: string[];
/** Active monitor tab; keys into `screens` to pick what renders below. */
value: string;

View file

@ -58,7 +58,7 @@ const UrlScroll = Text.withProps({
variant: "nowrap",
});
// Left / right clusters for a compact header line (mirrors HistoryEntry).
// Left / right clusters for a compact header line (mirrors ProtocolEntry).
const HeaderCluster = Group.withProps({
gap: "sm",
wrap: "nowrap",
@ -240,7 +240,7 @@ export function NetworkEntry({
// The list-level Expand/Collapse toggle is authoritative: each time the
// parent changes `isListExpanded`, every entry snaps to that state and
// any per-entry override is intentionally discarded. Mirrors
// HistoryEntry; do not change without aligning both. This re-runs on
// ProtocolEntry; do not change without aligning both. This re-runs on
// re-render when `isListExpanded` keeps its reference, but the setter
// is a no-op when the next value equals the current one.
useEffect(() => {

View file

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import type { MessageMethod } from "@inspector/core/mcp/types.js";
import { ProtocolControls } from "./ProtocolControls";
const SAMPLE_METHODS: MessageMethod[] = [
"tools/call",
"tools/list",
"resources/read",
"resources/list",
"prompts/get",
"prompts/list",
"sampling/createMessage",
"elicitation/create",
];
const meta: Meta<typeof ProtocolControls> = {
title: "Groups/ProtocolControls",
component: ProtocolControls,
args: {
searchText: "",
availableMethods: SAMPLE_METHODS,
visibleDirections: { client: true, server: true },
onSearchChange: fn(),
onMethodFilterChange: fn(),
onToggleDirection: fn(),
onToggleAllDirections: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ProtocolControls>;
export const Default: Story = {};
export const WithFilters: Story = {
args: {
searchText: "tools",
methodFilter: "tools/call",
},
};

View file

@ -0,0 +1,122 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { MessageMethod } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ProtocolControls } from "./ProtocolControls";
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("ProtocolControls", () => {
it("renders the title and search input", () => {
renderWithMantine(<ProtocolControls {...baseProps} />);
expect(screen.getByText("Protocol")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("invokes onSearchChange when typing in the search input", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderWithMantine(
<ProtocolControls {...baseProps} onSearchChange={onSearchChange} />,
);
await user.type(screen.getByPlaceholderText("Search..."), "a");
expect(onSearchChange).toHaveBeenCalledWith("a");
});
it("clears the search input when the Clear button is clicked", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderWithMantine(
<ProtocolControls
{...baseProps}
searchText="abc"
onSearchChange={onSearchChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("keeps the search Clear button out of the keyboard tab order", () => {
renderWithMantine(<ProtocolControls {...baseProps} searchText="abc" />);
expect(screen.getByRole("button", { name: "Clear" })).toHaveAttribute(
"tabindex",
"-1",
);
});
it("renders the method filter placeholder", () => {
renderWithMantine(<ProtocolControls {...baseProps} />);
expect(screen.getByPlaceholderText("All methods")).toBeInTheDocument();
});
it("displays the active method filter value", () => {
renderWithMantine(
<ProtocolControls {...baseProps} methodFilter="tools/list" />,
);
const inputs = screen.getAllByDisplayValue("tools/list");
expect(inputs.length).toBeGreaterThan(0);
});
it("invokes onMethodFilterChange with undefined when cleared", async () => {
const user = userEvent.setup();
const onMethodFilterChange = vi.fn();
const { container } = renderWithMantine(
<ProtocolControls
{...baseProps}
methodFilter="tools/list"
onMethodFilterChange={onMethodFilterChange}
/>,
);
const clearButton = container.querySelector(
"button.mantine-InputClearButton-root",
) as HTMLButtonElement | null;
expect(clearButton).not.toBeNull();
await user.click(clearButton!);
expect(onMethodFilterChange).toHaveBeenCalledWith(undefined);
});
it("renders the Filter by Message Direction section with both directions", () => {
renderWithMantine(<ProtocolControls {...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(
<ProtocolControls {...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(
<ProtocolControls
{...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

@ -0,0 +1,61 @@
import { Select, Stack, TextInput, Title } from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type {
MessageMethod,
MessageOrigin,
} from "@inspector/core/mcp/types.js";
import { MessageDirectionFilter } from "../MessageDirectionFilter/MessageDirectionFilter";
export interface ProtocolControlsProps {
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 ProtocolControls({
searchText,
methodFilter,
availableMethods,
visibleDirections,
onSearchChange,
onMethodFilterChange,
onToggleDirection,
onToggleAllDirections,
}: ProtocolControlsProps) {
return (
<Stack gap="md">
<Title order={4}>Protocol</Title>
<TextInput
placeholder="Search..."
value={searchText}
onChange={(event) => onSearchChange(event.currentTarget.value)}
rightSectionPointerEvents="auto"
rightSection={
searchText ? <ClearButton onClick={() => onSearchChange("")} /> : null
}
/>
<Title order={6}>Filter by Method</Title>
<Select
placeholder="All methods"
data={availableMethods}
value={methodFilter ?? null}
onChange={(value) =>
onMethodFilterChange((value as MessageMethod | null) ?? undefined)
}
clearable
/>
<MessageDirectionFilter
visibleDirections={visibleDirections}
onToggleDirection={onToggleDirection}
onToggleAllDirections={onToggleAllDirections}
/>
</Stack>
);
}

View file

@ -0,0 +1,130 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test";
import { ProtocolEntry } from "./ProtocolEntry";
const meta: Meta<typeof ProtocolEntry> = {
title: "Groups/ProtocolEntry",
component: ProtocolEntry,
args: {
onReplay: fn(),
onTogglePin: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ProtocolEntry>;
const toolCallEntry: MessageEntry = {
id: "req-1",
timestamp: new Date("2026-03-17T10:30:00Z"),
direction: "request",
origin: "client",
message: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "get_weather", arguments: { city: "San Francisco" } },
},
response: {
jsonrpc: "2.0",
id: 1,
result: {
content: [{ type: "text", text: "18°C, partly cloudy" }],
},
},
duration: 142,
};
const errorEntry: MessageEntry = {
id: "req-2",
timestamp: new Date("2026-03-17T10:31:15Z"),
direction: "request",
origin: "client",
message: {
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "query_database" },
},
response: {
jsonrpc: "2.0",
id: 2,
error: { code: -32000, message: "Connection timeout" },
},
duration: 3200,
};
const resourceReadEntry: MessageEntry = {
id: "req-3",
timestamp: new Date("2026-03-17T10:33:00Z"),
direction: "request",
origin: "client",
message: {
jsonrpc: "2.0",
id: 3,
method: "resources/read",
params: { uri: "file:///config.json" },
},
response: {
jsonrpc: "2.0",
id: 3,
result: {
contents: [{ uri: "file:///config.json", text: '{"debug": true}' }],
},
},
duration: 45,
};
const pendingEntry: MessageEntry = {
id: "req-4",
timestamp: new Date("2026-03-17T10:34:00Z"),
direction: "request",
origin: "client",
message: {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "long_operation" },
},
};
export const SuccessCollapsed: Story = {
args: {
entry: toolCallEntry,
isPinned: false,
isListExpanded: false,
},
};
export const SuccessExpanded: Story = {
args: {
entry: toolCallEntry,
isPinned: false,
isListExpanded: true,
},
};
export const Error: Story = {
args: {
entry: errorEntry,
isPinned: false,
isListExpanded: true,
},
};
export const Pinned: Story = {
args: {
entry: resourceReadEntry,
isPinned: true,
isListExpanded: false,
},
};
export const Pending: Story = {
args: {
entry: pendingEntry,
isPinned: false,
isListExpanded: false,
},
};

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