Merge pull request #1218 from modelcontextprotocol/v2/phase-2-history

Phase 2: Refactor History cluster to use MessageEntry
This commit is contained in:
Cliff Hall 2026-04-20 18:14:56 -04:00 • committed by GitHub
commit 7b74598f83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 478 additions and 380 deletions

View file

@ -5,12 +5,6 @@ import { HistoryControls } from "./HistoryControls";
const meta: Meta<typeof HistoryControls> = {
title: "Groups/HistoryControls",
component: HistoryControls,
};
export default meta;
type Story = StoryObj<typeof HistoryControls>;
export const Default: Story = {
args: {
searchText: "",
onSearchChange: fn(),
@ -18,11 +12,14 @@ export const Default: Story = {
},
};
export default meta;
type Story = StoryObj<typeof HistoryControls>;
export const Default: Story = {};
export const WithFilters: Story = {
args: {
searchText: "tools",
methodFilter: "tools/call",
onSearchChange: fn(),
onMethodFilterChange: fn(),
},
};

View file

@ -15,7 +15,7 @@ export interface HistoryControlsProps {
searchText: string;
methodFilter?: string;
onSearchChange: (text: string) => void;
onMethodFilterChange: (method: string) => void;
onMethodFilterChange: (method: string | undefined) => void;
}
export function HistoryControls({
@ -38,7 +38,7 @@ export function HistoryControls({
placeholder="All methods"
data={METHOD_OPTIONS}
value={methodFilter ?? null}
onChange={(value) => onMethodFilterChange(value ?? "")}
onChange={(value) => onMethodFilterChange(value ?? undefined)}
clearable
/>
</Stack>

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test";
import { HistoryEntry } from "./HistoryEntry";
@ -14,13 +15,79 @@ const meta: Meta<typeof HistoryEntry> = {
export default meta;
type Story = StoryObj<typeof HistoryEntry>;
const toolCallEntry: MessageEntry = {
id: "req-1",
timestamp: new Date("2026-03-17T10:30:00Z"),
direction: "request",
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",
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",
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",
message: {
jsonrpc: "2.0",
id: 4,
method: "tools/call",
params: { name: "long_operation" },
},
};
export const SuccessCollapsed: Story = {
args: {
timestamp: "2026-03-17T10:30:00Z",
method: "tools/call",
target: "get_weather",
status: "success",
durationMs: 142,
entry: toolCallEntry,
isPinned: false,
isListExpanded: false,
},
@ -28,79 +95,32 @@ export const SuccessCollapsed: Story = {
export const SuccessExpanded: Story = {
args: {
timestamp: "2026-03-17T10:30:00Z",
method: "tools/call",
target: "get_weather",
status: "success",
durationMs: 142,
entry: toolCallEntry,
isPinned: false,
isListExpanded: true,
parameters: {
city: "San Francisco",
units: "celsius",
},
response: {
temperature: 18,
conditions: "Partly cloudy",
humidity: 65,
},
},
};
export const Error: Story = {
args: {
timestamp: "2026-03-17T10:31:15Z",
method: "tools/call",
target: "query_database",
status: "error",
durationMs: 3200,
isPinned: false,
isListExpanded: false,
},
};
export const WithChildren: Story = {
args: {
timestamp: "2026-03-17T10:32:00Z",
method: "tools/call",
target: "complex_operation",
status: "success",
durationMs: 1250,
entry: errorEntry,
isPinned: false,
isListExpanded: true,
parameters: {
action: "process",
},
response: {
result: "completed",
},
childEntries: [
{
timestamp: "+120ms",
method: "sampling/createMessage",
target: "gpt-4",
status: "success",
durationMs: 800,
},
{
timestamp: "+950ms",
method: "elicitation/create",
target: "confirm_action",
status: "success",
durationMs: 200,
},
],
},
};
export const Pinned: Story = {
args: {
timestamp: "2026-03-17T10:33:00Z",
method: "resources/read",
target: "config.json",
status: "success",
durationMs: 45,
entry: resourceReadEntry,
isPinned: true,
isListExpanded: false,
},
};
export const Pending: Story = {
args: {
entry: pendingEntry,
isPinned: false,
isListExpanded: false,
},
};

View file

@ -9,25 +9,12 @@ import {
Stack,
Text,
} from "@mantine/core";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
export interface HistoryChildEntry {
timestamp: string;
method: string;
target?: string;
status: "success" | "error";
durationMs: number;
}
import { extractMethod } from "../historyUtils.js";
export interface HistoryEntryProps {
timestamp: string;
method: string;
target?: string;
status: "success" | "error";
durationMs: number;
parameters?: Record<string, unknown>;
response?: Record<string, unknown>;
childEntries?: HistoryChildEntry[];
entry: MessageEntry;
isPinned: boolean;
isListExpanded: boolean;
onReplay: () => void;
@ -65,64 +52,79 @@ const SubtleButton = Button.withProps({
size: "xs",
});
const ChildMethodBadge = Badge.withProps({
color: "dark",
size: "sm",
});
function formatDuration(ms: number): string {
return `${ms}ms`;
}
function formatStatusLabel(status: "success" | "error"): string {
return status === "success" ? "OK" : "Error";
}
function statusColor(status: "success" | "error"): string {
return status === "success" ? "green" : "red";
function formatTimestamp(date: Date): string {
return date.toISOString();
}
function formatPinLabel(isPinned: boolean): string {
return isPinned ? "Unpin" : "Pin";
}
function serializeJson(value: Record<string, unknown>): string {
function extractTarget(entry: MessageEntry): string | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
const params = msg.params as Record<string, unknown>;
if (typeof params.name === "string") return params.name;
if (typeof params.uri === "string") return params.uri;
return undefined;
}
function extractStatus(entry: MessageEntry): "success" | "error" | "pending" {
if (!entry.response) return "pending";
if ("error" in entry.response) return "error";
return "success";
}
function statusColor(status: "success" | "error" | "pending"): string {
if (status === "success") return "green";
if (status === "error") return "red";
return "gray";
}
function statusLabel(status: "success" | "error" | "pending"): string {
if (status === "success") return "OK";
if (status === "error") return "Error";
return "Pending";
}
function serializeMessage(value: unknown): string {
return JSON.stringify(value);
}
export function HistoryEntry({
timestamp,
method,
target,
status,
durationMs,
parameters,
response,
childEntries,
entry,
isPinned,
isListExpanded,
onReplay,
onTogglePin,
}: HistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded);
const method = extractMethod(entry);
const target = extractTarget(entry);
const status = extractStatus(entry);
useEffect(() => {
setIsExpanded(isListExpanded);
}, [isListExpanded]);
return (
<EntryContainer>
<Stack gap="sm">
<HeaderRow>
<Group gap="sm">
<TimestampText>{timestamp}</TimestampText>
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
<Badge color="dark">{method}</Badge>
{target && <TargetText>{target}</TargetText>}
</Group>
<Group gap="sm">
<DurationText>{formatDuration(durationMs)}</DurationText>
<Badge color={statusColor(status)}>
{formatStatusLabel(status)}
</Badge>
{entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
)}
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
</Group>
</HeaderRow>
@ -136,57 +138,35 @@ export function HistoryEntry({
</SubtleButton>
</Group>
{isExpanded && (
<Collapse in={isExpanded}>
<Stack gap="sm">
{parameters && (
<>
<Divider />
<Stack gap="xs">
<Text size="sm">Parameters:</Text>
<ContentViewer
block={{
type: "text",
text: serializeJson(parameters),
}}
copyable
/>
</Stack>
</>
)}
{response && (
<Stack gap="xs">
<Text size="sm">Response:</Text>
<ContentViewer
block={{
type: "text",
text: serializeJson(response),
}}
copyable
/>
</Stack>
)}
{childEntries && childEntries.length > 0 && (
<Stack gap="xs">
{childEntries.map((child, index) => (
<Group key={index} pl="lg" gap="sm">
<TimestampText>+-- </TimestampText>
<TimestampText>{child.timestamp}</TimestampText>
<ChildMethodBadge>{child.method}</ChildMethodBadge>
{child.target && <Text size="sm">{child.target}</Text>}
<Badge color={statusColor(child.status)} size="sm">
{formatStatusLabel(child.status)}
</Badge>
<DurationText>
{formatDuration(child.durationMs)}
</DurationText>
</Group>
))}
</Stack>
)}
</Stack>
</Collapse>
)}
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
{"params" in entry.message && entry.message.params && (
<Stack gap="xs">
<Text size="sm">Parameters:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.message.params),
}}
copyable
/>
</Stack>
)}
{entry.response && (
<Stack gap="xs">
<Text size="sm">Response:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.response),
}}
copyable
/>
</Stack>
)}
</Stack>
</Collapse>
</Stack>
</EntryContainer>
);

View file

@ -1,72 +1,105 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test";
import { HistoryListPanel } from "./HistoryListPanel.js";
import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
const meta: Meta<typeof HistoryListPanel> = {
title: "Groups/HistoryListPanel",
component: HistoryListPanel,
args: {
searchText: "",
pinnedIds: new Set<string>(),
onClearAll: fn(),
onExport: fn(),
onReplay: fn(),
onTogglePin: fn(),
},
};
export default meta;
type Story = StoryObj<typeof HistoryListPanel>;
function makeEntry(overrides: Partial<HistoryEntryProps>): HistoryEntryProps {
return {
timestamp: "2026-03-17T10:00:00Z",
method: "tools/call",
target: "send_message",
status: "success",
durationMs: 120,
isPinned: false,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
...overrides,
};
}
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: [
makeEntry({
timestamp: "2026-03-17T10:00:00Z",
method: "tools/call",
target: "send_message",
}),
makeEntry({
timestamp: "2026-03-17T10:01:00Z",
method: "resources/read",
target: "config.json",
durationMs: 45,
}),
makeEntry({
timestamp: "2026-03-17T10:02:00Z",
method: "tools/call",
target: "delete_records",
status: "error",
durationMs: 350,
}),
],
pinnedEntries: [
makeEntry({
timestamp: "2026-03-17T09:30:00Z",
method: "tools/list",
isPinned: true,
durationMs: 80,
}),
],
entries: sampleEntries,
pinnedIds: new Set(["req-4"]),
},
};
export const Empty: Story = {
args: {
entries: [],
pinnedEntries: [],
},
};

View file

@ -8,17 +8,20 @@ import {
Text,
Title,
} from "@mantine/core";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { HistoryEntry } from "../HistoryEntry/HistoryEntry";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
import { extractMethod } from "../historyUtils.js";
export interface HistoryRequestsPanelProps {
entries: HistoryEntryProps[];
pinnedEntries: HistoryEntryProps[];
export interface HistoryListPanelProps {
entries: MessageEntry[];
pinnedIds: Set<string>;
searchText: string;
methodFilter?: string;
onClearAll: () => void;
onExport: () => void;
onReplay: (id: string) => void;
onTogglePin: (id: string) => void;
}
const ToolbarButton = Button.withProps({
@ -39,10 +42,6 @@ const EmptyState = Text.withProps({
py: "xl",
});
function entryKey(entry: HistoryEntryProps): string {
return `${entry.timestamp}-${entry.method}`;
}
function formatPinnedTitle(count: number): string {
return `Pinned Requests (${count})`;
}
@ -52,16 +51,17 @@ function formatHistoryTitle(count: number): string {
}
function matchesFilters(
entry: HistoryEntryProps,
entry: MessageEntry,
searchText: string,
methodFilter?: string,
): boolean {
if (methodFilter && entry.method !== methodFilter) return false;
const method = extractMethod(entry);
if (methodFilter && method !== methodFilter) return false;
if (searchText) {
const term = searchText.toLowerCase();
const responseText = entry.response ? JSON.stringify(entry.response) : "";
const searchable =
`${entry.method} ${entry.target ?? ""} ${entry.timestamp} ${responseText}`.toLowerCase();
`${method} ${entry.id} ${JSON.stringify(entry.message)} ${responseText}`.toLowerCase();
if (!searchable.includes(term)) return false;
}
return true;
@ -69,12 +69,14 @@ function matchesFilters(
export function HistoryListPanel({
entries,
pinnedEntries,
pinnedIds,
searchText,
methodFilter,
onClearAll,
onExport,
}: HistoryRequestsPanelProps) {
onReplay,
onTogglePin,
}: HistoryListPanelProps) {
const [compact, setCompact] = useState(false);
const filteredEntries = useMemo(
@ -82,13 +84,17 @@ export function HistoryListPanel({
[entries, searchText, methodFilter],
);
const filteredPinned = useMemo(
() =>
pinnedEntries.filter((e) => matchesFilters(e, searchText, methodFilter)),
[pinnedEntries, searchText, methodFilter],
const pinnedEntries = useMemo(
() => filteredEntries.filter((e) => pinnedIds.has(e.id)),
[filteredEntries, pinnedIds],
);
const hasResults = filteredEntries.length > 0 || filteredPinned.length > 0;
const unpinnedEntries = useMemo(
() => filteredEntries.filter((e) => !pinnedIds.has(e.id)),
[filteredEntries, pinnedIds],
);
const hasResults = filteredEntries.length > 0;
return (
<PanelContainer>
@ -110,34 +116,40 @@ export function HistoryListPanel({
) : (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<Stack gap="md">
{filteredPinned.length > 0 && (
{pinnedEntries.length > 0 && (
<>
<Title order={5}>
{formatPinnedTitle(filteredPinned.length)}
{formatPinnedTitle(pinnedEntries.length)}
</Title>
{filteredPinned.map((entry) => (
{pinnedEntries.map((entry) => (
<HistoryEntry
key={entryKey(entry)}
{...entry}
key={entry.id}
entry={entry}
isPinned={true}
isListExpanded={!compact}
onReplay={() => onReplay(entry.id)}
onTogglePin={() => onTogglePin(entry.id)}
/>
))}
</>
)}
{filteredEntries.length > 0 && (
{unpinnedEntries.length > 0 && (
<>
<Group justify="space-between">
<Title order={5}>
{formatHistoryTitle(filteredEntries.length)}
{formatHistoryTitle(unpinnedEntries.length)}
</Title>
<ToolbarButton onClick={onClearAll}>Clear</ToolbarButton>
</Group>
{filteredEntries.map((entry) => (
{unpinnedEntries.map((entry) => (
<HistoryEntry
key={entryKey(entry)}
{...entry}
key={entry.id}
entry={entry}
isPinned={false}
isListExpanded={!compact}
onReplay={() => onReplay(entry.id)}
onTogglePin={() => onTogglePin(entry.id)}
/>
))}
</>

View file

@ -0,0 +1,8 @@
import type { MessageEntry } from "../../../../../core/mcp/types.js";
export function extractMethod(entry: MessageEntry): string {
if ("method" in entry.message) {
return entry.message.method;
}
return "response";
}

View file

@ -1,101 +1,126 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test";
import { HistoryScreen } from "./HistoryScreen";
import type { HistoryEntryProps } from "../../groups/HistoryEntry/HistoryEntry";
const meta: Meta<typeof HistoryScreen> = {
title: "Screens/HistoryScreen",
component: HistoryScreen,
parameters: { layout: "fullscreen" },
args: {
pinnedIds: new Set<string>(),
onClearAll: fn(),
onExport: fn(),
onReplay: fn(),
onTogglePin: fn(),
},
};
export default meta;
type Story = StoryObj<typeof HistoryScreen>;
const entry1: HistoryEntryProps = {
timestamp: "2026-03-17T10:00:00Z",
method: "tools/call",
target: "send_message",
status: "success",
durationMs: 120,
parameters: { message: "Hello, world!" },
response: { result: "Message sent successfully" },
isPinned: false,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
const entry2: HistoryEntryProps = {
timestamp: "2026-03-17T10:01:00Z",
method: "resources/read",
target: "config.json",
status: "success",
durationMs: 45,
parameters: { uri: "file:///config.json" },
response: { contents: [{ uri: "file:///config.json", text: "{}" }] },
isPinned: false,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
const entry3: HistoryEntryProps = {
timestamp: "2026-03-17T10:02:00Z",
method: "tools/call",
target: "delete_records",
status: "error",
durationMs: 350,
parameters: { ids: [1, 2, 3] },
response: { error: "Permission denied" },
isPinned: false,
isListExpanded: true,
onReplay: fn(),
onTogglePin: fn(),
};
const pinnedEntry1: HistoryEntryProps = {
timestamp: "2026-03-17T09:30:00Z",
method: "tools/list",
status: "success",
durationMs: 80,
response: { tools: ["send_message", "list_users"] },
isPinned: true,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
const pinnedEntry2: HistoryEntryProps = {
timestamp: "2026-03-17T09:35:00Z",
method: "prompts/get",
target: "greeting",
status: "success",
durationMs: 60,
parameters: { name: "greeting" },
response: {
messages: [{ role: "user", content: { type: "text", text: "Hello!" } }],
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, world!" } },
},
response: {
jsonrpc: "2.0",
id: 1,
result: {
content: [{ type: "text", text: "Message sent successfully" }],
},
},
duration: 120,
},
isPinned: true,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
};
{
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,
},
{
id: "req-5",
timestamp: new Date("2026-03-17T09:35:00Z"),
direction: "request",
message: {
jsonrpc: "2.0",
id: 5,
method: "prompts/get",
params: { name: "greeting" },
},
response: {
jsonrpc: "2.0",
id: 5,
result: {
messages: [{ role: "user", content: { type: "text", text: "Hello!" } }],
},
},
duration: 60,
},
];
export const WithEntries: Story = {
args: {
entries: [entry1, entry2, entry3],
pinnedEntries: [pinnedEntry1, pinnedEntry2],
entries: sampleEntries,
pinnedIds: new Set(["req-4", "req-5"]),
},
};
export const Empty: Story = {
args: {
entries: [],
pinnedEntries: [],
},
};

View file

@ -1,14 +1,16 @@
import { useState } from "react";
import { Card, Flex, Stack } from "@mantine/core";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { HistoryControls } from "../../groups/HistoryControls/HistoryControls";
import { HistoryListPanel } from "../../groups/HistoryListPanel/HistoryListPanel.js";
import type { HistoryEntryProps } from "../../groups/HistoryEntry/HistoryEntry";
export interface HistoryScreenProps {
entries: HistoryEntryProps[];
pinnedEntries: HistoryEntryProps[];
entries: MessageEntry[];
pinnedIds: Set<string>;
onClearAll: () => void;
onExport: () => void;
onReplay: (id: string) => void;
onTogglePin: (id: string) => void;
}
const ScreenLayout = Flex.withProps({
@ -30,9 +32,11 @@ const SidebarCard = Card.withProps({
export function HistoryScreen({
entries,
pinnedEntries,
pinnedIds,
onClearAll,
onExport,
onReplay,
onTogglePin,
}: HistoryScreenProps) {
const [searchText, setSearchText] = useState("");
const [methodFilter, setMethodFilter] = useState<string | undefined>();
@ -45,19 +49,19 @@ export function HistoryScreen({
searchText={searchText}
methodFilter={methodFilter}
onSearchChange={setSearchText}
onMethodFilterChange={(value) =>
setMethodFilter(value || undefined)
}
onMethodFilterChange={setMethodFilter}
/>
</SidebarCard>
</Sidebar>
<HistoryListPanel
entries={entries}
pinnedEntries={pinnedEntries}
pinnedIds={pinnedIds}
searchText={searchText}
methodFilter={methodFilter}
onClearAll={onClearAll}
onExport={onExport}
onReplay={onReplay}
onTogglePin={onTogglePin}
/>
</ScreenLayout>
);

View file

@ -599,79 +599,98 @@ export const HistoryActive: Story = {
<HistoryScreen
entries={[
{
timestamp: "2026-03-17T10:00:00Z",
method: "tools/call",
target: "send_message",
status: "success",
durationMs: 120,
parameters: { message: "Hello, world!" },
response: { result: "Message sent successfully" },
isPinned: false,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
},
{
timestamp: "2026-03-17T10:01:00Z",
method: "resources/read",
target: "config.json",
status: "success",
durationMs: 45,
parameters: { uri: "file:///config.json" },
response: {
contents: [{ uri: "file:///config.json", text: "{}" }],
id: "req-1",
timestamp: new Date("2026-03-17T10:00:00Z"),
direction: "request" as const,
message: {
jsonrpc: "2.0" as const,
id: 1,
method: "tools/call",
params: {
name: "send_message",
arguments: { message: "Hello, world!" },
},
},
isPinned: false,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
response: {
jsonrpc: "2.0" as const,
id: 1,
result: {
content: [{ type: "text", text: "Message sent successfully" }],
},
},
duration: 120,
},
{
timestamp: "2026-03-17T10:02:00Z",
method: "tools/call",
target: "delete_records",
status: "error",
durationMs: 350,
parameters: { ids: [1, 2, 3] },
response: { error: "Permission denied" },
isPinned: false,
isListExpanded: true,
onReplay: fn(),
onTogglePin: fn(),
},
]}
pinnedEntries={[
{
timestamp: "2026-03-17T09:30:00Z",
method: "tools/list",
status: "success",
durationMs: 80,
response: { tools: ["send_message", "list_users"] },
isPinned: true,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
},
{
timestamp: "2026-03-17T09:35:00Z",
method: "prompts/get",
target: "greeting",
status: "success",
durationMs: 60,
parameters: { name: "greeting" },
response: {
messages: [
{ role: "user", content: { type: "text", text: "Hello!" } },
],
},
isPinned: true,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
id: "req-2",
timestamp: new Date("2026-03-17T10:01:00Z"),
direction: "request" as const,
message: {
jsonrpc: "2.0" as const,
id: 2,
method: "resources/read",
params: { uri: "file:///config.json" },
},
response: {
jsonrpc: "2.0" as const,
id: 2,
result: {
contents: [{ uri: "file:///config.json", text: "{}" }],
},
},
duration: 45,
},
{
id: "req-3",
timestamp: new Date("2026-03-17T10:02:00Z"),
direction: "request" as const,
message: {
jsonrpc: "2.0" as const,
id: 3,
method: "tools/call",
params: { name: "delete_records", arguments: { ids: [1, 2, 3] } },
},
response: {
jsonrpc: "2.0" as const,
id: 3,
error: { code: -32000, message: "Permission denied" },
},
duration: 350,
},
{
id: "req-4",
timestamp: new Date("2026-03-17T09:30:00Z"),
direction: "request" as const,
message: { jsonrpc: "2.0" as const, id: 4, method: "tools/list" },
response: { jsonrpc: "2.0" as const, id: 4, result: { tools: [] } },
duration: 80,
},
{
id: "req-5",
timestamp: new Date("2026-03-17T09:35:00Z"),
direction: "request" as const,
message: {
jsonrpc: "2.0" as const,
id: 5,
method: "prompts/get",
params: { name: "greeting" },
},
response: {
jsonrpc: "2.0" as const,
id: 5,
result: {
messages: [
{ role: "user", content: { type: "text", text: "Hello!" } },
],
},
},
duration: 60,
},
]}
pinnedIds={new Set(["req-4", "req-5"])}
onClearAll={fn()}
onExport={fn()}
onReplay={fn()}
onTogglePin={fn()}
/>
),
},