inspector/clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
Cliff Hall da37692485
refactor(web): sweep AGENTS.md TypeScript + Mantine rule violations (#1771)
Codebase-wide sweep bringing the web client into strict compliance with the AGENTS.md TypeScript and Mantine/React conventions: ~170 inline 2+-static-prop elements extracted to named .withProps() constants across ~77 files, the AGENTS.md rule wording tightened to match (static-prop boundary + Accordion/Transition/data-*/Box exemptions), a real ScreenStage Box->Flex layout regression fixed (direction: column) with a Storybook regression guard, and an App.tsx double-cast tightened. Closes #1762.
2026-07-25 11:38:49 -04:00

151 lines
4.9 KiB
TypeScript

import { useState } from "react";
import { Alert, Card, Collapse, ScrollArea, Stack, Text } from "@mantine/core";
import type { ReadResourceResult } from "@modelcontextprotocol/client";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { ResourceLinkInfo } from "../../elements/ResourceLinkInfo/ResourceLinkInfo";
export interface ResourceLinkProps {
/** The linked resource's URI (always shown). */
uri: string;
/** Optional human-friendly name shown above the URI. */
name?: string;
/** Optional MIME type shown as a badge. */
mimeType?: string;
/**
* Read-on-demand handler. When provided, the card becomes expandable: the
* first expand calls this with the link's `uri` and renders the returned
* read result inline. Omit to render a static, non-expandable card.
*/
onReadResource?: (uri: string) => Promise<ReadResourceResult>;
}
// Recessed "inset" surface so each link card reads the same as a Protocol
// message card (ProtocolEntry), matching its colors in both light and dark
// modes; the inset variant also raises nested Code blocks (the expanded read
// result) onto a lighter surface via its cascade variable.
const LinkCard = Card.withProps({
withBorder: true,
padding: "sm",
radius: "md",
variant: "inset",
});
const ExpandedSection = Stack.withProps({
gap: "xs",
mt: "xs",
});
// Caps the inline read result so a large resource scrolls within the card
// instead of pushing the page down — mirrors V1's bounded resource view.
// `Autosize` sizes to the content up to `mah`, then scrolls; a plain
// ScrollArea would need a definite height to scroll, which this card (sized to
// its content) does not provide.
const ResultScroll = ScrollArea.Autosize.withProps({
mah: 400,
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
});
const LoadingText = Text.withProps({
size: "sm",
c: "dimmed",
});
const ErrorAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Failed to read resource",
});
/**
* Expandable card for a `resource_link` content block. Renders the link's
* metadata via {@link ResourceLinkInfo} and — when `onReadResource` is supplied
* — an expand affordance that reads the linked resource on demand and renders
* the full read result inline as formatted JSON (via {@link ContentViewer}).
* The fetched result is cached so collapsing and re-expanding does not re-read.
*/
export function ResourceLink({
uri,
name,
mimeType,
onReadResource,
}: ResourceLinkProps) {
const [expanded, setExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ReadResourceResult | null>(null);
const [error, setError] = useState<string | null>(null);
const expandable = Boolean(onReadResource);
async function toggle() {
if (!onReadResource) return;
if (expanded) {
setExpanded(false);
return;
}
setExpanded(true);
// Only a successful result is cached; re-expanding after an error retries
// the read so a transient failure isn't permanent.
if (result !== null) return;
// Don't fire a second read if one is already in flight (rapid toggle).
if (loading) return;
setError(null);
setLoading(true);
try {
setResult(await onReadResource(uri));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
// Same tooltip'd expand/collapse control as ProtocolEntry (ExpandToggle),
// placed in the header row's meta slot as a sibling of the URI's copy button.
// A per-resource `ariaLabel` keeps the toggles distinguishable to assistive
// tech when several links are listed (the visible tooltip stays "Expand").
const action = expandable ? (
<ExpandToggle
expanded={expanded}
onToggle={() => void toggle()}
ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
/>
) : undefined;
return (
<LinkCard>
<ResourceLinkInfo
uri={uri}
name={name}
mimeType={mimeType}
action={action}
/>
{/* Same expand/collapse animation as ProtocolEntry: content stays mounted
(so the cached read result survives a collapse) and animates via
Mantine's Collapse. */}
{expandable && (
<Collapse in={expanded}>
<ExpandedSection>
{loading ? (
<LoadingText>Loading resource…</LoadingText>
) : error !== null ? (
<ErrorAlert>{error}</ErrorAlert>
) : result !== null ? (
<ResultScroll>
<ContentViewer
block={{
type: "text",
text: JSON.stringify(result, null, 2),
}}
copyable
/>
</ResultScroll>
) : null}
</ExpandedSection>
</Collapse>
)}
</LinkCard>
);
}