Expose live resource subscriptions via a hook (#1325) (#1326)

* Expose live resource subscriptions via a hook (#1325)

Add ResourceSubscriptionsState that mirrors InspectorClient's subscribed
URIs as InspectorResourceSubscription[], resolving each URI against the
managed resources list (so the subscription tile shows server-supplied
name/title) and stamping lastUpdated on notifications/resources/updated.
Pair it with useResourceSubscriptions and wire App.tsx so the Resources
screen reflects subscribe/unsubscribe actions in real time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review notes on resourceSubscriptionsState

- Soften the onResourceUpdated comment: the client's dispatch is already
  guarded by subscribedResources.has(uri), so the re-check is true
  defense-in-depth rather than guarding a known hazard.
- Use this.getSubscriptions() in the statusChange handler so every emit
  goes through the defensive-copy path.
- Document the deliberate fallback to a synthetic Resource when a
  previously-listed resource is removed from the managed list while
  the user is still subscribed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Reflect subscribed state in preview + compact subscribed tile labels

- Wire `isSubscribed` on the ReadResourceState passed to ResourcesScreen
  by deriving it from the live subscriptions list in App.tsx. The
  ResourcePreviewPanel's SubscribeButton already flips its label to
  "Unsubscribe" when subscribed; without this derivation isSubscribed
  was always false and the button looked stuck on "Subscribe".
- In ResourceSubscribedItem, display only the last URI path segment
  (truncated with ellipsis if it still overflows) and surface the full
  URI via a hover tooltip. Keeps the Subscriptions pleat readable when
  resources have long path-style URIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-read resource on sidebar click

Clicking a resource in the URIs accordion now triggers onReadResource
in addition to setting the selection, so the preview panel jumps
straight to the pending → ok render path. Removes the unreachable
"Click to read this resource" placeholder, since the loader / preview /
error states always cover the rendered output for a selected resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Render markdown resources via react-markdown + fix preview overflow

- Add react-markdown / remark-gfm dependencies.
- ContentViewer accepts an optional mimeType prop; when it matches
  text/markdown (or text/x-markdown), text blocks render via
  react-markdown wrapped in a `.markdown-content` container. Non-markdown
  text now uses the existing Code "wrapping" variant so long lines stay
  inside the panel.
- ResourcePreviewPanel infers `text/markdown` from a `.md` / `.markdown`
  URI suffix when the server didn't supply a mimeType, and threads the
  effective mime through to ContentViewer per content item.
- ResourcesScreen flex children get miw=0 so a long unwrappable line in
  the resource body can no longer push the preview past the viewport's
  right edge.
- App.css gains a `.markdown-content` ruleset constraining nested pre,
  table, code, and img elements to the container's width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Contain resource preview: sticky header/footer, scrollable body

Restructure ResourcePreviewPanel into a fixed-height column with the
resource title + URI pinned to the top and the timestamp / annotations /
subscribe-refresh actions pinned to the bottom. The content viewer area
in the middle now owns its own ScrollArea, so a long markdown body
scrolls within the panel instead of pushing the subscribe button below
the viewport.

In ResourcesScreen, the selectedResource branch (and the template
branch's right pane) now hosts the panel inside a PreviewPane Flex
column with the screen's max-height, and renderReadState wraps in a
FillDetailCard sized to fill that column. The legacy outer
ScrollArea.Autosize is removed for these panes since scrolling is
internal now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-hide template after read; size preview to content with cap

- After the user clicks Read Resource on the template form,
  handleReadResource now clears selectedTemplateUri so the screen
  swaps from the template form to the resource preview. Previously
  both panels stayed mounted side-by-side.
- The preview no longer hard-fills the viewport. The Card uses a new
  "preview" theme variant (overflow: hidden) and is content-sized,
  capped at SCROLL_MAX_HEIGHT. ResourcePreviewPanel's flex column
  marks the header / meta / footer rows as `flex: 0 0 auto` and the
  ContentScroll as `flex: 0 1 auto` with `mih: 0`, so:
    - short content → card hugs it, footer sits right under the body
    - long content → card caps at viewport, ContentScroll shrinks
      and scrolls internally, footer stays pinned at the cap
- ScrollArea / Group imports in ResourcesScreen pruned to match the
  simpler single-pane layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Cap template form panel at 40% width

A bare text input + Read Resource button stretched across the whole
main content area looks awkward on wide displays. Apply maw=40% to
the PreviewPane in the template branch so the form keeps a reasonable
form-field width regardless of viewport.

The preview branch is unaffected — resource bodies still get the full
main area for content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Live completion/complete suggestions on resource template inputs

Wires the server's `completions` capability through to the resource
template form so each variable's input becomes an Autocomplete that
fires `completion/complete` (debounced, 300ms) on every keystroke and
surfaces the returned values as a dropdown — mirroring v1's behavior.

- core/mcp/inspectorClientProtocol.ts: surface getCompletions on the
  protocol so non-runtime callers (state managers, hooks, tests) can
  depend on it. FakeInspectorClient gains a vi.fn-backed stub.
- ResourceTemplatePanel: accepts onCompleteArgument + completionsSupported.
  When both are present it renders Mantine Autocomplete instead of
  TextInput, debounces keystrokes via per-arg timers, aborts in-flight
  requests on the next keystroke, and disables client-side filtering
  (the server already filtered for the typed prefix).
- ResourcesScreen / InspectorView: thread the props through; the
  screen-level callback re-injects the active template's URI as the
  `ref: "ref/resource"` so the panel-level callback stays ref-free.
- App.tsx: wires onCompleteArgument to inspectorClient.getCompletions
  and derives completionsSupported from `capabilities?.completions`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-19 20:15:51 -04:00 • committed by GitHub
commit 6678313373
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2752 additions and 145 deletions

View file

@ -104,4 +104,42 @@ describe("ContentViewer", () => {
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders text as markdown when mimeType is text/markdown", () => {
const block: ContentBlock = {
type: "text",
text: "# Title\n\nSome **bold** text.",
};
renderWithMantine(<ContentViewer block={block} mimeType="text/markdown" />);
expect(
screen.getByRole("heading", { level: 1, name: "Title" }),
).toBeInTheDocument();
expect(screen.getByText("bold")).toBeInTheDocument();
});
it("accepts mimeType with parameters (e.g. text/markdown; charset=utf-8)", () => {
const block: ContentBlock = { type: "text", text: "# Heading" };
renderWithMantine(
<ContentViewer block={block} mimeType="text/markdown; charset=utf-8" />,
);
expect(
screen.getByRole("heading", { level: 1, name: "Heading" }),
).toBeInTheDocument();
});
it("falls back to code rendering for non-markdown mime types", () => {
const block: ContentBlock = { type: "text", text: "# not markdown" };
renderWithMantine(<ContentViewer block={block} mimeType="text/plain" />);
expect(screen.getByText("# not markdown")).toBeInTheDocument();
// No <h1> generated by react-markdown
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
});
it("renders a copy overlay for markdown content when copyable", () => {
const block: ContentBlock = { type: "text", text: "# hi" };
renderWithMantine(
<ContentViewer block={block} mimeType="text/markdown" copyable />,
);
expect(screen.getByRole("button")).toBeInTheDocument();
});
});

View file

@ -1,10 +1,18 @@
import { Code, Flex, Image, Stack, Text } from "@mantine/core";
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "../CopyButton/CopyButton";
export interface ContentViewerProps {
block: ContentBlock;
copyable?: boolean;
/**
* Optional MIME type for the block. When `text/markdown` (or
* `text/x-markdown`), text content is rendered via react-markdown
* instead of as preformatted code.
*/
mimeType?: string;
}
function formatJson(content: string): string {
@ -21,6 +29,12 @@ function isJsonText(block: ContentBlock): boolean {
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
function isMarkdownMime(mimeType: string | undefined): boolean {
if (!mimeType) return false;
const base = mimeType.split(";")[0].trim().toLowerCase();
return base === "text/markdown" || base === "text/x-markdown";
}
function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
@ -36,21 +50,49 @@ const CopyOverlay = Flex.withProps({
right: 4,
});
const MarkdownWrapper = Flex.withProps({
className: "markdown-content",
direction: "column",
});
const PreviewImage = Image.withProps({
alt: "Content preview",
maw: 400,
radius: "md",
});
export function ContentViewer({ block, copyable = false }: ContentViewerProps) {
export function ContentViewer({
block,
copyable = false,
mimeType,
}: ContentViewerProps) {
switch (block.type) {
case "text": {
const renderAsMarkdown = isMarkdownMime(mimeType);
if (renderAsMarkdown) {
return (
<Stack gap="xs">
<ContentWrapper>
<MarkdownWrapper>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{block.text}
</ReactMarkdown>
</MarkdownWrapper>
{copyable && (
<CopyOverlay>
<CopyButton value={block.text} />
</CopyOverlay>
)}
</ContentWrapper>
</Stack>
);
}
const isJson = isJsonText(block);
const displayText = isJson ? formatJson(block.text) : block.text;
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
<Code block p={36} variant="wrapping">
{displayText}
</Code>
{copyable && (

View file

@ -199,4 +199,59 @@ describe("ResourcePreviewPanel", () => {
);
expect(screen.getByText("text/markdown")).toBeInTheDocument();
});
it("renders text/markdown content as markdown", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
resource={{ name: "readme", uri: "file:///readme.md" }}
contents={[
{
uri: "file:///readme.md",
mimeType: "text/markdown",
text: "# Hello",
},
]}
/>,
);
expect(
screen.getByRole("heading", { level: 1, name: "Hello" }),
).toBeInTheDocument();
});
it("infers markdown from a .md URI when mimeType is missing", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
resource={{ name: "notes", uri: "demo://resource/notes.md" }}
contents={[
{
uri: "demo://resource/notes.md",
text: "## From URI",
},
]}
/>,
);
expect(
screen.getByRole("heading", { level: 2, name: "From URI" }),
).toBeInTheDocument();
});
it("does not render plain-text content as markdown even with markdown-looking text", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
resource={{ name: "notes", uri: "file:///notes.txt" }}
contents={[
{
uri: "file:///notes.txt",
mimeType: "text/plain",
text: "# not a heading",
},
]}
/>,
);
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
expect(screen.getByText("# not a heading")).toBeInTheDocument();
});
});

