web: deep-link auto-connect + machine-readable connection status (#1576)

Closes #1576

Adds `?serverUrl=<url>&transport=http|sse&autoConnect=<token>` URL-driven
auto-connect so a driver can reach a connected inspector with one navigate.

- `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only
  serverUrl; `autoConnect` CSRF-gated on the per-launch API token),
  `deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID`
  (stable `deep-link` row so reloads reconnect to the same catalog row).
- App.tsx: ensure/connect effects (two-phase upsert-then-connect against the
  hydrated list), `serversRef` read in `onToggleConnection`,
  `connectErrorMessage` / `recordConnectError` recording connection-level
  failures.
- InspectorView header: `data-testid="connection-status"` with `data-status`,
  `data-error-message`, `data-deeplink` so drivers can `waitForSelector` and
  read failure reasons without scraping toasts.
- Docs: deep-link scheme + `data-*` contract in clients/web/README.md.

Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417
lifted-tab architecture and dropping the non-existent `normalizeServerUrl`
import in favor of `URL.href` canonicalization. Coverage gate green
(deepLink.ts 97.8/95.1/100/97.4).

Part of the #1579 decomposition (Wave 4).

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-11 22:48:28 -04:00
commit 0e93b13426
2 changed files with 575 additions and 3 deletions

View file

@ -259,6 +259,42 @@ describe("InspectorView", () => {
expect(onToggleConnection).toHaveBeenCalledWith("alpha");
});
it("exposes the machine-readable connection-status header attributes for drivers", () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
connectionStatus: "error",
connectErrorMessage: "handshake failed: 500",
deepLinkStatus: "rejected",
})}
/>,
);
const header = screen.getByTestId("connection-status");
expect(header).toHaveAttribute("data-status", "error");
expect(header).toHaveAttribute(
"data-error-message",
"handshake failed: 500",
);
expect(header).toHaveAttribute("data-deeplink", "rejected");
});
it("omits the error-message attribute when no connect error is recorded", () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
connectionStatus: "disconnected",
deepLinkStatus: "none",
})}
/>,
);
const header = screen.getByTestId("connection-status");
expect(header).toHaveAttribute("data-status", "disconnected");
expect(header).not.toHaveAttribute("data-error-message");
expect(header).toHaveAttribute("data-deeplink", "none");
});
it("renders the connected header when connectionStatus + initializeResult are set", () => {
renderWithMantine(
<StatefulInspectorViewHost

View file

@ -6,6 +6,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import {
AppShell,
Flex,
@ -304,6 +305,21 @@ const MonitorColumnGroup = Group.withProps({
});
export interface InspectorViewProps {
/**
* Validated deep-link parameters from the page URL. When present and
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
* app (with `appArgs` as the form values) once the connection is up and the
* app list contains it. The connect itself is driven by the parent.
*/
deepLink?: DeepLink;
/**
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
* on the `connection-status` testid. Distinguishes "no deep link" from
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
* idle, so an automated driver otherwise cannot tell them apart.
*/
deepLinkStatus?: DeepLinkParseStatus;
// Server list (static config; runtime connection state comes from the
// separate fields below and is merged into each card by this component).
servers: ServerEntry[];
@ -324,6 +340,13 @@ export interface InspectorViewProps {
*/
erroredServerId?: string;
connectionStatus: ConnectionStatus;
/**
* Last connection-level error message (handshake failure, OAuth start
* failure, deep-link automation failure). Surfaced as `data-error-message`
* on the header's `connection-status` testid so an automated driver can read
* *why* a connect failed without scraping a transient toast.
*/
connectErrorMessage?: string;
initializeResult?: InitializeResult;
latencyMs?: number;
@ -497,11 +520,13 @@ export interface InspectorViewProps {
}
export function InspectorView({
deepLinkStatus,
servers: serversInput,
serverListWritable = true,
activeServer,
erroredServerId,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
tools,
@ -1037,7 +1062,12 @@ export function InspectorView({
// Main-slot height clamp + overflow:hidden keep that scroll on the inner
// ScrollArea regions only.
<AppShell header={{ height: 60 }} padding={0}>
<AppShell.Header>
<AppShell.Header
data-testid="connection-status"
data-status={connectionStatus}
data-error-message={connectErrorMessage}
data-deeplink={deepLinkStatus}
>
{connectionStatus === "connected" && initializeResult ? (
<ViewHeader
connected