From a53dd0673d78f30d4993131ea6d2b922e01b5864 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 9 Jun 2026 13:30:57 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20reorderable=20ServerCards=20?= =?UTF-8?q?=E2=80=94=20drag-and-drop=20+=20keyboard=20(#1369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users control the order of servers on the Servers screen instead of hand-editing mcp.json. Each ServerCard gets a grip handle (in the header, left of the name) that drives an @dnd-kit sortable; pointer and keyboard sensors both work, with live-region announcements for screen readers. - Backend: PUT /api/servers/order rewrites mcp.json in the supplied order inside the write lock, reusing the atomic-write + watcher-notify path. Registered before /:id so "order" isn't captured as a server id. Rejects with 409 when the id set no longer matches disk and 400 on duplicate / malformed bodies, so a reorder racing an external edit can't drop or duplicate an entry. - Hook: useServers.reorderServers(orderedIds) — optimistic local reorder, PUT, refresh-to-disk-truth on failure. - UI: SortableServerCard wrapper owns the dnd-kit concerns; ServerListScreen wraps the grid in DndContext + SortableContext (rect strategy) with pointer + keyboard sensors and announcements. Reorder math and a11y copy extracted to reorderIds.ts / serverReorder.ts for direct unit testing. Renders plain cards (no grip) when no onReorder is wired. - Wired onServerReorder through InspectorView -> App.tsx. Tests: pure-helper units, hook (persist / optimistic / 409 revert), 9 backend integration tests (incl. id-set-mismatch rejection and the route-registration guard), ServerCard/ServerListScreen units, and a real-browser Storybook keyboard-reorder play (the dnd-kit keyboard sensor needs layout rects that happy-dom doesn't provide). Adds @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/package-lock.json | 56 ++++++ clients/web/package.json | 3 + clients/web/src/App.css | 14 ++ clients/web/src/App.tsx | 4 + .../groups/ServerCard/ServerCard.test.tsx | 32 ++++ .../groups/ServerCard/ServerCard.tsx | 11 ++ .../SortableServerCard/SortableServerCard.tsx | 65 +++++++ .../ServerListScreen.stories.tsx | 42 ++++- .../ServerListScreen.test.tsx | 53 +++++- .../ServerListScreen/ServerListScreen.tsx | 101 ++++++++-- .../ServerListScreen/reorderIds.test.ts | 40 ++++ .../screens/ServerListScreen/reorderIds.ts | 22 +++ .../ServerListScreen/serverReorder.test.ts | 102 ++++++++++ .../screens/ServerListScreen/serverReorder.ts | 74 ++++++++ .../InspectorView/InspectorView.stories.tsx | 1 + .../InspectorView/InspectorView.test.tsx | 1 + .../views/InspectorView/InspectorView.tsx | 4 + .../src/test/core/react/useServers.test.tsx | 136 ++++++++++++++ .../mcp/remote/servers-order.test.ts | 177 ++++++++++++++++++ core/mcp/remote/node/server.ts | 61 ++++++ core/react/useServers.ts | 48 +++++ 21 files changed, 1020 insertions(+), 27 deletions(-) create mode 100644 clients/web/src/components/groups/SortableServerCard/SortableServerCard.tsx create mode 100644 clients/web/src/components/screens/ServerListScreen/reorderIds.test.ts create mode 100644 clients/web/src/components/screens/ServerListScreen/reorderIds.ts create mode 100644 clients/web/src/components/screens/ServerListScreen/serverReorder.test.ts create mode 100644 clients/web/src/components/screens/ServerListScreen/serverReorder.ts create mode 100644 clients/web/src/test/integration/mcp/remote/servers-order.test.ts diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 73bde734a..02ec7f3ba 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -8,6 +8,9 @@ "name": "@modelcontextprotocol/inspector-web", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@hono/node-server": "^1.19.14", "@mantine/core": "^8.3.17", @@ -350,6 +353,59 @@ "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index a2f51301f..fd13fb1bc 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -23,6 +23,9 @@ "test:integration:watch": "npm run test-servers:build && vitest --project=integration" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@hono/node-server": "^1.19.14", "@mantine/core": "^8.3.17", diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 916d65785..8028d0e77 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -194,3 +194,17 @@ max-width: 100%; height: auto; } + +/* + * Reorder grip on ServerCard. The grab/grabbing cursor is a draggability + * affordance that can't be expressed as a Mantine prop (and the active-press + * state is a pseudo-selector), so the whole grab-cursor unit lives here rather + * than split across the theme. + */ +.server-drag-handle { + cursor: grab; +} + +.server-drag-handle:active { + cursor: grabbing; +} diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 40bc883c0..4c30ac024 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -433,6 +433,7 @@ function App() { updateServer, updateServerSettings, removeServer, + reorderServers, } = useServers({ baseUrl: typeof window !== "undefined" @@ -2149,6 +2150,9 @@ function App() { const target = servers.find((s) => s.id === id); if (target) setRemoveTarget(target); }} + onServerReorder={(orderedIds) => { + void reorderServers(orderedIds); + }} serverSupportsTaskToolCalls={ !!capabilities?.tasks?.requests?.tools?.call } diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx index 02cd05fd8..6ac745bf6 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx @@ -215,4 +215,36 @@ describe("ServerCard", () => { renderWithMantine(); expect(screen.queryByText("Connection refused")).not.toBeInTheDocument(); }); + + it("renders the dragHandle slot when provided", () => { + renderWithMantine( + grip} + />, + ); + expect(screen.getByRole("button", { name: "grip" })).toBeInTheDocument(); + }); + + it("renders no drag handle by default", () => { + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "grip" }), + ).not.toBeInTheDocument(); + }); + + it("renders the dragHandle before the server name in the header", () => { + renderWithMantine( + grip} + />, + ); + const grip = screen.getByRole("button", { name: "grip" }); + const name = screen.getByText("My MCP Server"); + // DOM order: the grip precedes the name (left of it in the header row). + expect(grip.compareDocumentPosition(name)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); }); diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index 98d91db31..a52b68d57 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core"; import type { MCPServerConfig, @@ -18,6 +19,14 @@ export interface ServerCardProps extends ServerEntry { onClone: (id: string) => void; onRemove: (id: string) => void; compact?: boolean; + /** + * Optional drag-handle affordance rendered at the start of the card header, + * before the server name. Supplied by the sortable wrapper + * (`SortableServerCard`); omitted when the card is rendered outside a reorder + * context, so the card stays a dumb display component with no knowledge of + * drag-and-drop. + */ + dragHandle?: ReactNode; } const HeaderLeft = Group.withProps({ @@ -103,6 +112,7 @@ export function ServerCard({ onClone, onRemove, compact = false, + dragHandle, }: ServerCardProps) { const isDimmed = activeServer !== undefined && activeServer !== id; const transport = getTransport(config); @@ -121,6 +131,7 @@ export function ServerCard({ + {dragHandle} {name} diff --git a/clients/web/src/components/groups/SortableServerCard/SortableServerCard.tsx b/clients/web/src/components/groups/SortableServerCard/SortableServerCard.tsx new file mode 100644 index 000000000..e2ee38b77 --- /dev/null +++ b/clients/web/src/components/groups/SortableServerCard/SortableServerCard.tsx @@ -0,0 +1,65 @@ +import { ActionIcon, Box } from "@mantine/core"; +import { RiDraggable } from "react-icons/ri"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { ServerCard, type ServerCardProps } from "../ServerCard/ServerCard"; + +export type SortableServerCardProps = ServerCardProps; + +/** + * Sortable wrapper around the dumb `ServerCard`. Owns all drag-and-drop + * concerns (the `@dnd-kit` sortable node, the per-frame transform, and the + * grip activator) so `ServerCard` itself stays a pure display component that + * only renders the `dragHandle` slot it's handed. + * + * The grip is the sole drag activator (pointer + keyboard) — bound via + * `listeners`/`attributes` and `setActivatorNodeRef` — so the card's own + * buttons (Clone / Edit / Remove / Settings) keep working without starting a + * drag. It's passed into `ServerCard.dragHandle`, which renders it at the start + * of the header row, before the server name. + */ +export function SortableServerCard(props: SortableServerCardProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: props.id }); + + const grip = ( + + + + ); + + return ( + + + + ); +} diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx index e374a4dae..8173df7e7 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type { ServerEntry } from "@inspector/core/mcp/types.js"; import { ServerListScreen } from "./ServerListScreen"; @@ -18,6 +18,7 @@ const meta: Meta = { onEdit: fn(), onClone: fn(), onRemove: fn(), + onReorder: fn(), compact: false, onToggleCompact: fn(), }, @@ -109,3 +110,42 @@ export const WithActiveServer: Story = { activeServer: connectedStdioServer.id, }, }; + +/** + * Accessible keyboard reorder: focus a card's grip, press Space to pick it up, + * an arrow key to move it, and Space again to drop. Runs in a real browser + * (via the storybook test runner) where layout rects exist for the `@dnd-kit` + * keyboard sensor — the path that's unreliable under happy-dom. At the default + * 1280px viewport the grid is three columns wide, so ArrowRight moves the + * first card one position to the right. + */ +export const KeyboardReorder: Story = { + args: { + servers: [connectedStdioServer, disconnectedStdioServer, failedHttpServer], + }, + play: async ({ canvasElement, args, step }) => { + const canvas = within(canvasElement); + const handle = await canvas.findByRole("button", { + name: "Reorder Local Dev Server", + }); + + await step("pick up the first card", async () => { + handle.focus(); + await userEvent.keyboard("[Space]"); + }); + await step("move it one position to the right", async () => { + await userEvent.keyboard("[ArrowRight]"); + }); + await step("drop it", async () => { + await userEvent.keyboard("[Space]"); + }); + + await waitFor(() => expect(args.onReorder).toHaveBeenCalled()); + // The first card swapped places with the second; the third is unmoved. + expect(args.onReorder).toHaveBeenCalledWith([ + disconnectedStdioServer.id, + connectedStdioServer.id, + failedHttpServer.id, + ]); + }, +}; diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx index 3517ceafe..873a112d0 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx @@ -11,6 +11,12 @@ const servers: ServerEntry[] = [ config: { type: "stdio", command: "echo" }, connection: { status: "disconnected" }, }, + { + id: "beta", + name: "Beta", + config: { type: "stdio", command: "echo" }, + connection: { status: "disconnected" }, + }, ]; const baseProps = { @@ -25,14 +31,16 @@ const baseProps = { onEdit: vi.fn(), onClone: vi.fn(), onRemove: vi.fn(), + onReorder: vi.fn(), compact: false, onToggleCompact: vi.fn(), }; describe("ServerListScreen", () => { - it("renders the server card", () => { + it("renders the server cards", () => { renderWithMantine(); expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); }); it("renders the empty state with no servers", () => { @@ -44,10 +52,43 @@ describe("ServerListScreen", () => { it("toggles compact mode when the list toggle is clicked", async () => { const user = userEvent.setup(); - renderWithMantine(); - const buttons = screen.getAllByRole("button"); - expect(buttons.length).toBeGreaterThan(0); - await user.click(buttons[0]); - expect(screen.getByText("Alpha")).toBeInTheDocument(); + const onToggleCompact = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /collapse all/i })); + expect(onToggleCompact).toHaveBeenCalledTimes(1); + }); + + describe("reorder affordances", () => { + it("renders a labelled drag handle per card when onReorder is provided", () => { + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Reorder Alpha" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reorder Beta" }), + ).toBeInTheDocument(); + }); + + it("marks each drag handle as a sortable activator (keyboard-operable)", () => { + renderWithMantine(); + const handle = screen.getByRole("button", { name: "Reorder Alpha" }); + // dnd-kit's useSortable spreads accessibility attributes onto the + // activator: a roledescription plus a non-negative tabindex so keyboard + // users can focus it and press Space to pick the card up. + expect(handle).toHaveAttribute("aria-roledescription", "sortable"); + expect(handle).toHaveAttribute("tabindex", "0"); + }); + + it("omits drag handles when onReorder is not provided", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^Reorder / }), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 5c45004d9..7dc9ba625 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -1,7 +1,25 @@ import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; import type { ServerEntry } from "@inspector/core/mcp/types.js"; import { ServerCard } from "../../groups/ServerCard/ServerCard"; import { ServerListControls } from "../../groups/ServerListControls/ServerListControls"; +import { SortableServerCard } from "../../groups/SortableServerCard/SortableServerCard"; +import { + buildReorderAnnouncements, + makeServerDragEndHandler, +} from "./serverReorder"; export interface ServerListScreenProps { servers: ServerEntry[]; @@ -18,6 +36,11 @@ export interface ServerListScreenProps { onEdit: (id: string) => void; onClone: (id: string) => void; onRemove: (id: string) => void; + /** + * Persist a new server ordering. Receives the complete set of server ids in + * the desired order. Omit to render the list without reorder affordances. + */ + onReorder?: (orderedIds: string[]) => void; compact: boolean; onToggleCompact: () => void; } @@ -45,9 +68,55 @@ export function ServerListScreen({ onEdit, onClone, onRemove, + onReorder, compact, onToggleCompact, }: ServerListScreenProps) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const ids = servers.map((s) => s.id); + const handleDragEnd = makeServerDragEndHandler(servers, onReorder); + const announcements = buildReorderAnnouncements(servers); + + // `reorderable` only when a persistence callback is wired. Without it we + // render plain `ServerCard`s (no grip, no DndContext) so the screen stays + // usable as a pure display — the SortableServerCard's grip would otherwise + // be a dead affordance. + const reorderable = onReorder !== undefined; + + const cardProps = (server: ServerEntry) => ({ + compact, + activeServer, + onToggleConnection, + onConnectionInfo, + onSettings, + onEdit, + onClone, + onRemove, + ...server, + }); + + const grid = ( + + {servers.map((server) => + reorderable ? ( + + ) : ( + + ), + )} + + ); + return ( No servers configured. Add a server to get started. - ) : ( - - {servers.map((server) => ( - - ))} - + + {grid} + + + ) : ( + grid )} diff --git a/clients/web/src/components/screens/ServerListScreen/reorderIds.test.ts b/clients/web/src/components/screens/ServerListScreen/reorderIds.test.ts new file mode 100644 index 000000000..5cd5ec23e --- /dev/null +++ b/clients/web/src/components/screens/ServerListScreen/reorderIds.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { reorderIds } from "./reorderIds"; + +describe("reorderIds", () => { + const ids = ["a", "b", "c", "d"]; + + it("moves an item forward (drop onto a later id)", () => { + expect(reorderIds(ids, "a", "c")).toEqual(["b", "c", "a", "d"]); + }); + + it("moves an item backward (drop onto an earlier id)", () => { + expect(reorderIds(ids, "d", "b")).toEqual(["a", "d", "b", "c"]); + }); + + it("moves to the very front", () => { + expect(reorderIds(ids, "c", "a")).toEqual(["c", "a", "b", "d"]); + }); + + it("moves to the very end", () => { + expect(reorderIds(ids, "a", "d")).toEqual(["b", "c", "d", "a"]); + }); + + it("returns the same array reference when active === over", () => { + expect(reorderIds(ids, "b", "b")).toBe(ids); + }); + + it("returns the same array reference when active id is missing", () => { + expect(reorderIds(ids, "x", "b")).toBe(ids); + }); + + it("returns the same array reference when over id is missing", () => { + expect(reorderIds(ids, "a", "x")).toBe(ids); + }); + + it("preserves the full id set (no drops or duplicates)", () => { + const out = reorderIds(ids, "a", "d"); + expect([...out].sort()).toEqual([...ids].sort()); + expect(out.length).toBe(ids.length); + }); +}); diff --git a/clients/web/src/components/screens/ServerListScreen/reorderIds.ts b/clients/web/src/components/screens/ServerListScreen/reorderIds.ts new file mode 100644 index 000000000..f58b2c880 --- /dev/null +++ b/clients/web/src/components/screens/ServerListScreen/reorderIds.ts @@ -0,0 +1,22 @@ +import { arrayMove } from "@dnd-kit/sortable"; + +/** + * Pure reorder: move `activeId` to where `overId` sits in `ids`. Returns the + * input array unchanged (referential identity preserved) when either id is + * missing or they're identical, so a no-movement drop is a no-op. Extracted + * from `ServerListScreen` for direct unit testing — the `@dnd-kit` + * keyboard/pointer sensors don't produce measurable layout rects under + * happy-dom, so the full gesture is exercised in the Storybook play (real + * browser) while this keeps the ordering math verifiable in the unit suite. + */ +export function reorderIds( + ids: string[], + activeId: string, + overId: string, +): string[] { + if (activeId === overId) return ids; + const from = ids.indexOf(activeId); + const to = ids.indexOf(overId); + if (from === -1 || to === -1) return ids; + return arrayMove(ids, from, to); +} diff --git a/clients/web/src/components/screens/ServerListScreen/serverReorder.test.ts b/clients/web/src/components/screens/ServerListScreen/serverReorder.test.ts new file mode 100644 index 000000000..35526e1a0 --- /dev/null +++ b/clients/web/src/components/screens/ServerListScreen/serverReorder.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from "vitest"; +import type { DragEndEvent } from "@dnd-kit/core"; +import type { ServerEntry } from "@inspector/core/mcp/types.js"; +import { + buildReorderAnnouncements, + makeServerDragEndHandler, +} from "./serverReorder"; + +const entry = (id: string, name: string): ServerEntry => ({ + id, + name, + config: { type: "stdio", command: "echo" }, + connection: { status: "disconnected" }, +}); + +const servers: ServerEntry[] = [ + entry("a", "Alpha"), + entry("b", "Beta"), + entry("c", "Gamma"), +]; + +// Minimal stand-ins for dnd-kit's active/over/event descriptors — the handlers +// only read `.id`, so we cast through `unknown` rather than constructing the +// full (sensor-populated) event shape. +const ref = (id: string) => ({ id }); +const endEvent = (activeId: string, overId: string | null): DragEndEvent => + ({ + active: ref(activeId), + over: overId === null ? null : ref(overId), + }) as unknown as DragEndEvent; + +describe("makeServerDragEndHandler", () => { + it("calls onReorder with the reordered ids when the card moved", () => { + const onReorder = vi.fn(); + makeServerDragEndHandler(servers, onReorder)(endEvent("a", "c")); + expect(onReorder).toHaveBeenCalledWith(["b", "c", "a"]); + }); + + it("does nothing when dropped on itself", () => { + const onReorder = vi.fn(); + makeServerDragEndHandler(servers, onReorder)(endEvent("a", "a")); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it("does nothing when there is no drop target", () => { + const onReorder = vi.fn(); + makeServerDragEndHandler(servers, onReorder)(endEvent("a", null)); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it("tolerates an absent onReorder callback (no throw)", () => { + expect(() => + makeServerDragEndHandler(servers, undefined)(endEvent("a", "b")), + ).not.toThrow(); + }); +}); + +describe("buildReorderAnnouncements", () => { + const a = buildReorderAnnouncements(servers); + + it("narrates pick-up with name and 1-based position", () => { + expect(a.onDragStart({ active: ref("b") } as never)).toBe( + "Picked up server Beta. It is in position 2 of 3.", + ); + }); + + it("narrates a move over a target", () => { + expect(a.onDragOver?.({ active: ref("a"), over: ref("c") } as never)).toBe( + "Server Alpha moved to position 3 of 3.", + ); + }); + + it("returns undefined for onDragOver when there is no target", () => { + expect( + a.onDragOver?.({ active: ref("a"), over: null } as never), + ).toBeUndefined(); + }); + + it("narrates a drop on a target", () => { + expect(a.onDragEnd?.({ active: ref("a"), over: ref("b") } as never)).toBe( + "Server Alpha dropped at position 2 of 3.", + ); + }); + + it("narrates a drop with no target", () => { + expect(a.onDragEnd?.({ active: ref("a"), over: null } as never)).toBe( + "Server Alpha dropped.", + ); + }); + + it("narrates a cancellation", () => { + expect(a.onDragCancel?.({ active: ref("c") } as never)).toBe( + "Reorder cancelled. Server Gamma returned to its original position.", + ); + }); + + it("falls back to the id when the server is unknown", () => { + expect(a.onDragStart({ active: ref("missing") } as never)).toBe( + "Picked up server missing. It is in position 0 of 3.", + ); + }); +}); diff --git a/clients/web/src/components/screens/ServerListScreen/serverReorder.ts b/clients/web/src/components/screens/ServerListScreen/serverReorder.ts new file mode 100644 index 000000000..e04a938a3 --- /dev/null +++ b/clients/web/src/components/screens/ServerListScreen/serverReorder.ts @@ -0,0 +1,74 @@ +import type { Announcements, DragEndEvent } from "@dnd-kit/core"; +import type { ServerEntry } from "@inspector/core/mcp/types.js"; +import { reorderIds } from "./reorderIds"; + +/** + * Reorder glue for `ServerListScreen`, extracted from the component so the + * announcement copy and the drag-end resolution are unit-testable directly. + * The `@dnd-kit` keyboard/pointer sensors don't yield measurable layout rects + * under happy-dom, so the live gesture can't be driven in the unit suite (it's + * covered by the Storybook play instead) — keeping this logic out of the + * component body is what lets it stay verified. + */ + +/** Display name for an id, falling back to the id when it isn't found. */ +function nameFor(servers: ServerEntry[], id: string): string { + return servers.find((s) => s.id === id)?.name ?? id; +} + +/** 1-based position of an id in the list, for "position 3 of 7" narration. */ +function positionFor(servers: ServerEntry[], id: string): number { + return servers.findIndex((s) => s.id === id) + 1; +} + +/** + * Live-region narration so keyboard / screen-reader users hear the pick-up, + * each move, and the drop. Fed to `DndContext`'s `accessibility.announcements`, + * which writes into dnd-kit's built-in `aria-live` region. + */ +export function buildReorderAnnouncements( + servers: ServerEntry[], +): Announcements { + const count = servers.length; + return { + onDragStart: ({ active }) => + `Picked up server ${nameFor(servers, String(active.id))}. It is in position ${positionFor( + servers, + String(active.id), + )} of ${count}.`, + onDragOver: ({ active, over }) => + over + ? `Server ${nameFor(servers, String(active.id))} moved to position ${positionFor( + servers, + String(over.id), + )} of ${count}.` + : undefined, + onDragEnd: ({ active, over }) => + over + ? `Server ${nameFor(servers, String(active.id))} dropped at position ${positionFor( + servers, + String(over.id), + )} of ${count}.` + : `Server ${nameFor(servers, String(active.id))} dropped.`, + onDragCancel: ({ active }) => + `Reorder cancelled. Server ${nameFor(servers, String(active.id))} returned to its original position.`, + }; +} + +/** + * Build the `DndContext.onDragEnd` handler bound to the current list and the + * persistence callback. Returns a no-op-on-no-movement handler: when the drop + * lands outside any target, on itself, or doesn't change the order, `onReorder` + * is not called. + */ +export function makeServerDragEndHandler( + servers: ServerEntry[], + onReorder: ((orderedIds: string[]) => void) | undefined, +): (event: DragEndEvent) => void { + return ({ active, over }: DragEndEvent): void => { + if (!over || active.id === over.id) return; + const ids = servers.map((s) => s.id); + const next = reorderIds(ids, String(active.id), String(over.id)); + if (next !== ids) onReorder?.(next); + }; +} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 228509b5e..c48f32bcf 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -356,6 +356,7 @@ const meta: Meta = { onServerEdit: fn(), onServerClone: fn(), onServerRemove: fn(), + onServerReorder: fn(), serverSupportsTaskToolCalls: false, onToolsUiChange: fn(), onCallTool: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index ae0d32650..350e0e8d2 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -88,6 +88,7 @@ function makeProps( onServerEdit: vi.fn(), onServerClone: vi.fn(), onServerRemove: vi.fn(), + onServerReorder: vi.fn(), serverSupportsTaskToolCalls: false, onToolsUiChange: vi.fn(), onCallTool: vi.fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 38b213170..941992a19 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -268,6 +268,8 @@ export interface InspectorViewProps { onServerEdit: (id: string) => void; onServerClone: (id: string) => void; onServerRemove: (id: string) => void; + /** Persist a new server ordering (drag-and-drop / keyboard reorder). */ + onServerReorder: (orderedIds: string[]) => void; // Per-primitive actions (route to `inspectorClient` methods / hook refresh). // Each `on{Screen}UiChange` persists that screen's lifted UI state (#1417). @@ -380,6 +382,7 @@ export function InspectorView({ onServerEdit, onServerClone, onServerRemove, + onServerReorder, serverSupportsTaskToolCalls, onToolsUiChange, onCallTool, @@ -549,6 +552,7 @@ export function InspectorView({ onEdit={onServerEdit} onClone={onServerClone} onRemove={onServerRemove} + onReorder={onServerReorder} compact={serversCompact} onToggleCompact={() => setServersCompact((c) => !c)} /> diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 1288a303c..d1ffc7a6b 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -492,6 +492,142 @@ describe("useServers", () => { }); }); + it("reorderServers persists the new order to disk and updates the list", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + alpha: { type: "stdio", command: "a" }, + beta: { type: "stdio", command: "b" }, + gamma: { type: "stdio", command: "g" }, + }, + }), + ); + + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), + ); + await waitFor(() => + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + "gamma", + ]), + ); + + await act(async () => { + await result.current.reorderServers(["gamma", "alpha", "beta"]); + }); + + await waitFor(() => { + expect(result.current.servers.map((s) => s.id)).toEqual([ + "gamma", + "alpha", + "beta", + ]); + }); + // mcp.json map iteration order reflects the new order. + expect(Object.keys(readConfig(h.configPath).mcpServers)).toEqual([ + "gamma", + "alpha", + "beta", + ]); + }); + + it("reorderServers updates the list optimistically before the round-trip resolves", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + alpha: { type: "stdio", command: "a" }, + beta: { type: "stdio", command: "b" }, + }, + }), + ); + + let resolvePut: (() => void) | undefined; + const gatedFetch: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (init?.method === "PUT" && url.endsWith("/api/servers/order")) { + // Hold the PUT response open so we can observe the optimistic state + // that must already be applied before the network settles. + await new Promise((r) => { + resolvePut = r; + }); + } + return h.fetchFn(input, init); + }; + + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn: gatedFetch }), + ); + await waitFor(() => + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]), + ); + + let pending: Promise; + act(() => { + pending = result.current.reorderServers(["beta", "alpha"]); + }); + + // Optimistic reorder is visible while the PUT is still in flight. + await waitFor(() => + expect(result.current.servers.map((s) => s.id)).toEqual([ + "beta", + "alpha", + ]), + ); + + await act(async () => { + resolvePut?.(); + await pending; + }); + }); + + it("reorderServers reverts to disk truth and throws when the set no longer matches (409)", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + alpha: { type: "stdio", command: "a" }, + beta: { type: "stdio", command: "b" }, + }, + }), + ); + + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), + ); + await waitFor(() => + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]), + ); + + // Incomplete set (missing `beta`) — the backend rejects with 409 and the + // hook re-fetches, snapping the list back to the on-disk order. + await expect( + act(async () => { + await result.current.reorderServers(["alpha"]); + }), + ).rejects.toThrow(/does not match/); + + await waitFor(() => { + expect(result.current.servers.map((s) => s.id)).toEqual([ + "alpha", + "beta", + ]); + }); + expect(Object.keys(readConfig(h.configPath).mcpServers)).toEqual([ + "alpha", + "beta", + ]); + }); + it("uses DEFAULT_SEED_CONFIG keys on the first load (seed-write contract)", async () => { const { result } = renderHook(() => useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), diff --git a/clients/web/src/test/integration/mcp/remote/servers-order.test.ts b/clients/web/src/test/integration/mcp/remote/servers-order.test.ts new file mode 100644 index 000000000..d95caa925 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/servers-order.test.ts @@ -0,0 +1,177 @@ +/** + * Integration tests for PUT /api/servers/order — the reorder route added for + * #1369. Spins up createRemoteApp against a per-test tmp mcpConfigPath and + * exercises the route via real HTTP, asserting on the resulting on-disk + * iteration order and the conflict/validation rejections. + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { InMemorySecretStore } from "@inspector/core/auth/node/secret-store.js"; +import type { MCPConfig } from "@inspector/core/mcp/types.js"; + +interface Harness { + baseUrl: string; + server: ServerType; + configPath: string; + tempDir: string; +} + +async function startServer( + configPath: string, +): Promise<{ baseUrl: string; server: ServerType }> { + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + mcpConfigPath: configPath, + initialConfig: { defaultEnvironment: {} }, + secretStore: new InMemorySecretStore(), + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server }); + }, + ); + server.on("error", reject); + }); +} + +async function setup(seed: MCPConfig): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "inspector-servers-order-")); + const configPath = join(tempDir, "mcp.json"); + writeFileSync(configPath, JSON.stringify(seed, null, 2)); + const { baseUrl, server } = await startServer(configPath); + return { baseUrl, server, configPath, tempDir }; +} + +async function teardown(h: Harness): Promise { + await new Promise((resolve) => h.server.close(() => resolve())); + try { + rmSync(h.tempDir, { recursive: true }); + } catch { + /* ignore */ + } +} + +function readOrder(path: string): string[] { + const cfg = JSON.parse(readFileSync(path, "utf-8")) as MCPConfig; + return Object.keys(cfg.mcpServers); +} + +const SEED: MCPConfig = { + mcpServers: { + alpha: { type: "stdio", command: "a" }, + beta: { type: "stdio", command: "b" }, + gamma: { type: "stdio", command: "g" }, + }, +}; + +async function putOrder(baseUrl: string, body: unknown): Promise { + return fetch(`${baseUrl}/api/servers/order`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("PUT /api/servers/order", () => { + let h: Harness; + + beforeEach(async () => { + h = await setup(SEED); + }); + + afterEach(async () => { + await teardown(h); + }); + + it("rewrites mcp.json in the supplied order", async () => { + const res = await putOrder(h.baseUrl, { + order: ["gamma", "alpha", "beta"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(readOrder(h.configPath)).toEqual(["gamma", "alpha", "beta"]); + }); + + it("preserves each entry's config when reordering (values untouched)", async () => { + await putOrder(h.baseUrl, { order: ["beta", "gamma", "alpha"] }); + const cfg = JSON.parse(readFileSync(h.configPath, "utf-8")) as MCPConfig; + expect(cfg.mcpServers.alpha).toEqual({ type: "stdio", command: "a" }); + expect(cfg.mcpServers.beta).toEqual({ type: "stdio", command: "b" }); + expect(cfg.mcpServers.gamma).toEqual({ type: "stdio", command: "g" }); + }); + + it("rejects an order missing an on-disk id (409) and leaves the file unchanged", async () => { + const res = await putOrder(h.baseUrl, { order: ["alpha", "beta"] }); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toMatch( + /does not match/, + ); + // Original order preserved. + expect(readOrder(h.configPath)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("rejects an order containing an unknown id (409)", async () => { + const res = await putOrder(h.baseUrl, { + order: ["alpha", "beta", "ghost"], + }); + expect(res.status).toBe(409); + expect(readOrder(h.configPath)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("rejects duplicate ids in the order (400)", async () => { + const res = await putOrder(h.baseUrl, { + order: ["alpha", "alpha", "beta"], + }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch( + /duplicate/, + ); + expect(readOrder(h.configPath)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("rejects a non-array order (400)", async () => { + const res = await putOrder(h.baseUrl, { order: "alpha,beta,gamma" }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch( + /array of strings/, + ); + }); + + it("rejects an order array with a non-string element (400)", async () => { + const res = await putOrder(h.baseUrl, { order: ["alpha", 2, "gamma"] }); + expect(res.status).toBe(400); + }); + + it("rejects an invalid JSON body (400)", async () => { + const res = await fetch(`${h.baseUrl}/api/servers/order`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "{ not json", + }); + expect(res.status).toBe(400); + }); + + it("is not captured by the :id route — 'order' is never treated as a server id", async () => { + // Regression guard for route registration order: a PUT to /order must hit + // the reorder handler, not PUT /api/servers/:id with id="order". + const res = await putOrder(h.baseUrl, { + order: ["gamma", "beta", "alpha"], + }); + expect(res.status).toBe(200); + expect(readOrder(h.configPath)).toEqual(["gamma", "beta", "alpha"]); + // No phantom "order" server was created. + expect(readOrder(h.configPath)).not.toContain("order"); + }); +}); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4a906a98d..70f0dd9f6 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1514,6 +1514,67 @@ export function createRemoteApp( } }); + // Reorder the on-disk `mcpServers` map. Registered before + // `PUT /api/servers/:id` so the literal `/order` segment isn't captured + // as an `:id` param ("order" would otherwise be a valid store id). The + // request body is `{ order: string[] }` — the complete set of server ids + // in the desired iteration order. We reject (409) unless that set matches + // the on-disk set exactly, so a reorder racing an external add/remove + // can't silently drop or duplicate an entry. Reordering touches no secret + // values, so we just permute the existing stripped entries and reuse the + // same atomic-write + watcher-notify path as the other mutators. + app.put("/api/servers/order", async (c) => { + let body: { order?: unknown }; + try { + body = (await c.req.json()) as { order?: unknown }; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + if ( + !Array.isArray(body.order) || + !body.order.every((id): id is string => typeof id === "string") + ) { + return c.json({ error: "order must be an array of strings" }, 400); + } + const order = body.order; + if (new Set(order).size !== order.length) { + return c.json({ error: "order contains duplicate ids" }, 400); + } + + try { + return await withWriteLock(async () => { + const current = await readMcpConfig(); + const currentIds = Object.keys(current.mcpServers); + // Exact-set match: same size and every requested id present on disk. + // The duplicate check above plus equal sizes guarantees this is a + // permutation, never a partial reorder that would drop an entry. + const currentSet = new Set(currentIds); + const sameSet = + currentIds.length === order.length && + order.every((id) => currentSet.has(id)); + if (!sameSet) { + return c.json( + { + error: + "order does not match the current server set (it may have changed on disk)", + }, + 409, + ); + } + const next: MCPConfig = { mcpServers: {} }; + for (const id of order) { + // Non-null: `sameSet` proves every `id` is a key of the map. + next.mcpServers[id] = current.mcpServers[id]!; + } + await writeMcpAndTrackMtime(serializeStore(next)); + return c.json({ ok: true }); + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `Failed to reorder servers: ${msg}` }, 500); + } + }); + app.put("/api/servers/:id", async (c) => { const originalId = c.req.param("id"); if (!originalId || !validateStoreId(originalId)) { diff --git a/core/react/useServers.ts b/core/react/useServers.ts index a546581a6..29c04c501 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -44,6 +44,14 @@ export interface UseServersResult { settings: InspectorServerSettings, ) => Promise; removeServer: (id: string) => Promise; + /** + * Persist a new ordering for the server list. `orderedIds` must be the + * complete set of current server ids in the desired order. The local list + * is reordered optimistically so the grid reflows instantly; on backend + * failure (e.g. the on-disk set changed underneath us) we re-fetch to snap + * back to disk truth. Routes through `PUT /api/servers/order`. + */ + reorderServers: (orderedIds: string[]) => Promise; } function buildHeaders( @@ -252,6 +260,45 @@ export function useServers(opts: UseServersOptions): UseServersResult { [base, authToken, doFetch, refresh], ); + const reorderServers = useCallback( + async (orderedIds: string[]): Promise => { + // Optimistic reorder: rebuild the local list in the requested order so + // the grid reflows immediately, before the round-trip resolves. Built + // from a lookup off the previous state (inside the setter) so we never + // capture a stale snapshot, and any id not currently present is simply + // skipped rather than producing an `undefined` hole. + setServers((prev) => { + const byId = new Map(prev.map((s) => [s.id, s])); + const reordered = orderedIds + .map((id) => byId.get(id)) + .filter((s): s is ServerEntry => s !== undefined); + // Defensive: if the requested order dropped any entries (shouldn't + // happen — callers pass the full set), keep the strays at the end so + // nothing vanishes from the UI before the refresh reconciles. + if (reordered.length !== prev.length) { + const seen = new Set(orderedIds); + for (const s of prev) if (!seen.has(s.id)) reordered.push(s); + } + return reordered; + }); + try { + const res = await doFetch(`${base}/api/servers/order`, { + method: "PUT", + headers: buildHeaders(authToken, true), + body: JSON.stringify({ order: orderedIds }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res)); + } + } catch (err) { + // Revert to disk truth — the optimistic order may not have landed. + await refresh(); + throw err instanceof Error ? err : new Error(String(err)); + } + }, + [base, authToken, doFetch, refresh], + ); + return { servers, loading, @@ -261,5 +308,6 @@ export function useServers(opts: UseServersOptions): UseServersResult { updateServer, updateServerSettings, removeServer, + reorderServers, }; } From 855701c7495f9022418a5f9796ca14c055012191 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 9 Jun 2026 13:56:07 -0400 Subject: [PATCH 2/2] fix(web): notify on reorder failure; skip reorder glue when not reorderable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #1447 review: - App.tsx: catch reorderServers rejection (409 from a racing external edit, or a network error) and show a red toast, matching the error-handling pattern used by every other mutation — previously the rejection was discarded with `void` and the drag silently bounced back. - ServerListScreen: only build handleDragEnd / announcements when `reorderable`, so the four-closure announcements object isn't allocated every render when no onReorder is wired. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.tsx | 13 ++++++++++++- .../screens/ServerListScreen/ServerListScreen.tsx | 12 ++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 4c30ac024..2c2832127 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2151,7 +2151,18 @@ function App() { if (target) setRemoveTarget(target); }} onServerReorder={(orderedIds) => { - void reorderServers(orderedIds); + // reorderServers reverts the optimistic order via an internal + // refresh() and re-throws on failure (409 from a racing external + // edit, or a network error). Surface that to the user so the drag + // doesn't silently bounce back — matching the toast pattern every + // other mutation here uses. + reorderServers(orderedIds).catch((err: unknown) => { + notifications.show({ + title: "Failed to reorder servers", + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }); }} serverSupportsTaskToolCalls={ !!capabilities?.tasks?.requests?.tools?.call diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 7dc9ba625..393d12644 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -80,8 +80,6 @@ export function ServerListScreen({ ); const ids = servers.map((s) => s.id); - const handleDragEnd = makeServerDragEndHandler(servers, onReorder); - const announcements = buildReorderAnnouncements(servers); // `reorderable` only when a persistence callback is wired. Without it we // render plain `ServerCard`s (no grip, no DndContext) so the screen stays @@ -89,6 +87,16 @@ export function ServerListScreen({ // be a dead affordance. const reorderable = onReorder !== undefined; + // Built only when reorderable — the drag-end handler and the fresh + // announcements object (four closures) are otherwise allocated every render + // for nothing. + const handleDragEnd = reorderable + ? makeServerDragEndHandler(servers, onReorder) + : undefined; + const announcements = reorderable + ? buildReorderAnnouncements(servers) + : undefined; + const cardProps = (server: ServerEntry) => ({ compact, activeServer,