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

View file

@ -334,9 +334,9 @@ describe("InspectorView", () => {
expect(onSetLogLevel).toHaveBeenCalledWith("warning");
});
it("toggles autoScroll locally on the Logs screen after connecting", async () => {
it("persists Logs sort direction to localStorage and restores it on remount", async () => {
const user = userEvent.setup();
renderWithMantine(
const { unmount } = renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
@ -350,11 +350,152 @@ describe("InspectorView", () => {
const tabSelect = await screen.findByDisplayValue("Servers");
await user.click(tabSelect);
await user.click(await screen.findByText("Logs"));
const autoScroll = await screen.findByRole("checkbox", {
name: "Auto-scroll",
const sortSelect = await screen.findByRole("textbox", {
name: "Logs sort direction",
});
expect(autoScroll).toBeChecked();
await user.click(autoScroll);
expect(autoScroll).not.toBeChecked();
expect(sortSelect).toHaveValue("Newest First");
await user.click(sortSelect);
await user.click(await screen.findByText("Oldest First"));
await waitFor(() =>
expect(window.localStorage.getItem("inspector.sortDirection.logs")).toBe(
"oldest-first",
),
);
unmount();
renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
latencyMs: 50,
})}
/>,
);
const tabSelect2 = await screen.findByDisplayValue("Servers");
await user.click(tabSelect2);
await user.click(await screen.findByText("Logs"));
const sortSelect2 = await screen.findByRole("textbox", {
name: "Logs sort direction",
});
await waitFor(() => expect(sortSelect2).toHaveValue("Oldest First"));
});
it("falls back to newest-first when a corrupted sort value is stored", async () => {
const user = userEvent.setup();
window.localStorage.setItem("inspector.sortDirection.history", "garbage");
renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
latencyMs: 50,
})}
/>,
);
const tabSelect = await screen.findByDisplayValue("Servers");
await user.click(tabSelect);
await user.click(await screen.findByText("History"));
const sortSelect = await screen.findByRole("textbox", {
name: "History sort direction",
});
await waitFor(() => expect(sortSelect).toHaveValue("Newest First"));
});
it("persists History list compact state to localStorage and restores it on remount", async () => {
const user = userEvent.setup();
const historyEntry = {
id: "req-1",
timestamp: new Date("2026-03-17T10:00:00Z"),
direction: "request" as const,
message: {
jsonrpc: "2.0" as const,
id: 1,
method: "tools/list",
},
};
const { unmount } = renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
latencyMs: 50,
history: [historyEntry],
})}
/>,
);
const tabSelect = await screen.findByDisplayValue("Servers");
await user.click(tabSelect);
await user.click(await screen.findByText("History"));
// Default is collapsed — ListToggle reads "Expand all".
await user.click(await screen.findByRole("button", { name: "Expand all" }));
await waitFor(() =>
expect(window.localStorage.getItem("inspector.listCompact.history")).toBe(
"false",
),
);
unmount();
renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
latencyMs: 50,
history: [historyEntry],
})}
/>,
);
const tabSelect2 = await screen.findByDisplayValue("Servers");
await user.click(tabSelect2);
await user.click(await screen.findByText("History"));
// After restore the list is expanded, so the ListToggle reads "Collapse all".
expect(
await screen.findByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
it("falls back to collapsed when a corrupted compact value is stored", async () => {
const user = userEvent.setup();
window.localStorage.setItem("inspector.listCompact.history", "garbage");
const historyEntry = {
id: "req-1",
timestamp: new Date("2026-03-17T10:00:00Z"),
direction: "request" as const,
message: {
jsonrpc: "2.0" as const,
id: 1,
method: "tools/list",
},
};
renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
latencyMs: 50,
history: [historyEntry],
})}
/>,
);
const tabSelect = await screen.findByDisplayValue("Servers");
await user.click(tabSelect);
await user.click(await screen.findByText("History"));
expect(
await screen.findByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
});
});

View file

