feat(sort): add SortToggle for Logs, History, and Network (#1371) (#1372)

* feat(sort): add SortToggle for Logs, History, and Network (#1371)

Introduces a reusable SortToggle element and wires it into the Logs,
History, and Network screens so users can flip chronological order.
Selection is persisted per-screen to localStorage under the
inspector.sortDirection.* namespace, with invalid stored values falling
back to the newest-first default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(logs): move auto-scroll checkbox left of the sort selector

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(logs): remove auto-scroll checkbox; ListToggle left of SortToggle

- Drops the Logs auto-scroll checkbox and its plumbing through
  LoggingScreen / InspectorView.
- Moves the ListToggle to the left of the SortToggle on the History
  and Network panel toolbars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sort): address review on #1372

- Hydrate sort direction synchronously (getInitialValueInEffect: false)
  to remove the one-frame newest-first flicker on first paint.
- Drop the redundant `[...matched].sort()` copy across the three panels;
  `.filter()` already returns a fresh array.
- Document why serializeSortDirection bypasses Mantine's default JSON
  stringify (raw enum value in storage).
- MemoryStorage.getItem: collapse `has` + `!` to `?? null`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(history): default the request list to collapsed for parity with Network

The History list opened expanded while Network opened collapsed; align
History with Network so both panels start in the same compact view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(history,network): persist list compact state to localStorage

Lifts the per-panel `compact` flag out of HistoryListPanel /
NetworkStreamPanel and into InspectorView's localStorage hooks, mirroring
the sort-direction shape:

  inspector.listCompact.history
  inspector.listCompact.network

Defaults to collapsed (compact=true) on both, with a corrupted-value
deserializer that clamps unknown strings back to the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(servers,resources): persist ListToggle state to localStorage

Lifts the per-screen compact preference out of ServerListScreen and
ResourceControls into InspectorView's localStorage hooks, matching the
History/Network shape:

  inspector.listCompact.servers
  inspector.listCompact.resources

Servers and Resources both default to expanded (compact=false). For
ResourceControls the displayed compact is still derived from openSections
(so per-section accordion clicks still work locally), and only explicit
ListToggle clicks update the persisted preference — the handler computes
the next compact value from the click outcome rather than blindly
flipping, so a half-open accordion still records the right post-state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sort-toggle): switch to icon button matching ListToggle style

Replaces the Mantine Select with a subtle Button (or ActionIcon for the
subtle variant), using TbSortDescending2 for newest-first and
TbSortAscending2 for oldest-first. Clicking the button flips between the
two values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sort-toggle): restore Select with sort icon in rightSection

Reverts SortToggle to a Mantine Select with simplified labels ("Newest
First" / "Oldest First") and the corresponding TbSortDescending2 /
TbSortAscending2 icon in the rightSection, replacing the default chevron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sort-toggle): swap icons so they match label direction

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sort-toggle): use down arrow for newest, up arrow for oldest

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(inspector-view): extract useSortDirection / useListCompact

Collapses seven near-identical useLocalStorage call sites in
InspectorView into two scoped helpers. Each helper carries the shared
namespace, serialize/deserialize adapters, and getInitialValueInEffect:
false; the call sites now read as "this screen has sort persisted to
this scope" rather than re-stating the storage shape every time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sort-toggle): apply prettier formatting

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-29 14:47:19 -04:00 • committed by GitHub
commit dd0f5de820
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 812 additions and 117 deletions

