Merge pull request #1218 from modelcontextprotocol/v2/phase-2-history
Phase 2: Refactor History cluster to use MessageEntry
This commit is contained in:
commit
7b74598f83
10 changed files with 478 additions and 380 deletions
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
8
clients/web/src/components/groups/historyUtils.ts
Normal file
8
clients/web/src/components/groups/historyUtils.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue