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:
parent
0fde2d0024
commit
ace4250d31
3 changed files with 413 additions and 405 deletions
|
|
@ -0,0 +1,118 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
|
||||
import { fn } from "storybook/test";
|
||||
import { ProtocolListPanel } from "./ProtocolListPanel.js";
|
||||
import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions";
|
||||
|
||||
const meta: Meta<typeof ProtocolListPanel> = {
|
||||
title: "Groups/ProtocolListPanel",
|
||||
component: ProtocolListPanel,
|
||||
args: {
|
||||
searchText: "",
|
||||
pinnedIds: new Set<string>(),
|
||||
onClearAll: fn(),
|
||||
onExport: fn(),
|
||||
onClearSection: fn(),
|
||||
onExportSection: fn(),
|
||||
onReplay: fn(),
|
||||
onTogglePin: fn(),
|
||||
sortDirection: "newest-first",
|
||||
onSortChange: fn(),
|
||||
compact: true,
|
||||
onToggleCompact: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProtocolListPanel>;
|
||||
|
||||
const sampleEntries: MessageEntry[] = [
|
||||
{
|
||||
id: "req-1",
|
||||
timestamp: new Date("2026-03-17T10:00:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "send_message", arguments: { message: "Hello!" } },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: "Sent" }] },
|
||||
},
|
||||
duration: 120,
|
||||
},
|
||||
{
|
||||
id: "req-2",
|
||||
timestamp: new Date("2026-03-17T10:01:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "resources/read",
|
||||
params: { uri: "file:///config.json" },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
result: {
|
||||
contents: [{ uri: "file:///config.json", text: "{}" }],
|
||||
},
|
||||
},
|
||||
duration: 45,
|
||||
},
|
||||
{
|
||||
id: "req-3",
|
||||
timestamp: new Date("2026-03-17T10:02:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "delete_records", arguments: { ids: [1, 2, 3] } },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
error: { code: -32000, message: "Permission denied" },
|
||||
},
|
||||
duration: 350,
|
||||
},
|
||||
{
|
||||
id: "req-4",
|
||||
timestamp: new Date("2026-03-17T09:30:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
method: "tools/list",
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
result: { tools: [] },
|
||||
},
|
||||
duration: 80,
|
||||
},
|
||||
];
|
||||
|
||||
export const WithEntries: Story = {
|
||||
args: {
|
||||
entries: sampleEntries,
|
||||
pinnedIds: new Set(["req-4"]),
|
||||
},
|
||||
// The list ScrollArea reserves a scrollbar gutter (offsetScrollbars) so the
|
||||
// bar never overlays the cards, and uses type="scroll" so it stays hidden
|
||||
// when idle rather than appearing on hover (#1474).
|
||||
play: async ({ canvasElement }) => {
|
||||
expectScrollbarGutterIdleHidden(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
entries: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ProtocolListPanel } from "./ProtocolListPanel";
|
||||
|
||||
const sampleEntries: MessageEntry[] = [
|
||||
{
|
||||
id: "req-1",
|
||||
timestamp: new Date("2026-03-17T10:00:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "send_message", arguments: { message: "Hello!" } },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: "Sent" }] },
|
||||
},
|
||||
duration: 120,
|
||||
},
|
||||
{
|
||||
id: "req-2",
|
||||
timestamp: new Date("2026-03-17T10:01:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "resources/read",
|
||||
params: { uri: "file:///config.json" },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
result: {
|
||||
contents: [{ uri: "file:///config.json", text: "{}" }],
|
||||
},
|
||||
},
|
||||
duration: 45,
|
||||
},
|
||||
{
|
||||
id: "req-3",
|
||||
timestamp: new Date("2026-03-17T10:02:00Z"),
|
||||
direction: "request",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/list",
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
result: { tools: [] },
|
||||
},
|
||||
duration: 80,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
onSortChange: vi.fn(),
|
||||
compact: true,
|
||||
onToggleCompact: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ProtocolListPanel", () => {
|
||||
it("renders the title and Export button", () => {
|
||||
renderWithMantine(<ProtocolListPanel {...baseProps} entries={[]} />);
|
||||
expect(screen.getByText("Requests")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no entries", () => {
|
||||
renderWithMantine(<ProtocolListPanel {...baseProps} entries={[]} />);
|
||||
expect(screen.getByText("No request history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when entries exist but none match the filter", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
searchText="zzznotfound"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No request history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the History header when there are no pinned entries", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel {...baseProps} entries={sampleEntries} />,
|
||||
);
|
||||
// With no pinned section to distinguish it from, the header is dropped...
|
||||
expect(screen.queryByText("History (3)")).toBeNull();
|
||||
// ...but the entries themselves are still shown.
|
||||
expect(screen.getByText("resources/read")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Pinned title with count when entries are pinned", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
pinnedIds={new Set(["req-1"])}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Pinned Requests (1)")).toBeInTheDocument();
|
||||
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(
|
||||
<ProtocolListPanel
|
||||
{...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 unpinned section headerless, with its entries shown", () => {
|
||||
// Only the unpinned section → no header at all, no accordion toggle,
|
||||
// entries always visible.
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel {...baseProps} entries={sampleEntries} />,
|
||||
);
|
||||
expect(screen.queryByText("History (3)")).toBeNull();
|
||||
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(
|
||||
<ProtocolListPanel
|
||||
{...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(<ProtocolListPanel {...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(
|
||||
<ProtocolListPanel
|
||||
{...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(
|
||||
<ProtocolListPanel {...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(
|
||||
<ProtocolListPanel
|
||||
{...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(
|
||||
<ProtocolListPanel
|
||||
{...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(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={directional}
|
||||
visibleDirections={{ client: true, server: false }}
|
||||
/>,
|
||||
);
|
||||
// The server-origin entry is filtered out, leaving the client one.
|
||||
expect(screen.getByText("tools/call")).toBeInTheDocument();
|
||||
expect(screen.queryByText("resources/read")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters entries by searchText (case-insensitive)", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
searchText="config.json"
|
||||
/>,
|
||||
);
|
||||
// Only the resources/read entry references config.json.
|
||||
expect(screen.getByText("resources/read")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tools/call")).toBeNull();
|
||||
expect(screen.queryByText("tools/list")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters entries by methodFilter", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
methodFilter="tools/list"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("tools/list")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tools/call")).toBeNull();
|
||||
expect(screen.queryByText("resources/read")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes onExport when Export is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExport = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
onExport={onExport}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Export" }));
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onClearAll when Clear is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClearAll = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes onReplay with the entry id when Replay is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplay = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={[sampleEntries[0]]}
|
||||
onReplay={onReplay}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Replay" }));
|
||||
expect(onReplay).toHaveBeenCalledWith("req-1");
|
||||
});
|
||||
|
||||
it("invokes onTogglePin with the entry id when Pin is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePin = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={[sampleEntries[0]]}
|
||||
onTogglePin={onTogglePin}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Pin" }));
|
||||
expect(onTogglePin).toHaveBeenCalledWith("req-1");
|
||||
});
|
||||
|
||||
it("renders only pinned section when all entries are pinned", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={[sampleEntries[0]]}
|
||||
pinnedIds={new Set(["req-1"])}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Pinned Requests (1)")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^History \(/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onReplay and onTogglePin from the pinned section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReplay = vi.fn();
|
||||
const onTogglePin = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={[sampleEntries[0]]}
|
||||
pinnedIds={new Set(["req-1"])}
|
||||
onReplay={onReplay}
|
||||
onTogglePin={onTogglePin}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Replay" }));
|
||||
expect(onReplay).toHaveBeenCalledWith("req-1");
|
||||
await user.click(screen.getByRole("button", { name: "Unpin" }));
|
||||
expect(onTogglePin).toHaveBeenCalledWith("req-1");
|
||||
});
|
||||
|
||||
it("renders entries newest-first by default", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel {...baseProps} entries={sampleEntries} />,
|
||||
);
|
||||
const methods = screen.getAllByText(
|
||||
/tools\/call|resources\/read|tools\/list/,
|
||||
);
|
||||
expect(methods[0]).toHaveTextContent("tools/list");
|
||||
expect(methods[methods.length - 1]).toHaveTextContent("tools/call");
|
||||
});
|
||||
|
||||
it("reorders entries when sortDirection is oldest-first", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
sortDirection="oldest-first"
|
||||
/>,
|
||||
);
|
||||
const methods = screen.getAllByText(
|
||||
/tools\/call|resources\/read|tools\/list/,
|
||||
);
|
||||
expect(methods[0]).toHaveTextContent("tools/call");
|
||||
expect(methods[methods.length - 1]).toHaveTextContent("tools/list");
|
||||
});
|
||||
|
||||
it("invokes onSortChange when the user picks a new sort", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSortChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
onSortChange={onSortChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("textbox", { name: "History sort direction" }),
|
||||
);
|
||||
await user.click(await screen.findByText("Oldest First"));
|
||||
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
|
||||
});
|
||||
|
||||
it("renders entries collapsed when compact is true (default parity with Network)", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel {...baseProps} entries={sampleEntries} compact />,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Expand" }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders entries expanded when compact is false", () => {
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
compact={false}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Collapse" }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("invokes onToggleCompact when the ListToggle is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleCompact = vi.fn();
|
||||
renderWithMantine(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
entries={sampleEntries}
|
||||
onToggleCompact={onToggleCompact}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Expand all" }));
|
||||
expect(onToggleCompact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type {
|
||||
MessageEntry,
|
||||
MessageMethod,
|
||||
MessageOrigin,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import {
|
||||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { extractMethod } from "../protocolUtils.js";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
export interface ProtocolListPanelProps {
|
||||
entries: MessageEntry[];
|
||||
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: ProtocolSectionName) => void;
|
||||
/** Export just one section's entries. */
|
||||
onExportSection: (section: ProtocolSectionName) => void;
|
||||
onReplay: (id: string) => void;
|
||||
onTogglePin: (id: string) => void;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (next: SortDirection) => void;
|
||||
compact: boolean;
|
||||
onToggleCompact: () => void;
|
||||
/** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
|
||||
onPin?: () => void;
|
||||
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
withBorder: true,
|
||||
p: "lg",
|
||||
flex: 1,
|
||||
variant: "panel",
|
||||
});
|
||||
|
||||
const EmptyState = Text.withProps({
|
||||
c: "dimmed",
|
||||
ta: "center",
|
||||
py: "xl",
|
||||
});
|
||||
|
||||
// The section header is a single "pleat" bar (rounded, with the filter-button
|
||||
// outline-on-hover treatment and 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
|
||||
// ProtocolControls.
|
||||
const SectionLinkButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "xs",
|
||||
});
|
||||
|
||||
function formatPinnedTitle(count: number): string {
|
||||
return `Pinned Requests (${count})`;
|
||||
}
|
||||
|
||||
function formatHistoryTitle(count: number): string {
|
||||
return `History (${count})`;
|
||||
}
|
||||
|
||||
type ProtocolSectionName = "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) —
|
||||
// unless `hideHeaderWhenAlone`, in which case the lone section drops its title
|
||||
// entirely (the "Protocol" label is redundant when there's nothing to
|
||||
// distinguish it from).
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
collapsible,
|
||||
hideHeaderWhenAlone = false,
|
||||
open,
|
||||
onToggle,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
collapsible: boolean;
|
||||
hideHeaderWhenAlone?: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!collapsible) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{hideHeaderWhenAlone ? null : <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 | undefined,
|
||||
// The embedded column exposes only the search box (no direction/method
|
||||
// controls), so it applies the text filter but skips those (#1616).
|
||||
ignoreDirectionAndMethod: boolean,
|
||||
): boolean {
|
||||
const method = extractMethod(entry);
|
||||
if (!ignoreDirectionAndMethod) {
|
||||
// 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;
|
||||
if (methodFilter && method !== methodFilter) return false;
|
||||
}
|
||||
if (searchText) {
|
||||
const term = searchText.toLowerCase();
|
||||
const responseText = entry.response ? JSON.stringify(entry.response) : "";
|
||||
const searchable =
|
||||
`${method} ${entry.id} ${JSON.stringify(entry.message)} ${responseText}`.toLowerCase();
|
||||
if (!searchable.includes(term)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ProtocolListPanel({
|
||||
entries,
|
||||
pinnedIds,
|
||||
searchText,
|
||||
methodFilter,
|
||||
visibleDirections,
|
||||
onClearAll,
|
||||
onExport,
|
||||
onClearSection,
|
||||
onExportSection,
|
||||
onReplay,
|
||||
onTogglePin,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
compact,
|
||||
onToggleCompact,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: ProtocolListPanelProps) {
|
||||
const viewportRef = useScrollMemory("protocol-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(() => {
|
||||
// Embedded column filters by text only (its direction/method controls live
|
||||
// in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns
|
||||
// a fresh array, so sorting in-place is safe.
|
||||
const sorted = entries
|
||||
.filter((e) =>
|
||||
matchesFilters(
|
||||
e,
|
||||
searchText,
|
||||
visibleDirections,
|
||||
methodFilter,
|
||||
embedded,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
if (sortDirection === "newest-first") sorted.reverse();
|
||||
return sorted;
|
||||
}, [
|
||||
entries,
|
||||
searchText,
|
||||
visibleDirections,
|
||||
methodFilter,
|
||||
sortDirection,
|
||||
embedded,
|
||||
]);
|
||||
|
||||
const pinnedEntries = useMemo(
|
||||
() => filteredEntries.filter((e) => pinnedIds.has(e.id)),
|
||||
[filteredEntries, pinnedIds],
|
||||
);
|
||||
|
||||
const unpinnedEntries = useMemo(
|
||||
() => filteredEntries.filter((e) => !pinnedIds.has(e.id)),
|
||||
[filteredEntries, pinnedIds],
|
||||
);
|
||||
|
||||
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>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>Requests</Title>
|
||||
<Group gap="xs">
|
||||
<SortToggle
|
||||
value={sortDirection}
|
||||
onChange={onSortChange}
|
||||
aria-label="History sort direction"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClearAll}
|
||||
disabled={unpinnedEntries.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant="default" onClick={onExport} disabled={!hasResults}>
|
||||
Export
|
||||
</Button>
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasResults ? (
|
||||
<EmptyState>No request history</EmptyState>
|
||||
) : (
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="md">
|
||||
{pinnedEntries.length > 0 && (
|
||||
<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) => (
|
||||
<ProtocolEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isPinned={true}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{unpinnedEntries.length > 0 && (
|
||||
<CollapsibleSection
|
||||
title={formatHistoryTitle(unpinnedEntries.length)}
|
||||
collapsible={bothSections}
|
||||
// With no pinned section to distinguish it from, the lone
|
||||
// "History (N)" header is redundant — drop it.
|
||||
hideHeaderWhenAlone
|
||||
open={historyOpen}
|
||||
onToggle={() => setHistoryOpen((v) => !v)}
|
||||
actions={
|
||||
bothSections ? (
|
||||
<SectionActions
|
||||
onClear={() => onClearSection("history")}
|
||||
onExport={() => onExportSection("history")}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{unpinnedEntries.map((entry) => (
|
||||
<ProtocolEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isPinned={false}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</Stack>
|
||||
</EmbeddableScrollArea>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue