From 3550b6403242ab386d768ce4b6b55b178ed4688d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 20:53:23 -0400 Subject: [PATCH] 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) --- .../ReplayButton/ReplayButton.stories.tsx | 19 +++ .../ReplayButton/ReplayButton.test.tsx | 19 +++ .../elements/ReplayButton/ReplayButton.tsx | 26 ++++ .../groups/HistoryEntry/HistoryEntry.test.tsx | 43 ++++++ .../groups/HistoryEntry/HistoryEntry.tsx | 133 +++++++++++++----- .../HistoryListPanel/HistoryListPanel.tsx | 2 + .../groups/NetworkEntry/NetworkEntry.test.tsx | 14 ++ .../groups/NetworkEntry/NetworkEntry.tsx | 116 +++++++++++---- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 1 + clients/web/src/theme/Text.ts | 10 ++ 10 files changed, 325 insertions(+), 58 deletions(-) create mode 100644 clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx create mode 100644 clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx create mode 100644 clients/web/src/components/elements/ReplayButton/ReplayButton.tsx diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx new file mode 100644 index 00000000..6391f2b4 --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { ReplayButton } from "./ReplayButton"; + +const meta: Meta = { + title: "Elements/ReplayButton", + component: ReplayButton, + args: { onReplay: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx new file mode 100644 index 00000000..e992e920 --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ReplayButton } from "./ReplayButton"; + +describe("ReplayButton", () => { + it("renders a labelled replay button", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + }); + + it("invokes onReplay when clicked", async () => { + const user = userEvent.setup(); + const onReplay = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Replay" })); + expect(onReplay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx new file mode 100644 index 00000000..6cac503e --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx @@ -0,0 +1,26 @@ +import { ActionIcon } from "@mantine/core"; +import { MdReplay } from "react-icons/md"; + +export interface ReplayButtonProps { + /** Re-send the owning history request. */ + onReplay: () => void; +} + +/** + * Icon form of the "Replay" action, used in the compact (column) HistoryEntry + * layout where the text button is replaced by a replay icon sitting next to the + * pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling. + */ +export function ReplayButton({ onReplay }: ReplayButtonProps) { + return ( + + + + ); +} diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx index 6ed5e769..8ebce289 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx @@ -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( + , + ); + // 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( + , + ); + 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( + , + ); + 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(); diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx index 77ac2e73..9546ee3b 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx @@ -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 && ( + + ); + const statusBadge = status !== "none" && ( + {statusLabel(status)} + ); + const durationText = entry.duration != null && ( + {formatDuration(entry.duration)} + ); + return ( - - - {formatTimestamp(entry.timestamp)} - {entry.origin && ( - - )} - {method} - {target && {target}} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {status !== "none" && ( - {statusLabel(status)} - )} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestamp(entry.timestamp)} + + {directionBadge} + + + {durationText} + {statusBadge} + + + + + {method} + {target && ( + + {target} + + )} + + + {canReplay && } + + setIsExpanded((v) => !v)} + /> + + + + ) : ( + <> + + + + {formatTimestamp(entry.timestamp)} + + {directionBadge} + {method} + {target && {target}} + + + {durationText} + {statusBadge} + + - - - {canReplay && ( - Replay - )} - - - - setIsExpanded((v) => !v)} - /> - - + + + {canReplay && ( + Replay + )} + + + + setIsExpanded((v) => !v)} + /> + + + + )} diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index a6a7514b..05133ba0 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -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)} /> diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 514e78e8..5f37ad51 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -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( + , + ); + // 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( diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index a0a58cfb..c80baee5 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -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 && ( + {formatDuration(entry.duration)} + )} + {isLongLivedStream(entry) && SSE} + {statusLabel(entry)} + + ); + const expandToggle = ( + setIsExpanded((v) => !v)} + /> + ); + return ( - - - {formatTimestamp(entry.timestamp)} - {entry.method} - - {entry.category} - - {entry.url} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {isLongLivedStream(entry) && SSE} - {statusLabel(entry)} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestamp(entry.timestamp)} + + {entry.method} + + {entry.category} + + + {metaBadges} + + + + {entry.url} + + {expandToggle} + + + ) : ( + <> + + + + {formatTimestamp(entry.timestamp)} + + {entry.method} + + {entry.category} + + {entry.url} + + + {metaBadges} + + - - setIsExpanded((v) => !v)} - /> - + + {expandToggle} + + + )} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 168c2aa0..509f5a98 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -147,6 +147,7 @@ export function NetworkStreamPanel({ key={entry.id} entry={entry} isListExpanded={!compact} + embedded={embedded} /> ))} diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index fa5cbde5..818ceb00 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -16,6 +16,16 @@ export const ThemeText = Text.extend({ }, }; } + // Single-line text that never wraps — used inside a horizontal ScrollArea so + // a long value (e.g. a network URL in the compact column) scrolls instead of + // wrapping to many lines (#1616). + if (props.variant === "nowrap") { + return { + root: { + whiteSpace: "nowrap", + }, + }; + } return { root: {} }; }, });