View file

@ -1,4 +1,12 @@
import { Button, Flex, Group, Stack, Text, Title } from "@mantine/core";
import {
Button,
Flex,
Group,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import type {
BlobResourceContents,
ContentBlock,
@ -46,6 +54,7 @@ function formatLastUpdated(date: Date): string {
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
flex: "0 0 auto",
});
const UriGroup = Group.withProps({
@ -62,6 +71,7 @@ const UriText = Text.withProps({
const MetaRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
flex: "0 0 auto",
});
const TimestampText = Text.withProps({
@ -76,6 +86,7 @@ const MimeText = Text.withProps({
const FooterRow = Group.withProps({
justify: "space-between",
flex: "0 0 auto",
});
const AnnotationGroup = Group.withProps({
@ -88,6 +99,58 @@ const ActionGroup = Group.withProps({
const Spacer = Flex.withProps({});
// The panel sizes to its content: when the resource body is short the
// Card hugs it; when the body would overflow the Card's `mah`, the
// browser shrinks shrinkable flex items (only ContentScroll, since the
// header / meta / footer rows opt out with `flex: 0 0 auto`) and the
// inner ScrollArea takes over scrolling — keeping the subscribe button
// pinned at the bottom edge of the cap.
const PanelStack = Stack.withProps({
gap: "md",
miw: 0,
mih: 0,
});
// Middle scroll region: basis sized to its own content, can shrink to
// fit the available space when content overflows, never grows past its
// content (so a short resource body doesn't push the footer down).
const ContentScroll = ScrollArea.withProps({
flex: "0 1 auto",
miw: 0,
mih: 0,
type: "auto",
scrollbars: "y",
offsetScrollbars: true,
});
const ContentStack = Stack.withProps({
gap: "md",
});
// Infer a markdown MIME from the URI when the server didn't supply one.
// MCP servers often return `text/plain` (or omit mimeType entirely) for
// `.md` resources; the file extension is the most reliable fallback signal.
function inferMimeFromUri(uri: string): string | undefined {
const path = uri.split("?")[0].split("#")[0];
const lower = path.toLowerCase();
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
return "text/markdown";
}
return undefined;
}
function effectiveMime(
itemMime: string | undefined,
resource: Resource,
): string {
return (
itemMime ??
resource.mimeType ??
inferMimeFromUri(resource.uri) ??
"application/octet-stream"
);
}
export function ResourcePreviewPanel({
resource,
contents,
@ -98,11 +161,10 @@ export function ResourcePreviewPanel({
onUnsubscribe,
}: ResourcePreviewPanelProps) {
const { uri, annotations } = resource;
const mimeType =
contents[0]?.mimeType ?? resource.mimeType ?? "application/octet-stream";
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
return (
<Stack gap="md">
<PanelStack>
<HeaderRow>
<Title order={4}>Resource</Title>
<UriGroup>
@ -110,9 +172,18 @@ export function ResourcePreviewPanel({
<CopyButton value={uri} />
</UriGroup>
</HeaderRow>
{contents.map((item, index) => (
<ContentViewer key={index} block={toContentBlock(item)} copyable />
))}
<ContentScroll>
<ContentStack>
{contents.map((item, index) => (
<ContentViewer
key={index}
block={toContentBlock(item)}
mimeType={effectiveMime(item.mimeType, resource)}
copyable
/>
))}
</ContentStack>
</ContentScroll>
<MetaRow>
{lastUpdated ? (
<TimestampText>{formatLastUpdated(lastUpdated)}</TimestampText>
@ -140,6 +211,6 @@ export function ResourcePreviewPanel({
/>
</ActionGroup>
</FooterRow>
</Stack>
</PanelStack>
);
}

View file

@ -5,31 +5,61 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ResourceSubscribedItem } from "./ResourceSubscribedItem";
const subscription: InspectorResourceSubscription = {
resource: { uri: "file:///x", name: "x" },
resource: { uri: "file:///foo/bar/config.json", name: "config.json" },
lastUpdated: new Date("2024-01-01T12:00:00Z"),
};
describe("ResourceSubscribedItem", () => {
it("renders the resource name", () => {
renderWithMantine(
<ResourceSubscribedItem
subscription={{ resource: subscription.resource }}
onUnsubscribe={() => {}}
/>,
);
expect(screen.getByText("x")).toBeInTheDocument();
});
it("prefers the resource title over the name", () => {
it("renders the last URI path segment, not the name or title", () => {
renderWithMantine(
<ResourceSubscribedItem
subscription={{
resource: { ...subscription.resource, title: "Display X" },
resource: {
uri: "file:///foo/bar/config.json",
name: "ignored-name",
title: "Ignored Title",
},
}}
onUnsubscribe={() => {}}
/>,
);
expect(screen.getByText("Display X")).toBeInTheDocument();
expect(screen.getByText("config.json")).toBeInTheDocument();
expect(screen.queryByText("ignored-name")).not.toBeInTheDocument();
expect(screen.queryByText("Ignored Title")).not.toBeInTheDocument();
});
it("falls back to the URI itself when it has no slash-separated segments", () => {
renderWithMantine(
<ResourceSubscribedItem
subscription={{ resource: { uri: "opaque", name: "n" } }}
onUnsubscribe={() => {}}
/>,
);
expect(screen.getByText("opaque")).toBeInTheDocument();
});
it("ignores trailing slashes when picking the last segment", () => {
renderWithMantine(
<ResourceSubscribedItem
subscription={{ resource: { uri: "file:///foo/bar/", name: "n" } }}
onUnsubscribe={() => {}}
/>,
);
expect(screen.getByText("bar")).toBeInTheDocument();
});
it("shows the full URI in a tooltip on hover", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourceSubscribedItem
subscription={subscription}
onUnsubscribe={() => {}}
/>,
);
await user.hover(screen.getByText("config.json"));
expect(
await screen.findByText("file:///foo/bar/config.json"),
).toBeInTheDocument();
});
it("renders the last updated timestamp when present", () => {

View file

@ -1,4 +1,4 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import { Button, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js";
export interface ResourceSubscribedItemProps {
@ -9,6 +9,7 @@ export interface ResourceSubscribedItemProps {
const NameText = Text.withProps({
size: "sm",
fw: 500,
truncate: "end",
});
const TimestampText = Text.withProps({
@ -24,12 +25,27 @@ const SubtleButton = Button.withProps({
const ItemRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
gap: "xs",
});
const NameStack = Stack.withProps({
gap: 2,
flex: 1,
miw: 0,
});
function formatLastUpdated(date: Date): string {
return date.toLocaleString();
}
// Strip the URI down to its last non-empty path segment so the tile shows
// a compact label (e.g. `file:///foo/bar/config.json` → `config.json`).
// The full URI is restored via a tooltip on hover.
function lastUriSegment(uri: string): string {
const segments = uri.split("/").filter(Boolean);
return segments[segments.length - 1] ?? uri;
}
export function ResourceSubscribedItem({
subscription,
onUnsubscribe,
@ -37,12 +53,14 @@ export function ResourceSubscribedItem({
const { resource, lastUpdated } = subscription;
return (
<ItemRow>
<Stack gap={2}>
<NameText>{resource.title ?? resource.name}</NameText>
<NameStack>
<Tooltip label={resource.uri} withinPortal>
<NameText>{lastUriSegment(resource.uri)}</NameText>
</Tooltip>
{lastUpdated && (
<TimestampText>{formatLastUpdated(lastUpdated)}</TimestampText>
)}
</Stack>
</NameStack>
<SubtleButton onClick={onUnsubscribe}>Unsubscribe</SubtleButton>
</ItemRow>
);

View file

@ -137,4 +137,95 @@ describe("ResourceTemplatePanel", () => {
screen.getByRole("button", { name: "Read Resource" }),
).not.toBeDisabled();
});
describe("completions", () => {
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue(["alpha", "alphabet"]);
renderWithMantine(
<ResourceTemplatePanel
template={singleVarTemplate}
onReadResource={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
await user.type(screen.getByRole("textbox", { name: "userId" }), "al");
// Wait past the 300ms debounce.
await new Promise((r) => setTimeout(r, 400));
expect(onCompleteArgument).toHaveBeenCalledTimes(1);
expect(onCompleteArgument).toHaveBeenCalledWith("userId", "al", {});
// Server-returned values surface in the Autocomplete dropdown.
expect(await screen.findByText("alpha")).toBeInTheDocument();
expect(screen.getByText("alphabet")).toBeInTheDocument();
});
it("passes sibling variables as completion context", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi
.fn<
(
argName: string,
value: string,
context: Record<string, string>,
) => Promise<string[]>
>()
.mockResolvedValue([]);
renderWithMantine(
<ResourceTemplatePanel
template={titledTemplate}
onReadResource={vi.fn()}
completionsSupported
onCompleteArgument={onCompleteArgument}
/>,
);
await user.type(
screen.getByRole("textbox", { name: "tableName" }),
"users",
);
await new Promise((r) => setTimeout(r, 400));
// The completing arg ("tableName") is excluded from context; only
// the other variables ride along.
expect(onCompleteArgument).toHaveBeenLastCalledWith(
"tableName",
"users",
{ rowId: "" },
);
await user.type(screen.getByRole("textbox", { name: "rowId" }), "42");
await new Promise((r) => setTimeout(r, 400));
expect(onCompleteArgument).toHaveBeenLastCalledWith("rowId", "42", {
tableName: "users",
});
});
it("does not call onCompleteArgument when completions are unsupported", async () => {
const user = userEvent.setup();
const onCompleteArgument = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={singleVarTemplate}
onReadResource={vi.fn()}
completionsSupported={false}
onCompleteArgument={onCompleteArgument}
/>,
);
await user.type(screen.getByLabelText("userId"), "ab");
await new Promise((r) => setTimeout(r, 400));
expect(onCompleteArgument).not.toHaveBeenCalled();
});
});
});

View file

@ -1,5 +1,13 @@
import { useState, useMemo } from "react";
import { Button, Group, Stack, Text, TextInput, Title } from "@mantine/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
Button,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
@ -7,8 +15,27 @@ import { CopyButton } from "../../elements/CopyButton/CopyButton";
export interface ResourceTemplatePanelProps {
template: ResourceTemplate;
onReadResource: (uri: string) => void;
/**
* When provided, each keystroke in a variable input dispatches a
* (debounced) `completion/complete` request to the server. The
* resolved values are surfaced as a dropdown via Mantine `Autocomplete`.
* Wire to `InspectorClient.getCompletions` in the host App.
*/
onCompleteArgument?: (
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
/**
* Gates whether to render Autocomplete (with live completions) vs the
* plain TextInput. Typically derived from the server's
* `completions` capability.
*/
completionsSupported?: boolean;
}
const COMPLETION_DEBOUNCE_MS = 300;
function parseVariableNames(uriTemplate: string): string[] {
const names: string[] = [];
const regex = /\{(\w+)\}/g;
@ -69,6 +96,8 @@ const AnnotationGroup = Group.withProps({
export function ResourceTemplatePanel({
template,
onReadResource,
onCompleteArgument,
completionsSupported = false,
}: ResourceTemplatePanelProps) {
const { name, title, uriTemplate, description, annotations } = template;
@ -80,9 +109,79 @@ export function ResourceTemplatePanel({
const [variables, setVariables] = useState<Record<string, string>>(() =>
Object.fromEntries(variableNames.map((n) => [n, ""])),
);
const [completions, setCompletions] = useState<Record<string, string[]>>({});
// Reset state when the user switches to a different template.
useEffect(() => {
setVariables(Object.fromEntries(variableNames.map((n) => [n, ""])));
setCompletions({});
}, [uriTemplate, variableNames]);
// Latest in-flight controller per argument, so a faster keystroke can
// abort an outstanding completion request and the late response can't
// overwrite the fresh one.
const requestsRef = useRef<Map<string, AbortController>>(new Map());
// Debounce timer per argument so we don't spam the server on every key.
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
// Drop pending timers / abort in-flight requests on unmount.
useEffect(() => {
const timers = timersRef.current;
const requests = requestsRef.current;
return () => {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
for (const c of requests.values()) c.abort();
requests.clear();
};
}, []);
const useAutocomplete = completionsSupported && !!onCompleteArgument;
const runCompletion = useCallback(
async (varName: string, value: string, context: Record<string, string>) => {
if (!onCompleteArgument) return;
requestsRef.current.get(varName)?.abort();
const controller = new AbortController();
requestsRef.current.set(varName, controller);
try {
const values = await onCompleteArgument(varName, value, context);
if (controller.signal.aborted) return;
setCompletions((prev) => ({ ...prev, [varName]: values }));
} catch {
if (!controller.signal.aborted) {
setCompletions((prev) => ({ ...prev, [varName]: [] }));
}
} finally {
if (requestsRef.current.get(varName) === controller) {
requestsRef.current.delete(varName);
}
}
},
[onCompleteArgument],
);
function handleVariableChange(varName: string, value: string) {
setVariables((prev) => ({ ...prev, [varName]: value }));
setVariables((prev) => {
const next = { ...prev, [varName]: value };
if (useAutocomplete) {
// Schedule a debounced completion call. The `context` carries the
// other variables' current values so the server can disambiguate
// when one variable depends on another.
const context: Record<string, string> = { ...next };
delete context[varName];
const existing = timersRef.current.get(varName);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
timersRef.current.delete(varName);
void runCompletion(varName, value, context);
}, COMPLETION_DEBOUNCE_MS);
timersRef.current.set(varName, timer);
}
return next;
});
}
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
@ -104,17 +203,33 @@ export function ResourceTemplatePanel({
</HeaderRow>
{description && <DescriptionText>{description}</DescriptionText>}
<Stack gap="sm">
{variableNames.map((varName) => (
<TextInput
key={varName}
label={varName}
placeholder={`Enter ${varName}`}
value={variables[varName] ?? ""}
onChange={(e) =>
handleVariableChange(varName, e.currentTarget.value)
}
/>
))}
{variableNames.map((varName) =>
useAutocomplete ? (
<Autocomplete
key={varName}
label={varName}
placeholder={`Enter ${varName}`}
value={variables[varName] ?? ""}
data={completions[varName] ?? []}
// The server already filtered the values for the typed
// prefix; passing options through verbatim avoids hiding
// valid suggestions when the input is empty or doesn't
// substring-match what the server returned.
filter={({ options }) => options}
onChange={(value) => handleVariableChange(varName, value)}
/>
) : (
<TextInput
key={varName}
label={varName}
placeholder={`Enter ${varName}`}
value={variables[varName] ?? ""}
onChange={(e) =>
handleVariableChange(varName, e.currentTarget.value)
}
/>
),
)}
</Stack>
<FooterRow>
<AnnotationGroup>

View file

@ -110,15 +110,35 @@ describe("ResourcesScreen", () => {
await user.click(screen.getByText("Templates (1)"));
await user.click(screen.getByText("files"));
expect(
screen.getByText("Enter a URI and click Read to preview"),
screen.getByRole("button", { name: "Read Resource" }),
).toBeInTheDocument();
});
it("renders empty state when a resource is selected but no readState", async () => {
it("hides the template panel once the user reads the resource", async () => {
const user = userEvent.setup();
renderWithMantine(<ResourcesScreen {...baseProps} />);
const onReadResource = vi.fn();
renderWithMantine(
<ResourcesScreen {...baseProps} onReadResource={onReadResource} />,
);
await user.click(screen.getByText("Templates (1)"));
await user.click(screen.getByText("files"));
await user.type(screen.getByLabelText("path"), "alpha");
await user.click(screen.getByRole("button", { name: "Read Resource" }));
expect(onReadResource).toHaveBeenCalledWith("file:///alpha");
// After read, the template form is gone and the preview branch is active.
expect(
screen.queryByRole("button", { name: "Read Resource" }),
).not.toBeInTheDocument();
});
it("auto-reads when a resource is clicked in the sidebar", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourcesScreen {...baseProps} onReadResource={onReadResource} />,
);
await user.click(screen.getByText("x.txt"));
expect(screen.getByText("Click to read this resource")).toBeInTheDocument();
expect(onReadResource).toHaveBeenCalledWith("file:///x");
});
it("forwards refresh and subscribe events from the preview panel", async () => {

View file

@ -1,14 +1,5 @@
import { useState } from "react";
import {
Alert,
Card,
Flex,
Group,
Loader,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { Alert, Card, Flex, Loader, Stack, Text } from "@mantine/core";
import type {
ReadResourceResult,
Resource,
@ -34,10 +25,19 @@ export interface ResourcesScreenProps {
subscriptions: InspectorResourceSubscription[];
readState?: ReadResourceState;
listChanged: boolean;
completionsSupported?: boolean;
onRefreshList: () => void;
onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onCompleteArgument?: (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
}
const ScreenLayout = Flex.withProps({
@ -62,6 +62,26 @@ const DetailCard = Card.withProps({
padding: "lg",
});
// Card that sizes to its content but caps at the screen's available
// height. When content fits, the card stays compact (footer sits right
// under the body); when content would overflow, the inner ScrollArea
// inside ResourcePreviewPanel shrinks and scrolls.
const PreviewCard = Card.withProps({
withBorder: true,
padding: "lg",
variant: "preview",
});
// Column that pins the preview card to the top of the available space
// and bounds its growth via the consumer-set `mah`. The card inside
// keeps its natural height up to that cap.
const PreviewPane = Flex.withProps({
flex: 1,
miw: 0,
direction: "column",
align: "stretch",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
@ -77,10 +97,12 @@ export function ResourcesScreen({
subscriptions,
readState,
listChanged,
completionsSupported,
onRefreshList,
onReadResource,
onSubscribeResource,
onUnsubscribeResource,
onCompleteArgument,
}: ResourcesScreenProps) {
const [selectedResourceUri, setSelectedResourceUri] = useState<
string | undefined
@ -107,6 +129,7 @@ export function ResourcesScreen({
function handleSelectResource(uri: string) {
setSelectedTemplateUri(undefined);
setSelectedResourceUri(uri);
onReadResource(uri);
}
function handleSelectTemplate(uriTemplate: string) {
@ -115,6 +138,11 @@ export function ResourcesScreen({
}
function handleReadResource(uri: string) {
// Once the user reads (either from the template form or a refresh
// inside the preview panel), hand the screen over to the preview:
// clearing the template selection hides the template form so only
// the rendered resource is shown.
setSelectedTemplateUri(undefined);
setSelectedResourceUri(uri);
onReadResource(uri);
}
@ -124,28 +152,28 @@ export function ResourcesScreen({
if (readState.status === "pending") {
return (
<DetailCard>
<PreviewCard>
<Stack align="center" py="xl">
<Loader size="sm" />
<Text c="dimmed">Reading resource...</Text>
</Stack>
</DetailCard>
</PreviewCard>
);
}
if (readState.status === "error") {
return (
<DetailCard>
<PreviewCard>
<Alert color="red" variant="light" title="Read Error">
{readState.error ?? "Failed to read resource"}
</Alert>
</DetailCard>
</PreviewCard>
);
}
if (readState.result && readResource) {
return (
<DetailCard>
<PreviewCard>
<ResourcePreviewPanel
resource={readResource}
contents={readState.result.contents}
@ -155,7 +183,7 @@ export function ResourcesScreen({
onSubscribe={() => onSubscribeResource(readResource.uri)}
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
/>
</DetailCard>
</PreviewCard>
);
}
@ -182,31 +210,44 @@ export function ResourcesScreen({
</Sidebar>
{selectedTemplate ? (
<Group flex={1} gap="md" align="flex-start" wrap="nowrap">
<ScrollArea.Autosize flex={1} mah={SCROLL_MAX_HEIGHT}>
<DetailCard>
<ResourceTemplatePanel
template={selectedTemplate}
onReadResource={handleReadResource}
/>
</DetailCard>
</ScrollArea.Autosize>
<ScrollArea.Autosize flex={1} mah={SCROLL_MAX_HEIGHT}>
{renderReadState() ?? (
<DetailCard>
<EmptyState>Enter a URI and click Read to preview</EmptyState>
</DetailCard>
)}
</ScrollArea.Autosize>
</Group>
) : selectedResource ? (
<ScrollArea.Autosize flex={1} mah={SCROLL_MAX_HEIGHT}>
{renderReadState() ?? (
<DetailCard>
<EmptyState>Click to read this resource</EmptyState>
</DetailCard>
)}
</ScrollArea.Autosize>
// Template form only — once the user clicks Read Resource,
// handleReadResource clears the template selection so the
// resource branch takes over and the preview is shown alone.
// maw=40% keeps the form from stretching across the whole
// main area; an unconstrained text input + Read button at
// viewport width looks weird, especially on wide displays.
<PreviewPane mah={SCROLL_MAX_HEIGHT} maw="40%">
<PreviewCard>
<ResourceTemplatePanel
template={selectedTemplate}
onReadResource={handleReadResource}
completionsSupported={completionsSupported}
onCompleteArgument={
onCompleteArgument
? (argName, value, context) =>
onCompleteArgument(
{
type: "ref/resource",
uri: selectedTemplate.uriTemplate,
},
argName,
value,
context,
)
: undefined
}
/>
</PreviewCard>
</PreviewPane>
) : readResource ? (
// Sized-to-content preview pane, capped at the screen's available
// height. When the resource body fits, the card hugs its content
// and the subscribe/refresh row sits right under it. When the body
// would overflow, the inner ScrollArea inside ResourcePreviewPanel
// shrinks and scrolls, keeping the footer pinned at the cap.
// miw=0 prevents wide content (long unbroken lines, tables) from
// pushing the pane past the viewport's right edge.
<PreviewPane mah={SCROLL_MAX_HEIGHT}>{renderReadState()}</PreviewPane>
) : (
<DetailCard flex={1}>
<EmptyState>Select a resource to preview</EmptyState>

View file

@ -174,6 +174,15 @@ export interface InspectorViewProps {
onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void;
onRefreshResources: () => void;
onCompleteArgument?: (
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context: Record<string, string>,
) => Promise<string[]>;
completionsSupported?: boolean;
onCancelTask: (taskId: string) => void;
onClearCompletedTasks: () => void;
@ -240,6 +249,8 @@ export function InspectorView({
onSubscribeResource,
onUnsubscribeResource,
onRefreshResources,
onCompleteArgument,
completionsSupported,
onCancelTask,
onClearCompletedTasks,
onRefreshTasks,
@ -394,10 +405,12 @@ export function InspectorView({
subscriptions={subscriptions}
readState={readResourceState}
listChanged={false}
completionsSupported={completionsSupported}
onRefreshList={onRefreshResources}
onReadResource={onReadResource}
onSubscribeResource={onSubscribeResource}
onUnsubscribeResource={onUnsubscribeResource}
onCompleteArgument={onCompleteArgument}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Tasks"}>