feat(web): compact two-line HistoryEntry/NetworkEntry for the narrow column (#1616)
In the pinned monitoring column (embedded), the single-line entry headers wrap badly. Add compact two-line variants gated on `embedded`: - HistoryEntry: line 1 = time + client/server badge + elapsed + status; line 2 = method (and target, truncated) on the left with the controls on the right, where Replay becomes an icon (new ReplayButton element) next to Pin/Expand. - NetworkEntry: line 1 = time + HTTP method + category + elapsed + status; line 2 = the URL in a horizontal ScrollArea (new Text `nowrap` variant) with the expand toggle on the right. The full-size (non-embedded) layouts are unchanged. `embedded` is threaded from the History/Network panels into the entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bcf10c75dd
commit
3550b64032
6 changed files with 325 additions and 58 deletions
|
|
@ -208,6 +208,49 @@ describe("HistoryEntry", () => {
|
|||
expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand"));
|
||||
});
|
||||
|
||||
it("renders the compact two-line layout with Replay as an icon when embedded", () => {
|
||||
renderWithMantine(
|
||||
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
|
||||
);
|
||||
// Line 1 essentials plus the method are still shown.
|
||||
expect(screen.getByText("client → server")).toBeInTheDocument();
|
||||
expect(screen.getByText("142ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("OK")).toBeInTheDocument();
|
||||
expect(screen.getByText("tools/call")).toBeInTheDocument();
|
||||
// Replay is an icon button (aria-label), not the text button.
|
||||
expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Replay")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps action order Replay, Pin, Expand in the compact layout", () => {
|
||||
renderWithMantine(
|
||||
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
|
||||
);
|
||||
const names = screen
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(names.indexOf("Replay")).toBeLessThan(names.indexOf("Pin"));
|
||||
expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand"));
|
||||
});
|
||||
|
||||
it("does not render a Replay icon for a non-replayable method when embedded", () => {
|
||||
// A server→client response isn't replayable.
|
||||
const responseEntry: MessageEntry = {
|
||||
id: "resp-1",
|
||||
timestamp: new Date("2026-03-17T10:30:00Z"),
|
||||
direction: "response",
|
||||
origin: "server",
|
||||
message: { jsonrpc: "2.0", id: 1, result: {} },
|
||||
};
|
||||
renderWithMantine(
|
||||
<HistoryEntry {...baseProps} entry={responseEntry} embedded />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Replay" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onTogglePin when Pin button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePin = vi.fn();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
|||
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
|
||||
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||
import { PinToggle } from "../../elements/PinToggle/PinToggle";
|
||||
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
|
||||
import { extractMethod, isReplayableHistoryMethod } from "../historyUtils.js";
|
||||
|
||||
export interface HistoryEntryProps {
|
||||
|
|
@ -22,6 +23,12 @@ export interface HistoryEntryProps {
|
|||
isListExpanded: boolean;
|
||||
onReplay: () => void;
|
||||
onTogglePin: () => void;
|
||||
/**
|
||||
* Compact two-line header for the narrow monitoring column (#1616): line 1 is
|
||||
* time + direction + duration + status; line 2 is the method (and target) with
|
||||
* the controls — Replay as an icon — on the right.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const EntryContainer = Card.withProps({
|
||||
|
|
@ -34,6 +41,19 @@ const HeaderRow = Group.withProps({
|
|||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
// Left / right clusters within a compact header line. The left cluster shrinks
|
||||
// (`miw: 0`) so a long target can truncate rather than push the row wider.
|
||||
const HeaderCluster = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const ControlsCluster = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const TimestampText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
|
|
@ -108,6 +128,7 @@ export function HistoryEntry({
|
|||
isListExpanded,
|
||||
onReplay,
|
||||
onTogglePin,
|
||||
embedded = false,
|
||||
}: HistoryEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
const method = extractMethod(entry);
|
||||
|
|
@ -119,44 +140,88 @@ export function HistoryEntry({
|
|||
setIsExpanded(isListExpanded);
|
||||
}, [isListExpanded]);
|
||||
|
||||
const directionBadge = entry.origin && (
|
||||
<MessageDirectionBadge
|
||||
direction={entry.origin === "client" ? "outgoing" : "incoming"}
|
||||
/>
|
||||
);
|
||||
const statusBadge = status !== "none" && (
|
||||
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
|
||||
);
|
||||
const durationText = entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
<Stack gap="sm">
|
||||
<HeaderRow>
|
||||
<Group gap="sm">
|
||||
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
|
||||
{entry.origin && (
|
||||
<MessageDirectionBadge
|
||||
direction={entry.origin === "client" ? "outgoing" : "incoming"}
|
||||
/>
|
||||
)}
|
||||
<Badge color="dark">{method}</Badge>
|
||||
{target && <TargetText>{target}</TargetText>}
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{status !== "none" && (
|
||||
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
{embedded ? (
|
||||
// Compact two-line header for the narrow column.
|
||||
<Stack gap="xs">
|
||||
<HeaderRow>
|
||||
<HeaderCluster>
|
||||
<TimestampText>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</TimestampText>
|
||||
{directionBadge}
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>
|
||||
{durationText}
|
||||
{statusBadge}
|
||||
</ControlsCluster>
|
||||
</HeaderRow>
|
||||
<HeaderRow>
|
||||
<HeaderCluster flex={1}>
|
||||
<Badge color="dark">{method}</Badge>
|
||||
{target && (
|
||||
<TargetText truncate="end" miw={0}>
|
||||
{target}
|
||||
</TargetText>
|
||||
)}
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>
|
||||
{canReplay && <ReplayButton onReplay={onReplay} />}
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</ControlsCluster>
|
||||
</HeaderRow>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<HeaderRow>
|
||||
<Group gap="sm">
|
||||
<TimestampText>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</TimestampText>
|
||||
{directionBadge}
|
||||
<Badge color="dark">{method}</Badge>
|
||||
{target && <TargetText>{target}</TargetText>}
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{durationText}
|
||||
{statusBadge}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Group gap="xs">
|
||||
{canReplay && (
|
||||
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Group gap="xs">
|
||||
{canReplay && (
|
||||
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ export function HistoryListPanel({
|
|||
entry={entry}
|
||||
isPinned={true}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
|
|
@ -349,6 +350,7 @@ export function HistoryListPanel({
|
|||
entry={entry}
|
||||
isPinned={false}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,20 @@ describe("NetworkEntry", () => {
|
|||
expect(screen.getByText("transport")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the compact two-line layout (line 1 meta, line 2 URL) when embedded", () => {
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={baseEntry} isListExpanded={false} embedded />,
|
||||
);
|
||||
// Line 1: method, category, duration, status.
|
||||
expect(screen.getByText("POST")).toBeInTheDocument();
|
||||
expect(screen.getByText("transport")).toBeInTheDocument();
|
||||
expect(screen.getByText("45ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("200 OK")).toBeInTheDocument();
|
||||
// Line 2: the URL (in its scroll area) and the expand toggle.
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows body / header detail when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
|
|
@ -19,6 +20,12 @@ import { maskSecretsInBody } from "../../../utils/maskSecrets";
|
|||
export interface NetworkEntryProps {
|
||||
entry: FetchRequestEntry;
|
||||
isListExpanded: boolean;
|
||||
/**
|
||||
* Compact two-line header for the narrow monitoring column (#1616): line 1 is
|
||||
* time + method + category + duration + status; line 2 is the URL in a
|
||||
* horizontal scroll area with the expand toggle on the right.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const EntryContainer = Card.withProps({
|
||||
|
|
@ -43,6 +50,26 @@ const UrlText = Text.withProps({
|
|||
truncate: "end",
|
||||
});
|
||||
|
||||
// Compact-header URL: never wraps, so a long URL scrolls horizontally inside its
|
||||
// ScrollArea instead of wrapping to many lines.
|
||||
const UrlScroll = Text.withProps({
|
||||
size: "sm",
|
||||
fw: 500,
|
||||
variant: "nowrap",
|
||||
});
|
||||
|
||||
// Left / right clusters for a compact header line (mirrors HistoryEntry).
|
||||
const HeaderCluster = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const ControlsCluster = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const DurationText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
|
|
@ -197,7 +224,11 @@ function BodyPreview({
|
|||
);
|
||||
}
|
||||
|
||||
export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
||||
export function NetworkEntry({
|
||||
entry,
|
||||
isListExpanded,
|
||||
embedded = false,
|
||||
}: NetworkEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
|
||||
// The list-level Expand/Collapse toggle is authoritative: each time the
|
||||
|
|
@ -210,33 +241,70 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
|||
setIsExpanded(isListExpanded);
|
||||
}, [isListExpanded]);
|
||||
|
||||
const metaBadges = (
|
||||
<>
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
||||
<Badge color={statusColor(entry)}>{statusLabel(entry)}</Badge>
|
||||
</>
|
||||
);
|
||||
const expandToggle = (
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
<Stack gap="sm">
|
||||
<HeaderRow>
|
||||
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
|
||||
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
<UrlText>{entry.url}</UrlText>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
||||
<Badge color={statusColor(entry)}>{statusLabel(entry)}</Badge>
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
{embedded ? (
|
||||
// Compact two-line header for the narrow column.
|
||||
<Stack gap="xs">
|
||||
<HeaderRow>
|
||||
<HeaderCluster>
|
||||
<TimestampText>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>{metaBadges}</ControlsCluster>
|
||||
</HeaderRow>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<ScrollArea type="hover" scrollbarSize={6} flex={1} miw={0}>
|
||||
<UrlScroll>{entry.url}</UrlScroll>
|
||||
</ScrollArea>
|
||||
{expandToggle}
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<HeaderRow>
|
||||
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
|
||||
<TimestampText>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
<UrlText>{entry.url}</UrlText>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{metaBadges}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{expandToggle}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function NetworkStreamPanel({
|
|||
key={entry.id}
|
||||
entry={entry}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
|
|||
Loading…
Reference in a new issue