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:
cliffhall 2026-07-08 20:53:23 -04:00
commit 3550b64032
6 changed files with 325 additions and 58 deletions

View file

@ -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();

View file

@ -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">

View file

@ -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)}
/>

View file

@ -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(

View file

@ -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">

View file

@ -147,6 +147,7 @@ export function NetworkStreamPanel({
key={entry.id}
entry={entry}
isListExpanded={!compact}
embedded={embedded}
/>
))}
</Stack>