Merge remote-tracking branch 'origin/v2/main' into v2/sdk-v2-migration-1624
# Conflicts: # clients/web/src/components/groups/ResourceLink/ResourceLink.tsx # clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx # clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
This commit is contained in:
commit
39362dd5ff
3 changed files with 591 additions and 197 deletions
|
|
@ -41,13 +41,16 @@ export const Static: Story = {
|
|||
args: {
|
||||
uri: URI,
|
||||
name: "Readme",
|
||||
description: "Project documentation",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(URI)).toBeInTheDocument();
|
||||
expect(canvas.queryByRole("button")).not.toBeInTheDocument();
|
||||
// Copy button is present; there's no expand control in the static card.
|
||||
expect(canvas.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: /^Expand resource/ }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -55,18 +58,16 @@ export const Expandable: Story = {
|
|||
args: {
|
||||
uri: URI,
|
||||
name: "Readme",
|
||||
description: "Click to read on demand",
|
||||
mimeType: "text/markdown",
|
||||
onReadResource: readMarkdown,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByRole("button", {
|
||||
name: `Expand resource ${URI}`,
|
||||
});
|
||||
await userEvent.click(button);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(canvas.getByText("Resource:")).toBeInTheDocument(),
|
||||
expect(canvas.getByText(/Read on demand/)).toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -77,7 +78,6 @@ export const LargeResult: Story = {
|
|||
args: {
|
||||
uri: BLOB_URI,
|
||||
name: "Blob Resource",
|
||||
description: "A large gzipped resource",
|
||||
mimeType: "application/gzip",
|
||||
onReadResource: readLargeBlob,
|
||||
},
|
||||
|
|
@ -86,8 +86,10 @@ export const LargeResult: Story = {
|
|||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }),
|
||||
);
|
||||
// The `"blob"` key appears only in the expanded read result's JSON (not in
|
||||
// the metadata badge), so it confirms the inline result rendered.
|
||||
await waitFor(() =>
|
||||
expect(canvas.getByText("Resource:")).toBeInTheDocument(),
|
||||
expect(canvas.getByText(/"blob":/)).toBeInTheDocument(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,24 +15,22 @@ const readResult = (text: string): ReadResourceResult => ({
|
|||
});
|
||||
|
||||
describe("ResourceLink", () => {
|
||||
it("renders uri, name, description, and mimeType", () => {
|
||||
it("renders uri, name, and mimeType", () => {
|
||||
renderWithMantine(
|
||||
<ResourceLink
|
||||
uri={URI}
|
||||
name="Readme"
|
||||
description="The project readme"
|
||||
mimeType="text/markdown"
|
||||
/>,
|
||||
<ResourceLink uri={URI} name="Readme" mimeType="text/markdown" />,
|
||||
);
|
||||
expect(screen.getByText(URI)).toBeInTheDocument();
|
||||
expect(screen.getByText("Readme")).toBeInTheDocument();
|
||||
expect(screen.getByText("The project readme")).toBeInTheDocument();
|
||||
expect(screen.getByText("text/markdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not interactive without onReadResource", () => {
|
||||
it("renders no expand control without onReadResource", () => {
|
||||
renderWithMantine(<ResourceLink uri={URI} />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
// The URI copy button is always present, but there's no expand affordance.
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: `Expand resource ${URI}` }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads the resource on demand and renders the result inline", async () => {
|
||||
|
|
@ -42,22 +40,19 @@ describe("ResourceLink", () => {
|
|||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", {
|
||||
name: `Expand resource ${URI}`,
|
||||
});
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(button);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||
);
|
||||
|
||||
expect(onReadResource).toHaveBeenCalledWith(URI);
|
||||
// The full read result is rendered inline as formatted JSON.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Resource:")).toBeInTheDocument(),
|
||||
expect(screen.getByText(/"hello body"/)).toBeInTheDocument(),
|
||||
);
|
||||
// The full read result is rendered as formatted JSON.
|
||||
expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
|
||||
// The toggle flips to the collapse control once expanded.
|
||||
expect(
|
||||
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses and re-expands without re-reading (result cached)", async () => {
|
||||
|
|
@ -74,13 +69,16 @@ describe("ResourceLink", () => {
|
|||
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Collapse — content is hidden.
|
||||
// Collapse — the toggle flips back to the expand control. (The read result
|
||||
// stays mounted inside the animated Collapse, so it isn't re-read.)
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
||||
);
|
||||
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Re-expand — content returns without a second read.
|
||||
// Re-expand — content is still present without a second read.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||
);
|
||||
|
|
@ -95,7 +93,9 @@ describe("ResourceLink", () => {
|
|||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
||||
);
|
||||
|
|
@ -111,7 +111,9 @@ describe("ResourceLink", () => {
|
|||
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Card, Collapse, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import type { ReadResourceResult } from "@modelcontextprotocol/client";
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
|
||||
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 beneath the URI. */
|
||||
/** Optional human-friendly name shown above the URI. */
|
||||
name?: string;
|
||||
/** Optional description shown beneath the name. */
|
||||
description?: string;
|
||||
/** Optional MIME type shown as a badge. */
|
||||
mimeType?: string;
|
||||
/**
|
||||
|
|
@ -30,15 +20,15 @@ export interface ResourceLinkProps {
|
|||
onReadResource?: (uri: string) => Promise<ReadResourceResult>;
|
||||
}
|
||||
|
||||
const LinkCard = Paper.withProps({
|
||||
// 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,
|
||||
p: "sm",
|
||||
padding: "sm",
|
||||
radius: "md",
|
||||
});
|
||||
|
||||
const HeaderButton = UnstyledButton.withProps({
|
||||
w: "100%",
|
||||
ta: "left",
|
||||
variant: "inset",
|
||||
});
|
||||
|
||||
const ExpandedSection = Stack.withProps({
|
||||
|
|
@ -58,12 +48,6 @@ const ResultScroll = ScrollArea.Autosize.withProps({
|
|||
offsetScrollbars: true,
|
||||
});
|
||||
|
||||
const ResourceLabel = Text.withProps({
|
||||
size: "sm",
|
||||
fw: 600,
|
||||
c: "green",
|
||||
});
|
||||
|
||||
const LoadingText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
|
|
@ -79,7 +63,6 @@ const LoadingText = Text.withProps({
|
|||
export function ResourceLink({
|
||||
uri,
|
||||
name,
|
||||
description,
|
||||
mimeType,
|
||||
onReadResource,
|
||||
}: ResourceLinkProps) {
|
||||
|
|
@ -113,49 +96,43 @@ export function ResourceLink({
|
|||
}
|
||||
}
|
||||
|
||||
const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine;
|
||||
// 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 ? (
|
||||
loading ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<Chevron size={16} aria-hidden />
|
||||
)
|
||||
) : undefined;
|
||||
|
||||
const info = (
|
||||
<ResourceLinkInfo
|
||||
uri={uri}
|
||||
name={name}
|
||||
description={description}
|
||||
mimeType={mimeType}
|
||||
action={action}
|
||||
<ExpandToggle
|
||||
expanded={expanded}
|
||||
onToggle={() => void toggle()}
|
||||
ariaLabel={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
|
||||
/>
|
||||
);
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<LinkCard>
|
||||
{expandable ? (
|
||||
<HeaderButton
|
||||
onClick={() => void toggle()}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
|
||||
>
|
||||
{info}
|
||||
</HeaderButton>
|
||||
) : (
|
||||
info
|
||||
)}
|
||||
{expanded && (
|
||||
<ExpandedSection>
|
||||
{loading ? (
|
||||
<LoadingText>Loading resource…</LoadingText>
|
||||
) : error !== null ? (
|
||||
<Alert color="red" variant="light" title="Failed to read resource">
|
||||
{error}
|
||||
</Alert>
|
||||
) : result !== null ? (
|
||||
<>
|
||||
<ResourceLabel>Resource:</ResourceLabel>
|
||||
<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 ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
title="Failed to read resource"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
) : result !== null ? (
|
||||
<ResultScroll>
|
||||
<ContentViewer
|
||||
block={{
|
||||
|
|
@ -165,9 +142,9 @@ export function ResourceLink({
|
|||
copyable
|
||||
/>
|
||||
</ResultScroll>
|
||||
</>
|
||||
) : null}
|
||||
</ExpandedSection>
|
||||
) : null}
|
||||
</ExpandedSection>
|
||||
</Collapse>
|
||||
)}
|
||||
</LinkCard>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue