Fork resource subscriptions by protocol era: legacy keeps resources/subscribe; modern (2026-07-28) models subscriptions as a filter over one long-lived subscriptions/listen stream with reconnect-by-re-listen (capped backoff), an optimistic Connecting→Listening header badge, and a fix so the browser proxy no longer blocks listen() ~60s. Closes #1630.
This commit is contained in:
parent
a71e6d2012
commit
320daf2f67
4 changed files with 1656 additions and 90 deletions
|
|
@ -0,0 +1,26 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
|
||||
|
||||
const meta: Meta<typeof SubscriptionStreamBadge> = {
|
||||
title: "Elements/SubscriptionStreamBadge",
|
||||
component: SubscriptionStreamBadge,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SubscriptionStreamBadge>;
|
||||
|
||||
export const Connecting: Story = {
|
||||
args: { status: "connecting" },
|
||||
};
|
||||
|
||||
export const Acknowledged: Story = {
|
||||
args: { status: "acknowledged" },
|
||||
};
|
||||
|
||||
export const Reconnecting: Story = {
|
||||
args: { status: "reconnecting" },
|
||||
};
|
||||
|
||||
export const Ended: Story = {
|
||||
args: { status: "ended" },
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { SubscriptionStreamBadge } from "./SubscriptionStreamBadge";
|
||||
import { subscriptionStreamPresentation } from "./subscriptionStreamUtils";
|
||||
|
||||
describe("subscriptionStreamPresentation", () => {
|
||||
it("maps each status to a color and label", () => {
|
||||
expect(subscriptionStreamPresentation("connecting")).toMatchObject({
|
||||
color: "blue",
|
||||
label: "Connecting…",
|
||||
});
|
||||
expect(subscriptionStreamPresentation("acknowledged")).toMatchObject({
|
||||
color: "green",
|
||||
label: "Listening",
|
||||
});
|
||||
expect(subscriptionStreamPresentation("reconnecting")).toMatchObject({
|
||||
color: "yellow",
|
||||
label: "Reconnecting…",
|
||||
});
|
||||
expect(subscriptionStreamPresentation("ended")).toMatchObject({
|
||||
color: "gray",
|
||||
label: "Stream ended",
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the listen stream in every tooltip", () => {
|
||||
for (const status of [
|
||||
"connecting",
|
||||
"acknowledged",
|
||||
"reconnecting",
|
||||
"ended",
|
||||
] as const) {
|
||||
expect(subscriptionStreamPresentation(status).tooltip).toContain(
|
||||
"subscriptions/listen stream",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubscriptionStreamBadge", () => {
|
||||
it("renders a labelled badge by default", () => {
|
||||
renderWithMantine(<SubscriptionStreamBadge status="acknowledged" />);
|
||||
expect(screen.getByText("Listening")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a labelled reconnecting badge", () => {
|
||||
renderWithMantine(<SubscriptionStreamBadge status="reconnecting" />);
|
||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a labelled ended badge", () => {
|
||||
renderWithMantine(<SubscriptionStreamBadge status="ended" />);
|
||||
expect(screen.getByText("Stream ended")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
|
||||
import { subscriptionStreamPresentation } from "./subscriptionStreamUtils";
|
||||
|
||||
export interface SubscriptionStreamBadgeProps {
|
||||
/** Lifecycle status of the modern `subscriptions/listen` stream. */
|
||||
status: ResourceSubscriptionStreamStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator for the modern-era resource-subscription listen stream
|
||||
* (#1630). Only meaningful on the modern era — the caller gates rendering on
|
||||
* `streamState.active`. Renders a labelled dot badge (green/yellow/gray) in the
|
||||
* Subscriptions section header, explaining the stream in a tooltip.
|
||||
*/
|
||||
export function SubscriptionStreamBadge({
|
||||
status,
|
||||
}: SubscriptionStreamBadgeProps) {
|
||||
const { color, label, tooltip } = subscriptionStreamPresentation(status);
|
||||
return (
|
||||
<Tooltip label={tooltip} multiline w={260} withArrow>
|
||||
<Badge variant="dot" color={color}>
|
||||
{label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { ResourceSubscriptionStreamStatus } from "../../../../../../core/mcp/types.js";
|
||||
|
||||
export interface StreamPresentation {
|
||||
/** Mantine palette color name conveying the status. */
|
||||
color: string;
|
||||
/** Short label shown on the panel badge. */
|
||||
label: string;
|
||||
/** Full explanation shown in the tooltip (both variants). */
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const STREAM_INTRO =
|
||||
"On modern (2026-07-28) servers, resource subscriptions are a filter over one long-lived subscriptions/listen stream.";
|
||||
|
||||
// Keyed by status so it's exhaustive at compile time: a new
|
||||
// `ResourceSubscriptionStreamStatus` that isn't handled here is a type error
|
||||
// (no unreachable `default` needed, so it stays fully covered).
|
||||
const PRESENTATION: Record<
|
||||
ResourceSubscriptionStreamStatus,
|
||||
StreamPresentation
|
||||
> = {
|
||||
connecting: {
|
||||
color: "blue",
|
||||
label: "Connecting…",
|
||||
tooltip: `${STREAM_INTRO} Opening the stream — waiting for the server to acknowledge the subscription.`,
|
||||
},
|
||||
acknowledged: {
|
||||
color: "green",
|
||||
label: "Listening",
|
||||
tooltip: `${STREAM_INTRO} The server acknowledged the subscription and the stream is open, carrying resources/updated notifications.`,
|
||||
},
|
||||
reconnecting: {
|
||||
color: "yellow",
|
||||
label: "Reconnecting…",
|
||||
tooltip: `${STREAM_INTRO} The stream dropped unexpectedly; re-listening to re-establish it (there is no resumability, so the full filter is re-sent).`,
|
||||
},
|
||||
ended: {
|
||||
color: "gray",
|
||||
label: "Stream ended",
|
||||
tooltip: `${STREAM_INTRO} The stream is closed and won't reconnect on its own — either the server ended it (for example, on shutdown) or reconnection was abandoned after repeated failures. Re-subscribe to try again.`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a modern listen-stream status to its badge color, label, and tooltip
|
||||
* copy (#1630). Kept in its own module so the badge component file exports only
|
||||
* a component (react-refresh rule).
|
||||
*/
|
||||
export function subscriptionStreamPresentation(
|
||||
status: ResourceSubscriptionStreamStatus,
|
||||
): StreamPresentation {
|
||||
return PRESENTATION[status];
|
||||
}
|
||||
Loading…
Reference in a new issue