inspector/clients/web/src/components/screens/PromptsScreen/PromptsScreen.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

343 lines
11 KiB
TypeScript

import {
Alert,
Card,
CloseButton,
Flex,
Group,
Loader,
Stack,
Text,
} from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client";
import { PromptControls } from "../../groups/PromptControls/PromptControls";
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
import { PromptArgumentsForm } from "../../groups/PromptArgumentsForm/PromptArgumentsForm";
import { PromptMessagesDisplay } from "../../groups/PromptMessagesDisplay/PromptMessagesDisplay";
export interface GetPromptState {
status: "idle" | "pending" | "ok" | "error";
result?: GetPromptResult;
error?: string;
/**
* Name of the prompt the in-flight / latest result is for. Used to
* route the result panel only to the matching sidebar selection.
*/
promptName?: string;
}
export interface PromptsScreenProps {
prompts: Prompt[];
getPromptState?: GetPromptState;
ui: PromptsUiState;
listChanged: boolean;
completionsSupported?: boolean;
onUiChange: (next: PromptsUiState) => 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;
onGetPrompt: (name: string, args: Record<string, string>) => void;
onCopyMessages?: () => void;
onCompleteArgument?: (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
}
// Selection, argument values, the "submitted" marker, and the sidebar search —
// controlled by the parent (App) as one object so they persist across tab
// navigation within a live session (#1417).
export interface PromptsUiState {
selectedPromptName?: string;
argumentValues: Record<string, string>;
submittedFor?: string;
search: 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",
});
// `sidebar` variant makes the card a full-height flex column capped at the
// screen height, so PromptControls' list fills the card and scrolls internally
// once it overflows (matching the Resources sidebar).
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
variant: "sidebar",
});
const DetailCard = Card.withProps({
withBorder: true,
padding: "lg",
});
// Sized-to-content card with overflow handling. When the inner content
// fits, the card hugs it. When it doesn't, the inner ScrollArea inside
// PromptMessagesDisplay shrinks (flex 0 1 auto, mih 0) and scrolls.
const PreviewCard = Card.withProps({
withBorder: true,
padding: "lg",
variant: "preview",
});
// Column wrapper that pins the card to the top of the available space
// and bounds its growth via the consumer-set `mah`.
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 prompt is being fetched.
const CenteredStatus = Stack.withProps({
align: "center",
py: "xl",
});
const PromptErrorAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Prompt Error",
});
const SCROLL_MAX_HEIGHT =
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)";
function hasArguments(prompt: Prompt): boolean {
return !!prompt.arguments && prompt.arguments.length > 0;
}
export function PromptsScreen({
prompts,
getPromptState,
ui,
listChanged,
completionsSupported,
onUiChange,
onRefreshList,
malformedListItems,
loadError,
pagination,
onGetPrompt,
onCopyMessages,
onCompleteArgument,
}: PromptsScreenProps) {
const { selectedPromptName, argumentValues, submittedFor, search } = ui;
const selectedPrompt = selectedPromptName
? prompts.find((p) => p.name === selectedPromptName)
: undefined;
function handleSelectPrompt(name: string) {
// Re-clicking the active prompt in the sidebar shouldn't wipe the
// user's typed argument values or trigger a re-fetch — sidebar is
// for navigation, ✕ is for dismiss. Closing-then-reselecting is
// its own thing (the close handler clears submittedFor). PromptControls
// already swallows a re-click on the active item (it only fires
// onSelectPrompt when the name differs), so this is a redundant guard
// that never fires through the UI.
/* v8 ignore next -- unreachable: PromptControls never re-emits the active name */
if (name === selectedPromptName) return;
// Auto-fetch no-argument prompts the moment they're selected — the
// form pane would otherwise just render a bare Get Prompt button
// with nothing to fill in. Prompts with arguments wait for submit.
const target = prompts.find((p) => p.name === name);
const autoFetch = !!target && !hasArguments(target);
onUiChange({
...ui,
argumentValues: {},
selectedPromptName: name,
submittedFor: autoFetch ? name : undefined,
});
if (autoFetch) onGetPrompt(name, {});
}
function handleSubmit() {
// Defensive guard: handleSubmit is only wired to the argument form's
// onGetPrompt, which renders solely when `selectedPrompt` is truthy, so
// this never fires through the UI.
/* v8 ignore next -- unreachable: form only renders with a selected prompt */
if (!selectedPrompt) return;
onUiChange({ ...ui, submittedFor: selectedPrompt.name });
onGetPrompt(selectedPrompt.name, argumentValues);
}
function handleClosePreview() {
// For prompts with arguments, flip back to the form so the user can
// edit and re-submit (argumentValues are preserved). For no-arg
// prompts there's no form to return to, so drop the selection and
// fall back to the empty state.
if (selectedPrompt && hasArguments(selectedPrompt)) {
onUiChange({ ...ui, submittedFor: undefined });
} else {
onUiChange({
...ui,
selectedPromptName: undefined,
submittedFor: undefined,
});
}
}
// The preview is "active" when we've submitted (or auto-fetched) the
// currently-selected prompt and the parent's state is tagged with
// the matching prompt name. The name match guards against a stale
// result from a previously-selected prompt leaking into the new
// prompt's pane. App.tsx tags every state transition with
// `promptName`, so we don't need a fallback for untagged states.
const previewActive =
!!selectedPrompt &&
!!getPromptState &&
submittedFor === selectedPrompt.name &&
getPromptState.promptName === selectedPrompt.name;
function renderPreview() {
// Defensive guard: renderPreview is only invoked from the `previewActive`
// branch below, and `previewActive` already requires a truthy
// `getPromptState`, so neither arm of this condition is reachable here.
/* v8 ignore next -- unreachable: only called when previewActive && getPromptState */
if (!previewActive || !getPromptState) return null;
if (getPromptState.status === "pending") {
return (
<PreviewCard>
<Stack gap="md">
<Group justify="flex-start">
<CloseButton
aria-label="Close messages"
onClick={handleClosePreview}
/>
</Group>
<CenteredStatus>
<Loader size="sm" />
<Text c="dimmed">Loading prompt...</Text>
</CenteredStatus>
</Stack>
</PreviewCard>
);
}
if (getPromptState.status === "error") {
return (
<PreviewCard>
<Stack gap="md">
<Group justify="flex-start">
<CloseButton
aria-label="Close messages"
onClick={handleClosePreview}
/>
</Group>
<PromptErrorAlert>
{getPromptState.error ?? "Failed to get prompt"}
</PromptErrorAlert>
</Stack>
</PreviewCard>
);
}
if (getPromptState.result) {
return (
<PreviewCard>
<PromptMessagesDisplay
messages={getPromptState.result.messages}
onCopyAll={onCopyMessages}
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="prompts-screen"
data-prompt-count={prompts.length}
data-get-status={getPromptState?.status ?? "idle"}
>
<Sidebar>
<SidebarCard>
<PromptControls
prompts={prompts}
selectedName={selectedPromptName}
searchText={search}
listChanged={listChanged}
onRefreshList={onRefreshList}
loadError={loadError}
malformedListItems={malformedListItems}
pagination={pagination}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onSelectPrompt={handleSelectPrompt}
/>
</SidebarCard>
</Sidebar>
{previewActive ? (
// Result branch — sized to content, capped at viewport. Mirrors
// the resource preview layout (see ResourcesScreen).
<PreviewPane mah={SCROLL_MAX_HEIGHT}>{renderPreview()}</PreviewPane>
) : selectedPrompt && hasArguments(selectedPrompt) ? (
// Argument-form branch — fills the content pane's width (like the Tools
// input form), replaced by the result once the user clicks Get Prompt
// and previewActive flips on.
<PreviewPane mah={SCROLL_MAX_HEIGHT}>
<PreviewCard>
<PromptArgumentsForm
prompt={selectedPrompt}
argumentValues={argumentValues}
onArgumentChange={(argName, value) =>
onUiChange({
...ui,
argumentValues: { ...argumentValues, [argName]: value },
})
}
onGetPrompt={handleSubmit}
completionsSupported={completionsSupported}
onCompleteArgument={
onCompleteArgument
? (argName, value, context) =>
onCompleteArgument(
{ type: "ref/prompt", name: selectedPrompt.name },
argName,
value,
context,
)
: undefined
}
/>
</PreviewCard>
</PreviewPane>
) : (
<DetailCard flex={1}>
<EmptyState>Select a prompt to view details</EmptyState>
</DetailCard>
)}
</ScreenLayout>
);
}