diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx index 84b83209..f8a5f9f8 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { ResourcePreviewPanel } from "./ResourcePreviewPanel"; const meta: Meta = { @@ -108,6 +108,44 @@ export const SubscriptionsUnsupported: Story = { }, }; +// Markdown, CSV, and HTML resources expose a "View Source" link (left of +// Refresh) that swaps the rendered preview for the raw resource text. The play +// function drives the toggle: rendered heading → raw source → back. +export const MarkdownWithViewSource: Story = { + args: { + resource: { + name: "README.md", + uri: "file:///README.md", + }, + contents: [ + { + uri: "file:///README.md", + mimeType: "text/markdown", + text: "# Project\n\nA **bold** intro with a [link](https://example.com).", + }, + ], + isSubscribed: false, + lastUpdated: new Date("2026-03-17T10:30:00Z"), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Rendered by default. + await expect( + canvas.getByRole("heading", { level: 1, name: "Project" }), + ).toBeInTheDocument(); + // Switch to raw source. + await userEvent.click(canvas.getByRole("button", { name: "View Source" })); + await expect(canvas.getByText(/# Project/)).toBeInTheDocument(); + // Switch back to rendered. + await userEvent.click( + canvas.getByRole("button", { name: "View Rendered" }), + ); + await expect( + canvas.getByRole("heading", { level: 1, name: "Project" }), + ).toBeInTheDocument(); + }, +}; + export const WithAnnotations: Story = { args: { resource: { diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx index 36821a83..23e1070b 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx @@ -306,6 +306,168 @@ describe("ResourcePreviewPanel", () => { expect(screen.getByText("# not a heading")).toBeInTheDocument(); }); + describe("View Source toggle", () => { + const markdownProps = { + ...baseProps, + resource: { name: "readme", uri: "file:///readme.md" }, + contents: [ + { + uri: "file:///readme.md", + mimeType: "text/markdown", + text: "# Hello", + }, + ], + }; + + it("toggles a markdown preview between rendered and raw source", async () => { + const user = userEvent.setup(); + renderWithMantine(); + + // Rendered by default: heading shown, no raw "# Hello" text. + expect( + screen.getByRole("heading", { level: 1, name: "Hello" }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View Source" })); + + // Source mode: raw markdown text shown, heading gone, label flipped. + expect(screen.getByText("# Hello")).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { level: 1 }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View Rendered" })); + + // Back to rendered. + expect( + screen.getByRole("heading", { level: 1, name: "Hello" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "View Source" }), + ).toBeInTheDocument(); + }); + + it("reflects toggle state via aria-pressed", async () => { + const user = userEvent.setup(); + renderWithMantine(); + expect( + screen.getByRole("button", { name: "View Source" }), + ).toHaveAttribute("aria-pressed", "false"); + await user.click(screen.getByRole("button", { name: "View Source" })); + expect( + screen.getByRole("button", { name: "View Rendered" }), + ).toHaveAttribute("aria-pressed", "true"); + }); + + it("only switches source-toggleable items in a mixed multi-part resource", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + // Toggle gated on the first (markdown) item. + await user.click(screen.getByRole("button", { name: "View Source" })); + // Markdown switches to raw text... + expect(screen.getByText("# Hi")).toBeInTheDocument(); + // ...but the image is not forced through the text decoder — it still + // renders as an image rather than garbled bytes or a binary notice. + const img = screen + .getAllByRole("img") + .find((el) => el.getAttribute("src")?.startsWith("data:image/png")); + expect(img).toBeDefined(); + }); + + it("offers the toggle for CSV resources", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "View Source" }), + ).toBeInTheDocument(); + }); + + it("offers the toggle for HTML resources", () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + renderWithMantine( + hi