View file

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { SortToggle } from "./SortToggle";
const meta: Meta<typeof SortToggle> = {
title: "Elements/SortToggle",
component: SortToggle,
args: {
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof SortToggle>;
export const NewestFirst: Story = {
args: {
value: "newest-first",
},
};
export const OldestFirst: Story = {
args: {
value: "oldest-first",
},
};
export const FlipsDirection: Story = {
args: {
value: "newest-first",
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const select = await body.findByRole("textbox", {
name: "Sort direction",
});
await userEvent.click(select);
await userEvent.click(await body.findByText("Oldest First"));
await expect(args.onChange).toHaveBeenCalledWith("oldest-first");
},
};

View file

@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { SortToggle } from "./SortToggle";
describe("SortToggle", () => {
it("renders the current value as the selected option", () => {
renderWithMantine(<SortToggle value="newest-first" onChange={() => {}} />);
expect(screen.getByDisplayValue("Newest First")).toBeInTheDocument();
});
it("renders 'Oldest First' when value is oldest-first", () => {
renderWithMantine(<SortToggle value="oldest-first" onChange={() => {}} />);
expect(screen.getByDisplayValue("Oldest First")).toBeInTheDocument();
});
it("uses the default aria-label", () => {
renderWithMantine(<SortToggle value="newest-first" onChange={() => {}} />);
expect(
screen.getByRole("textbox", { name: "Sort direction" }),
).toBeInTheDocument();
});
it("honors a custom aria-label", () => {
renderWithMantine(
<SortToggle
value="newest-first"
onChange={() => {}}
aria-label="Logs sort"
/>,
);
expect(
screen.getByRole("textbox", { name: "Logs sort" }),
).toBeInTheDocument();
});
it("invokes onChange with the new direction when the user picks another option", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(<SortToggle value="newest-first" onChange={onChange} />);
await user.click(screen.getByRole("textbox", { name: "Sort direction" }));
await user.click(await screen.findByText("Oldest First"));
expect(onChange).toHaveBeenCalledWith("oldest-first");
});
});

View file

@ -0,0 +1,42 @@
import { Select } from "@mantine/core";
import { TbSortAscending2, TbSortDescending2 } from "react-icons/tb";
export type SortDirection = "oldest-first" | "newest-first";
export interface SortToggleProps {
value: SortDirection;
onChange: (next: SortDirection) => void;
"aria-label"?: string;
}
const OPTIONS: { value: SortDirection; label: string }[] = [
{ value: "newest-first", label: "Newest First" },
{ value: "oldest-first", label: "Oldest First" },
];
function isSortDirection(value: string | null): value is SortDirection {
return value === "oldest-first" || value === "newest-first";
}
export function SortToggle({
value,
onChange,
"aria-label": ariaLabel = "Sort direction",
}: SortToggleProps) {
const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2;
return (
<Select
size="sm"
w={150}
data={OPTIONS}
value={value}
onChange={(next) => {
if (isSortDirection(next)) onChange(next);
}}
allowDeselect={false}
withCheckIcon={false}
rightSection={<Icon size={16} />}
aria-label={ariaLabel}
/>
);
}

View file

@ -13,6 +13,10 @@ const meta: Meta<typeof HistoryListPanel> = {
onExport: fn(), onExport: fn(),
onReplay: fn(), onReplay: fn(),
onTogglePin: fn(), onTogglePin: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
compact: true,
onToggleCompact: fn(),
}, },
}; };

View file

