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
2 changed files with 479 additions and 380 deletions

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,11 @@ 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;
}
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 +51,86 @@ 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 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);
}
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 +144,33 @@ 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>
)}
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
<Stack gap="xs">
<Text size="sm">Request:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.message),
}}
copyable
/>
</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>
</EntryContainer>
);