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
3 changed files with 812 additions and 117 deletions

View file

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

View file

@ -43,10 +43,10 @@ const baseProps = {
entries,
filterText: "",
visibleLevels: allVisible,
autoScroll: true,
onToggleAutoScroll: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
sortDirection: "newest-first" as const,
onSortChange: vi.fn(),
};
describe("LogStreamPanel", () => {
@ -149,18 +149,39 @@ describe("LogStreamPanel", () => {
expect(onExport).toHaveBeenCalledTimes(1);
});
it("invokes onToggleAutoScroll when the auto-scroll checkbox is clicked", async () => {
const user = userEvent.setup();
const onToggleAutoScroll = vi.fn();
renderWithMantine(
<LogStreamPanel {...baseProps} onToggleAutoScroll={onToggleAutoScroll} />,
it("renders entries newest-first by default", () => {
renderWithMantine(<LogStreamPanel {...baseProps} />);
const items = screen.getAllByText(
/Server started|Failed to read|Loading config|deprecated/,
);
await user.click(screen.getByLabelText("Auto-scroll"));
expect(onToggleAutoScroll).toHaveBeenCalledTimes(1);
// Newest receivedAt is the warning entry; oldest is "Server started".
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", () => {
renderWithMantine(<LogStreamPanel {...baseProps} autoScroll={false} />);
expect(screen.getByLabelText("Auto-scroll")).not.toBeChecked();
it("reorders entries when sortDirection is oldest-first", () => {
renderWithMantine(
<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 {
Button,
Checkbox,
Group,
Paper,
ScrollArea,
@ -12,15 +11,19 @@ import {
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
import { LogEntry } from "../../elements/LogEntry/LogEntry";
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
import {
SortToggle,
type SortDirection,
} from "../../elements/SortToggle/SortToggle";
export interface LogStreamPanelProps {
entries: LogEntryData[];
filterText: string;
visibleLevels: Record<LoggingLevel, boolean>;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onClear: () => void;
onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
}
const PanelContainer = Paper.withProps({
@ -61,25 +64,29 @@ export function LogStreamPanel({
entries,
filterText,
visibleLevels,
autoScroll,
onToggleAutoScroll,
onClear,
onExport,
sortDirection,
onSortChange,
}: LogStreamPanelProps) {
const filteredEntries = useMemo(
() => entries.filter((e) => matchesFilters(e, filterText, visibleLevels)),
[entries, filterText, visibleLevels],
);
const filteredEntries = useMemo(() => {
// `.filter()` returns a fresh array, so sorting in-place is safe.
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 (
<PanelContainer>
<Group justify="space-between" mb="sm">
<Title order={4}>Log Stream</Title>
<Group>
<Checkbox
label="Auto-scroll"
checked={autoScroll}
onChange={onToggleAutoScroll}
<SortToggle
value={sortDirection}
onChange={onSortChange}
aria-label="Logs sort direction"
/>
<Button
variant="default"