From a35dbe38cc3fa84f5f28e49c6f387362d9baa9f7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 27 May 2026 20:15:08 -0400 Subject: [PATCH 01/13] 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) --- .../SortToggle/SortToggle.stories.tsx | 42 +++++++++++ .../elements/SortToggle/SortToggle.test.tsx | 45 +++++++++++ .../elements/SortToggle/SortToggle.tsx | 39 ++++++++++ .../HistoryListPanel.stories.tsx | 2 + .../HistoryListPanel.test.tsx | 45 +++++++++++ .../HistoryListPanel/HistoryListPanel.tsx | 27 ++++++- .../LogStreamPanel/LogStreamPanel.stories.tsx | 2 + .../LogStreamPanel/LogStreamPanel.test.tsx | 38 ++++++++++ .../groups/LogStreamPanel/LogStreamPanel.tsx | 27 ++++++- .../NetworkStreamPanel.stories.tsx | 2 + .../NetworkStreamPanel.test.tsx | 65 ++++++++++++++++ .../NetworkStreamPanel/NetworkStreamPanel.tsx | 28 +++++-- .../HistoryScreen/HistoryScreen.stories.tsx | 2 + .../HistoryScreen/HistoryScreen.test.tsx | 2 + .../screens/HistoryScreen/HistoryScreen.tsx | 7 ++ .../LoggingScreen/LoggingScreen.stories.tsx | 2 + .../LoggingScreen/LoggingScreen.test.tsx | 2 + .../screens/LoggingScreen/LoggingScreen.tsx | 7 ++ .../NetworkScreen/NetworkScreen.stories.tsx | 2 + .../NetworkScreen/NetworkScreen.test.tsx | 2 + .../screens/NetworkScreen/NetworkScreen.tsx | 7 ++ .../InspectorView/InspectorView.test.tsx | 74 +++++++++++++++++++ .../views/InspectorView/InspectorView.tsx | 44 +++++++++++ clients/web/src/test/setup.ts | 32 ++++++++ 24 files changed, 532 insertions(+), 13 deletions(-) create mode 100644 clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx create mode 100644 clients/web/src/components/elements/SortToggle/SortToggle.test.tsx create mode 100644 clients/web/src/components/elements/SortToggle/SortToggle.tsx diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx new file mode 100644 index 000000000..0b12224b9 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { SortToggle } from "./SortToggle"; + +const meta: Meta = { + title: "Elements/SortToggle", + component: SortToggle, + args: { + onChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +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); + const oldestOption = await body.findByText("Sort: Oldest First"); + await userEvent.click(oldestOption); + await expect(args.onChange).toHaveBeenCalledWith("oldest-first"); + }, +}; diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx new file mode 100644 index 000000000..d20f8056b --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx @@ -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( {}} />); + expect(screen.getByDisplayValue("Sort: Newest First")).toBeInTheDocument(); + }); + + it("renders 'Oldest First' when value is oldest-first", () => { + renderWithMantine( {}} />); + expect(screen.getByDisplayValue("Sort: Oldest First")).toBeInTheDocument(); + }); + + it("uses the default aria-label", () => { + renderWithMantine( {}} />); + expect( + screen.getByRole("textbox", { name: "Sort direction" }), + ).toBeInTheDocument(); + }); + + it("honors a custom aria-label", () => { + renderWithMantine( + {}} + 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(); + await user.click(screen.getByRole("textbox", { name: "Sort direction" })); + await user.click(await screen.findByText("Sort: Oldest First")); + expect(onChange).toHaveBeenCalledWith("oldest-first"); + }); +}); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx new file mode 100644 index 000000000..2b8a35343 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -0,0 +1,39 @@ +import { Select } from "@mantine/core"; + +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: "Sort: Newest First" }, + { value: "oldest-first", label: "Sort: 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) { + return ( + { - if (isSortDirection(next)) onChange(next); - }} - allowDeselect={false} - withCheckIcon={false} + variant="subtle" aria-label={ariaLabel} - /> + onClick={handleClick} + > + + ); } diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx index a3ea817c7..de0933c1e 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx @@ -260,9 +260,8 @@ describe("HistoryListPanel", () => { />, ); await user.click( - screen.getByRole("textbox", { name: "History sort direction" }), + screen.getByRole("button", { name: "History sort direction" }), ); - await user.click(await screen.findByText("Sort: Oldest First")); expect(onSortChange).toHaveBeenCalledWith("oldest-first"); }); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx index def5466f7..864a85d64 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx @@ -172,16 +172,15 @@ describe("LogStreamPanel", () => { ); }); - it("invokes onSortChange when the user picks a new sort", async () => { + it("invokes onSortChange with the flipped direction when the toggle is clicked", async () => { const user = userEvent.setup(); const onSortChange = vi.fn(); renderWithMantine( , ); await user.click( - screen.getByRole("textbox", { name: "Logs sort direction" }), + screen.getByRole("button", { name: "Logs sort direction" }), ); - await user.click(await screen.findByText("Sort: Oldest First")); expect(onSortChange).toHaveBeenCalledWith("oldest-first"); }); }); diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx index ef65a4048..452ace255 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx @@ -207,9 +207,8 @@ describe("NetworkStreamPanel", () => { , ); await user.click( - screen.getByRole("textbox", { name: "Network sort direction" }), + screen.getByRole("button", { name: "Network sort direction" }), ); - await user.click(await screen.findByText("Sort: Oldest First")); expect(onSortChange).toHaveBeenCalledWith("oldest-first"); }); }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 5398255a6..c1d713d6e 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -351,13 +351,10 @@ describe("InspectorView", () => { await user.click(tabSelect); await user.click(await screen.findByText("Logs")); - const sortSelect = await screen.findByRole("textbox", { - name: "Logs sort direction", - }); - expect(sortSelect).toHaveValue("Sort: Newest First"); - await user.click(sortSelect); - await user.click(await screen.findByText("Sort: Oldest First")); - + // Default is newest-first; clicking the toggle flips to oldest-first. + await user.click( + await screen.findByRole("button", { name: "Logs sort direction" }), + ); await waitFor(() => expect(window.localStorage.getItem("inspector.sortDirection.logs")).toBe( "oldest-first", @@ -379,10 +376,16 @@ describe("InspectorView", () => { 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("Sort: Oldest First")); + // The restored value is oldest-first; one more click flips back to + // newest-first — proves the previous oldest-first value was hydrated. + await user.click( + await screen.findByRole("button", { name: "Logs sort direction" }), + ); + await waitFor(() => + expect(window.localStorage.getItem("inspector.sortDirection.logs")).toBe( + "newest-first", + ), + ); }); it("falls back to newest-first when a corrupted sort value is stored", async () => { @@ -402,10 +405,17 @@ describe("InspectorView", () => { 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("Sort: Newest First")); + // Garbage gets clamped to newest-first; clicking flips to oldest-first + // and writes that out — proves the initial in-memory value was the + // default, not "garbage". + await user.click( + await screen.findByRole("button", { name: "History sort direction" }), + ); + await waitFor(() => + expect( + window.localStorage.getItem("inspector.sortDirection.history"), + ).toBe("oldest-first"), + ); }); it("persists History list compact state to localStorage and restores it on remount", async () => { From f2f9796945c5522eff8e85b31447347503d79b84 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 29 May 2026 13:43:27 -0400 Subject: [PATCH 09/13] 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) --- .../SortToggle/SortToggle.stories.tsx | 15 ++---- .../elements/SortToggle/SortToggle.test.tsx | 46 +++++++----------- .../elements/SortToggle/SortToggle.tsx | 47 +++++++++---------- .../HistoryListPanel.test.tsx | 3 +- .../LogStreamPanel/LogStreamPanel.test.tsx | 5 +- .../NetworkStreamPanel.test.tsx | 3 +- .../InspectorView/InspectorView.test.tsx | 40 ++++++---------- 7 files changed, 66 insertions(+), 93 deletions(-) diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx index d22676350..5f159c933 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx @@ -25,22 +25,17 @@ export const OldestFirst: Story = { }, }; -export const Subtle: Story = { - args: { - value: "newest-first", - variant: "subtle", - }, -}; - export const FlipsDirection: Story = { args: { value: "newest-first", }, play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Sort direction" }), - ); + 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"); }, }; diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx index 51ed3c12d..a921b451c 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx @@ -4,10 +4,20 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { SortToggle } from "./SortToggle"; describe("SortToggle", () => { - it("renders a button with the default aria-label", () => { + it("renders the current value as the selected option", () => { + renderWithMantine( {}} />); + expect(screen.getByDisplayValue("Newest First")).toBeInTheDocument(); + }); + + it("renders 'Oldest First' when value is oldest-first", () => { + renderWithMantine( {}} />); + expect(screen.getByDisplayValue("Oldest First")).toBeInTheDocument(); + }); + + it("uses the default aria-label", () => { renderWithMantine( {}} />); expect( - screen.getByRole("button", { name: "Sort direction" }), + screen.getByRole("textbox", { name: "Sort direction" }), ).toBeInTheDocument(); }); @@ -19,37 +29,17 @@ describe("SortToggle", () => { aria-label="Logs sort" />, ); - expect(screen.getByRole("button", { name: "Logs sort" })).toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: "Logs sort" }), + ).toBeInTheDocument(); }); - it("flips to oldest-first when clicked while newest-first", async () => { + it("invokes onChange with the new direction when the user picks another option", async () => { const user = userEvent.setup(); const onChange = vi.fn(); renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Sort direction" })); - expect(onChange).toHaveBeenCalledWith("oldest-first"); - }); - - it("flips to newest-first when clicked while oldest-first", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Sort direction" })); - expect(onChange).toHaveBeenCalledWith("newest-first"); - }); - - it("renders the subtle variant as an ActionIcon (still a button)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - renderWithMantine( - , - ); - const btn = screen.getByRole("button", { name: "Sort direction" }); - await user.click(btn); + await user.click(screen.getByRole("textbox", { name: "Sort direction" })); + await user.click(await screen.findByText("Oldest First")); expect(onChange).toHaveBeenCalledWith("oldest-first"); }); }); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx index 7fac5a800..ef31b0f16 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Button } from "@mantine/core"; +import { Select } from "@mantine/core"; import { TbSortAscending2, TbSortDescending2 } from "react-icons/tb"; export type SortDirection = "oldest-first" | "newest-first"; @@ -6,43 +6,38 @@ export type SortDirection = "oldest-first" | "newest-first"; export interface SortToggleProps { value: SortDirection; onChange: (next: SortDirection) => void; - variant?: "default" | "subtle"; "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, - variant = "default", "aria-label": ariaLabel = "Sort direction", }: SortToggleProps) { const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; - const handleClick = () => - onChange(value === "newest-first" ? "oldest-first" : "newest-first"); - - if (variant === "subtle") { - return ( - - - - ); - } - return ( - + /> ); } diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx index de0933c1e..a96077240 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx @@ -260,8 +260,9 @@ describe("HistoryListPanel", () => { />, ); await user.click( - screen.getByRole("button", { name: "History sort direction" }), + screen.getByRole("textbox", { name: "History sort direction" }), ); + await user.click(await screen.findByText("Oldest First")); expect(onSortChange).toHaveBeenCalledWith("oldest-first"); }); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx index 864a85d64..c24b91dec 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx @@ -172,15 +172,16 @@ describe("LogStreamPanel", () => { ); }); - it("invokes onSortChange with the flipped direction when the toggle is clicked", async () => { + it("invokes onSortChange when the user picks a new sort", async () => { const user = userEvent.setup(); const onSortChange = vi.fn(); renderWithMantine( , ); await user.click( - screen.getByRole("button", { name: "Logs sort direction" }), + screen.getByRole("textbox", { name: "Logs sort direction" }), ); + await user.click(await screen.findByText("Oldest First")); expect(onSortChange).toHaveBeenCalledWith("oldest-first"); }); }); diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx index 452ace255..f97c1d594 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx @@ -207,8 +207,9 @@ describe("NetworkStreamPanel", () => { , ); await user.click( - screen.getByRole("button", { name: "Network sort direction" }), + screen.getByRole("textbox", { name: "Network sort direction" }), ); + await user.click(await screen.findByText("Oldest First")); expect(onSortChange).toHaveBeenCalledWith("oldest-first"); }); }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index c1d713d6e..1dfdda1db 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -351,10 +351,13 @@ describe("InspectorView", () => { await user.click(tabSelect); await user.click(await screen.findByText("Logs")); - // Default is newest-first; clicking the toggle flips to oldest-first. - await user.click( - await screen.findByRole("button", { name: "Logs sort direction" }), - ); + const sortSelect = await screen.findByRole("textbox", { + name: "Logs sort direction", + }); + 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", @@ -376,16 +379,10 @@ describe("InspectorView", () => { const tabSelect2 = await screen.findByDisplayValue("Servers"); await user.click(tabSelect2); await user.click(await screen.findByText("Logs")); - // The restored value is oldest-first; one more click flips back to - // newest-first — proves the previous oldest-first value was hydrated. - await user.click( - await screen.findByRole("button", { name: "Logs sort direction" }), - ); - await waitFor(() => - expect(window.localStorage.getItem("inspector.sortDirection.logs")).toBe( - "newest-first", - ), - ); + 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 () => { @@ -405,17 +402,10 @@ describe("InspectorView", () => { const tabSelect = await screen.findByDisplayValue("Servers"); await user.click(tabSelect); await user.click(await screen.findByText("History")); - // Garbage gets clamped to newest-first; clicking flips to oldest-first - // and writes that out — proves the initial in-memory value was the - // default, not "garbage". - await user.click( - await screen.findByRole("button", { name: "History sort direction" }), - ); - await waitFor(() => - expect( - window.localStorage.getItem("inspector.sortDirection.history"), - ).toBe("oldest-first"), - ); + 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 () => { From 3ab90268beae0dfdad42d09b1e77321f2a9774e4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 29 May 2026 13:46:46 -0400 Subject: [PATCH 10/13] fix(sort-toggle): swap icons so they match label direction Co-Authored-By: Claude Opus 4.7 (1M context) --- clients/web/src/components/elements/SortToggle/SortToggle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx index ef31b0f16..5723e5e2e 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -24,7 +24,7 @@ export function SortToggle({ "aria-label": ariaLabel = "Sort direction", }: SortToggleProps) { const Icon = - value === "newest-first" ? TbSortDescending2 : TbSortAscending2; + value === "newest-first" ? TbSortAscending2 : TbSortDescending2; return ( Date: Fri, 29 May 2026 14:28:58 -0400 Subject: [PATCH 12/13] 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) --- .../views/InspectorView/InspectorView.tsx | 111 ++++++++---------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 2c513d185..71c15245d 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -77,6 +77,34 @@ 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..` namespace +// keeps related preferences grouped and easy to clear in bulk. +function useSortDirection(scope: "logs" | "history" | "network") { + return useLocalStorage({ + key: `inspector.sortDirection.${scope}`, + defaultValue: SORT_DEFAULT, + deserialize: deserializeSortDirection, + serialize: serializeSortDirection, + getInitialValueInEffect: false, + }); +} + +function useListCompact( + scope: "history" | "network" | "servers" | "resources", + defaultValue: boolean, +) { + return useLocalStorage({ + key: `inspector.listCompact.${scope}`, + defaultValue, + deserialize: deserializeListCompact, + serialize: serializeListCompact, + getInitialValueInEffect: false, + }); +} + const SERVERS_TAB = "Servers"; const NETWORK_TAB = "Network"; @@ -319,68 +347,27 @@ export function InspectorView({ const [selectedTab, setSelectedTab] = useState(SERVERS_TAB); const appRendererRef = useRef(null); - // Per-screen sort direction, persisted to localStorage so the choice - // survives reloads and syncs across browser tabs. `inspector..` - // namespace; new preferences (theme defaults, compact-mode, etc.) should - // follow the same shape. `getInitialValueInEffect: false` reads synchronously - // on first render — SPA only, no SSR, so this avoids a one-frame flicker - // where the persisted "oldest-first" briefly renders as "newest-first". - const [logsSort, setLogsSort] = useLocalStorage({ - key: "inspector.sortDirection.logs", - defaultValue: SORT_DEFAULT, - deserialize: deserializeSortDirection, - serialize: serializeSortDirection, - getInitialValueInEffect: false, - }); - const [historySort, setHistorySort] = useLocalStorage({ - key: "inspector.sortDirection.history", - defaultValue: SORT_DEFAULT, - deserialize: deserializeSortDirection, - serialize: serializeSortDirection, - getInitialValueInEffect: false, - }); - const [networkSort, setNetworkSort] = useLocalStorage({ - key: "inspector.sortDirection.network", - defaultValue: SORT_DEFAULT, - deserialize: deserializeSortDirection, - serialize: serializeSortDirection, - getInitialValueInEffect: false, - }); - - // Per-screen list-row compact (collapsed) preference. Same persistence - // shape as the sort hooks — synchronous read on first render to avoid a - // one-frame flip from default → stored value. - const [historyCompact, setHistoryCompact] = useLocalStorage({ - key: "inspector.listCompact.history", - defaultValue: LIST_COMPACT_DEFAULT, - deserialize: deserializeListCompact, - serialize: serializeListCompact, - getInitialValueInEffect: false, - }); - const [networkCompact, setNetworkCompact] = useLocalStorage({ - key: "inspector.listCompact.network", - defaultValue: LIST_COMPACT_DEFAULT, - deserialize: deserializeListCompact, - serialize: serializeListCompact, - getInitialValueInEffect: false, - }); - const [serversCompact, setServersCompact] = useLocalStorage({ - key: "inspector.listCompact.servers", - defaultValue: false, - deserialize: deserializeListCompact, - serialize: serializeListCompact, - getInitialValueInEffect: false, - }); - // Resources defaults to expanded so new users see the sidebar's content - // sections opened (mirrors the pre-persistence behavior of showing - // sections that had entries). - const [resourcesCompact, setResourcesCompact] = useLocalStorage({ - key: "inspector.listCompact.resources", - defaultValue: false, - deserialize: deserializeListCompact, - serialize: serializeListCompact, - getInitialValueInEffect: false, - }); + 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 From e2c20eb831becf35f5056855a34a79f348c5d725 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 29 May 2026 14:38:30 -0400 Subject: [PATCH 13/13] fix(sort-toggle): apply prettier formatting Co-Authored-By: Claude Opus 4.7 (1M context) --- clients/web/src/components/elements/SortToggle/SortToggle.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx index ef31b0f16..ea87b4974 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -23,8 +23,7 @@ export function SortToggle({ onChange, "aria-label": ariaLabel = "Sort direction", }: SortToggleProps) { - const Icon = - value === "newest-first" ? TbSortDescending2 : TbSortAscending2; + const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; return (