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
2 changed files with 2752 additions and 145 deletions

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>