* 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:
parent
4d74b94bed
commit
6678313373
11 changed files with 2752 additions and 145 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue