inspector/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.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

105 lines
3.6 KiB
TypeScript

import { useState } from "react";
import {
Collapse,
Group,
Paper,
ScrollArea,
Stack,
Title,
} from "@mantine/core";
import type { CallToolResult } from "@modelcontextprotocol/client";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
export interface StructuredOutputPanelProps {
/** The result's `structuredContent` — the tool's schema-validated payload. */
structuredContent: NonNullable<CallToolResult["structuredContent"]>;
/** Whether the section starts expanded. Defaults to `true`. */
defaultExpanded?: boolean;
}
// Bordered box matching the "Resource Links" group in the result panel, so the
// two supplementary sections of a tool result read as siblings.
const StructuredBox = Paper.withProps({
withBorder: true,
radius: "md",
p: "md",
variant: "panel",
});
const StructuredInner = Stack.withProps({
gap: "sm",
});
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
});
// h4 (size h5) for the same reason as the "Resource Links" heading: the panel's
// "Results" title is h3, so a sub-box heading is h4 and the heading order never
// skips a level (axe `heading-order`).
const StructuredHeader = Title.withProps({
order: 4,
size: "h5",
});
// Caps the payload so a large structured result scrolls within the box instead
// of pushing the content blocks out of view. `Autosize` sizes to the content up
// to `mah`, so a small object still takes only what it needs.
const StructuredScroll = ScrollArea.Autosize.withProps({
mah: 400,
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
});
/**
* Collapsible "Structured Output" section for a tool result's
* `structuredContent` (#1908). A tool declaring an `outputSchema` returns its
* real payload here — the `content[]` blocks usually only summarize it — so v1
* rendered it as its own inspectable JSON section. Without this, the payload is
* dropped from the Tools screen entirely, with no hint it was ever returned.
*
* The JSON is pretty-printed and syntax-highlighted through {@link ContentViewer}
* (an `application/json` text block), so it is copyable and scannable field by
* field.
*/
export function StructuredOutputPanel({
structuredContent,
defaultExpanded = true,
}: StructuredOutputPanelProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
// `data-testid` so the headless tab smoke (#2148) can assert this section
// rendered without matching on its heading copy. A `data-*` attribute is
// not part of a component's typed props, so it is passed at the call site.
<StructuredBox data-testid="structured-output">
<StructuredInner>
<HeaderRow>
<StructuredHeader>Structured Output</StructuredHeader>
<ExpandToggle
expanded={expanded}
onToggle={() => setExpanded((value) => !value)}
ariaLabel={`${expanded ? "Collapse" : "Expand"} structured output`}
/>
</HeaderRow>
{/* Content stays mounted across a collapse (Mantine `Collapse`), so the
highlighted JSON isn't re-rendered from scratch on every toggle. */}
<Collapse in={expanded}>
<StructuredScroll>
<ContentViewer
block={{
type: "text",
text: JSON.stringify(structuredContent, null, 2),
}}
mimeType="application/json"
copyable
/>
</StructuredScroll>
</Collapse>
</StructuredInner>
</StructuredBox>
);
}