@ -1,5 +1,6 @@
import { useMemo, useRef, useState, type ReactNode } from "react";
import { AppShell, Box, Stack, Transition } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import type {
InitializeResult,
LoggingLevel,
@ -42,8 +43,68 @@ import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen";
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import { getServerType } from "@inspector/core/mcp/config.js";
const SORT_DEFAULT: SortDirection = "newest-first";
// Storage adapters live alongside the view. The deserializer accepts anything
// (manual edit, schema drift, future option removed) and clamps to the default
// so the toggle never renders an unselectable state.
function deserializeSortDirection(raw: string | undefined): SortDirection {
return raw === "oldest-first" || raw === "newest-first" ? raw : SORT_DEFAULT;
}
// Overrides Mantine's default `JSON.stringify` so the stored value is the
// raw enum literal (`"oldest-first"`), not a JSON-quoted string. Keeps the
// localStorage value human-readable and lets tests assert on it directly.
function serializeSortDirection(value: SortDirection): string {
return value;
}
const LIST_COMPACT_DEFAULT = true;
// Same shape as the sort adapters: store the boolean as `"true"` / `"false"`
// so the localStorage value stays human-readable, and clamp any other value
// back to the default rather than coercing it to `false`.
function deserializeListCompact(raw: string | undefined): boolean {
if (raw === "true") return true;
if (raw === "false") return false;
return LIST_COMPACT_DEFAULT;
}
function serializeListCompact(value: boolean): string {
return value ? "true" : "false";
}
// One useLocalStorage call per scope, all with the same persistence shape.
// `getInitialValueInEffect: false` reads synchronously on first render —
// SPA only, no SSR — so the persisted value lands without a one-frame
// flicker through the default. The `inspector.<kind>.<scope>` namespace
// keeps related preferences grouped and easy to clear in bulk.
function useSortDirection(scope: "logs" | "history" | "network") {
return useLocalStorage<SortDirection>({
key: `inspector.sortDirection.${scope}`,
defaultValue: SORT_DEFAULT,
deserialize: deserializeSortDirection,
serialize: serializeSortDirection,
getInitialValueInEffect: false,
});
}
function useListCompact(
scope: "history" | "network" | "servers" | "resources",
defaultValue: boolean,
) {
return useLocalStorage<boolean>({
key: `inspector.listCompact.${scope}`,
defaultValue,
deserialize: deserializeListCompact,
serialize: serializeListCompact,
getInitialValueInEffect: false,
});
}
const SERVERS_TAB = "Servers";
const NETWORK_TAB = "Network";
@ -284,9 +345,30 @@ export function InspectorView({
// dispatching live in the parent; this component only owns navigation
// (which tab is visible) and a couple of view-local toggles.
const [selectedTab, setSelectedTab] = useState<string>(SERVERS_TAB);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
const appRendererRef = useRef<AppRendererHandle>(null);
const [logsSort, setLogsSort] = useSortDirection("logs");
const [historySort, setHistorySort] = useSortDirection("history");
const [networkSort, setNetworkSort] = useSortDirection("network");
// Servers and Resources default to expanded (collapsed=false) so new
// users see content on first paint; History/Network default to
// collapsed (the lists are long enough that compact is the better
// first-paint state).
const [historyCompact, setHistoryCompact] = useListCompact(
"history",
LIST_COMPACT_DEFAULT,
);
const [networkCompact, setNetworkCompact] = useListCompact(
"network",
LIST_COMPACT_DEFAULT,
);
const [serversCompact, setServersCompact] = useListCompact("servers", false);
const [resourcesCompact, setResourcesCompact] = useListCompact(
"resources",
false,
);
// Only show the non-Servers tabs when actually connected. Network is
// additionally hidden for stdio servers — there is no HTTP traffic to
// surface there, so the tab would always be empty. Capability-aware
@ -380,6 +462,8 @@ export function InspectorView({
onEdit={onServerEdit}
onClone={onServerClone}
onRemove={onServerRemove}
compact={serversCompact}
onToggleCompact={() => setServersCompact((c) => !c)}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Tools"}>
@ -431,6 +515,8 @@ export function InspectorView({
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onCompleteArgument={onCompleteArgument}
compact={resourcesCompact}
onCompactChange={setResourcesCompact}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Tasks"}>
@ -449,8 +535,8 @@ export function InspectorView({
onSetLevel={onSetLogLevel}
onClear={onClearLogs}
onExport={onExportLogs}
autoScroll={autoScroll}
onToggleAutoScroll={() => setAutoScroll((prev) => !prev)}
sortDirection={logsSort}
onSortChange={setLogsSort}
/>
</ScreenStage>
<ScreenStage active={activeTab === "History"}>
@ -461,6 +547,10 @@ export function InspectorView({
onExport={onExportHistory}
onReplay={onReplayHistory}
onTogglePin={onTogglePinHistory}
sortDirection={historySort}
onSortChange={setHistorySort}
compact={historyCompact}
onToggleCompact={() => setHistoryCompact((c) => !c)}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Network"}>
@ -468,6 +558,10 @@ export function InspectorView({
entries={network}
onClear={onClearNetwork}
onExport={onExportNetwork}
sortDirection={networkSort}
onSortChange={setNetworkSort}
compact={networkCompact}
onToggleCompact={() => setNetworkCompact((c) => !c)}
/>
</ScreenStage>
</ScreenStageContainer>