Skip to content

Commit a35dbe3

Browse files
cliffhallclaude
andcommitted
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>
1 parent a717828 commit a35dbe3

24 files changed

Lines changed: 532 additions & 13 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { expect, fn, userEvent, within } from "storybook/test";
3+
import { SortToggle } from "./SortToggle";
4+
5+
const meta: Meta<typeof SortToggle> = {
6+
title: "Elements/SortToggle",
7+
component: SortToggle,
8+
args: {
9+
onChange: fn(),
10+
},
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof SortToggle>;
15+
16+
export const NewestFirst: Story = {
17+
args: {
18+
value: "newest-first",
19+
},
20+
};
21+
22+
export const OldestFirst: Story = {
23+
args: {
24+
value: "oldest-first",
25+
},
26+
};
27+
28+
export const FlipsDirection: Story = {
29+
args: {
30+
value: "newest-first",
31+
},
32+
play: async ({ canvasElement, args }) => {
33+
const body = within(canvasElement.ownerDocument.body);
34+
const select = await body.findByRole("textbox", {
35+
name: "Sort direction",
36+
});
37+
await userEvent.click(select);
38+
const oldestOption = await body.findByText("Sort: Oldest First");
39+
await userEvent.click(oldestOption);
40+
await expect(args.onChange).toHaveBeenCalledWith("oldest-first");
41+
},
42+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import userEvent from "@testing-library/user-event";
3+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
4+
import { SortToggle } from "./SortToggle";
5+
6+
describe("SortToggle", () => {
7+
it("renders the current value as the selected option", () => {
8+
renderWithMantine(<SortToggle value="newest-first" onChange={() => {}} />);
9+
expect(screen.getByDisplayValue("Sort: Newest First")).toBeInTheDocument();
10+
});
11+
12+
it("renders 'Oldest First' when value is oldest-first", () => {
13+
renderWithMantine(<SortToggle value="oldest-first" onChange={() => {}} />);
14+
expect(screen.getByDisplayValue("Sort: Oldest First")).toBeInTheDocument();
15+
});
16+
17+
it("uses the default aria-label", () => {
18+
renderWithMantine(<SortToggle value="newest-first" onChange={() => {}} />);
19+
expect(
20+
screen.getByRole("textbox", { name: "Sort direction" }),
21+
).toBeInTheDocument();
22+
});
23+
24+
it("honors a custom aria-label", () => {
25+
renderWithMantine(
26+
<SortToggle
27+
value="newest-first"
28+
onChange={() => {}}
29+
aria-label="Logs sort"
30+
/>,
31+
);
32+
expect(
33+
screen.getByRole("textbox", { name: "Logs sort" }),
34+
).toBeInTheDocument();
35+
});
36+
37+
it("invokes onChange with the new direction when the user picks another option", async () => {
38+
const user = userEvent.setup();
39+
const onChange = vi.fn();
40+
renderWithMantine(<SortToggle value="newest-first" onChange={onChange} />);
41+
await user.click(screen.getByRole("textbox", { name: "Sort direction" }));
42+
await user.click(await screen.findByText("Sort: Oldest First"));
43+
expect(onChange).toHaveBeenCalledWith("oldest-first");
44+
});
45+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Select } from "@mantine/core";
2+
3+
export type SortDirection = "oldest-first" | "newest-first";
4+
5+
export interface SortToggleProps {
6+
value: SortDirection;
7+
onChange: (next: SortDirection) => void;
8+
"aria-label"?: string;
9+
}
10+
11+
const OPTIONS: { value: SortDirection; label: string }[] = [
12+
{ value: "newest-first", label: "Sort: Newest First" },
13+
{ value: "oldest-first", label: "Sort: Oldest First" },
14+
];
15+
16+
function isSortDirection(value: string | null): value is SortDirection {
17+
return value === "oldest-first" || value === "newest-first";
18+
}
19+
20+
export function SortToggle({
21+
value,
22+
onChange,
23+
"aria-label": ariaLabel = "Sort direction",
24+
}: SortToggleProps) {
25+
return (
26+
<Select
27+
size="sm"
28+
w={190}
29+
data={OPTIONS}
30+
value={value}
31+
onChange={(next) => {
32+
if (isSortDirection(next)) onChange(next);
33+
}}
34+
allowDeselect={false}
35+
withCheckIcon={false}
36+
aria-label={ariaLabel}
37+
/>
38+
);
39+
}

clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const meta: Meta<typeof HistoryListPanel> = {
1313
onExport: fn(),
1414
onReplay: fn(),
1515
onTogglePin: fn(),
16+
sortDirection: "newest-first",
17+
onSortChange: fn(),
1618
},
1719
};
1820

clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ const baseProps = {
6666
onExport: vi.fn(),
6767
onReplay: vi.fn(),
6868
onTogglePin: vi.fn(),
69+
sortDirection: "newest-first" as const,
70+
onSortChange: vi.fn(),
6971
};
7072

7173
describe("HistoryListPanel", () => {
@@ -219,6 +221,49 @@ describe("HistoryListPanel", () => {
219221
expect(onTogglePin).toHaveBeenCalledWith("req-1");
220222
});
221223

224+
it("renders entries newest-first by default", () => {
225+
renderWithMantine(
226+
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
227+
);
228+
const methods = screen.getAllByText(
229+
/tools\/call|resources\/read|tools\/list/,
230+
);
231+
expect(methods[0]).toHaveTextContent("tools/list");
232+
expect(methods[methods.length - 1]).toHaveTextContent("tools/call");
233+
});
234+
235+
it("reorders entries when sortDirection is oldest-first", () => {
236+
renderWithMantine(
237+
<HistoryListPanel
238+
{...baseProps}
239+
entries={sampleEntries}
240+
sortDirection="oldest-first"
241+
/>,
242+
);
243+
const methods = screen.getAllByText(
244+
/tools\/call|resources\/read|tools\/list/,
245+
);
246+
expect(methods[0]).toHaveTextContent("tools/call");
247+
expect(methods[methods.length - 1]).toHaveTextContent("tools/list");
248+
});
249+
250+
it("invokes onSortChange when the user picks a new sort", async () => {
251+
const user = userEvent.setup();
252+
const onSortChange = vi.fn();
253+
renderWithMantine(
254+
<HistoryListPanel
255+
{...baseProps}
256+
entries={sampleEntries}
257+
onSortChange={onSortChange}
258+
/>,
259+
);
260+
await user.click(
261+
screen.getByRole("textbox", { name: "History sort direction" }),
262+
);
263+
await user.click(await screen.findByText("Sort: Oldest First"));
264+
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
265+
});
266+
222267
it("toggles compact list state when ListToggle is clicked", async () => {
223268
const user = userEvent.setup();
224269
renderWithMantine(

clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js";
1212
import { HistoryEntry } from "../HistoryEntry/HistoryEntry";
1313
import { ListToggle } from "../../elements/ListToggle/ListToggle";
14+
import {
15+
SortToggle,
16+
type SortDirection,
17+
} from "../../elements/SortToggle/SortToggle";
1418
import { extractMethod } from "../historyUtils.js";
1519

1620
export interface HistoryListPanelProps {
@@ -22,6 +26,8 @@ export interface HistoryListPanelProps {
2226
onExport: () => void;
2327
onReplay: (id: string) => void;
2428
onTogglePin: (id: string) => void;
29+
sortDirection: SortDirection;
30+
onSortChange: (next: SortDirection) => void;
2531
}
2632

2733
const PanelContainer = Paper.withProps({
@@ -71,13 +77,21 @@ export function HistoryListPanel({
7177
onExport,
7278
onReplay,
7379
onTogglePin,
80+
sortDirection,
81+
onSortChange,
7482
}: HistoryListPanelProps) {
7583
const [compact, setCompact] = useState(false);
7684

77-
const filteredEntries = useMemo(
78-
() => entries.filter((e) => matchesFilters(e, searchText, methodFilter)),
79-
[entries, searchText, methodFilter],
80-
);
85+
const filteredEntries = useMemo(() => {
86+
const matched = entries.filter((e) =>
87+
matchesFilters(e, searchText, methodFilter),
88+
);
89+
const sorted = [...matched].sort(
90+
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
91+
);
92+
if (sortDirection === "newest-first") sorted.reverse();
93+
return sorted;
94+
}, [entries, searchText, methodFilter, sortDirection]);
8195

8296
const pinnedEntries = useMemo(
8397
() => filteredEntries.filter((e) => pinnedIds.has(e.id)),
@@ -96,6 +110,11 @@ export function HistoryListPanel({
96110
<Group justify="space-between" mb="sm">
97111
<Title order={4}>Requests</Title>
98112
<Group gap="xs">
113+
<SortToggle
114+
value={sortDirection}
115+
onChange={onSortChange}
116+
aria-label="History sort direction"
117+
/>
99118
{hasResults && (
100119
<ListToggle
101120
compact={compact}

clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const meta: Meta<typeof LogStreamPanel> = {
2323
onToggleAutoScroll: fn(),
2424
onClear: fn(),
2525
onExport: fn(),
26+
sortDirection: "newest-first",
27+
onSortChange: fn(),
2628
},
2729
};
2830

clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const baseProps = {
4747
onToggleAutoScroll: vi.fn(),
4848
onClear: vi.fn(),
4949
onExport: vi.fn(),
50+
sortDirection: "newest-first" as const,
51+
onSortChange: vi.fn(),
5052
};
5153

5254
describe("LogStreamPanel", () => {
@@ -163,4 +165,40 @@ describe("LogStreamPanel", () => {
163165
renderWithMantine(<LogStreamPanel {...baseProps} autoScroll={false} />);
164166
expect(screen.getByLabelText("Auto-scroll")).not.toBeChecked();
165167
});
168+
169+
it("renders entries newest-first by default", () => {
170+
renderWithMantine(<LogStreamPanel {...baseProps} />);
171+
const items = screen.getAllByText(
172+
/Server started|Failed to read|Loading config|deprecated/,
173+
);
174+
// Newest receivedAt is the warning entry; oldest is "Server started".
175+
expect(items[0]).toHaveTextContent('{"code":42,"msg":"deprecated"}');
176+
expect(items[items.length - 1]).toHaveTextContent("Server started");
177+
});
178+
179+
it("reorders entries when sortDirection is oldest-first", () => {
180+
renderWithMantine(
181+
<LogStreamPanel {...baseProps} sortDirection="oldest-first" />,
182+
);
183+
const items = screen.getAllByText(
184+
/Server started|Failed to read|Loading config|deprecated/,
185+
);
186+
expect(items[0]).toHaveTextContent("Server started");
187+
expect(items[items.length - 1]).toHaveTextContent(
188+
'{"code":42,"msg":"deprecated"}',
189+
);
190+
});
191+
192+
it("invokes onSortChange when the user picks a new sort", async () => {
193+
const user = userEvent.setup();
194+
const onSortChange = vi.fn();
195+
renderWithMantine(
196+
<LogStreamPanel {...baseProps} onSortChange={onSortChange} />,
197+
);
198+
await user.click(
199+
screen.getByRole("textbox", { name: "Logs sort direction" }),
200+
);
201+
await user.click(await screen.findByText("Sort: Oldest First"));
202+
expect(onSortChange).toHaveBeenCalledWith("oldest-first");
203+
});
166204
});

clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import {
1212
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
1313
import { LogEntry } from "../../elements/LogEntry/LogEntry";
1414
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
15+
import {
16+
SortToggle,
17+
type SortDirection,
18+
} from "../../elements/SortToggle/SortToggle";
1519

1620
export interface LogStreamPanelProps {
1721
entries: LogEntryData[];
@@ -21,6 +25,8 @@ export interface LogStreamPanelProps {
2125
onToggleAutoScroll: () => void;
2226
onClear: () => void;
2327
onExport: () => void;
28+
sortDirection: SortDirection;
29+
onSortChange: (next: SortDirection) => void;
2430
}
2531

2632
const PanelContainer = Paper.withProps({
@@ -65,17 +71,30 @@ export function LogStreamPanel({
6571
onToggleAutoScroll,
6672
onClear,
6773
onExport,
74+
sortDirection,
75+
onSortChange,
6876
}: LogStreamPanelProps) {
69-
const filteredEntries = useMemo(
70-
() => entries.filter((e) => matchesFilters(e, filterText, visibleLevels)),
71-
[entries, filterText, visibleLevels],
72-
);
77+
const filteredEntries = useMemo(() => {
78+
const matched = entries.filter((e) =>
79+
matchesFilters(e, filterText, visibleLevels),
80+
);
81+
const sorted = [...matched].sort(
82+
(a, b) => a.receivedAt.getTime() - b.receivedAt.getTime(),
83+
);
84+
if (sortDirection === "newest-first") sorted.reverse();
85+
return sorted;
86+
}, [entries, filterText, visibleLevels, sortDirection]);
7387

7488
return (
7589
<PanelContainer>
7690
<Group justify="space-between" mb="sm">
7791
<Title order={4}>Log Stream</Title>
7892
<Group>
93+
<SortToggle
94+
value={sortDirection}
95+
onChange={onSortChange}
96+
aria-label="Logs sort direction"
97+
/>
7998
<Checkbox
8099
label="Auto-scroll"
81100
checked={autoScroll}

clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const meta: Meta<typeof NetworkStreamPanel> = {
1212
visibleCategories: { auth: true, transport: true },
1313
onClear: fn(),
1414
onExport: fn(),
15+
sortDirection: "newest-first",
16+
onSortChange: fn(),
1517
},
1618
};
1719

0 commit comments

Comments
 (0)