feat(web): red border on a server card whose connection attempt failed (#1621)

Flags the server whose last connection attempt failed with a red card
border, cleared when another server is connected or a new connection is
attempted — a persistent, at-a-glance companion to the failure toast and
the auto-opened monitoring column.

- App tracks `failedServerId`: set in onToggleConnection's non-auth
  handshake catch, cleared at the start of any new connect attempt. It is
  deliberately kept out of resetSessionScopedUiState, since the failure's
  own `disconnect` event would otherwise clear it the same tick (that same
  event clears `activeServer`, which is why the flag is tracked separately).
- New `errored` Card theme variant (red border via a new
  `--inspector-error-border` token), mirroring the `highlighted` green
  border. Precedence: disabled (dimmed) > errored > highlighted.
- Plumbed `erroredServerId` through InspectorView → ServerListScreen →
  ServerCard (`errored` prop).

Full `npm run ci` passes (validate + coverage + smoke + storybook).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
cliffhall 2026-07-09 14:48:17 -04:00
commit c6999dff96
6 changed files with 219 additions and 6 deletions

View file

@ -87,6 +87,21 @@ export const Failed: Story = {
},
};
// The red border flagging a server whose last connection attempt failed
// (#1621). The status settles back to "disconnected" (the failure clears the
// active session), but the border persists until another server is
// connected/attempted.
export const ErroredBorder: Story = {
args: {
id: "3d2c1b0a-9f8e-4d6c-8b7a-1a2b3c4d5e6f",
name: "My MCP Server",
config: stdioConfig,
info: { name: "My MCP Server", version: "1.2.0" },
connection: disconnected,
errored: true,
},
};
export const LongServerName: Story = {
args: {
id: "a1b2c3d4-e5f6-4789-9abc-def012345678",

View file

@ -363,4 +363,56 @@ describe("ServerCard", () => {
expect(onClearHighlight).toHaveBeenCalledTimes(1);
});
});
describe("connection-failed border (#1621)", () => {
it("draws the red errored-variant border when errored", () => {
const { container } = renderWithMantine(
<ServerCard {...baseProps} connection={disconnected} errored />,
);
expect(
container.querySelector('[data-variant="errored"]'),
).not.toBeNull();
});
it("does not draw the errored border when not errored", () => {
const { container } = renderWithMantine(
<ServerCard {...baseProps} connection={disconnected} />,
);
expect(container.querySelector('[data-variant="errored"]')).toBeNull();
});
it("prefers the dimmed (disabled) variant over the errored border", () => {
// A dimmed card (another server active) is inert; that wins over the
// error border so the card can't look interactive.
const { container } = renderWithMantine(
<ServerCard
{...baseProps}
connection={disconnected}
activeServer="other"
errored
/>,
);
expect(
container.querySelector('[data-variant="disabled"]'),
).not.toBeNull();
expect(container.querySelector('[data-variant="errored"]')).toBeNull();
});
it("prefers the errored border over the freshly-added highlight", () => {
const { container } = renderWithMantine(
<ServerCard
{...baseProps}
connection={disconnected}
errored
highlighted
/>,
);
expect(
container.querySelector('[data-variant="errored"]'),
).not.toBeNull();
expect(
container.querySelector('[data-variant="highlighted"]'),
).toBeNull();
});
});
});

View file

@ -38,6 +38,12 @@ export interface ServerCardProps extends ServerEntry {
* eye. Cleared by `onClearHighlight` on any click on the card.
*/
highlighted?: boolean;
/**
* When true, this server's last connection attempt failed: the card draws a
* red border to flag it (#1621). The parent clears this when another server
* is connected or a new connection is attempted.
*/
errored?: boolean;
/**
* Whether a highlighted card scrolls itself into view. When several cards are
* highlighted at once (a batch import) only the first should scroll, so the
@ -135,6 +141,7 @@ export function ServerCard({
writable = true,
dragHandle,
highlighted = false,
errored = false,
scrollOnHighlight = true,
onClearHighlight,
}: ServerCardProps) {
@ -155,12 +162,15 @@ export function ServerCard({
connection.status === "connected" ? connection.protocolVersion : undefined;
// A dimmed card (another server is active) is inert, so the disabled variant
// wins; otherwise a freshly-added card draws the highlighted green border.
// wins; otherwise a failed-connection card draws the red error border, then a
// freshly-added card draws the highlighted green border.
const variant = isDimmed
? "disabled"
: highlighted
? "highlighted"
: undefined;
: errored
? "errored"
: highlighted
? "highlighted"
: undefined;
return (
<Card

View file

@ -136,6 +136,27 @@ describe("ServerListScreen", () => {
});
});
describe("connection-failed border (#1621)", () => {
it("draws the red errored border only on the failed server's card", () => {
const { container } = renderWithMantine(
<ServerListScreen {...baseProps} erroredServerId="beta" />,
);
const errored = container.querySelectorAll('[data-variant="errored"]');
expect(errored).toHaveLength(1);
// The errored card is Beta's — its name is within the flagged card.
expect(errored[0]).toHaveTextContent("Beta");
});
it("draws no errored border when erroredServerId is unset", () => {
const { container } = renderWithMantine(
<ServerListScreen {...baseProps} />,
);
expect(
container.querySelectorAll('[data-variant="errored"]'),
).toHaveLength(0);
});
});
describe("freshly-added highlight", () => {
it("draws a green highlight border on every highlighted server", () => {
const { container } = renderWithMantine(

View file

@ -39,6 +39,11 @@ export interface ServerListScreenProps {
writable?: boolean;
/** Id of the server the wiring layer treats as active (drives card dimming). */
activeServer?: string;
/**
* Id of the server whose last connection attempt failed (#1621). Its card
* draws a red border until another server is connected or attempted.
*/
erroredServerId?: string;
onAddManually: () => void;
onImportConfig: () => void;
onImportServerJson: () => void;
@ -78,6 +83,7 @@ export function ServerListScreen({
servers,
writable = true,
activeServer,
erroredServerId,
onAddManually,
onImportConfig,
onImportServerJson,
@ -138,6 +144,7 @@ export function ServerListScreen({
onClone,
onRemove,
highlighted: highlightedServerIds?.includes(server.id) ?? false,
errored: server.id === erroredServerId,
scrollOnHighlight: server.id === firstHighlightedId,
onClearHighlight: onClearHighlight
? () => onClearHighlight(server.id)

View file

@ -347,6 +347,13 @@ export interface InspectorViewProps {
// Connection state — driven by the parent via `useInspectorClient`.
activeServer?: string;
/**
* Id of the server whose last connection attempt failed (#1621). Its card in
* the Servers screen draws a red border until another server is connected or
* a new connection is attempted. Independent of `activeServer`, which the
* parent clears on the failure's `disconnect` event.
*/
erroredServerId?: string;
connectionStatus: ConnectionStatus;
initializeResult?: InitializeResult;
latencyMs?: number;
@ -524,6 +531,7 @@ export function InspectorView({
servers: serversInput,
serverListWritable = true,
activeServer,
erroredServerId,
connectionStatus,
initializeResult,
latencyMs,
@ -1062,6 +1070,7 @@ export function InspectorView({
servers={servers}
writable={serverListWritable}
activeServer={dimCardsAgainst}
erroredServerId={erroredServerId}
onAddManually={onServerAdd}
onImportConfig={onServerImportConfig}
onImportServerJson={onServerImportJson}