@ -66,6 +66,10 @@ const baseProps = {
onExport: vi.fn(), onExport: vi.fn(),
onReplay: vi.fn(), onReplay: vi.fn(),
onTogglePin: vi.fn(), onTogglePin: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
compact: true,
onToggleCompact: vi.fn(),
}; };
describe("HistoryListPanel", () => { describe("HistoryListPanel", () => {
@ -219,22 +223,82 @@ describe("HistoryListPanel", () => {
expect(onTogglePin).toHaveBeenCalledWith("req-1"); expect(onTogglePin).toHaveBeenCalledWith("req-1");
}); });
it("toggles compact list state when ListToggle is clicked", async () => { it("renders entries newest-first by default", () => {
const user = userEvent.setup();
renderWithMantine( renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} />, <HistoryListPanel {...baseProps} entries={sampleEntries} />,
); );
// Initially expanded — Collapse buttons exist on each entry, and the const methods = screen.getAllByText(
// ListToggle exposes its aria-label as "Collapse all". /tools\/call|resources\/read|tools\/list/,
expect( );
screen.getAllByRole("button", { name: "Collapse" }).length, expect(methods[0]).toHaveTextContent("tools/list");
).toBeGreaterThan(0); expect(methods[methods.length - 1]).toHaveTextContent("tools/call");
});
await user.click(screen.getByRole("button", { name: "Collapse all" })); it("reorders entries when sortDirection is oldest-first", () => {
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
sortDirection="oldest-first"
/>,
);
const methods = screen.getAllByText(
/tools\/call|resources\/read|tools\/list/,
);
expect(methods[0]).toHaveTextContent("tools/call");
expect(methods[methods.length - 1]).toHaveTextContent("tools/list");
});
// After toggle, entries collapsed — they show Expand it("invokes onSortChange when the user picks a new sort", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
onSortChange={onSortChange}
/>,
);
await user.click(
screen.getByRole("textbox", { name: "History sort direction" }),
);
await user.click(await screen.findByText("Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
});
it("renders entries collapsed when compact is true (default parity with Network)", () => {
renderWithMantine(
<HistoryListPanel {...baseProps} entries={sampleEntries} compact />,
);
expect( expect(
screen.getAllByRole("button", { name: "Expand" }).length, screen.getAllByRole("button", { name: "Expand" }).length,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
it("renders entries expanded when compact is false", () => {
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
compact={false}
/>,
);
expect(
screen.getAllByRole("button", { name: "Collapse" }).length,
).toBeGreaterThan(0);
});
it("invokes onToggleCompact when the ListToggle is clicked", async () => {
const user = userEvent.setup();
const onToggleCompact = vi.fn();
renderWithMantine(
<HistoryListPanel
{...baseProps}
entries={sampleEntries}
onToggleCompact={onToggleCompact}
/>,
);
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(onToggleCompact).toHaveBeenCalledTimes(1);
});
}); });

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { import {
Button, Button,
Group, Group,
@ -11,6 +11,10 @@ import {
import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js"; import type { MessageEntry, MessageMethod } from "@inspector/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 {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
import { extractMethod } from "../historyUtils.js"; import { extractMethod } from "../historyUtils.js";
export interface HistoryListPanelProps { export interface HistoryListPanelProps {
@ -22,6 +26,10 @@ export interface HistoryListPanelProps {
onExport: () => void; onExport: () => void;
onReplay: (id: string) => void; onReplay: (id: string) => void;
onTogglePin: (id: string) => void; onTogglePin: (id: string) => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
} }
const PanelContainer = Paper.withProps({ const PanelContainer = Paper.withProps({
@ -71,13 +79,19 @@ export function HistoryListPanel({
onExport, onExport,
onReplay, onReplay,
onTogglePin, onTogglePin,
sortDirection,
onSortChange,
compact,
onToggleCompact,
}: HistoryListPanelProps) { }: HistoryListPanelProps) {
const [compact, setCompact] = useState(false); const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const filteredEntries = useMemo( const sorted = entries
() => entries.filter((e) => matchesFilters(e, searchText, methodFilter)), .filter((e) => matchesFilters(e, searchText, methodFilter))
[entries, searchText, methodFilter], .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
); if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, searchText, methodFilter, sortDirection]);
const pinnedEntries = useMemo( const pinnedEntries = useMemo(
() => filteredEntries.filter((e) => pinnedIds.has(e.id)), () => filteredEntries.filter((e) => pinnedIds.has(e.id)),
@ -97,11 +111,13 @@ export function HistoryListPanel({
<Title order={4}>Requests</Title> <Title order={4}>Requests</Title>
<Group gap="xs"> <Group gap="xs">
{hasResults && ( {hasResults && (
<ListToggle <ListToggle compact={compact} onToggle={onToggleCompact} />
compact={compact}
onToggle={() => setCompact((c) => !c)}
/>
)} )}
<SortToggle
value={sortDirection}
onChange={onSortChange}
aria-label="History sort direction"
/>
<Button <Button
variant="default" variant="default"
onClick={onClearAll} onClick={onClearAll}

View file

@ -19,10 +19,10 @@ const meta: Meta<typeof LogStreamPanel> = {
alert: true, alert: true,
emergency: true, emergency: true,
}, },
autoScroll: true,
onToggleAutoScroll: fn(),
onClear: fn(), onClear: fn(),
onExport: fn(), onExport: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
}, },
}; };

View file

@ -43,10 +43,10 @@ const baseProps = {
entries, entries,
filterText: "", filterText: "",
visibleLevels: allVisible, visibleLevels: allVisible,
autoScroll: true,
onToggleAutoScroll: vi.fn(),
onClear: vi.fn(), onClear: vi.fn(),
onExport: vi.fn(), onExport: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
}; };
describe("LogStreamPanel", () => { describe("LogStreamPanel", () => {
@ -149,18 +149,39 @@ describe("LogStreamPanel", () => {
expect(onExport).toHaveBeenCalledTimes(1); expect(onExport).toHaveBeenCalledTimes(1);
}); });
it("invokes onToggleAutoScroll when the auto-scroll checkbox is clicked", async () => { it("renders entries newest-first by default", () => {
const user = userEvent.setup(); renderWithMantine(<LogStreamPanel {...baseProps} />);
const onToggleAutoScroll = vi.fn(); const items = screen.getAllByText(
renderWithMantine( /Server started|Failed to read|Loading config|deprecated/,
<LogStreamPanel {...baseProps} onToggleAutoScroll={onToggleAutoScroll} />,
); );
await user.click(screen.getByLabelText("Auto-scroll")); // Newest receivedAt is the warning entry; oldest is "Server started".
expect(onToggleAutoScroll).toHaveBeenCalledTimes(1); expect(items[0]).toHaveTextContent('{"code":42,"msg":"deprecated"}');
expect(items[items.length - 1]).toHaveTextContent("Server started");
}); });
it("renders auto-scroll checkbox with correct checked state", () => { it("reorders entries when sortDirection is oldest-first", () => {
renderWithMantine(<LogStreamPanel {...baseProps} autoScroll={false} />); renderWithMantine(
expect(screen.getByLabelText("Auto-scroll")).not.toBeChecked(); <LogStreamPanel {...baseProps} sortDirection="oldest-first" />,
);
const items = screen.getAllByText(
/Server started|Failed to read|Loading config|deprecated/,
);
expect(items[0]).toHaveTextContent("Server started");
expect(items[items.length - 1]).toHaveTextContent(
'{"code":42,"msg":"deprecated"}',
);
});
it("invokes onSortChange when the user picks a new sort", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderWithMantine(
<LogStreamPanel {...baseProps} onSortChange={onSortChange} />,
);
await user.click(
screen.getByRole("textbox", { name: "Logs sort direction" }),
);
await user.click(await screen.findByText("Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
}); });
}); });

View file

@ -1,7 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
Button, Button,
Checkbox,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@ -12,15 +11,19 @@ import {
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
import { LogEntry } from "../../elements/LogEntry/LogEntry"; import { LogEntry } from "../../elements/LogEntry/LogEntry";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
export interface LogStreamPanelProps { export interface LogStreamPanelProps {
entries: LogEntryData[]; entries: LogEntryData[];
filterText: string; filterText: string;
visibleLevels: Record<LoggingLevel, boolean>; visibleLevels: Record<LoggingLevel, boolean>;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onClear: () => void; onClear: () => void;
onExport: () => void; onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
} }
const PanelContainer = Paper.withProps({ const PanelContainer = Paper.withProps({
@ -61,25 +64,29 @@ export function LogStreamPanel({
entries, entries,
filterText, filterText,
visibleLevels, visibleLevels,
autoScroll,
onToggleAutoScroll,
onClear, onClear,
onExport, onExport,
sortDirection,
onSortChange,
}: LogStreamPanelProps) { }: LogStreamPanelProps) {
const filteredEntries = useMemo( const filteredEntries = useMemo(() => {
() => entries.filter((e) => matchesFilters(e, filterText, visibleLevels)), // `.filter()` returns a fresh array, so sorting in-place is safe.
[entries, filterText, visibleLevels], const sorted = entries
); .filter((e) => matchesFilters(e, filterText, visibleLevels))
.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
if (sortDirection === "newest-first") sorted.reverse();
return sorted;
}, [entries, filterText, visibleLevels, sortDirection]);
return ( return (
<PanelContainer> <PanelContainer>
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={4}>Log Stream</Title> <Title order={4}>Log Stream</Title>
<Group> <Group>
<Checkbox <SortToggle
label="Auto-scroll" value={sortDirection}
checked={autoScroll} onChange={onSortChange}
onChange={onToggleAutoScroll} aria-label="Logs sort direction"
/> />
<Button <Button
variant="default" variant="default"

View file

@ -12,6 +12,10 @@ const meta: Meta<typeof NetworkStreamPanel> = {
visibleCategories: { auth: true, transport: true }, visibleCategories: { auth: true, transport: true },
onClear: fn(), onClear: fn(),
onExport: fn(), onExport: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
compact: true,
onToggleCompact: fn(),
}, },
}; };

View file

@ -27,6 +27,10 @@ const baseProps = {
visibleCategories: { auth: true, transport: true } as const, visibleCategories: { auth: true, transport: true } as const,
onClear: vi.fn(), onClear: vi.fn(),
onExport: vi.fn(), onExport: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
compact: true,
onToggleCompact: vi.fn(),
}; };
describe("NetworkStreamPanel", () => { describe("NetworkStreamPanel", () => {
@ -124,14 +128,88 @@ describe("NetworkStreamPanel", () => {
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
}); });
it("toggles between compact and expanded list views", async () => { it("renders entries collapsed when compact is true", () => {
const user = userEvent.setup(); renderWithMantine(<NetworkStreamPanel {...baseProps} compact />);
renderWithMantine(<NetworkStreamPanel {...baseProps} />);
// List starts compact -> entry should show Expand button
expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Expand all" })); });
it("renders entries expanded when compact is false", () => {
renderWithMantine(<NetworkStreamPanel {...baseProps} compact={false} />);
expect( expect(
screen.getByRole("button", { name: "Collapse" }), screen.getByRole("button", { name: "Collapse" }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("invokes onToggleCompact when the ListToggle is clicked", async () => {
const user = userEvent.setup();
const onToggleCompact = vi.fn();
renderWithMantine(
<NetworkStreamPanel {...baseProps} onToggleCompact={onToggleCompact} />,
);
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(onToggleCompact).toHaveBeenCalledTimes(1);
});
it("renders entries newest-first by default", () => {
const older: FetchRequestEntry = {
...entry,
id: "older",
url: "https://example.com/older",
timestamp: new Date("2026-03-17T09:00:00Z"),
};
const newer: FetchRequestEntry = {
...entry,
id: "newer",
url: "https://example.com/newer",
timestamp: new Date("2026-03-17T11:00:00Z"),
};
renderWithMantine(
<NetworkStreamPanel {...baseProps} entries={[older, newer]} />,
);
const urls = screen.getAllByText(/example\.com\//);
expect(urls[0]).toHaveTextContent("https://example.com/newer");
expect(urls[urls.length - 1]).toHaveTextContent(
"https://example.com/older",
);
});
it("reorders entries when sortDirection is oldest-first", () => {
const older: FetchRequestEntry = {
...entry,
id: "older",
url: "https://example.com/older",
timestamp: new Date("2026-03-17T09:00:00Z"),
};
const newer: FetchRequestEntry = {
...entry,
id: "newer",
url: "https://example.com/newer",
timestamp: new Date("2026-03-17T11:00:00Z"),
};
renderWithMantine(
<NetworkStreamPanel
{...baseProps}
entries={[newer, older]}
sortDirection="oldest-first"
/>,
);
const urls = screen.getAllByText(/example\.com\//);
expect(urls[0]).toHaveTextContent("https://example.com/older");
expect(urls[urls.length - 1]).toHaveTextContent(
"https://example.com/newer",
);
});
it("invokes onSortChange when the user picks a new sort", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderWithMantine(
<NetworkStreamPanel {...baseProps} onSortChange={onSortChange} />,
);
await user.click(
screen.getByRole("textbox", { name: "Network sort direction" }),
);
await user.click(await screen.findByText("Oldest First"));
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
});
}); });

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { import {
Button, Button,
Group, Group,
@ -14,6 +14,10 @@ import type {
} from "@inspector/core/mcp/types.js"; } from "@inspector/core/mcp/types.js";
import { NetworkEntry } from "../NetworkEntry/NetworkEntry"; import { NetworkEntry } from "../NetworkEntry/NetworkEntry";
import { ListToggle } from "../../elements/ListToggle/ListToggle"; import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
export interface NetworkStreamPanelProps { export interface NetworkStreamPanelProps {
entries: FetchRequestEntry[]; entries: FetchRequestEntry[];
@ -21,6 +25,10 @@ export interface NetworkStreamPanelProps {
visibleCategories: Record<FetchRequestCategory, boolean>; visibleCategories: Record<FetchRequestCategory, boolean>;
onClear: () => void; onClear: () => void;
onExport: () => void; onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
} }
const PanelContainer = Paper.withProps({ const PanelContainer = Paper.withProps({
@ -82,14 +90,19 @@ export function NetworkStreamPanel({
visibleCategories, visibleCategories,
onClear, onClear,
onExport, onExport,
sortDirection,
onSortChange,
compact,
onToggleCompact,
}: NetworkStreamPanelProps) { }: NetworkStreamPanelProps) {
const [compact, setCompact] = useState(true); const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
const filteredEntries = useMemo( const sorted = entries
() => .filter((e) => matchesFilters(e, filterText, visibleCategories))
entries.filter((e) => matchesFilters(e, filterText, visibleCategories)), .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
[entries, filterText, visibleCategories], if (sortDirection === "newest-first") sorted.reverse();
); return sorted;
}, [entries, filterText, visibleCategories, sortDirection]);
const hasEntries = entries.length > 0; const hasEntries = entries.length > 0;
const hasResults = filteredEntries.length > 0; const hasResults = filteredEntries.length > 0;
@ -100,11 +113,13 @@ export function NetworkStreamPanel({
<Title order={4}>{formatTitle(filteredEntries.length)}</Title> <Title order={4}>{formatTitle(filteredEntries.length)}</Title>
<Group gap="xs"> <Group gap="xs">
{hasResults && ( {hasResults && (
<ListToggle <ListToggle compact={compact} onToggle={onToggleCompact} />
compact={compact}
onToggle={() => setCompact((c) => !c)}
/>
)} )}
<SortToggle
value={sortDirection}
onChange={onSortChange}
aria-label="Network sort direction"
/>
<Button variant="default" onClick={onClear} disabled={!hasEntries}> <Button variant="default" onClick={onClear} disabled={!hasEntries}>
Clear Clear
</Button> </Button>

View file

@ -16,6 +16,8 @@ const meta: Meta<typeof ResourceControls> = {
onSelectTemplate: fn(), onSelectTemplate: fn(),
onUnsubscribeResource: fn(), onUnsubscribeResource: fn(),
listChanged: false, listChanged: false,
compact: false,
onCompactChange: fn(),
}, },
}; };

View file

@ -33,6 +33,8 @@ const baseProps = {
onSelectUri: vi.fn(), onSelectUri: vi.fn(),
onSelectTemplate: vi.fn(), onSelectTemplate: vi.fn(),
onUnsubscribeResource: vi.fn(), onUnsubscribeResource: vi.fn(),
compact: false,
onCompactChange: vi.fn(),
}; };
describe("ResourceControls", () => { describe("ResourceControls", () => {
@ -150,23 +152,40 @@ describe("ResourceControls", () => {
expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument(); expect(screen.getByText("Subscriptions (0)")).toBeInTheDocument();
}); });
it("toggles all sections via the ListToggle button", async () => { it("seeds the accordion to all-open when compact is false", () => {
renderWithMantine(<ResourceControls {...baseProps} compact={false} />);
// All three sections open → ListToggle reads "Collapse all".
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
it("seeds the accordion to all-closed when compact is true", () => {
renderWithMantine(<ResourceControls {...baseProps} compact />);
// All three sections closed → ListToggle reads "Expand all".
expect(
screen.getByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
});
it("invokes onCompactChange with the new preference when the ListToggle is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithMantine(<ResourceControls {...baseProps} />); const onCompactChange = vi.fn();
const toggleButtons = screen.getAllByRole("button"); renderWithMantine(
// The list toggle is the last in the search row; just exercise it. <ResourceControls
// Since all three sections start open, clicking it should collapse them. {...baseProps}
const toggle = toggleButtons.find( compact={false}
(btn) => btn.querySelector("svg") && btn !== toggleButtons[0], onCompactChange={onCompactChange}
/>,
); );
if (toggle) { // All sections start open → ListToggle reads "Collapse all"; clicking
await user.click(toggle); // it collapses everything and records compact=true.
} await user.click(screen.getByRole("button", { name: "Collapse all" }));
// After collapse, click again to re-expand. expect(onCompactChange).toHaveBeenCalledWith(true);
if (toggle) { // Now collapsed → ListToggle reads "Expand all"; clicking re-expands
await user.click(toggle); // and records compact=false.
} await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(screen.getByText("URIs (2)")).toBeInTheDocument(); expect(onCompactChange).toHaveBeenLastCalledWith(false);
}); });
it("filters by resource title when title is set", async () => { it("filters by resource title when title is set", async () => {

View file

@ -28,6 +28,13 @@ export interface ResourceControlsProps {
onSelectUri: (uri: string) => void; onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void; onSelectTemplate: (uriTemplate: string) => void;
onUnsubscribeResource: (uri: string) => void; onUnsubscribeResource: (uri: string) => void;
/**
* Persisted preference for the ListToggle. Seeds initial accordion state
* on mount; the user can still toggle individual sections during a session
* without affecting this value. Only an explicit ListToggle click updates it.
*/
compact: boolean;
onCompactChange: (next: boolean) => void;
} }
function panelMaxHeight(openCount: number): string { function panelMaxHeight(openCount: number): string {
@ -52,6 +59,8 @@ export function ResourceControls({
onSelectUri, onSelectUri,
onSelectTemplate, onSelectTemplate,
onUnsubscribeResource, onUnsubscribeResource,
compact: initialCompact,
onCompactChange,
}: ResourceControlsProps) { }: ResourceControlsProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const query = searchText.toLowerCase(); const query = searchText.toLowerCase();
@ -74,17 +83,27 @@ export function ResourceControls({
s.resource.uri.toLowerCase().includes(query), s.resource.uri.toLowerCase().includes(query),
); );
const defaultOpen = [
...(filteredResources.length > 0 ? ["resources"] : []),
...(filteredTemplates.length > 0 ? ["templates"] : []),
...(filteredSubscriptions.length > 0 ? ["subscriptions"] : []),
];
const allSections = ["resources", "templates", "subscriptions"]; const allSections = ["resources", "templates", "subscriptions"];
const [openSections, setOpenSections] = useState<string[]>(defaultOpen); // Seed the accordion from the persisted preference: empty when the user
// last left the panel compact, all three open when they explicitly
// expanded. Per-section accordion clicks during a session update the
// local list but don't change the persisted preference.
const [openSections, setOpenSections] = useState<string[]>(
initialCompact ? [] : [...allSections],
);
const allExpanded = openSections.length === allSections.length; const allExpanded = openSections.length === allSections.length;
const maxHeight = panelMaxHeight(openSections.length); const maxHeight = panelMaxHeight(openSections.length);
function handleToggleList() {
// Compute the next compact value from what the click will produce so a
// half-open accordion (user toggled a single section) still persists the
// right preference: clicking "expand all" should record `compact=false`
// even if the visible state was already partially expanded.
const nextCompact = allExpanded;
setOpenSections(nextCompact ? [] : [...allSections]);
onCompactChange(nextCompact);
}
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between"> <Group justify="space-between">
@ -98,10 +117,7 @@ export function ResourceControls({
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.currentTarget.value)} onChange={(e) => setSearchText(e.currentTarget.value)}
/> />
<ListToggle <ListToggle compact={!allExpanded} onToggle={handleToggleList} />
compact={!allExpanded}
onToggle={() => setOpenSections(allExpanded ? [] : [...allSections])}
/>
</Group> </Group>
<Accordion multiple value={openSections} onChange={setOpenSections}> <Accordion multiple value={openSections} onChange={setOpenSections}>
<Accordion.Item value="resources"> <Accordion.Item value="resources">

View file

@ -13,6 +13,10 @@ const meta: Meta<typeof HistoryScreen> = {
onExport: fn(), onExport: fn(),
onReplay: fn(), onReplay: fn(),
onTogglePin: fn(), onTogglePin: fn(),
sortDirection: "newest-first",
onSortChange: fn(),
compact: true,
onToggleCompact: fn(),
}, },
}; };

View file

@ -26,6 +26,10 @@ const baseProps = {
onExport: vi.fn(), onExport: vi.fn(),
onReplay: vi.fn(), onReplay: vi.fn(),
onTogglePin: vi.fn(), onTogglePin: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
compact: true,
onToggleCompact: vi.fn(),
}; };
describe("HistoryScreen", () => { describe("HistoryScreen", () => {

View file

@ -4,6 +4,7 @@ import type { MessageEntry, MessageMethod } from "@inspector/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 { extractMethod } from "../../groups/historyUtils.js"; import { extractMethod } from "../../groups/historyUtils.js";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
export interface HistoryScreenProps { export interface HistoryScreenProps {
entries: MessageEntry[]; entries: MessageEntry[];
@ -12,6 +13,10 @@ export interface HistoryScreenProps {
onExport: () => void; onExport: () => void;
onReplay: (id: string) => void; onReplay: (id: string) => void;
onTogglePin: (id: string) => void; onTogglePin: (id: string) => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
} }
const ScreenLayout = Flex.withProps({ const ScreenLayout = Flex.withProps({
@ -38,6 +43,10 @@ export function HistoryScreen({
onExport, onExport,
onReplay, onReplay,
onTogglePin, onTogglePin,
sortDirection,
onSortChange,
compact,
onToggleCompact,
}: HistoryScreenProps) { }: HistoryScreenProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [methodFilter, setMethodFilter] = useState<MessageMethod | undefined>(); const [methodFilter, setMethodFilter] = useState<MessageMethod | undefined>();
@ -74,6 +83,10 @@ export function HistoryScreen({
onExport={onExport} onExport={onExport}
onReplay={onReplay} onReplay={onReplay}
onTogglePin={onTogglePin} onTogglePin={onTogglePin}
sortDirection={sortDirection}
onSortChange={onSortChange}
compact={compact}
onToggleCompact={onToggleCompact}
/> />
</ScreenLayout> </ScreenLayout>
); );

View file

@ -13,8 +13,8 @@ const meta: Meta<typeof LoggingScreen> = {
onSetLevel: fn(), onSetLevel: fn(),
onClear: fn(), onClear: fn(),
onExport: fn(), onExport: fn(),
autoScroll: true, sortDirection: "newest-first",
onToggleAutoScroll: fn(), onSortChange: fn(),
}, },
}; };

View file

@ -9,8 +9,8 @@ const baseProps = {
onSetLevel: vi.fn(), onSetLevel: vi.fn(),
onClear: vi.fn(), onClear: vi.fn(),
onExport: vi.fn(), onExport: vi.fn(),
autoScroll: true, sortDirection: "newest-first" as const,
onToggleAutoScroll: vi.fn(), onSortChange: vi.fn(),
}; };
describe("LoggingScreen", () => { describe("LoggingScreen", () => {

Some files were not shown because too many files have changed in this diff Show more