Phase 2: Refactor History cluster to use MessageEntry

Replace ad hoc flat props with MessageEntry from core/mcp/types.ts
across the History group components and HistoryScreen.

- HistoryEntry: accept `entry: MessageEntry` instead of flat props
  (timestamp, method, target, status, durationMs, parameters, response);
  derive method/target/status from embedded JSON-RPC objects; fix
  Collapse animation by removing outer {isExpanded && ...} wrapper;
  drop childEntries (not part of MessageEntry)
- HistoryControls: type onMethodFilterChange as (string | undefined)
- HistoryListPanel: accept `MessageEntry[]` + `pinnedIds: Set<string>`
  instead of separate entries/pinnedEntries arrays with pre-bound
  callbacks; pass onReplay/onTogglePin by entry ID
- HistoryScreen: accept `MessageEntry[]` + `pinnedIds` + ID-based
  callbacks; collapse two-array model into single list with pinned
  overlay
- Delete local HistoryEntryProps data shape, HistoryChildEntry type
- Update all stories with real JSON-RPC MessageEntry fixtures
- Update ConnectedView HistoryActive story

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-20 16:55:23 -04:00
commit f104e6e21a
9 changed files with 479 additions and 380 deletions

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { HistoryEntry } from "./HistoryEntry"; import { HistoryEntry } from "./HistoryEntry";
@ -14,13 +15,79 @@ const meta: Meta<typeof HistoryEntry> = {
export default meta; export default meta;
type Story = StoryObj<typeof HistoryEntry>; 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 = { export const SuccessCollapsed: Story = {
args: { args: {
timestamp: "2026-03-17T10:30:00Z", entry: toolCallEntry,
method: "tools/call",
target: "get_weather",
status: "success",
durationMs: 142,
isPinned: false, isPinned: false,
isListExpanded: false, isListExpanded: false,
}, },
@ -28,79 +95,32 @@ export const SuccessCollapsed: Story = {
export const SuccessExpanded: Story = { export const SuccessExpanded: Story = {
args: { args: {
timestamp: "2026-03-17T10:30:00Z", entry: toolCallEntry,
method: "tools/call",
target: "get_weather",
status: "success",
durationMs: 142,
isPinned: false, isPinned: false,
isListExpanded: true, isListExpanded: true,
parameters: {
city: "San Francisco",
units: "celsius",
},
response: {
temperature: 18,
conditions: "Partly cloudy",
humidity: 65,
},
}, },
}; };
export const Error: Story = { export const Error: Story = {
args: { args: {
timestamp: "2026-03-17T10:31:15Z", entry: errorEntry,
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,
isPinned: false, isPinned: false,
isListExpanded: true, 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 = { export const Pinned: Story = {
args: { args: {
timestamp: "2026-03-17T10:33:00Z", entry: resourceReadEntry,
method: "resources/read",
target: "config.json",
status: "success",
durationMs: 45,
isPinned: true, isPinned: true,
isListExpanded: false, isListExpanded: false,
}, },
}; };
export const Pending: Story = {
args: {
entry: pendingEntry,
isPinned: false,
isListExpanded: false,
},
};

View file

@ -9,25 +9,11 @@ import {
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
export interface HistoryChildEntry {
timestamp: string;
method: string;
target?: string;
status: "success" | "error";
durationMs: number;
}
export interface HistoryEntryProps { export interface HistoryEntryProps {
timestamp: string; entry: MessageEntry;
method: string;
target?: string;
status: "success" | "error";
durationMs: number;
parameters?: Record<string, unknown>;
response?: Record<string, unknown>;
childEntries?: HistoryChildEntry[];
isPinned: boolean; isPinned: boolean;
isListExpanded: boolean; isListExpanded: boolean;
onReplay: () => void; onReplay: () => void;
@ -65,64 +51,86 @@ const SubtleButton = Button.withProps({
size: "xs", size: "xs",
}); });
const ChildMethodBadge = Badge.withProps({
color: "dark",
size: "sm",
});
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
return `${ms}ms`; return `${ms}ms`;
} }
function formatStatusLabel(status: "success" | "error"): string { function formatTimestamp(date: Date): string {
return status === "success" ? "OK" : "Error"; return date.toISOString();
}
function statusColor(status: "success" | "error"): string {
return status === "success" ? "green" : "red";
} }
function formatPinLabel(isPinned: boolean): string { function formatPinLabel(isPinned: boolean): string {
return isPinned ? "Unpin" : "Pin"; return isPinned ? "Unpin" : "Pin";
} }
function serializeJson(value: Record<string, unknown>): string { function extractMethod(entry: MessageEntry): string {
if ("method" in entry.message) {
return entry.message.method;
}
return "response";
}
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); return JSON.stringify(value);
} }
export function HistoryEntry({ export function HistoryEntry({
timestamp, entry,
method,
target,
status,
durationMs,
parameters,
response,
childEntries,
isPinned, isPinned,
isListExpanded, isListExpanded,
onReplay, onReplay,
onTogglePin, onTogglePin,
}: HistoryEntryProps) { }: HistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded); const [isExpanded, setIsExpanded] = useState(isListExpanded);
const method = extractMethod(entry);
const target = extractTarget(entry);
const status = extractStatus(entry);
useEffect(() => { useEffect(() => {
setIsExpanded(isListExpanded); setIsExpanded(isListExpanded);
}, [isListExpanded]); }, [isListExpanded]);
return ( return (
<EntryContainer> <EntryContainer>
<Stack gap="sm"> <Stack gap="sm">
<HeaderRow> <HeaderRow>
<Group gap="sm"> <Group gap="sm">
<TimestampText>{timestamp}</TimestampText> <TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
<Badge color="dark">{method}</Badge> <Badge color="dark">{method}</Badge>
{target && <TargetText>{target}</TargetText>} {target && <TargetText>{target}</TargetText>}
</Group> </Group>
<Group gap="sm"> <Group gap="sm">
<DurationText>{formatDuration(durationMs)}</DurationText> {entry.duration != null && (
<Badge color={statusColor(status)}> <DurationText>{formatDuration(entry.duration)}</DurationText>
{formatStatusLabel(status)} )}
</Badge> <Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
</Group> </Group>
</HeaderRow> </HeaderRow>
@ -136,57 +144,33 @@ export function HistoryEntry({
</SubtleButton> </SubtleButton>
</Group> </Group>
{isExpanded && ( <Collapse in={isExpanded}>
<Collapse in={isExpanded}> <Stack gap="sm">
<Stack gap="sm"> <Divider />
{parameters && ( <Stack gap="xs">
<> <Text size="sm">Request:</Text>
<Divider /> <ContentViewer
<Stack gap="xs"> block={{
<Text size="sm">Parameters:</Text> type: "text",
<ContentViewer text: serializeMessage(entry.message),
block={{ }}
type: "text", copyable
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> </Stack>
</Collapse> {entry.response && (
)} <Stack gap="xs">
<Text size="sm">Response:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.response),
}}
copyable
/>
</Stack>
)}
</Stack>
</Collapse>
</Stack> </Stack>
</EntryContainer> </EntryContainer>
); );

View file

@ -1,72 +1,105 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { HistoryListPanel } from "./HistoryListPanel.js"; import { HistoryListPanel } from "./HistoryListPanel.js";
import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
const meta: Meta<typeof HistoryListPanel> = { const meta: Meta<typeof HistoryListPanel> = {
title: "Groups/HistoryListPanel", title: "Groups/HistoryListPanel",
component: HistoryListPanel, component: HistoryListPanel,
args: { args: {
searchText: "", searchText: "",
pinnedIds: new Set<string>(),
onClearAll: fn(), onClearAll: fn(),
onExport: fn(), onExport: fn(),
onReplay: fn(),
onTogglePin: fn(),
}, },
}; };
export default meta; export default meta;
type Story = StoryObj<typeof HistoryListPanel>; type Story = StoryObj<typeof HistoryListPanel>;
function makeEntry(overrides: Partial<HistoryEntryProps>): HistoryEntryProps { const sampleEntries: MessageEntry[] = [
return { {
timestamp: "2026-03-17T10:00:00Z", id: "req-1",
method: "tools/call", timestamp: new Date("2026-03-17T10:00:00Z"),
target: "send_message", direction: "request",
status: "success", message: {
durationMs: 120, jsonrpc: "2.0",
isPinned: false, id: 1,
isListExpanded: false, method: "tools/call",
onReplay: fn(), params: { name: "send_message", arguments: { message: "Hello!" } },
onTogglePin: fn(), },
...overrides, 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 = { export const WithEntries: Story = {
args: { args: {
entries: [ entries: sampleEntries,
makeEntry({ pinnedIds: new Set(["req-4"]),
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,
}),
],
}, },
}; };
export const Empty: Story = { export const Empty: Story = {
args: { args: {
entries: [], entries: [],
pinnedEntries: [],
}, },
}; };

View file

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

View file

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

View file

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

View file

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