" }]} + />, + ); + expect( + screen.getByRole("button", { name: "View Source" }), + ).toBeInTheDocument(); + vi.restoreAllMocks(); + }); + + it("hides the toggle for non-toggleable kinds (JSON, plain text, image)", () => { + const { rerender } = renderWithMantine( + , + ); + // JSON + expect( + screen.queryByRole("button", { name: "View Source" }), + ).not.toBeInTheDocument(); + // Plain text + rerender( + , + ); + expect( + screen.queryByRole("button", { name: "View Source" }), + ).not.toBeInTheDocument(); + }); + + it("resets to the rendered view when the resource changes", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "View Source" })); + expect( + screen.getByRole("button", { name: "View Rendered" }), + ).toBeInTheDocument(); + + // Switch to a different markdown resource — the toggle should reset. + rerender( + , + ); + expect( + screen.getByRole("button", { name: "View Source" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 1, name: "Two" }), + ).toBeInTheDocument(); + }); + }); + describe("URI-suffix MIME inference", () => { it("infers text/csv from a .csv URI and renders a table", () => { renderWithMantine( diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx index 015f4891..f74c2904 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx @@ -8,6 +8,7 @@ import { Text, Title, } from "@mantine/core"; +import { useState } from "react"; import type { BlobResourceContents, Resource, @@ -15,6 +16,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton"; @@ -44,6 +46,19 @@ function formatLastUpdated(date: Date): string { return `Last updated: ${date.toLocaleString()}`; } +// MIME kinds whose rendered preview (react-markdown, the CSV table, the +// sandboxed HTML iframe) hides the underlying text. For these the panel offers +// a "View Source" toggle that swaps the renderer for the raw resource text. +const SOURCE_TOGGLEABLE_KINDS = new Set(["markdown", "csv", "html"]); + +// MIME forced on ContentViewer in source mode so it routes through the plain +// preformatted-text renderer regardless of the resource's real type. +const SOURCE_MIME = "text/plain"; + +function isSourceToggleable(mimeType: string): boolean { + return SOURCE_TOGGLEABLE_KINDS.has(getMimeKind(mimeType)); +} + const HeaderRow = Group.withProps({ justify: "space-between", wrap: "nowrap", @@ -95,6 +110,14 @@ const ActionGroup = Group.withProps({ gap: "xs", }); +// Subtle footer action button. Shared by Refresh and the View Source toggle so +// the two stay visually identical (the toggle is deliberately styled to match +// Refresh, which sits immediately to its right). +const FooterButton = Button.withProps({ + variant: "subtle", + size: "sm", +}); + const Spacer = Flex.withProps({}); // The panel sizes to its content: when the resource body is short the @@ -180,6 +203,17 @@ export function ResourcePreviewPanel({ const { uri, annotations } = resource; const mimeType = effectiveMime(contents[0]?.mimeType, resource); + const [showSource, setShowSource] = useState(false); + // Reset to the rendered view when the previewed resource changes (the panel + // is reused, not remounted, across resources). React's documented + // "adjust state during render" pattern — no effect, so no cascading render. + const [prevUri, setPrevUri] = useState(uri); + if (uri !== prevUri) { + setPrevUri(uri); + setShowSource(false); + } + const sourceToggleable = isSourceToggleable(mimeType); + return ( @@ -196,14 +230,26 @@ export function ResourcePreviewPanel({ - {contents.map((item, index) => ( - - ))} + {contents.map((item, index) => { + const itemMime = effectiveMime(item.mimeType, resource); + // The toggle is gated on the first content item but applies per + // item: in source mode only the source-toggleable items (the ones + // whose rendered view hides their text) switch to plain text, so a + // mixed multi-part resource doesn't force an image/PDF blob through + // the text decoder. + const renderMime = + showSource && isSourceToggleable(itemMime) + ? SOURCE_MIME + : itemMime; + return ( + + ); + })} @@ -224,9 +270,15 @@ export function ResourcePreviewPanel({ )} - + {sourceToggleable && ( + setShowSource((shown) => !shown)} + > + {showSource ? "View Rendered" : "View Source"} + + )} + Refresh {subscriptionsSupported && (