* 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:
parent
a7178280f0
commit
dd0f5de820
2 changed files with 812 additions and 117 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue