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>
407 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|