inspector/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx
cliffhall f62410a9b3 chore(web): connected-flow smoke for the Tools, Resources and Prompts tabs
The web client had exactly one connected-flow smoke and it was the Apps
tab. `smoke:web:browser` proves the bundle boots and paints but never
connects; `smoke:web:app` / `smoke:web:elicit` connect but only drive the
Apps surface. So the three highest-traffic tabs were unexercised through
the prod bundle end to end — everything covering them (unit tests,
Storybook, the integration project) mocks or bypasses the prod server, so
a regression living only in the built bundle or in the prod Hono
backend's wiring of a tab passed all of them.

`scripts/smoke-web-tabs.mjs` drives all three against one server on one
browser launch (~5s added wall-clock): Tools runs `list_items` and
asserts its `structuredContent` section rendered (#1908), Resources reads
`events` with the template list asserted separately — `resources/list`
and `resources/templates/list` are different calls and either can fail
alone — and Prompts fetches the argument-less `simple_prompt`.

Every assertion is on a `data-*` attribute, never on copy, mirroring the
`data-app-status` precedent: each screen now carries a readiness contract
(`data-tool-count` / `data-call-status` and its Resources / Prompts
equivalents), tabulated in the web README and pinned by screen unit tests
so a rename fails loudly there rather than as an opaque timeout here.

The deep-link connect step is extracted to `lib/deep-link-connect.mjs`
rather than copied a third time. That matters because `parseDeepLink`
ignores a link it cannot validate rather than reporting one, so a drifted
copy fails as an opaque connect timeout in whichever smoke was not
updated. `smoke:web:elicit`'s hand-rolled copy is retired onto it.

Chromium-only and deliberately not in `ENGINE_SMOKES`: the engine tiers
exist for the MCP Apps sandbox's genuinely divergent primitives, and
these tabs are ordinary React and Mantine. Network, Protocol, Logs and
Tasks remain uncovered — a stated gap, not an oversight.

Closes #2148

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 23:09:44 -04:00

407 lines
13 KiB
TypeScript

import {
Alert,
Card,
CloseButton,
Flex,
Group,
Loader,
Stack,
Text,
} from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
import type {
ProtocolEra,
ReadResourceResult,
Resource,
ResourceTemplateType as ResourceTemplate,
} from "@modelcontextprotocol/client";
import type {
InspectorResourceSubscription,
ResourceSubscriptionStreamState,
} from "../../../../../../core/mcp/types.js";
import { ResourceControls } from "../../groups/ResourceControls/ResourceControls";
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel";
export interface ReadResourceState {
status: "idle" | "pending" | "ok" | "error";
uri?: string;
result?: ReadResourceResult;
error?: string;
lastUpdated?: Date;
isSubscribed?: boolean;
}
export interface ResourcesScreenProps {
resources: Resource[];
templates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
/**
* Modern-era `subscriptions/listen` stream state (#1630). On the modern era
* the Subscriptions section shows a stream-status badge and a header dot;
* `active: false` (the legacy default) renders neither.
*/
subscriptionStreamState?: ResourceSubscriptionStreamState;
/** Negotiated protocol era; gates the modern subscription stream chrome. */
protocolEra?: ProtocolEra;
readState?: ReadResourceState;
ui: ResourcesUiState;
listChanged: boolean;
completionsSupported?: boolean;
/**
* Whether the connected server advertises the `resources.subscribe`
* capability. When false, the Subscribe/Unsubscribe button and the
* Subscriptions accordion section are hidden. Defaults to true so the
* controls render unless a caller explicitly marks them unsupported.
*/
subscriptionsSupported?: boolean;
onUiChange: (next: ResourcesUiState) => void;
onRefreshList: () => void;
/** A failed list load, rendered above the sidebar list (#1953). */
/**
* Entries dropped from this screen's list result(s) as malformed; the list
* panel warns about them above the list (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls rendered in the sidebar (#1721). */
pagination: ListPaginationControlsProps;
onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onCompleteArgument?: (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
compact: boolean;
onCompactChange: (next: boolean) => void;
}
// Selection (resource URI, template URI, the originating-template marker), the
// sidebar search, and accordion open-sections — controlled by the parent (App)
// as one object so they persist across tab navigation within a live session
// (#1417). `openSections` undefined → ResourceControls falls back to the
// compact-derived default.
export interface ResourcesUiState {
selectedResourceUri?: string;
selectedTemplateUri?: string;
originatingTemplateUri?: string;
search: string;
openSections?: string[];
}
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))",
gap: "md",
p: "xl",
});
const Sidebar = Stack.withProps({
// Widened from 340 to comfortably fit the pagination controls
// (Load-next-page button + status) without cramping list entries (#1721).
w: 360,
flex: "0 0 auto",
});
// Card that grows with its content but is capped at the screen height by the
// `sidebar` variant (`max-height: 100%`), like the Tools panel. The column
// layout lets ResourceControls' accordion take over per-section scrolling once
// the content would overflow that cap.
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
variant: "sidebar",
});
const DetailCard = Card.withProps({
withBorder: true,
padding: "lg",
});
// Card that sizes to its content but caps at the screen's available
// height. When content fits, the card stays compact (footer sits right
// under the body); when content would overflow, the inner ScrollArea
// inside ResourcePreviewPanel shrinks and scrolls.
const PreviewCard = Card.withProps({
withBorder: true,
padding: "lg",
variant: "preview",
});
// Column that pins the preview card to the top of the available space
// and bounds its growth via the consumer-set `mah`. The card inside
// keeps its natural height up to that cap.
const PreviewPane = Flex.withProps({
flex: 1,
miw: 0,
direction: "column",
align: "stretch",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
// Centered loader/status column shown while a resource is being read.
const CenteredStatus = Stack.withProps({
align: "center",
py: "xl",
});
const ReadErrorAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Read Error",
});
const SCROLL_MAX_HEIGHT =
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)";
export function ResourcesScreen({
resources,
templates,
subscriptions,
subscriptionStreamState,
protocolEra,
readState,
ui,
listChanged,
completionsSupported,
subscriptionsSupported = true,
onUiChange,
onRefreshList,
malformedListItems,
loadError,
pagination,
onReadResource,
onSubscribeResource,
onUnsubscribeResource,
onCompleteArgument,
compact,
onCompactChange,
}: ResourcesScreenProps) {
const {
selectedResourceUri,
selectedTemplateUri,
originatingTemplateUri,
search,
openSections,
} = ui;
const selectedResource = selectedResourceUri
? resources.find((r) => r.uri === selectedResourceUri)
: undefined;
const selectedTemplate = selectedTemplateUri
? templates.find((t) => t.uriTemplate === selectedTemplateUri)
: undefined;
// For template-expanded URIs that don't appear in the resources list,
// construct a synthetic Resource so the preview panel can render.
const readResource: Resource | undefined =
selectedResource ??
(readState?.uri && readState.uri === selectedResourceUri
? { name: readState.uri, uri: readState.uri }
: undefined);
function handleSelectResource(uri: string) {
onUiChange({
...ui,
selectedTemplateUri: undefined,
selectedResourceUri: uri,
originatingTemplateUri: undefined,
});
onReadResource(uri);
}
function handleSelectTemplate(uriTemplate: string) {
onUiChange({
...ui,
selectedResourceUri: undefined,
selectedTemplateUri: uriTemplate,
originatingTemplateUri: undefined,
});
}
function handleReadResource(uri: string) {
// Once the user reads (either from the template form or a refresh
// inside the preview panel), hand the screen over to the preview:
// clearing the template selection hides the template form so only
// the rendered resource is shown. We remember the template URI so
// closing the preview can restore the form.
onUiChange({
...ui,
originatingTemplateUri: selectedTemplateUri ?? originatingTemplateUri,
selectedTemplateUri: undefined,
selectedResourceUri: uri,
});
onReadResource(uri);
}
function handleClosePreview() {
if (originatingTemplateUri) {
onUiChange({
...ui,
selectedResourceUri: undefined,
selectedTemplateUri: originatingTemplateUri,
originatingTemplateUri: undefined,
});
} else {
onUiChange({ ...ui, selectedResourceUri: undefined });
}
}
function renderReadState() {
if (!readState) return null;
if (readState.status === "pending") {
return (
<PreviewCard>
<Stack gap="md">
<Group justify="flex-start">
<CloseButton
aria-label="Close preview"
onClick={handleClosePreview}
/>
</Group>
<CenteredStatus>
<Loader size="sm" />
<Text c="dimmed">Reading resource...</Text>
</CenteredStatus>
</Stack>
</PreviewCard>
);
}
if (readState.status === "error") {
return (
<PreviewCard>
<Stack gap="md">
<Group justify="flex-start">
<CloseButton
aria-label="Close preview"
onClick={handleClosePreview}
/>
</Group>
<ReadErrorAlert>
{readState.error ?? "Failed to read resource"}
</ReadErrorAlert>
</Stack>
</PreviewCard>
);
}
if (readState.result && readResource) {
return (
<PreviewCard>
<ResourcePreviewPanel
resource={readResource}
contents={readState.result.contents}
lastUpdated={readState.lastUpdated}
isSubscribed={readState.isSubscribed ?? false}
subscriptionsSupported={subscriptionsSupported}
onRefresh={() => handleReadResource(readResource.uri)}
onSubscribe={() => onSubscribeResource(readResource.uri)}
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
onClose={handleClosePreview}
/>
</PreviewCard>
);
}
return null;
}
return (
// `data-*` readiness contract for the headless tab smoke (#2148); see
// clients/web/README.md#core-tab-automation-contract.
<ScreenLayout
data-testid="resources-screen"
data-resource-count={resources.length}
data-template-count={templates.length}
data-read-status={readState?.status ?? "idle"}
>
<Sidebar>
<SidebarCard>
<ResourceControls
resources={resources}
templates={templates}
subscriptions={subscriptions}
subscriptionsSupported={subscriptionsSupported}
subscriptionStreamState={subscriptionStreamState}
protocolEra={protocolEra}
selectedUri={selectedResourceUri}
selectedTemplateUri={selectedTemplateUri}
searchText={search}
openSections={openSections}
listChanged={listChanged}
onRefreshList={onRefreshList}
loadError={loadError}
malformedListItems={malformedListItems}
pagination={pagination}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onOpenSectionsChange={(value) =>
onUiChange({ ...ui, openSections: value })
}
onSelectUri={handleSelectResource}
onSelectTemplate={handleSelectTemplate}
onUnsubscribeResource={onUnsubscribeResource}
compact={compact}
onCompactChange={onCompactChange}
/>
</SidebarCard>
</Sidebar>
{selectedTemplate ? (
// Template form only — once the user clicks Read Resource,
// handleReadResource clears the template selection so the
// resource branch takes over and the preview is shown alone.
// Fills the main area width (like the preview pane) rather than
// being capped, so the URI preview and variable inputs get the
// full width on wide displays.
<PreviewPane mah={SCROLL_MAX_HEIGHT}>
<PreviewCard>
<ResourceTemplatePanel
template={selectedTemplate}
onReadResource={handleReadResource}
completionsSupported={completionsSupported}
onCompleteArgument={
onCompleteArgument
? (argName, value, context) =>
onCompleteArgument(
{
type: "ref/resource",
uri: selectedTemplate.uriTemplate,
},
argName,
value,
context,
)
: undefined
}
/>
</PreviewCard>
</PreviewPane>
) : readResource ? (
// Sized-to-content preview pane, capped at the screen's available
// height. When the resource body fits, the card hugs its content
// and the subscribe/refresh row sits right under it. When the body
// would overflow, the inner ScrollArea inside ResourcePreviewPanel
// shrinks and scrolls, keeping the footer pinned at the cap.
// miw=0 prevents wide content (long unbroken lines, tables) from
// pushing the pane past the viewport's right edge.
<PreviewPane mah={SCROLL_MAX_HEIGHT}>{renderReadState()}</PreviewPane>
) : (
<DetailCard flex={1}>
<EmptyState>Select a resource to preview</EmptyState>
</DetailCard>
)}
</ScreenLayout>
);
}