* feat(web): expandable resource_link with read-on-demand (#1449) Replace the placeholder `resource_link` rendering in ContentViewer with a dedicated, Mantine-native `ResourceLink` element that mirrors V1's behavior: - Shows uri (monospace, link-styled), optional name/description, and a mimeType badge. - Expand/collapse affordance (chevron + loading spinner) that is keyboard accessible (native button: role, aria-expanded, aria-label, Enter/Space). - On first expand, reads the linked resource on demand via an injected callback and renders the full read result inline as formatted JSON under a green "Resource:" label, capped to a max height with scroll for large results. The fetched result is cached across collapse/expand. The read handler is threaded as a "dumb component + callback as prop" through ToolResultPanel → ToolsScreen → InspectorView, with App supplying a handler that returns the `ReadResourceResult` from `inspectorClient.readResource`. Also extract the shared `resourceContentsToBlock` helper into `utils/resourceContents.ts` (previously private to ResourcePreviewPanel). Tests: unit coverage for ResourceLink (collapsed/expanded/error/cache) and the ContentViewer wiring, plus Storybook stories with play functions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): scroll large resource_link read results within the card A plain ScrollArea with only `mah` does not scroll when its parent (the content-sized card) has no definite height, so a large read result overflowed the card instead of scrolling. Switch to ScrollArea.Autosize, which grows with content up to the max height and then scrolls. Adds a LargeResult story covering the bounded-scroll behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web): make ResourceLink a group; extract ResourceLinkInfo element ResourceLink is stateful (expand/loading/cached result) and fetches data via a callback — group-like behavior, and the only `elements/` component using useState. Move it to `groups/` and split out the pure-display metadata as a new `elements/ResourceLinkInfo` element (uri/name/description/mime + an optional action slot). This keeps the layer dependencies clean (no element→group import, which moving ResourceLink alone would have introduced): - ContentViewer (element) renders ResourceLinkInfo (element) statically for `resource_link` blocks and no longer takes an onReadResource prop. - ToolResultPanel (group) renders the interactive ResourceLink (group) for `resource_link` blocks, ContentViewer for the rest. - ResourceLink (group) composes ResourceLinkInfo + ContentViewer. Also reverts the now-single-consumer resourceContents.ts extraction, restoring ResourcePreviewPanel's local toContentBlock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): make ResourceLink read retryable and guard double-fetch Address PR review: a failed read is no longer cached as terminal — only a successful result is cached, so collapsing and re-expanding retries the read after a transient failure. Add a loading guard so toggling while a read is in flight does not fire a redundant second read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(web): prettier formatting for ResourceLink test Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: remove accidentally-committed build artifacts; gitignore clients/*/build A stray 'git add -A' swept 8 compiled build files into d25c9382. Remove them from tracking and add clients/*/build to .gitignore so client build output is never committed again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a10ced988c
commit
8d4363d0ea
15 changed files with 728 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@
|
||||||
.claude/
|
.claude/
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
clients/*/build
|
||||||
test-servers/build
|
test-servers/build
|
||||||
.env
|
.env
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
|
|
||||||
|
|
@ -1604,6 +1604,19 @@ function App() {
|
||||||
[inspectorClient],
|
[inspectorClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Read-on-demand handler for `resource_link` blocks in a tool result. Unlike
|
||||||
|
// `onReadResource` (which drives the Resources screen's preview panel via
|
||||||
|
// shared state), this returns the contents directly so each ResourceLink can
|
||||||
|
// own and inline its own fetched content.
|
||||||
|
const onReadResourceContents = useCallback(
|
||||||
|
async (uri: string) => {
|
||||||
|
if (!inspectorClient) throw new Error("Client is not connected");
|
||||||
|
const invocation = await inspectorClient.readResource(uri);
|
||||||
|
return invocation.result;
|
||||||
|
},
|
||||||
|
[inspectorClient],
|
||||||
|
);
|
||||||
|
|
||||||
const onSubscribeResource = useCallback(
|
const onSubscribeResource = useCallback(
|
||||||
(uri: string) => {
|
(uri: string) => {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
|
|
@ -2172,6 +2185,7 @@ function App() {
|
||||||
void onCallTool(name, args, runAsTask);
|
void onCallTool(name, args, runAsTask);
|
||||||
}}
|
}}
|
||||||
onClearToolResult={onClearToolResult}
|
onClearToolResult={onClearToolResult}
|
||||||
|
onReadResourceContents={onReadResourceContents}
|
||||||
onRefreshTools={onRefreshTools}
|
onRefreshTools={onRefreshTools}
|
||||||
onPromptsUiChange={setPromptsUi}
|
onPromptsUiChange={setPromptsUi}
|
||||||
onGetPrompt={(name, args) => {
|
onGetPrompt={(name, args) => {
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,15 @@ export const JsonWithCopy: Story = {
|
||||||
copyable: true,
|
copyable: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ResourceLinkBlock: Story = {
|
||||||
|
args: {
|
||||||
|
block: {
|
||||||
|
type: "resource_link",
|
||||||
|
uri: "file:///docs/readme.md",
|
||||||
|
name: "Readme",
|
||||||
|
description: "Project documentation",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,21 @@ describe("ContentViewer", () => {
|
||||||
expect(screen.getByText("ui://app")).toBeInTheDocument();
|
expect(screen.getByText("ui://app")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a resource_link statically (no expand control)", () => {
|
||||||
|
const block: ContentBlock = {
|
||||||
|
type: "resource_link",
|
||||||
|
uri: "ui://app",
|
||||||
|
name: "Cool App",
|
||||||
|
description: "An app",
|
||||||
|
mimeType: "text/html",
|
||||||
|
};
|
||||||
|
renderWithMantine(<ContentViewer block={block} />);
|
||||||
|
expect(screen.getByText("Cool App")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("An app")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("text/html")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders nothing for unknown block types", () => {
|
it("renders nothing for unknown block types", () => {
|
||||||
renderWithMantine(<ContentViewer block={{ type: "unknown" } as never} />);
|
renderWithMantine(<ContentViewer block={{ type: "unknown" } as never} />);
|
||||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Code, Flex, Image, Stack, Text } from "@mantine/core";
|
import { Code, Flex, Image, Stack } from "@mantine/core";
|
||||||
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { CopyButton } from "../CopyButton/CopyButton";
|
import { CopyButton } from "../CopyButton/CopyButton";
|
||||||
|
import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
|
||||||
|
|
||||||
export interface ContentViewerProps {
|
export interface ContentViewerProps {
|
||||||
block: ContentBlock;
|
block: ContentBlock;
|
||||||
|
|
@ -131,12 +132,16 @@ export function ContentViewer({
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
case "resource_link":
|
case "resource_link":
|
||||||
|
// Static metadata only. The interactive, read-on-demand presentation
|
||||||
|
// lives in the `groups/ResourceLink` group, rendered by content-block
|
||||||
|
// hosts (e.g. ToolResultPanel) that can supply a read handler.
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<ResourceLinkInfo
|
||||||
<Text size="sm" c="blue">
|
uri={block.uri}
|
||||||
{block.name ?? block.uri}
|
name={block.name}
|
||||||
</Text>
|
description={block.description}
|
||||||
</Stack>
|
mimeType={block.mimeType}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { ResourceLinkInfo } from "./ResourceLinkInfo";
|
||||||
|
|
||||||
|
const meta: Meta<typeof ResourceLinkInfo> = {
|
||||||
|
title: "Elements/ResourceLinkInfo",
|
||||||
|
component: ResourceLinkInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ResourceLinkInfo>;
|
||||||
|
|
||||||
|
export const Full: Story = {
|
||||||
|
args: {
|
||||||
|
uri: "file:///docs/readme.md",
|
||||||
|
name: "Readme",
|
||||||
|
description: "Project documentation",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UriOnly: Story = {
|
||||||
|
args: {
|
||||||
|
uri: "demo://resource/dynamic/text/2",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { ResourceLinkInfo } from "./ResourceLinkInfo";
|
||||||
|
|
||||||
|
const URI = "file:///docs/readme.md";
|
||||||
|
|
||||||
|
describe("ResourceLinkInfo", () => {
|
||||||
|
it("renders uri, name, description, and mimeType", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLinkInfo
|
||||||
|
uri={URI}
|
||||||
|
name="Readme"
|
||||||
|
description="The project 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("renders only the uri when optional fields are absent", () => {
|
||||||
|
renderWithMantine(<ResourceLinkInfo uri={URI} />);
|
||||||
|
expect(screen.getByText(URI)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("text/markdown")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action slot when provided", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLinkInfo uri={URI} action={<span>chevron</span>} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("chevron")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface ResourceLinkInfoProps {
|
||||||
|
/** The linked resource's URI (always shown). */
|
||||||
|
uri: string;
|
||||||
|
/** Optional human-friendly name shown beneath the URI. */
|
||||||
|
name?: string;
|
||||||
|
/** Optional description shown beneath the name. */
|
||||||
|
description?: string;
|
||||||
|
/** Optional MIME type shown as a badge. */
|
||||||
|
mimeType?: string;
|
||||||
|
/**
|
||||||
|
* Optional trailing element placed at the end of the URI row — e.g. an
|
||||||
|
* expand/collapse indicator supplied by an interactive wrapper.
|
||||||
|
*/
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderStack = Stack.withProps({
|
||||||
|
gap: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const UriRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
wrap: "nowrap",
|
||||||
|
gap: "xs",
|
||||||
|
align: "flex-start",
|
||||||
|
});
|
||||||
|
|
||||||
|
const UriText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
c: "blue",
|
||||||
|
ff: "monospace",
|
||||||
|
variant: "monoBreak",
|
||||||
|
flex: 1,
|
||||||
|
miw: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetaGroup = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const MimeBadge = Badge.withProps({
|
||||||
|
size: "sm",
|
||||||
|
variant: "light",
|
||||||
|
color: "blue",
|
||||||
|
// MIME types are conventionally lowercase; keep them as-is rather than
|
||||||
|
// letting Badge's default uppercase transform mangle them.
|
||||||
|
tt: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
const NameText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
fw: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const DescriptionText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-display metadata for a `resource_link`: the URI (monospace, link-styled),
|
||||||
|
* optional name / description, and a MIME-type badge. The optional `action`
|
||||||
|
* slot lets an interactive wrapper (e.g. {@link ResourceLink}) place an
|
||||||
|
* expand/collapse indicator in the URI row.
|
||||||
|
*/
|
||||||
|
export function ResourceLinkInfo({
|
||||||
|
uri,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
mimeType,
|
||||||
|
action,
|
||||||
|
}: ResourceLinkInfoProps) {
|
||||||
|
return (
|
||||||
|
<HeaderStack>
|
||||||
|
<UriRow>
|
||||||
|
<UriText>{uri}</UriText>
|
||||||
|
<MetaGroup>
|
||||||
|
{mimeType && <MimeBadge>{mimeType}</MimeBadge>}
|
||||||
|
{action}
|
||||||
|
</MetaGroup>
|
||||||
|
</UriRow>
|
||||||
|
{name && <NameText>{name}</NameText>}
|
||||||
|
{description && <DescriptionText>{description}</DescriptionText>}
|
||||||
|
</HeaderStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||||
|
import { ResourceLink } from "./ResourceLink";
|
||||||
|
|
||||||
|
const URI = "file:///docs/readme.md";
|
||||||
|
|
||||||
|
const readMarkdown = async (): Promise<ReadResourceResult> => ({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri: URI,
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
text: "# Readme\n\nRead on demand.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const BLOB_URI = "demo://resource/session/blob.gz";
|
||||||
|
|
||||||
|
const readLargeBlob = async (): Promise<ReadResourceResult> => ({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri: BLOB_URI,
|
||||||
|
mimeType: "application/gzip",
|
||||||
|
// A long base64 blob so the rendered JSON exceeds the card's max height
|
||||||
|
// and scrolls within it rather than pushing the page down.
|
||||||
|
blob: "H4sIAAAAAAAA".repeat(400),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta: Meta<typeof ResourceLink> = {
|
||||||
|
title: "Groups/ResourceLink",
|
||||||
|
component: ResourceLink,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ResourceLink>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 waitFor(() =>
|
||||||
|
expect(canvas.getByText("Resource:")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// A large read result: the inline JSON exceeds the card's max height and
|
||||||
|
// scrolls within the bounded area instead of overflowing the card.
|
||||||
|
export const LargeResult: Story = {
|
||||||
|
args: {
|
||||||
|
uri: BLOB_URI,
|
||||||
|
name: "Blob Resource",
|
||||||
|
description: "A large gzipped resource",
|
||||||
|
mimeType: "application/gzip",
|
||||||
|
onReadResource: readLargeBlob,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(
|
||||||
|
canvas.getByRole("button", { name: `Expand resource ${BLOB_URI}` }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(canvas.getByText("Resource:")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import {
|
||||||
|
renderWithMantine,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "../../../test/renderWithMantine";
|
||||||
|
import { ResourceLink } from "./ResourceLink";
|
||||||
|
|
||||||
|
const URI = "file:///docs/readme.md";
|
||||||
|
|
||||||
|
const readResult = (text: string): ReadResourceResult => ({
|
||||||
|
contents: [{ uri: URI, mimeType: "text/plain", text }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ResourceLink", () => {
|
||||||
|
it("renders uri, name, description, and mimeType", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLink
|
||||||
|
uri={URI}
|
||||||
|
name="Readme"
|
||||||
|
description="The project 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", () => {
|
||||||
|
renderWithMantine(<ResourceLink uri={URI} />);
|
||||||
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the resource on demand and renders the result inline", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReadResource = vi.fn().mockResolvedValue(readResult("hello body"));
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: `Expand resource ${URI}`,
|
||||||
|
});
|
||||||
|
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(onReadResource).toHaveBeenCalledWith(URI);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("Resource:")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
// The full read result is rendered as formatted JSON.
|
||||||
|
expect(screen.getByText(/"hello body"/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses and re-expands without re-reading (result cached)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReadResource = vi.fn().mockResolvedValue(readResult("cached body"));
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapse — content is hidden.
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Re-expand — content returns without a second read.
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/"cached body"/)).toBeInTheDocument();
|
||||||
|
expect(onReadResource).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an alert when the read fails", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReadResource = vi.fn().mockRejectedValue(new Error("nope"));
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nope")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the read on re-expand after a failure", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReadResource = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("transient"))
|
||||||
|
.mockResolvedValueOnce(readResult("recovered body"));
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First expand fails.
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("Failed to read resource")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapse, then re-expand — the read is retried (error is not cached).
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/"recovered body"/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Failed to read resource"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(onReadResource).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire a second read when toggled while a read is in flight", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let resolveRead: (value: ReadResourceResult) => void = () => {};
|
||||||
|
const onReadResource = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<ReadResourceResult>((resolve) => {
|
||||||
|
resolveRead = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceLink uri={URI} onReadResource={onReadResource} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand — read is in flight (loading), then collapse and re-expand.
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: `Expand resource ${URI}` }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still only the original in-flight read — no redundant fetch.
|
||||||
|
expect(onReadResource).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveRead(readResult("in flight body"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/"in flight body"/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
174
clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
Normal file
174
clients/web/src/components/groups/ResourceLink/ResourceLink.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
|
||||||
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
|
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. */
|
||||||
|
name?: string;
|
||||||
|
/** Optional description shown beneath the name. */
|
||||||
|
description?: 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkCard = Paper.withProps({
|
||||||
|
withBorder: true,
|
||||||
|
p: "sm",
|
||||||
|
radius: "md",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderButton = UnstyledButton.withProps({
|
||||||
|
w: "100%",
|
||||||
|
ta: "left",
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ResourceLabel = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
fw: 600,
|
||||||
|
c: "green",
|
||||||
|
});
|
||||||
|
|
||||||
|
const LoadingText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
description,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine;
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<ResultScroll>
|
||||||
|
<ContentViewer
|
||||||
|
block={{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
}}
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
</ResultScroll>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</ExpandedSection>
|
||||||
|
)}
|
||||||
|
</LinkCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import {
|
||||||
|
renderWithMantine,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "../../../test/renderWithMantine";
|
||||||
import { ToolResultPanel } from "./ToolResultPanel";
|
import { ToolResultPanel } from "./ToolResultPanel";
|
||||||
|
|
||||||
const okResult: CallToolResult = {
|
const okResult: CallToolResult = {
|
||||||
|
|
@ -37,6 +41,34 @@ describe("ToolResultPanel", () => {
|
||||||
expect(screen.getByText("No results yet")).toBeInTheDocument();
|
expect(screen.getByText("No results yet")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a resource_link block as an expandable ResourceLink", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReadResource = vi.fn().mockResolvedValue({
|
||||||
|
contents: [{ uri: "demo://r/1", text: "linked body" }],
|
||||||
|
});
|
||||||
|
const result: CallToolResult = {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "ok" },
|
||||||
|
{ type: "resource_link", uri: "demo://r/1", name: "Linked" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
renderWithMantine(
|
||||||
|
<ToolResultPanel
|
||||||
|
result={result}
|
||||||
|
onClear={() => {}}
|
||||||
|
onReadResource={onReadResource}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("ok")).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Expand resource demo://r/1" }),
|
||||||
|
);
|
||||||
|
expect(onReadResource).toHaveBeenCalledWith("demo://r/1");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/"linked body"/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("invokes onClear when Clear is clicked", async () => {
|
it("invokes onClear when Clear is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClear = vi.fn();
|
const onClear = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,21 @@ import {
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type {
|
||||||
|
CallToolResult,
|
||||||
|
ReadResourceResult,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
|
import { ResourceLink } from "../ResourceLink/ResourceLink";
|
||||||
|
|
||||||
export interface ToolResultPanelProps {
|
export interface ToolResultPanelProps {
|
||||||
result: CallToolResult;
|
result: CallToolResult;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
/**
|
||||||
|
* Read-on-demand handler so `resource_link` blocks in the result can fetch
|
||||||
|
* and inline their contents.
|
||||||
|
*/
|
||||||
|
onReadResource?: (uri: string) => Promise<ReadResourceResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClearButton = Button.withProps({
|
const ClearButton = Button.withProps({
|
||||||
|
|
@ -49,7 +58,11 @@ const ResultStack = Stack.withProps({
|
||||||
gap: "md",
|
gap: "md",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) {
|
export function ToolResultPanel({
|
||||||
|
result,
|
||||||
|
onClear,
|
||||||
|
onReadResource,
|
||||||
|
}: ToolResultPanelProps) {
|
||||||
return (
|
return (
|
||||||
<PanelStack>
|
<PanelStack>
|
||||||
<HeaderRow>
|
<HeaderRow>
|
||||||
|
|
@ -68,13 +81,24 @@ export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) {
|
||||||
) : result.content.length === 0 ? (
|
) : result.content.length === 0 ? (
|
||||||
<Text c="dimmed">No results yet</Text>
|
<Text c="dimmed">No results yet</Text>
|
||||||
) : (
|
) : (
|
||||||
result.content.map((block, index) => (
|
result.content.map((block, index) =>
|
||||||
<ContentViewer
|
block.type === "resource_link" ? (
|
||||||
key={index}
|
<ResourceLink
|
||||||
block={block}
|
key={index}
|
||||||
copyable={block.type === "text"}
|
uri={block.uri}
|
||||||
/>
|
name={block.name}
|
||||||
))
|
description={block.description}
|
||||||
|
mimeType={block.mimeType}
|
||||||
|
onReadResource={onReadResource}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ContentViewer
|
||||||
|
key={index}
|
||||||
|
block={block}
|
||||||
|
copyable={block.type === "text"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</ResultStack>
|
</ResultStack>
|
||||||
</ResultScroll>
|
</ResultScroll>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { Card, Flex, Stack, Text } from "@mantine/core";
|
import { Card, Flex, Stack, Text } from "@mantine/core";
|
||||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
import type {
|
||||||
|
CallToolResult,
|
||||||
|
ReadResourceResult,
|
||||||
|
Tool,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { ToolControls } from "../../groups/ToolControls/ToolControls";
|
import { ToolControls } from "../../groups/ToolControls/ToolControls";
|
||||||
import {
|
import {
|
||||||
ToolDetailPanel,
|
ToolDetailPanel,
|
||||||
|
|
@ -46,6 +50,11 @@ export interface ToolsScreenProps {
|
||||||
) => void;
|
) => void;
|
||||||
onCancelCall?: () => void;
|
onCancelCall?: () => void;
|
||||||
onClearResult?: () => void;
|
onClearResult?: () => void;
|
||||||
|
/**
|
||||||
|
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||||
|
* Passed through to the result panel so links can inline their contents.
|
||||||
|
*/
|
||||||
|
onReadResource?: (uri: string) => Promise<ReadResourceResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caps the detail/result columns at the screen's available height: full
|
// Caps the detail/result columns at the screen's available height: full
|
||||||
|
|
@ -110,6 +119,7 @@ export function ToolsScreen({
|
||||||
onCallTool,
|
onCallTool,
|
||||||
onCancelCall,
|
onCancelCall,
|
||||||
onClearResult,
|
onClearResult,
|
||||||
|
onReadResource,
|
||||||
}: ToolsScreenProps) {
|
}: ToolsScreenProps) {
|
||||||
const { selectedToolName, formValues, search } = ui;
|
const { selectedToolName, formValues, search } = ui;
|
||||||
const selectedTool = selectedToolName
|
const selectedTool = selectedToolName
|
||||||
|
|
@ -177,6 +187,7 @@ export function ToolsScreen({
|
||||||
<ToolResultPanel
|
<ToolResultPanel
|
||||||
result={callState.result}
|
result={callState.result}
|
||||||
onClear={() => onClearResult?.()}
|
onClear={() => onClearResult?.()}
|
||||||
|
onReadResource={onReadResource}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState>Results will appear here</EmptyState>
|
<EmptyState>Results will appear here</EmptyState>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
Prompt,
|
Prompt,
|
||||||
|
ReadResourceResult,
|
||||||
Resource,
|
Resource,
|
||||||
ResourceTemplate,
|
ResourceTemplate,
|
||||||
Task,
|
Task,
|
||||||
|
|
@ -284,6 +285,11 @@ export interface InspectorViewProps {
|
||||||
onCancelToolCall?: () => void;
|
onCancelToolCall?: () => void;
|
||||||
onClearToolResult?: () => void;
|
onClearToolResult?: () => void;
|
||||||
onRefreshTools: () => void;
|
onRefreshTools: () => void;
|
||||||
|
/**
|
||||||
|
* Read-on-demand handler for `resource_link` blocks in a tool result.
|
||||||
|
* Returns the linked resource's contents so the result panel can inline them.
|
||||||
|
*/
|
||||||
|
onReadResourceContents?: (uri: string) => Promise<ReadResourceResult>;
|
||||||
|
|
||||||
onPromptsUiChange: (next: PromptsUiState) => void;
|
onPromptsUiChange: (next: PromptsUiState) => void;
|
||||||
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
onGetPrompt: (name: string, args: Record<string, string>) => void;
|
||||||
|
|
@ -388,6 +394,7 @@ export function InspectorView({
|
||||||
onCallTool,
|
onCallTool,
|
||||||
onCancelToolCall,
|
onCancelToolCall,
|
||||||
onClearToolResult,
|
onClearToolResult,
|
||||||
|
onReadResourceContents,
|
||||||
onRefreshTools,
|
onRefreshTools,
|
||||||
onPromptsUiChange,
|
onPromptsUiChange,
|
||||||
onGetPrompt,
|
onGetPrompt,
|
||||||
|
|
@ -598,6 +605,7 @@ export function InspectorView({
|
||||||
onCallTool={onCallTool}
|
onCallTool={onCallTool}
|
||||||
onCancelCall={onCancelToolCall}
|
onCancelCall={onCancelToolCall}
|
||||||
onClearResult={onClearToolResult}
|
onClearResult={onClearToolResult}
|
||||||
|
onReadResource={onReadResourceContents}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Apps"}>
|
<ScreenStage active={activeTab === "Apps"}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue