From ac9065f5cf1ddfb16ab19fdf7a3080eec6f150af Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 16:35:05 -0400 Subject: [PATCH 1/9] 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) --- clients/web/src/App.tsx | 24 +- .../state/resourceSubscriptionsState.test.ts | 222 ++++++++++++++++++ .../react/useResourceSubscriptions.test.tsx | 69 ++++++ core/mcp/state/index.ts | 2 + core/mcp/state/resourceSubscriptionsState.ts | 145 ++++++++++++ core/react/useResourceSubscriptions.ts | 46 ++++ 6 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts create mode 100644 clients/web/src/test/core/react/useResourceSubscriptions.test.tsx create mode 100644 core/mcp/state/resourceSubscriptionsState.ts create mode 100644 core/react/useResourceSubscriptions.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 0187f7582..b2b66b60d 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -16,6 +16,7 @@ import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsSta import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js"; import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js"; import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js"; +import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js"; import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js"; import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js"; import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js"; @@ -26,6 +27,7 @@ import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js"; import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; +import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen"; @@ -173,6 +175,8 @@ function App() { useState(null); const [managedRequestorTasksState, setManagedRequestorTasksState] = useState(null); + const [resourceSubscriptionsState, setResourceSubscriptionsState] = + useState(null); const [messageLogState, setMessageLogState] = useState(null); const [fetchRequestLogState, setFetchRequestLogState] = @@ -237,6 +241,9 @@ function App() { inspectorClient, managedRequestorTasksState, ); + const { subscriptions } = useResourceSubscriptions( + resourceSubscriptionsState, + ); const { messages } = useMessageLog(messageLogState); // Capture observed handshake latency at the connecting → connected edge. @@ -304,6 +311,7 @@ function App() { managedResourcesState?.destroy(); managedResourceTemplatesState?.destroy(); managedRequestorTasksState?.destroy(); + resourceSubscriptionsState?.destroy(); messageLogState?.destroy(); fetchRequestLogState?.destroy(); stderrLogState?.destroy(); @@ -325,11 +333,19 @@ function App() { setInspectorClient(client); setManagedToolsState(new ManagedToolsState(client)); setManagedPromptsState(new ManagedPromptsState(client)); - setManagedResourcesState(new ManagedResourcesState(client)); + const nextResourcesState = new ManagedResourcesState(client); + setManagedResourcesState(nextResourcesState); setManagedResourceTemplatesState( new ManagedResourceTemplatesState(client), ); setManagedRequestorTasksState(new ManagedRequestorTasksState(client)); + // ResourceSubscriptionsState consults the managed resources list to + // resolve subscribed URIs to full Resource objects (so the subscription + // tile shows the server-supplied name/title). Pass the freshly created + // state to avoid the React update lag from setManagedResourcesState. + setResourceSubscriptionsState( + new ResourceSubscriptionsState(client, nextResourcesState), + ); setMessageLogState(new MessageLogState(client)); setFetchRequestLogState(new FetchRequestLogState(client)); setStderrLogState(new StderrLogState(client)); @@ -342,6 +358,7 @@ function App() { managedResourcesState, managedResourceTemplatesState, managedRequestorTasksState, + resourceSubscriptionsState, messageLogState, fetchRequestLogState, stderrLogState, @@ -557,10 +574,7 @@ function App() { prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} - // TODO(#1325): drop the empty fallback once `useResourceSubscriptions` - // surfaces the live subscription list — subscribe/unsubscribe buttons - // currently fire but the screen never reflects the result. - subscriptions={[]} + subscriptions={subscriptions} logs={logs} tasks={tasks} history={messages} diff --git a/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts b/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts new file mode 100644 index 000000000..ac21c06d6 --- /dev/null +++ b/clients/web/src/test/core/mcp/state/resourceSubscriptionsState.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState"; +import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState"; +import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; +import type { InspectorResourceSubscription } from "@inspector/core/mcp/types"; + +function resource(uri: string, extras: Partial = {}): Resource { + return { uri, name: uri, ...extras }; +} + +function waitForSubscriptionsChange( + state: ResourceSubscriptionsState, +): Promise { + return new Promise((resolve) => { + state.addEventListener("subscriptionsChange", (e) => resolve(e.detail), { + once: true, + }); + }); +} + +describe("ResourceSubscriptionsState", () => { + let client: FakeInspectorClient; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-19T10:00:00Z")); + client = new FakeInspectorClient({ status: "connected" }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts empty and getSubscriptions returns a defensive copy", () => { + const state = new ResourceSubscriptionsState(client); + expect(state.getSubscriptions()).toEqual([]); + const a = state.getSubscriptions(); + const b = state.getSubscriptions(); + expect(a).not.toBe(b); + }); + + it("rebuilds subscriptions from resourceSubscriptionsChange events", async () => { + const state = new ResourceSubscriptionsState(client); + const changePromise = waitForSubscriptionsChange(state); + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///a", + "file:///b", + ]); + const next = await changePromise; + expect(next).toEqual([ + { resource: { uri: "file:///a", name: "file:///a" } }, + { resource: { uri: "file:///b", name: "file:///b" } }, + ]); + }); + + it("resolves Resource references via ManagedResourcesState when provided", async () => { + const resourcesState = new ManagedResourcesState(client); + client.queueResourcePages({ + resources: [resource("file:///a", { name: "Alpha", title: "Title A" })], + }); + await resourcesState.refresh(); + + const state = new ResourceSubscriptionsState(client, resourcesState); + const changePromise = waitForSubscriptionsChange(state); + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///a", + "file:///unknown", + ]); + const next = await changePromise; + expect(next[0].resource).toEqual({ + uri: "file:///a", + name: "Alpha", + title: "Title A", + }); + // Unknown URI falls back to a synthetic Resource + expect(next[1].resource).toEqual({ + uri: "file:///unknown", + name: "file:///unknown", + }); + }); + + it("stamps lastUpdated on resourceUpdated for a tracked URI", async () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + expect(state.getSubscriptions()[0].lastUpdated).toBeUndefined(); + + const changePromise = waitForSubscriptionsChange(state); + client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" }); + const next = await changePromise; + expect(next[0].lastUpdated).toEqual(new Date("2026-05-19T10:00:00Z")); + }); + + it("ignores resourceUpdated for URIs that are not subscribed", () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + const handler = vi.fn(); + state.addEventListener("subscriptionsChange", handler); + client.dispatchTypedEvent("resourceUpdated", { + uri: "file:///not-tracked", + }); + expect(handler).not.toHaveBeenCalled(); + expect(state.getSubscriptions()[0].lastUpdated).toBeUndefined(); + }); + + it("preserves lastUpdated across re-subscribes and drops it on unsubscribe", async () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///a", + "file:///b", + ]); + client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" }); + expect(state.getSubscriptions()[0].lastUpdated).toBeInstanceOf(Date); + + // Unsubscribe from "a", subscribe to "c". lastUpdated for "a" is dropped. + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///b", + "file:///c", + ]); + expect(state.getSubscriptions().map((s) => s.resource.uri)).toEqual([ + "file:///b", + "file:///c", + ]); + expect(state.getSubscriptions().every((s) => !s.lastUpdated)).toBe(true); + + // Re-subscribe to "a" — no lastUpdated since the prior entry was dropped. + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///a", + "file:///b", + "file:///c", + ]); + expect(state.getSubscriptions()[0].lastUpdated).toBeUndefined(); + }); + + it("preserves lastUpdated when an unrelated URI is added", () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" }); + const stampedAt = state.getSubscriptions()[0].lastUpdated; + expect(stampedAt).toBeInstanceOf(Date); + + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///a", + "file:///b", + ]); + const subs = state.getSubscriptions(); + expect(subs[0].lastUpdated).toEqual(stampedAt); + expect(subs[1].lastUpdated).toBeUndefined(); + }); + + it("re-resolves Resource references when ManagedResourcesState refreshes", async () => { + const resourcesState = new ManagedResourcesState(client); + const state = new ResourceSubscriptionsState(client, resourcesState); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + expect(state.getSubscriptions()[0].resource.name).toBe("file:///a"); + + client.queueResourcePages({ + resources: [resource("file:///a", { name: "Resolved Name" })], + }); + const changePromise = waitForSubscriptionsChange(state); + await resourcesState.refresh(); + const next = await changePromise; + expect(next[0].resource.name).toBe("Resolved Name"); + }); + + it("does not re-emit on resourcesChange when no URIs are subscribed", async () => { + const resourcesState = new ManagedResourcesState(client); + const state = new ResourceSubscriptionsState(client, resourcesState); + const handler = vi.fn(); + state.addEventListener("subscriptionsChange", handler); + + client.queueResourcePages({ resources: [resource("file:///a")] }); + await resourcesState.refresh(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("clears subscriptions on statusChange to disconnected", async () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + expect(state.getSubscriptions()).toHaveLength(1); + + const changePromise = waitForSubscriptionsChange(state); + client.setStatus("disconnected"); + const next = await changePromise; + expect(next).toEqual([]); + expect(state.getSubscriptions()).toEqual([]); + }); + + it("does not clear subscriptions on non-disconnected status changes", () => { + const state = new ResourceSubscriptionsState(client); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + client.setStatus("error"); + expect(state.getSubscriptions()).toHaveLength(1); + }); + + it("destroy unsubscribes from client and resources state events", () => { + const resourcesState = new ManagedResourcesState(client); + const state = new ResourceSubscriptionsState(client, resourcesState); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + expect(state.getSubscriptions()).toHaveLength(1); + + state.destroy(); + expect(state.getSubscriptions()).toEqual([]); + + // Further events from the client must not affect the destroyed state. + const handler = vi.fn(); + state.addEventListener("subscriptionsChange", handler); + client.dispatchTypedEvent("resourceSubscriptionsChange", [ + "file:///a", + "file:///b", + ]); + client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" }); + expect(handler).not.toHaveBeenCalled(); + expect(state.getSubscriptions()).toEqual([]); + }); + + it("destroy is idempotent", () => { + const state = new ResourceSubscriptionsState(client); + state.destroy(); + expect(() => state.destroy()).not.toThrow(); + }); +}); diff --git a/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx b/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx new file mode 100644 index 000000000..164444fc5 --- /dev/null +++ b/clients/web/src/test/core/react/useResourceSubscriptions.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; +import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState"; +import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions"; + +describe("useResourceSubscriptions", () => { + let client: FakeInspectorClient; + let state: ResourceSubscriptionsState; + + beforeEach(() => { + client = new FakeInspectorClient({ status: "connected" }); + state = new ResourceSubscriptionsState(client); + }); + + it("returns the initial snapshot from the state", () => { + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + const { result } = renderHook(() => useResourceSubscriptions(state)); + expect(result.current.subscriptions.map((s) => s.resource.uri)).toEqual([ + "file:///a", + ]); + }); + + it("returns empty subscriptions when state is null", () => { + const { result } = renderHook(() => useResourceSubscriptions(null)); + expect(result.current.subscriptions).toEqual([]); + }); + + it("updates when state dispatches subscriptionsChange", async () => { + const { result } = renderHook(() => useResourceSubscriptions(state)); + expect(result.current.subscriptions).toEqual([]); + + act(() => { + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + }); + + await waitFor(() => { + expect(result.current.subscriptions.map((s) => s.resource.uri)).toEqual([ + "file:///a", + ]); + }); + }); + + it("resets to empty when the state prop becomes null", async () => { + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + const { result, rerender } = renderHook( + ({ s }: { s: ResourceSubscriptionsState | null }) => + useResourceSubscriptions(s), + { initialProps: { s: state as ResourceSubscriptionsState | null } }, + ); + await waitFor(() => { + expect(result.current.subscriptions).toHaveLength(1); + }); + + rerender({ s: null }); + await waitFor(() => { + expect(result.current.subscriptions).toEqual([]); + }); + }); + + it("unsubscribes from the state on unmount", () => { + const { result, unmount } = renderHook(() => + useResourceSubscriptions(state), + ); + unmount(); + client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]); + expect(result.current.subscriptions).toEqual([]); + }); +}); diff --git a/core/mcp/state/index.ts b/core/mcp/state/index.ts index 7f1cb2a37..af1e9f65e 100644 --- a/core/mcp/state/index.ts +++ b/core/mcp/state/index.ts @@ -48,3 +48,5 @@ export type { PagedRequestorTasksStateEventMap, LoadPageResult as PagedRequestorTasksLoadPageResult, } from "./pagedRequestorTasksState.js"; +export { ResourceSubscriptionsState } from "./resourceSubscriptionsState.js"; +export type { ResourceSubscriptionsStateEventMap } from "./resourceSubscriptionsState.js"; diff --git a/core/mcp/state/resourceSubscriptionsState.ts b/core/mcp/state/resourceSubscriptionsState.ts new file mode 100644 index 000000000..cb402c7c3 --- /dev/null +++ b/core/mcp/state/resourceSubscriptionsState.ts @@ -0,0 +1,145 @@ +/** + * ResourceSubscriptionsState: tracks the live resource-subscription list the + * Resources screen renders. Subscribes to the InspectorClient's + * `resourceSubscriptionsChange` (URI list) and `resourceUpdated` events, + * resolves each URI against the optional `ManagedResourcesState` so subscriptions + * carry the server-supplied Resource (name/title), and stamps `lastUpdated` + * when a `notifications/resources/updated` arrives for a tracked URI. + * + * When no resource is found in the managed list (e.g. a template-expanded URI + * the user subscribed to before the resources list refreshed), a synthetic + * Resource `{ uri, name: uri }` is used — mirroring the fallback pattern in + * ResourcesScreen. + */ + +import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; +import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js"; +import type { + ManagedResourcesState, + ManagedResourcesStateEventMap, +} from "./managedResourcesState.js"; +import type { InspectorResourceSubscription } from "../types.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import { + TypedEventTarget, + type TypedEventGeneric, +} from "../typedEventTarget.js"; + +export interface ResourceSubscriptionsStateEventMap { + subscriptionsChange: InspectorResourceSubscription[]; +} + +/** + * State manager that mirrors `InspectorClient.subscribedResources` as a list of + * `InspectorResourceSubscription` objects keyed by URI, preserving each + * subscription's `lastUpdated` across re-derivations. + */ +export class ResourceSubscriptionsState extends TypedEventTarget { + private subscribedUris: string[] = []; + private lastUpdatedByUri: Map = new Map(); + private subscriptions: InspectorResourceSubscription[] = []; + private client: InspectorClientProtocol | null = null; + private resourcesState: ManagedResourcesState | null = null; + private unsubscribe: (() => void) | null = null; + + constructor( + client: InspectorClientProtocol, + resourcesState: ManagedResourcesState | null = null, + ) { + super(); + this.client = client; + this.resourcesState = resourcesState; + + const onSubscriptionsChange = ( + event: TypedEventGeneric< + InspectorClientEventMap, + "resourceSubscriptionsChange" + >, + ): void => { + this.subscribedUris = event.detail; + // Drop lastUpdated entries for URIs no longer subscribed + const active = new Set(event.detail); + for (const uri of this.lastUpdatedByUri.keys()) { + if (!active.has(uri)) this.lastUpdatedByUri.delete(uri); + } + this.rebuild(); + }; + + const onResourceUpdated = ( + event: TypedEventGeneric, + ): void => { + const { uri } = event.detail; + // Only stamp + emit if the URI is currently subscribed. The client + // already guards on subscribedResources before dispatching, but we + // re-check so out-of-order events can't resurrect a stale entry. + if (!this.subscribedUris.includes(uri)) return; + this.lastUpdatedByUri.set(uri, new Date()); + this.rebuild(); + }; + + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.subscribedUris = []; + this.lastUpdatedByUri.clear(); + this.subscriptions = []; + this.dispatchTypedEvent("subscriptionsChange", []); + } + }; + + const onResourcesChange = ( + _event: TypedEventGeneric< + ManagedResourcesStateEventMap, + "resourcesChange" + >, + ): void => { + // Re-resolve Resource references in case names/titles changed server-side. + if (this.subscribedUris.length > 0) this.rebuild(); + }; + + client.addEventListener( + "resourceSubscriptionsChange", + onSubscriptionsChange, + ); + client.addEventListener("resourceUpdated", onResourceUpdated); + client.addEventListener("statusChange", onStatusChange); + resourcesState?.addEventListener("resourcesChange", onResourcesChange); + + this.unsubscribe = () => { + this.client?.removeEventListener( + "resourceSubscriptionsChange", + onSubscriptionsChange, + ); + this.client?.removeEventListener("resourceUpdated", onResourceUpdated); + this.client?.removeEventListener("statusChange", onStatusChange); + this.resourcesState?.removeEventListener( + "resourcesChange", + onResourcesChange, + ); + this.client = null; + this.resourcesState = null; + }; + } + + getSubscriptions(): InspectorResourceSubscription[] { + return [...this.subscriptions]; + } + + private rebuild(): void { + const resources = this.resourcesState?.getResources() ?? []; + const byUri = new Map(resources.map((r) => [r.uri, r])); + this.subscriptions = this.subscribedUris.map((uri) => { + const resource: Resource = byUri.get(uri) ?? { uri, name: uri }; + const lastUpdated = this.lastUpdatedByUri.get(uri); + return lastUpdated ? { resource, lastUpdated } : { resource }; + }); + this.dispatchTypedEvent("subscriptionsChange", this.getSubscriptions()); + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.subscribedUris = []; + this.lastUpdatedByUri.clear(); + this.subscriptions = []; + } +} diff --git a/core/react/useResourceSubscriptions.ts b/core/react/useResourceSubscriptions.ts new file mode 100644 index 000000000..579ab5aa7 --- /dev/null +++ b/core/react/useResourceSubscriptions.ts @@ -0,0 +1,46 @@ +import { useState, useEffect } from "react"; +import type { + ResourceSubscriptionsState, + ResourceSubscriptionsStateEventMap, +} from "../mcp/state/resourceSubscriptionsState.js"; +import type { InspectorResourceSubscription } from "../mcp/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; + +export interface UseResourceSubscriptionsResult { + subscriptions: InspectorResourceSubscription[]; +} + +/** + * React hook that subscribes to ResourceSubscriptionsState and returns the + * current InspectorResourceSubscription[]. When the state is null (no active + * server), returns an empty array. + */ +export function useResourceSubscriptions( + state: ResourceSubscriptionsState | null, +): UseResourceSubscriptionsResult { + const [subscriptions, setSubscriptions] = useState< + InspectorResourceSubscription[] + >(state?.getSubscriptions() ?? []); + + useEffect(() => { + if (!state) { + setSubscriptions([]); + return; + } + setSubscriptions(state.getSubscriptions()); + const onSubscriptionsChange = ( + event: TypedEventGeneric< + ResourceSubscriptionsStateEventMap, + "subscriptionsChange" + >, + ) => { + setSubscriptions(event.detail); + }; + state.addEventListener("subscriptionsChange", onSubscriptionsChange); + return () => { + state.removeEventListener("subscriptionsChange", onSubscriptionsChange); + }; + }, [state]); + + return { subscriptions }; +} From 3e94585a1a22c425c2a09aa9a8be8fe1487bf3f0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 17:22:27 -0400 Subject: [PATCH 2/9] 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) --- core/mcp/state/resourceSubscriptionsState.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/core/mcp/state/resourceSubscriptionsState.ts b/core/mcp/state/resourceSubscriptionsState.ts index cb402c7c3..419ed86f3 100644 --- a/core/mcp/state/resourceSubscriptionsState.ts +++ b/core/mcp/state/resourceSubscriptionsState.ts @@ -9,7 +9,10 @@ * When no resource is found in the managed list (e.g. a template-expanded URI * the user subscribed to before the resources list refreshed), a synthetic * Resource `{ uri, name: uri }` is used — mirroring the fallback pattern in - * ResourcesScreen. + * ResourcesScreen. If the server later removes a previously-listed resource + * while the user is still subscribed, the tile regresses to that synthetic + * form: the managed list is the source of truth, so displaying a stale name + * for a server-removed resource is intentionally avoided. */ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; @@ -69,9 +72,10 @@ export class ResourceSubscriptionsState extends TypedEventTarget, ): void => { const { uri } = event.detail; - // Only stamp + emit if the URI is currently subscribed. The client - // already guards on subscribedResources before dispatching, but we - // re-check so out-of-order events can't resurrect a stale entry. + // Belt-and-braces: the client's dispatch site is already guarded by + // subscribedResources.has(uri), so this re-check should be redundant. + // It stays correct if a future change ever decouples dispatch from + // subscription state. if (!this.subscribedUris.includes(uri)) return; this.lastUpdatedByUri.set(uri, new Date()); this.rebuild(); @@ -82,7 +86,7 @@ export class ResourceSubscriptionsState extends TypedEventTarget Date: Tue, 19 May 2026 18:42:34 -0400 Subject: [PATCH 3/9] 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) --- clients/web/src/App.tsx | 18 ++++++- .../ResourceSubscribedItem.test.tsx | 48 +++++++++++++++---- .../ResourceSubscribedItem.tsx | 26 ++++++++-- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index b2b66b60d..112289ac9 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -562,6 +562,22 @@ function App() { /* TODO: not wired yet */ }, []); + // The Resources screen needs `isSubscribed` to flip the Subscribe button + // label to "Unsubscribe". Derive it from the live subscriptions list rather + // than threading it through every setReadResourceState site — that way the + // button reflects state changes from any source (preview panel, subscribed + // tile, or future server-initiated subscribe notifications). + const effectiveReadResourceState = useMemo< + ReadResourceState | undefined + >(() => { + if (!readResourceState) return undefined; + if (!readResourceState.uri) return readResourceState; + const isSubscribed = subscriptions.some( + (s) => s.resource.uri === readResourceState.uri, + ); + return { ...readResourceState, isSubscribed }; + }, [readResourceState, subscriptions]); + return ( { - it("renders the resource name", () => { + it("renders the last URI path segment, not the name or title", () => { renderWithMantine( {}} />, ); - expect(screen.getByText("x")).toBeInTheDocument(); + expect(screen.getByText("config.json")).toBeInTheDocument(); + expect(screen.queryByText("ignored-name")).not.toBeInTheDocument(); + expect(screen.queryByText("Ignored Title")).not.toBeInTheDocument(); }); - it("prefers the resource title over the name", () => { + it("falls back to the URI itself when it has no slash-separated segments", () => { renderWithMantine( {}} + />, + ); + expect(screen.getByText("opaque")).toBeInTheDocument(); + }); + + it("ignores trailing slashes when picking the last segment", () => { + renderWithMantine( + {}} />, ); - expect(screen.getByText("Display X")).toBeInTheDocument(); + expect(screen.getByText("bar")).toBeInTheDocument(); + }); + + it("shows the full URI in a tooltip on hover", async () => { + const user = userEvent.setup(); + renderWithMantine( + {}} + />, + ); + 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", () => { diff --git a/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx b/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx index 84b4d0c8f..1b96579fb 100644 --- a/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx +++ b/clients/web/src/components/groups/ResourceSubscribedItem/ResourceSubscribedItem.tsx @@ -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 ( - - {resource.title ?? resource.name} + + + {lastUriSegment(resource.uri)} + {lastUpdated && ( {formatLastUpdated(lastUpdated)} )} - + Unsubscribe ); From 13b03dd6b5d53ff2fd9ec6016ff171436c9ea822 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 18:56:39 -0400 Subject: [PATCH 4/9] Auto-read resource on sidebar click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../screens/ResourcesScreen/ResourcesScreen.test.tsx | 9 ++++++--- .../screens/ResourcesScreen/ResourcesScreen.tsx | 7 ++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx index ddae8039e..b4686eb87 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx @@ -114,11 +114,14 @@ describe("ResourcesScreen", () => { ).toBeInTheDocument(); }); - it("renders empty state when a resource is selected but no readState", async () => { + it("auto-reads when a resource is clicked in the sidebar", async () => { const user = userEvent.setup(); - renderWithMantine(); + const onReadResource = vi.fn(); + renderWithMantine( + , + ); 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 () => { diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 08860cff6..71cc10e94 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -107,6 +107,7 @@ export function ResourcesScreen({ function handleSelectResource(uri: string) { setSelectedTemplateUri(undefined); setSelectedResourceUri(uri); + onReadResource(uri); } function handleSelectTemplate(uriTemplate: string) { @@ -201,11 +202,7 @@ export function ResourcesScreen({ ) : selectedResource ? ( - {renderReadState() ?? ( - - Click to read this resource - - )} + {renderReadState()} ) : ( From ee4ea54213f7632dfdb702223ebb4cb052e22d05 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 19:10:39 -0400 Subject: [PATCH 5/9] 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) --- clients/web/package-lock.json | 1571 ++++++++++++++++- clients/web/package.json | 2 + clients/web/src/App.css | 35 + .../ContentViewer/ContentViewer.test.tsx | 38 + .../elements/ContentViewer/ContentViewer.tsx | 46 +- .../ResourcePreviewPanel.test.tsx | 55 + .../ResourcePreviewPanel.tsx | 34 +- .../ResourcesScreen/ResourcesScreen.tsx | 11 +- 8 files changed, 1726 insertions(+), 66 deletions(-) diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 498b20391..f3903443c 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -24,6 +24,8 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "zod": "^4.3.6", "zustand": "^5.0.13" }, @@ -2245,6 +2247,15 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2263,9 +2274,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -2291,6 +2310,15 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2305,6 +2333,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -2312,6 +2349,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -2346,7 +2389,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2390,6 +2432,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -2702,6 +2750,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -3188,6 +3242,16 @@ "npm": ">=6" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3360,6 +3424,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -3394,6 +3468,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3457,6 +3571,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3597,6 +3721,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3667,7 +3804,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3689,6 +3825,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4121,6 +4270,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -4239,6 +4398,12 @@ "express": ">= 4.11" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4634,6 +4799,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4676,6 +4881,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4764,6 +4979,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -4782,6 +5003,30 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4803,6 +5048,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -4841,6 +5096,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -4859,6 +5124,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5358,6 +5635,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5449,6 +5736,16 @@ "node": ">=10" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5458,91 +5755,936 @@ "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/unified" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -5747,6 +6889,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6104,6 +7271,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6240,6 +7417,33 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-number-format": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", @@ -6415,6 +7619,72 @@ "node": ">=8" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6747,6 +8017,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -6841,6 +8121,20 @@ "node": ">=10" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -6921,6 +8215,24 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -7047,6 +8359,26 @@ "node": ">=6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7175,6 +8507,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7358,6 +8777,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", @@ -7750,6 +9197,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/clients/web/package.json b/clients/web/package.json index 1501f1604..401d4f434 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -39,6 +39,8 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "zod": "^4.3.6", "zustand": "^5.0.13" }, diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 0c064dd19..703644ef3 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -147,3 +147,38 @@ .grid-align-start { align-items: start; } + +/* ── Markdown content (third-party HTML from react-markdown) ───── */ + +.markdown-content { + max-width: 100%; + overflow-wrap: anywhere; +} + +.markdown-content > :first-child { + margin-top: 0; +} + +.markdown-content > :last-child { + margin-bottom: 0; +} + +.markdown-content pre { + max-width: 100%; + overflow-x: auto; +} + +.markdown-content code { + word-break: break-word; +} + +.markdown-content table { + display: block; + max-width: 100%; + overflow-x: auto; +} + +.markdown-content img { + max-width: 100%; + height: auto; +} diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx index b0e356f88..9e5a26e49 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx @@ -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(); + 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( + , + ); + 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(); + expect(screen.getByText("# not markdown")).toBeInTheDocument(); + // No

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( + , + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx index a5710e32b..00d4a68c8 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx @@ -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 ( + + + + + {block.text} + + + {copyable && ( + + + + )} + + + ); + } const isJson = isJsonText(block); const displayText = isJson ? formatJson(block.text) : block.text; return ( - + {displayText} {copyable && ( diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx index c36666062..d6db9ae6c 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx @@ -199,4 +199,59 @@ describe("ResourcePreviewPanel", () => { ); expect(screen.getByText("text/markdown")).toBeInTheDocument(); }); + + it("renders text/markdown content as markdown", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("heading", { level: 1, name: "Hello" }), + ).toBeInTheDocument(); + }); + + it("infers markdown from a .md URI when mimeType is missing", () => { + renderWithMantine( + , + ); + 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( + , + ); + expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument(); + expect(screen.getByText("# not a heading")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx index 8a2a0b975..9109485c9 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx @@ -88,6 +88,30 @@ const ActionGroup = Group.withProps({ const Spacer = Flex.withProps({}); +// 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,8 +122,7 @@ 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 ( @@ -111,7 +134,12 @@ export function ResourcePreviewPanel({ {contents.map((item, index) => ( - + ))} {lastUpdated ? ( diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 71cc10e94..a6c1beada 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -183,8 +183,8 @@ export function ResourcesScreen({ {selectedTemplate ? ( - - + + - + {renderReadState() ?? ( Enter a URI and click Read to preview @@ -201,7 +201,10 @@ export function ResourcesScreen({ ) : selectedResource ? ( - + // miw=0 lets the flex item shrink below its content's intrinsic + // width; without it a single long unwrappable line in the resource + // body would push the panel past the viewport's right edge. + {renderReadState()} ) : ( From a77027b07de0040d941094c80adeb932c23dc27b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 19:18:08 -0400 Subject: [PATCH 6/9] 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) --- .../ResourcePreviewPanel.tsx | 59 +++++++++++++++---- .../ResourcesScreen/ResourcesScreen.tsx | 59 +++++++++++++------ 2 files changed, 90 insertions(+), 28 deletions(-) diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx index 9109485c9..c36630ac9 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx @@ -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, @@ -88,6 +96,31 @@ const ActionGroup = Group.withProps({ const Spacer = Flex.withProps({}); +// Outer container fills the parent (a fixed-height Card in the resource +// branch of ResourcesScreen) so the header/footer can pin to its edges +// while the content area scrolls. +const PanelStack = Stack.withProps({ + gap: "md", + h: "100%", + flex: 1, + miw: 0, +}); + +// The middle scroll region. flex=1 lets it absorb the height left over +// after the header / meta / footer rows; miw=0 prevents wide markdown +// (tables, long links) from pushing the panel past the viewport. +const ContentScroll = ScrollArea.withProps({ + flex: 1, + miw: 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. @@ -125,7 +158,7 @@ export function ResourcePreviewPanel({ const mimeType = effectiveMime(contents[0]?.mimeType, resource); return ( - + Resource @@ -133,14 +166,18 @@ export function ResourcePreviewPanel({ - {contents.map((item, index) => ( - - ))} + + + {contents.map((item, index) => ( + + ))} + + {lastUpdated ? ( {formatLastUpdated(lastUpdated)} @@ -168,6 +205,6 @@ export function ResourcePreviewPanel({ /> - + ); } diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index a6c1beada..c0a8a7037 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -62,6 +62,24 @@ const DetailCard = Card.withProps({ padding: "lg", }); +// Same as DetailCard but stretched to fill its parent's height. Used in +// the preview pane so the ResourcePreviewPanel can pin its header/footer +// to the card's edges while the content scrolls in the middle. +const FillDetailCard = Card.withProps({ + withBorder: true, + padding: "lg", + h: "100%", +}); + +// Fixed-height column that hosts the FillDetailCard. Replaces the prior +// ScrollArea.Autosize wrapping so the panel's internal scroll region — +// not the whole card — handles overflow. +const PreviewPane = Flex.withProps({ + flex: 1, + miw: 0, + direction: "column", +}); + const EmptyState = Text.withProps({ c: "dimmed", ta: "center", @@ -125,28 +143,28 @@ export function ResourcesScreen({ if (readState.status === "pending") { return ( - + Reading resource... - + ); } if (readState.status === "error") { return ( - + {readState.error ?? "Failed to read resource"} - + ); } if (readState.result && readResource) { return ( - + onSubscribeResource(readResource.uri)} onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} /> - + ); } @@ -183,7 +201,14 @@ export function ResourcesScreen({ {selectedTemplate ? ( - + - + {renderReadState() ?? ( - + Enter a URI and click Read to preview - + )} - + ) : selectedResource ? ( - // miw=0 lets the flex item shrink below its content's intrinsic - // width; without it a single long unwrappable line in the resource - // body would push the panel past the viewport's right edge. - - {renderReadState()} - + // Fixed-height column lets the preview panel pin its header and + // subscribe/refresh footer to the card's edges while the resource + // body scrolls inside the panel. miw=0 prevents wide content + // (long unbroken lines, tables) from pushing the pane past the + // viewport's right edge. + {renderReadState()} ) : ( Select a resource to preview From adb5f29ba72cdda22ce7b76ef75359267973632e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 19:32:48 -0400 Subject: [PATCH 7/9] Auto-hide template after read; size preview to content with cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../ResourcePreviewPanel.tsx | 24 +++-- .../ResourcesScreen/ResourcesScreen.test.tsx | 19 +++- .../ResourcesScreen/ResourcesScreen.tsx | 95 ++++++++----------- clients/web/src/theme/Card.ts | 13 +++ 4 files changed, 87 insertions(+), 64 deletions(-) diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx index c36630ac9..d92cdf7f6 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx @@ -54,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({ @@ -70,6 +71,7 @@ const UriText = Text.withProps({ const MetaRow = Group.withProps({ justify: "space-between", wrap: "nowrap", + flex: "0 0 auto", }); const TimestampText = Text.withProps({ @@ -84,6 +86,7 @@ const MimeText = Text.withProps({ const FooterRow = Group.withProps({ justify: "space-between", + flex: "0 0 auto", }); const AnnotationGroup = Group.withProps({ @@ -96,22 +99,25 @@ const ActionGroup = Group.withProps({ const Spacer = Flex.withProps({}); -// Outer container fills the parent (a fixed-height Card in the resource -// branch of ResourcesScreen) so the header/footer can pin to its edges -// while the content area scrolls. +// 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", - h: "100%", - flex: 1, miw: 0, + mih: 0, }); -// The middle scroll region. flex=1 lets it absorb the height left over -// after the header / meta / footer rows; miw=0 prevents wide markdown -// (tables, long links) from pushing the panel past the viewport. +// 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: 1, + flex: "0 1 auto", miw: 0, + mih: 0, type: "auto", scrollbars: "y", offsetScrollbars: true, diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx index b4686eb87..8991754a8 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx @@ -110,10 +110,27 @@ 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("hides the template panel once the user reads the resource", async () => { + const user = userEvent.setup(); + const onReadResource = vi.fn(); + renderWithMantine( + , + ); + 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(); diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index c0a8a7037..1d397dbbc 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -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, @@ -62,22 +53,24 @@ const DetailCard = Card.withProps({ padding: "lg", }); -// Same as DetailCard but stretched to fill its parent's height. Used in -// the preview pane so the ResourcePreviewPanel can pin its header/footer -// to the card's edges while the content scrolls in the middle. -const FillDetailCard = Card.withProps({ +// 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", - h: "100%", + variant: "preview", }); -// Fixed-height column that hosts the FillDetailCard. Replaces the prior -// ScrollArea.Autosize wrapping so the panel's internal scroll region — -// not the whole card — handles overflow. +// 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({ @@ -134,6 +127,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); } @@ -143,28 +141,28 @@ export function ResourcesScreen({ if (readState.status === "pending") { return ( - + Reading resource... - + ); } if (readState.status === "error") { return ( - + {readState.error ?? "Failed to read resource"} - + ); } if (readState.result && readResource) { return ( - + onSubscribeResource(readResource.uri)} onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} /> - + ); } @@ -201,36 +199,25 @@ export function ResourcesScreen({ {selectedTemplate ? ( - - - - - - - - {renderReadState() ?? ( - - Enter a URI and click Read to preview - - )} - - - ) : selectedResource ? ( - // Fixed-height column lets the preview panel pin its header and - // subscribe/refresh footer to the card's edges while the resource - // body scrolls inside the panel. miw=0 prevents wide content - // (long unbroken lines, tables) from pushing the pane past the - // viewport's right edge. + // 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. + + + + + + ) : 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. {renderReadState()} ) : ( diff --git a/clients/web/src/theme/Card.ts b/clients/web/src/theme/Card.ts index 14d75f038..e00765c5f 100644 --- a/clients/web/src/theme/Card.ts +++ b/clients/web/src/theme/Card.ts @@ -20,6 +20,19 @@ export const ThemeCard = Card.extend({ }, }; } + if (props.variant === "preview") { + // Container for the resource preview / template form panels: sizes to + // content (no forced height) but caps at the screen's available area + // via consumer-set `mah`. `overflow: hidden` lets a flex-shrunk inner + // ScrollArea take over scrolling when content exceeds the cap, instead + // of the whole card bleeding past the viewport. + return { + root: { + backgroundColor: "var(--inspector-surface-card)", + overflow: "hidden", + }, + }; + } return { root: { backgroundColor: "var(--inspector-surface-card)" }, }; From ed854943137b2cdcc11ae42ddf4ec3cc2e3000d7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 19 May 2026 19:37:58 -0400 Subject: [PATCH 8/9] Cap template form panel at 40% width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/screens/ResourcesScreen/ResourcesScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 1d397dbbc..a83fa3774 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -202,7 +202,10 @@ export function ResourcesScreen({ // 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. + Date: Tue, 19 May 2026 19:58:07 -0400 Subject: [PATCH 9/9] Live completion/complete suggestions on resource template inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- clients/web/src/App.tsx | 23 +++ .../ResourceTemplatePanel.test.tsx | 91 +++++++++++ .../ResourceTemplatePanel.tsx | 143 ++++++++++++++++-- .../ResourcesScreen/ResourcesScreen.tsx | 26 ++++ .../views/InspectorView/InspectorView.tsx | 13 ++ core/mcp/__tests__/fakeInspectorClient.ts | 14 ++ core/mcp/inspectorClientProtocol.ts | 11 ++ 7 files changed, 307 insertions(+), 14 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 112289ac9..8b9784d92 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -508,6 +508,27 @@ function App() { [inspectorClient], ); + const onCompleteArgument = useCallback( + async ( + ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + argumentName: string, + argumentValue: string, + context: Record, + ): Promise => { + if (!inspectorClient) return []; + const result = await inspectorClient.getCompletions( + ref, + argumentName, + argumentValue, + context, + ); + return result.values; + }, + [inspectorClient], + ); + const onCancelTask = useCallback( (taskId: string) => { if (!inspectorClient) return; @@ -630,6 +651,8 @@ function App() { onSubscribeResource={onSubscribeResource} onUnsubscribeResource={onUnsubscribeResource} onRefreshResources={onRefreshResources} + onCompleteArgument={onCompleteArgument} + completionsSupported={capabilities?.completions !== undefined} onCancelTask={onCancelTask} onClearCompletedTasks={todoNoop} onRefreshTasks={onRefreshTasks} diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index 3e1871162..c9a7ef842 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -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, + ) => Promise + >() + .mockResolvedValue(["alpha", "alphabet"]); + + renderWithMantine( + , + ); + + 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, + ) => Promise + >() + .mockResolvedValue([]); + + renderWithMantine( + , + ); + + 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( + , + ); + await user.type(screen.getByLabelText("userId"), "ab"); + await new Promise((r) => setTimeout(r, 400)); + expect(onCompleteArgument).not.toHaveBeenCalled(); + }); + }); }); diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index b7e76e300..1368fa9bc 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -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, + ) => Promise; + /** + * 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>(() => Object.fromEntries(variableNames.map((n) => [n, ""])), ); + const [completions, setCompletions] = useState>({}); + + // 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>(new Map()); + // Debounce timer per argument so we don't spam the server on every key. + const timersRef = useRef>>( + 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) => { + 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 = { ...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({ {description && {description}} - {variableNames.map((varName) => ( - - handleVariableChange(varName, e.currentTarget.value) - } - /> - ))} + {variableNames.map((varName) => + useAutocomplete ? ( + options} + onChange={(value) => handleVariableChange(varName, value)} + /> + ) : ( + + handleVariableChange(varName, e.currentTarget.value) + } + /> + ), + )} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index a83fa3774..b22806e83 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -25,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, + ) => Promise; } const ScreenLayout = Flex.withProps({ @@ -88,10 +97,12 @@ export function ResourcesScreen({ subscriptions, readState, listChanged, + completionsSupported, onRefreshList, onReadResource, onSubscribeResource, onUnsubscribeResource, + onCompleteArgument, }: ResourcesScreenProps) { const [selectedResourceUri, setSelectedResourceUri] = useState< string | undefined @@ -210,6 +221,21 @@ export function ResourcesScreen({ + onCompleteArgument( + { + type: "ref/resource", + uri: selectedTemplate.uriTemplate, + }, + argName, + value, + context, + ) + : undefined + } /> diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index c8e940512..ef13e1416 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -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, + ) => Promise; + 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} /> diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 9881a1d06..c0ccd1859 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -120,6 +120,20 @@ export class FakeInspectorClient setLoggingLevel = vi.fn(async (_level: LoggingLevel) => {}); + getCompletions = vi.fn( + async ( + _ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + _argumentName: string, + _argumentValue: string, + _context?: Record, + _metadata?: Record, + ): Promise<{ values: string[]; total?: number; hasMore?: boolean }> => ({ + values: [], + }), + ); + constructor(options: FakeInspectorClientOptions = {}) { super(); this.status = options.status ?? "disconnected"; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 1a64f6a15..612010d14 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -102,4 +102,15 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { // Misc surface required by hooks/state setLoggingLevel(level: LoggingLevel): Promise; getSessionId(): string | undefined; + + // Completions (resource templates / prompt arguments) + getCompletions( + ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + argumentName: string, + argumentValue: string, + context?: Record, + metadata?: Record, + ): Promise<{ values: string[]; total?: number; hasMore?: boolean }>; }