Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions clients/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions clients/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions clients/web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 15 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ function App() {
updateServer,
updateServerSettings,
removeServer,
reorderServers,
} = useServers({
baseUrl:
typeof window !== "undefined"
Expand Down Expand Up @@ -2149,6 +2150,20 @@ function App() {
const target = servers.find((s) => s.id === id);
if (target) setRemoveTarget(target);
}}
onServerReorder={(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
}
Expand Down
32 changes: 32 additions & 0 deletions clients/web/src/components/groups/ServerCard/ServerCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
11 changes: 11 additions & 0 deletions clients/web/src/components/groups/ServerCard/ServerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Badge, Button, Card, Group, Stack, Text } from "@mantine/core";
import type {
MCPServerConfig,
Expand All @@ -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({
Expand Down Expand Up @@ -103,6 +112,7 @@ export function ServerCard({
onClone,
onRemove,
compact = false,
dragHandle,
}: ServerCardProps) {
const isDimmed = activeServer !== undefined && activeServer !== id;
const transport = getTransport(config);
Expand All @@ -121,6 +131,7 @@ export function ServerCard({
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<HeaderLeft>
{dragHandle}
<ServerName>{name}</ServerName>
</HeaderLeft>
<HeaderRight>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,6 +18,7 @@ const meta: Meta<typeof ServerListScreen> = {
onEdit: fn(),
onClone: fn(),
onRemove: fn(),
onReorder: fn(),
compact: false,
onToggleCompact: fn(),
},
Expand Down Expand Up @@ -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,
]);
},
};
Loading
Loading