feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369) (#1447)

* feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369)

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) <noreply@anthropic.com>

* fix(web): notify on reorder failure; skip reorder glue when not reorderable

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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-09 14:15:58 -04:00 • committed by GitHub
commit d5f12b2a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<void>((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<void>;
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 }),