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
13 changed files with 1039 additions and 27 deletions

View file

@ -215,4 +215,36 @@ describe("ServerCard", () => {
renderWithMantine(<ServerCard {...baseProps} connection={errored} />);
expect(screen.queryByText("Connection refused")).not.toBeInTheDocument();
});
it("renders the dragHandle slot when provided", () => {
renderWithMantine(
<ServerCard
{...baseProps}
dragHandle={<button type="button">grip</button>}
/>,
);
expect(screen.getByRole("button", { name: "grip" })).toBeInTheDocument();
});
it("renders no drag handle by default", () => {
renderWithMantine(<ServerCard {...baseProps} />);
expect(
screen.queryByRole("button", { name: "grip" }),
).not.toBeInTheDocument();
});
it("renders the dragHandle before the server name in the header", () => {
renderWithMantine(
<ServerCard
{...baseProps}
dragHandle={<button type="button">grip</button>}
/>,
);
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,
);
});
});

View file

@ -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({
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<HeaderLeft>
{dragHandle}
<ServerName>{name}</ServerName>
</HeaderLeft>
<HeaderRight>

View file

@ -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 = (
<ActionIcon
ref={setActivatorNodeRef}
variant="subtle"
color="gray"
size="md"
className="server-drag-handle"
aria-label={`Reorder ${props.name}`}
{...attributes}
{...listeners}
>
<RiDraggable size={16} />
</ActionIcon>
);
return (
<Box
ref={setNodeRef}
// dnd-kit positions the item with a transform that changes every
// animation frame during a drag — the one place an inline style is
// unavoidable, since the value can't be a static theme variant or prop.
// While dragging we lift the item above its siblings and fade it
// slightly so the drop target underneath stays legible.
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 2 : undefined,
opacity: isDragging ? 0.85 : undefined,
}}
>
<ServerCard {...props} dragHandle={grip} />
</Box>
);
}

View file

@ -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<typeof ServerListScreen> = {
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,
]);
},
};

View file

@ -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(<ServerListScreen {...baseProps} />);
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(<ServerListScreen {...baseProps} />);
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(
<ServerListScreen {...baseProps} onToggleCompact={onToggleCompact} />,
);
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(<ServerListScreen {...baseProps} />);
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(<ServerListScreen {...baseProps} />);
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(
<ServerListScreen {...baseProps} onReorder={undefined} />,
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /^Reorder / }),
).not.toBeInTheDocument();
});
});
});

View file

@ -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,63 @@ 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);
// `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;
// 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,
onToggleConnection,
onConnectionInfo,
onSettings,
onEdit,
onClone,
onRemove,
...server,
});
const grid = (
<SimpleGrid
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="lg"
className="grid-align-start"
>
{servers.map((server) =>
reorderable ? (
<SortableServerCard key={server.id} {...cardProps(server)} />
) : (
<ServerCard key={server.id} {...cardProps(server)} />
),
)}
</SimpleGrid>
);
return (
<PageContainer>
<ServerListControls
@ -65,27 +142,19 @@ export function ServerListScreen({
<EmptyState>
No servers configured. Add a server to get started.
</EmptyState>
) : (
<SimpleGrid
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="lg"
className="grid-align-start"
) : reorderable ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
accessibility={{ announcements }}
>
{servers.map((server) => (
<ServerCard
key={server.id}
compact={compact}
activeServer={activeServer}
onToggleConnection={onToggleConnection}
onConnectionInfo={onConnectionInfo}
onSettings={onSettings}
onEdit={onEdit}
onClone={onClone}
onRemove={onRemove}
{...server}
/>
))}
</SimpleGrid>
<SortableContext items={ids} strategy={rectSortingStrategy}>
{grid}
</SortableContext>
</DndContext>
) : (
grid
)}
</ScrollArea.Autosize>
</PageContainer>

View file

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

View file

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

View file

@ -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.",
);
});
});

View file

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

View file

@ -356,6 +356,7 @@ const meta: Meta<typeof InspectorView> = {
onServerEdit: fn(),
onServerClone: fn(),
onServerRemove: fn(),
onServerReorder: fn(),
serverSupportsTaskToolCalls: false,
onToolsUiChange: fn(),
onCallTool: fn(),

View file

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

View file

@ -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)}
/>