fix(web): hide resource subscription UI when server lacks subscribe capability (#1479)
* fix(web): hide resource subscription UI when server lacks subscribe capability (#1478) Thread a `subscriptionsSupported` flag derived from `capabilities?.resources?.subscribe === true` from App through InspectorView → ResourcesScreen to the leaf components. When false: - ResourcePreviewPanel hides the Subscribe/Unsubscribe button (Refresh stays). - ResourceControls omits the Subscriptions accordion section entirely, dropping it from `allSections` so the expand/collapse-all toggle and open-section bookkeeping stay correct even if a stale "subscriptions" entry persists from a prior subscription-capable session. The flag defaults to true at the leaf components so existing callers, tests, and stories keep rendering the subscription controls unless a server is explicitly marked unsupported. Adds unit tests and Storybook stories covering the hidden and shown states across all three components. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): give the Servers screen scroll the same gutter/idle-hidden treatment (#1474) The server grid's ScrollArea.Autosize used the default type="hover" with no offsetScrollbars, so the scrollbar popped in on hover and overlaid the right edge of the server cards (occluding their action icons / status badges) — the same problem #1474 fixed for the History/Network/Logging list panels. Apply the same `type="scroll"` + `offsetScrollbars` treatment, and reuse the shared `expectScrollbarGutterIdleHidden` story assertion to regression-protect the gutter + idle-hidden behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): stop the InspectorView shell from scrolling as a whole AppShell.Main carried Mantine's default `padding="md"` on top of the fixed-header offset, but every screen already sizes itself to `calc(100vh - header)` and supplies its own `xl` padding (with no allowance for Main's padding). That extra inset pushed screen content past the viewport, so the whole InspectorView gained a page-level scrollbar instead of confining overflow to each screen's inner ScrollArea. Set the shell `padding` to 0 (Main now contributes only the header offset, matching what the screens were built for) and clamp the Main slot to `height: 100dvh` with `overflow: hidden` in the theme, so the view can never scroll regardless of content — only the inner ScrollArea regions do. Adds an InspectorView story play assertion guarding the invariant (Main clips + is viewport-clamped) and a ManyServers fixture that confirms it holds under enough content to overflow, with the inner server-grid ScrollArea absorbing the overflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): address review — align viewport units + drop stale subscriptions intent Responding to the @claude review on #1479: - Align all screen height calcs from `100vh` to `100dvh` so they match the Main slot's `100dvh` clamp. On a mobile viewport where `dvh < vh` a `100vh` child would have exceeded Main's `100dvh` content box and been clipped by `overflow: hidden`; the units are now in lockstep. - Restrict `intendedButEmptySections` in ResourceControls to `allSections` so a stale "subscriptions" entry persisted from a prior subscription-capable session is dropped from persisted open-sections instead of being perpetually re-appended once the section is no longer rendered. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4157063e6
commit
ef921ba052
22 changed files with 346 additions and 47 deletions
|
|
@ -2316,6 +2316,7 @@ function App() {
|
||||||
onRefreshResources={onRefreshResources}
|
onRefreshResources={onRefreshResources}
|
||||||
onCompleteArgument={onCompleteArgument}
|
onCompleteArgument={onCompleteArgument}
|
||||||
completionsSupported={capabilities?.completions !== undefined}
|
completionsSupported={capabilities?.completions !== undefined}
|
||||||
|
subscriptionsSupported={capabilities?.resources?.subscribe === true}
|
||||||
onTasksUiChange={setTasksUi}
|
onTasksUiChange={setTasksUi}
|
||||||
onCancelTask={(taskId) => {
|
onCancelTask={(taskId) => {
|
||||||
void onCancelTask(taskId);
|
void onCancelTask(taskId);
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,25 @@ export const EmptySectionCollapsed: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Server does not advertise resources.subscribe: the Subscriptions section is
|
||||||
|
// omitted entirely, leaving only URIs and Templates (#1478).
|
||||||
|
export const SubscriptionsUnsupported: Story = {
|
||||||
|
args: {
|
||||||
|
resources: sampleResources,
|
||||||
|
templates: sampleTemplates,
|
||||||
|
subscriptions: sampleSubscriptions,
|
||||||
|
subscriptionsSupported: false,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
|
||||||
|
// Only the two remaining sections render, both open → both chevrons down.
|
||||||
|
const transforms = chevronTransforms(canvasElement);
|
||||||
|
expect(transforms).toHaveLength(2);
|
||||||
|
for (const t of transforms) expect(t).toBe(ROTATED_DOWN);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Many URIs with sparse Templates/Subscriptions: under the old equal `/ n`
|
// Many URIs with sparse Templates/Subscriptions: under the old equal `/ n`
|
||||||
// height split, URIs scrolled while the others left their share unused. Now the
|
// height split, URIs scrolled while the others left their share unused. Now the
|
||||||
// sections size to content inside one bounded scroll region.
|
// sections size to content inside one bounded scroll region.
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,58 @@ describe("ResourceControls", () => {
|
||||||
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain("templates");
|
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain("templates");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the Subscriptions section when subscriptionsSupported is false", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceControls {...baseProps} subscriptionsSupported={false} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("URIs (2)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Templates (1)")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Subscriptions/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the Subscriptions section by default (subscriptionsSupported omitted)", () => {
|
||||||
|
renderWithMantine(<ResourceControls {...baseProps} />);
|
||||||
|
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 'Collapse all' with subscriptions hidden when the two visible sections are open", () => {
|
||||||
|
// allSections drops "subscriptions", so the remaining two open sections
|
||||||
|
// must still count as fully expanded — even if persisted openSections
|
||||||
|
// still carries a stale "subscriptions" entry.
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceControls
|
||||||
|
{...baseProps}
|
||||||
|
subscriptionsSupported={false}
|
||||||
|
compact={false}
|
||||||
|
openSections={["resources", "templates", "subscriptions"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse all" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a stale 'subscriptions' entry from persisted state when subscriptions are unsupported", async () => {
|
||||||
|
// A "subscriptions" value persisted from a prior subscription-capable
|
||||||
|
// session must not be perpetually re-appended once the section is no longer
|
||||||
|
// rendered — toggling a visible section should emit it out of the open set.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOpenSectionsChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourceControls
|
||||||
|
{...baseProps}
|
||||||
|
subscriptionsSupported={false}
|
||||||
|
openSections={["resources", "templates", "subscriptions"]}
|
||||||
|
onOpenSectionsChange={onOpenSectionsChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Templates \(1\)/ }));
|
||||||
|
expect(onOpenSectionsChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain(
|
||||||
|
"subscriptions",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters by resource title when title is set", async () => {
|
it("filters by resource title when title is set", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const resourcesWithTitle: Resource[] = [
|
const resourcesWithTitle: Resource[] = [
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ export interface ResourceControlsProps {
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
templates: ResourceTemplate[];
|
templates: ResourceTemplate[];
|
||||||
subscriptions: InspectorResourceSubscription[];
|
subscriptions: InspectorResourceSubscription[];
|
||||||
|
/**
|
||||||
|
* Whether the connected server advertises the `resources.subscribe`
|
||||||
|
* capability. When false, the Subscriptions accordion section is hidden
|
||||||
|
* entirely. Defaults to true so the section renders unless a caller
|
||||||
|
* explicitly marks subscriptions unsupported.
|
||||||
|
*/
|
||||||
|
subscriptionsSupported?: boolean;
|
||||||
selectedUri?: string;
|
selectedUri?: string;
|
||||||
selectedTemplateUri?: string;
|
selectedTemplateUri?: string;
|
||||||
// Search text + accordion open-sections are controlled by the parent (App,
|
// Search text + accordion open-sections are controlled by the parent (App,
|
||||||
|
|
@ -63,6 +70,7 @@ export function ResourceControls({
|
||||||
resources,
|
resources,
|
||||||
templates,
|
templates,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
|
subscriptionsSupported = true,
|
||||||
selectedUri,
|
selectedUri,
|
||||||
selectedTemplateUri,
|
selectedTemplateUri,
|
||||||
searchText = "",
|
searchText = "",
|
||||||
|
|
@ -97,15 +105,25 @@ export function ResourceControls({
|
||||||
s.resource.uri.toLowerCase().includes(query),
|
s.resource.uri.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allSections = ["resources", "templates", "subscriptions"];
|
// Subscriptions are only meaningful when the server advertises the
|
||||||
|
// `resources.subscribe` capability; otherwise the section is omitted
|
||||||
|
// entirely (no header, no panel) — see #1478.
|
||||||
|
const allSections = subscriptionsSupported
|
||||||
|
? ["resources", "templates", "subscriptions"]
|
||||||
|
: ["resources", "templates"];
|
||||||
// Open-sections is parent-controlled (persists across navigation). When the
|
// Open-sections is parent-controlled (persists across navigation). When the
|
||||||
// parent hasn't set it yet (undefined), fall back to the persisted `compact`
|
// parent hasn't set it yet (undefined), fall back to the persisted `compact`
|
||||||
// preference: empty when last left compact, all three open when expanded.
|
// preference: empty when last left compact, all sections open when expanded.
|
||||||
// Per-section accordion clicks update the lifted value but don't change the
|
// Per-section accordion clicks update the lifted value but don't change the
|
||||||
// persisted preference.
|
// persisted preference.
|
||||||
const openSections =
|
const openSections =
|
||||||
controlledOpenSections ?? (initialCompact ? [] : [...allSections]);
|
controlledOpenSections ?? (initialCompact ? [] : [...allSections]);
|
||||||
const allExpanded = openSections.length === allSections.length;
|
// Persisted open-sections may still carry "subscriptions" from a prior
|
||||||
|
// subscription-capable session, so compare only the sections we actually
|
||||||
|
// render when deciding whether everything is expanded.
|
||||||
|
const allExpanded =
|
||||||
|
openSections.filter((section) => allSections.includes(section)).length ===
|
||||||
|
allSections.length;
|
||||||
|
|
||||||
// Empty sections have a disabled control and nothing to show, so keep them
|
// Empty sections have a disabled control and nothing to show, so keep them
|
||||||
// out of the accordion's open set — they render collapsed (chevron points
|
// out of the accordion's open set — they render collapsed (chevron points
|
||||||
|
|
@ -118,15 +136,20 @@ export function ResourceControls({
|
||||||
subscriptions: filteredSubscriptions.length,
|
subscriptions: filteredSubscriptions.length,
|
||||||
};
|
};
|
||||||
const visibleOpenSections = openSections.filter(
|
const visibleOpenSections = openSections.filter(
|
||||||
(section) => (sectionItemCounts[section] ?? 0) > 0,
|
(section) =>
|
||||||
|
allSections.includes(section) && (sectionItemCounts[section] ?? 0) > 0,
|
||||||
);
|
);
|
||||||
// Open-in-intent but currently empty (so excluded from the accordion's
|
// Open-in-intent but currently empty (so excluded from the accordion's
|
||||||
// `value`). Mantine derives the next open-array by toggling the clicked
|
// `value`). Mantine derives the next open-array by toggling the clicked
|
||||||
// section against the `value` we hand it, which omits these — so without
|
// section against the `value` we hand it, which omits these — so without
|
||||||
// merging them back, toggling any populated section would silently drop an
|
// merging them back, toggling any populated section would silently drop an
|
||||||
// empty section's intent and it wouldn't reappear once it has items again.
|
// empty section's intent and it wouldn't reappear once it has items again.
|
||||||
|
// Restricted to `allSections` so a stale "subscriptions" entry persisted from
|
||||||
|
// a prior subscription-capable session isn't perpetually re-appended once the
|
||||||
|
// section is no longer rendered — it's dropped from persisted state instead.
|
||||||
const intendedButEmptySections = openSections.filter(
|
const intendedButEmptySections = openSections.filter(
|
||||||
(section) => !visibleOpenSections.includes(section),
|
(section) =>
|
||||||
|
allSections.includes(section) && !visibleOpenSections.includes(section),
|
||||||
);
|
);
|
||||||
function handleOpenSectionsChange(next: string[]) {
|
function handleOpenSectionsChange(next: string[]) {
|
||||||
// Safe to append unconditionally: empty-section controls are `disabled`, so
|
// Safe to append unconditionally: empty-section controls are `disabled`, so
|
||||||
|
|
@ -240,28 +263,35 @@ export function ResourceControls({
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|
||||||
<Accordion.Item
|
{subscriptionsSupported && (
|
||||||
value="subscriptions"
|
<Accordion.Item
|
||||||
flex={sectionFlex(
|
value="subscriptions"
|
||||||
visibleOpenSections.includes("subscriptions"),
|
flex={sectionFlex(
|
||||||
filteredSubscriptions.length,
|
visibleOpenSections.includes("subscriptions"),
|
||||||
)}
|
filteredSubscriptions.length,
|
||||||
>
|
)}
|
||||||
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
|
>
|
||||||
{formatSectionCount("Subscriptions", filteredSubscriptions.length)}
|
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
|
||||||
</Accordion.Control>
|
{formatSectionCount(
|
||||||
<Accordion.Panel>
|
"Subscriptions",
|
||||||
<Stack gap="xs">
|
filteredSubscriptions.length,
|
||||||
{filteredSubscriptions.map((sub) => (
|
)}
|
||||||
<ResourceSubscribedItem
|
</Accordion.Control>
|
||||||
key={sub.resource.uri}
|
<Accordion.Panel>
|
||||||
subscription={sub}
|
<Stack gap="xs">
|
||||||
onUnsubscribe={() => onUnsubscribeResource(sub.resource.uri)}
|
{filteredSubscriptions.map((sub) => (
|
||||||
/>
|
<ResourceSubscribedItem
|
||||||
))}
|
key={sub.resource.uri}
|
||||||
</Stack>
|
subscription={sub}
|
||||||
</Accordion.Panel>
|
onUnsubscribe={() =>
|
||||||
</Accordion.Item>
|
onUnsubscribeResource(sub.resource.uri)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,25 @@ export const NotSubscribed: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SubscriptionsUnsupported: Story = {
|
||||||
|
args: {
|
||||||
|
resource: {
|
||||||
|
name: "data.json",
|
||||||
|
uri: "file:///data.json",
|
||||||
|
},
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
uri: "file:///data.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify({ status: "active" }, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isSubscribed: false,
|
||||||
|
// Server does not advertise resources.subscribe — only Refresh shows.
|
||||||
|
subscriptionsSupported: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const WithAnnotations: Story = {
|
export const WithAnnotations: Story = {
|
||||||
args: {
|
args: {
|
||||||
resource: {
|
resource: {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,30 @@ describe("ResourcePreviewPanel", () => {
|
||||||
expect(onUnsubscribe).toHaveBeenCalledTimes(1);
|
expect(onUnsubscribe).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the Subscribe button when subscriptionsSupported is false", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourcePreviewPanel {...baseProps} subscriptionsSupported={false} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Subscribe" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
// Refresh stays available regardless of subscription support.
|
||||||
|
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Unsubscribe button when subscriptionsSupported is false even if subscribed", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ResourcePreviewPanel
|
||||||
|
{...baseProps}
|
||||||
|
isSubscribed
|
||||||
|
subscriptionsSupported={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Unsubscribe" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("invokes onRefresh when Refresh is clicked", async () => {
|
it("invokes onRefresh when Refresh is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onRefresh = vi.fn();
|
const onRefresh = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ export interface ResourcePreviewPanelProps {
|
||||||
contents: (TextResourceContents | BlobResourceContents)[];
|
contents: (TextResourceContents | BlobResourceContents)[];
|
||||||
lastUpdated?: Date;
|
lastUpdated?: Date;
|
||||||
isSubscribed: boolean;
|
isSubscribed: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the connected server advertises the `resources.subscribe`
|
||||||
|
* capability. When false, the Subscribe/Unsubscribe button is hidden.
|
||||||
|
* Defaults to true so the button renders unless explicitly unsupported.
|
||||||
|
*/
|
||||||
|
subscriptionsSupported?: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onSubscribe: () => void;
|
onSubscribe: () => void;
|
||||||
onUnsubscribe: () => void;
|
onUnsubscribe: () => void;
|
||||||
|
|
@ -168,6 +174,7 @@ export function ResourcePreviewPanel({
|
||||||
contents,
|
contents,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
|
subscriptionsSupported = true,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onSubscribe,
|
onSubscribe,
|
||||||
onUnsubscribe,
|
onUnsubscribe,
|
||||||
|
|
@ -223,10 +230,12 @@ export function ResourcePreviewPanel({
|
||||||
<Button variant="subtle" size="sm" onClick={onRefresh}>
|
<Button variant="subtle" size="sm" onClick={onRefresh}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<SubscribeButton
|
{subscriptionsSupported && (
|
||||||
subscribed={isSubscribed}
|
<SubscribeButton
|
||||||
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
|
subscribed={isSubscribed}
|
||||||
/>
|
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</FooterRow>
|
</FooterRow>
|
||||||
</PanelStack>
|
</PanelStack>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export interface AppsUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
align: "flex-start",
|
align: "flex-start",
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export interface HistoryUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export interface LogsUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export interface NetworkUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export interface PromptsUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
@ -104,7 +104,7 @@ const EmptyState = Text.withProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
const SCROLL_MAX_HEIGHT =
|
const SCROLL_MAX_HEIGHT =
|
||||||
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
|
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
|
||||||
|
|
||||||
function hasArguments(prompt: Prompt): boolean {
|
function hasArguments(prompt: Prompt): boolean {
|
||||||
return !!prompt.arguments && prompt.arguments.length > 0;
|
return !!prompt.arguments && prompt.arguments.length > 0;
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,21 @@ export const AllSections: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Server without the resources.subscribe capability: the Subscriptions
|
||||||
|
// accordion section is omitted and no Subscribe button appears (#1478).
|
||||||
|
export const SubscriptionsUnsupported: Story = {
|
||||||
|
args: {
|
||||||
|
resources: sampleResources,
|
||||||
|
templates: sampleTemplates,
|
||||||
|
subscriptions: sampleSubscriptions,
|
||||||
|
subscriptionsSupported: false,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const manyResources: Resource[] = Array.from({ length: 40 }, (_, i) => ({
|
const manyResources: Resource[] = Array.from({ length: 40 }, (_, i) => ({
|
||||||
name: `resource-${String(i + 1).padStart(2, "0")}.wav`,
|
name: `resource-${String(i + 1).padStart(2, "0")}.wav`,
|
||||||
uri: `file:///kit/resource-${i + 1}.wav`,
|
uri: `file:///kit/resource-${i + 1}.wav`,
|
||||||
|
|
|
||||||
|
|
@ -317,4 +317,27 @@ describe("ResourcesScreen", () => {
|
||||||
await user.click(screen.getByRole("button", { name: "Unsubscribe" }));
|
await user.click(screen.getByRole("button", { name: "Unsubscribe" }));
|
||||||
expect(onUnsubscribeResource).toHaveBeenCalledWith("file:///x");
|
expect(onUnsubscribeResource).toHaveBeenCalledWith("file:///x");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the Subscriptions section and Subscribe button when subscriptionsSupported is false", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(
|
||||||
|
<ControlledResourcesScreen
|
||||||
|
subscriptionsSupported={false}
|
||||||
|
readState={{
|
||||||
|
status: "ok",
|
||||||
|
uri: "file:///x",
|
||||||
|
result: okResult,
|
||||||
|
isSubscribed: false,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Sidebar Subscriptions accordion section is gone.
|
||||||
|
expect(screen.queryByText(/Subscriptions/)).not.toBeInTheDocument();
|
||||||
|
// Opening a resource preview shows Refresh but no Subscribe button.
|
||||||
|
await user.click(screen.getByText("x.txt"));
|
||||||
|
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Subscribe" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ export interface ResourcesScreenProps {
|
||||||
ui: ResourcesUiState;
|
ui: ResourcesUiState;
|
||||||
listChanged: boolean;
|
listChanged: boolean;
|
||||||
completionsSupported?: boolean;
|
completionsSupported?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the connected server advertises the `resources.subscribe`
|
||||||
|
* capability. When false, the Subscribe/Unsubscribe button and the
|
||||||
|
* Subscriptions accordion section are hidden. Defaults to true so the
|
||||||
|
* controls render unless a caller explicitly marks them unsupported.
|
||||||
|
*/
|
||||||
|
subscriptionsSupported?: boolean;
|
||||||
onUiChange: (next: ResourcesUiState) => void;
|
onUiChange: (next: ResourcesUiState) => void;
|
||||||
onRefreshList: () => void;
|
onRefreshList: () => void;
|
||||||
onReadResource: (uri: string) => void;
|
onReadResource: (uri: string) => void;
|
||||||
|
|
@ -67,7 +74,7 @@ export interface ResourcesUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
@ -119,7 +126,7 @@ const EmptyState = Text.withProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
const SCROLL_MAX_HEIGHT =
|
const SCROLL_MAX_HEIGHT =
|
||||||
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
|
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
|
||||||
|
|
||||||
export function ResourcesScreen({
|
export function ResourcesScreen({
|
||||||
resources,
|
resources,
|
||||||
|
|
@ -129,6 +136,7 @@ export function ResourcesScreen({
|
||||||
ui,
|
ui,
|
||||||
listChanged,
|
listChanged,
|
||||||
completionsSupported,
|
completionsSupported,
|
||||||
|
subscriptionsSupported = true,
|
||||||
onUiChange,
|
onUiChange,
|
||||||
onRefreshList,
|
onRefreshList,
|
||||||
onReadResource,
|
onReadResource,
|
||||||
|
|
@ -255,6 +263,7 @@ export function ResourcesScreen({
|
||||||
contents={readState.result.contents}
|
contents={readState.result.contents}
|
||||||
lastUpdated={readState.lastUpdated}
|
lastUpdated={readState.lastUpdated}
|
||||||
isSubscribed={readState.isSubscribed ?? false}
|
isSubscribed={readState.isSubscribed ?? false}
|
||||||
|
subscriptionsSupported={subscriptionsSupported}
|
||||||
onRefresh={() => handleReadResource(readResource.uri)}
|
onRefresh={() => handleReadResource(readResource.uri)}
|
||||||
onSubscribe={() => onSubscribeResource(readResource.uri)}
|
onSubscribe={() => onSubscribeResource(readResource.uri)}
|
||||||
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
|
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
|
||||||
|
|
@ -275,6 +284,7 @@ export function ResourcesScreen({
|
||||||
resources={resources}
|
resources={resources}
|
||||||
templates={templates}
|
templates={templates}
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
|
subscriptionsSupported={subscriptionsSupported}
|
||||||
selectedUri={selectedResourceUri}
|
selectedUri={selectedResourceUri}
|
||||||
selectedTemplateUri={selectedTemplateUri}
|
selectedTemplateUri={selectedTemplateUri}
|
||||||
searchText={search}
|
searchText={search}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||||
|
import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions";
|
||||||
import { ServerListScreen } from "./ServerListScreen";
|
import { ServerListScreen } from "./ServerListScreen";
|
||||||
|
|
||||||
const meta: Meta<typeof ServerListScreen> = {
|
const meta: Meta<typeof ServerListScreen> = {
|
||||||
|
|
@ -79,6 +80,11 @@ export const MultipleServers: Story = {
|
||||||
args: {
|
args: {
|
||||||
servers: [connectedStdioServer, disconnectedStdioServer, failedHttpServer],
|
servers: [connectedStdioServer, disconnectedStdioServer, failedHttpServer],
|
||||||
},
|
},
|
||||||
|
// The server grid scroll region reserves a scrollbar gutter and hides the
|
||||||
|
// bar when idle, matching the History/Network/Logging list panels (#1474).
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
expectScrollbarGutterIdleHidden(canvasElement);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SingleServer: Story = {
|
export const SingleServer: Story = {
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,15 @@ export function ServerListScreen({
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 60px) - var(--mantine-spacing-xl) * 2 - 60px)">
|
<ScrollArea.Autosize
|
||||||
|
mah="calc(100dvh - var(--app-shell-header-height, 60px) - var(--mantine-spacing-xl) * 2 - 60px)"
|
||||||
|
// Same scrollbar treatment as the History/Network/Logging list panels
|
||||||
|
// (#1474): reserve a gutter so the bar never overlays the right edge of
|
||||||
|
// the server cards (occluding their action icons / status badges), and
|
||||||
|
// only show it while actively scrolling rather than popping in on hover.
|
||||||
|
type="scroll"
|
||||||
|
offsetScrollbars
|
||||||
|
>
|
||||||
{servers.length === 0 ? (
|
{servers.length === 0 ? (
|
||||||
<EmptyState>
|
<EmptyState>
|
||||||
No servers configured. Add a server to get started.
|
No servers configured. Add a server to get started.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export interface TasksUiState {
|
||||||
|
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export interface ToolsScreenProps {
|
||||||
// viewport minus the app-shell header and the screen's top+bottom xl padding,
|
// viewport minus the app-shell header and the screen's top+bottom xl padding,
|
||||||
// leaving the bottom margin the overflow used to eat.
|
// leaving the bottom margin the overflow used to eat.
|
||||||
const SCROLL_MAX_HEIGHT =
|
const SCROLL_MAX_HEIGHT =
|
||||||
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
|
"calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
|
||||||
|
|
||||||
// No `align` override: children stretch to the row's full height, giving each
|
// No `align` override: children stretch to the row's full height, giving each
|
||||||
// column pane a definite height. That definite height is what lets a column's
|
// column pane a definite height. That definite height is what lets a column's
|
||||||
|
|
@ -69,7 +69,7 @@ const SCROLL_MAX_HEIGHT =
|
||||||
// see the Prompts/Resources preview panes this mirrors).
|
// see the Prompts/Resources preview panes this mirrors).
|
||||||
const ScreenLayout = Flex.withProps({
|
const ScreenLayout = Flex.withProps({
|
||||||
variant: "screen",
|
variant: "screen",
|
||||||
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
h: "calc(100dvh - var(--app-shell-header-height, 0px))",
|
||||||
gap: "md",
|
gap: "md",
|
||||||
p: "xl",
|
p: "xl",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type {
|
||||||
ServerEntry,
|
ServerEntry,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { fn } from "storybook/test";
|
import { expect, fn } from "storybook/test";
|
||||||
import { InspectorView } from "./InspectorView";
|
import { InspectorView } from "./InspectorView";
|
||||||
import {
|
import {
|
||||||
EMPTY_TOOLS_UI,
|
EMPTY_TOOLS_UI,
|
||||||
|
|
@ -397,7 +397,24 @@ const meta: Meta<typeof InspectorView> = {
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof InspectorView>;
|
type Story = StoryObj<typeof InspectorView>;
|
||||||
|
|
||||||
export const Default: Story = {};
|
export const Default: Story = {
|
||||||
|
// The InspectorView shell must never scroll as a whole — only the inner
|
||||||
|
// ScrollArea regions within each screen do. Guard the structural invariant:
|
||||||
|
// AppShell.Main is clamped to the viewport height and clips its overflow, so
|
||||||
|
// no amount of screen content can surface a page-level scrollbar.
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const main = canvasElement.querySelector(".mantine-AppShell-main");
|
||||||
|
if (!(main instanceof HTMLElement)) {
|
||||||
|
throw new Error("AppShell main not found");
|
||||||
|
}
|
||||||
|
expect(getComputedStyle(main).overflowY).toBe("hidden");
|
||||||
|
// Clamped to the viewport (within a pixel of rounding), so the shell can't
|
||||||
|
// grow past it and push the page into scrolling.
|
||||||
|
expect(main.getBoundingClientRect().height).toBeLessThanOrEqual(
|
||||||
|
window.innerHeight + 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const NoServers: Story = {
|
export const NoServers: Story = {
|
||||||
args: {
|
args: {
|
||||||
|
|
@ -405,6 +422,42 @@ export const NoServers: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A server list long enough to overflow the viewport (as in the reported
|
||||||
|
// screenshot). The shell stays clamped to the viewport and the overflow is
|
||||||
|
// confined to the inner server-grid ScrollArea — the page never scrolls.
|
||||||
|
const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
id: `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`,
|
||||||
|
name: `Server ${i + 1}`,
|
||||||
|
config: { command: `npx -y @modelcontextprotocol/server-${i + 1}` },
|
||||||
|
info: { name: `Server ${i + 1}`, version: "1.0.0" },
|
||||||
|
connection: { status: "disconnected" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ManyServers: Story = {
|
||||||
|
args: {
|
||||||
|
servers: manyServers,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
// Even under enough content to overflow, the shell stays viewport-clamped
|
||||||
|
// and clips — so the page can't scroll.
|
||||||
|
const main = canvasElement.querySelector(".mantine-AppShell-main");
|
||||||
|
if (!(main instanceof HTMLElement)) {
|
||||||
|
throw new Error("AppShell main not found");
|
||||||
|
}
|
||||||
|
expect(getComputedStyle(main).overflowY).toBe("hidden");
|
||||||
|
expect(main.getBoundingClientRect().bottom).toBeLessThanOrEqual(
|
||||||
|
window.innerHeight + 1,
|
||||||
|
);
|
||||||
|
// The overflow lives in the inner server-grid ScrollArea, which is actually
|
||||||
|
// scrollable here — confirming the grid (not the page) absorbs it.
|
||||||
|
const viewport = main.querySelector(".mantine-ScrollArea-viewport");
|
||||||
|
if (!(viewport instanceof HTMLElement)) {
|
||||||
|
throw new Error("server-grid scroll viewport not found");
|
||||||
|
}
|
||||||
|
expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Renders the connected-state shell (full tab list, ViewHeader in connected
|
// Renders the connected-state shell (full tab list, ViewHeader in connected
|
||||||
// mode). The other tabs still render their disconnected fixtures because
|
// mode). The other tabs still render their disconnected fixtures because
|
||||||
// the lists are passed through as static data — that's fine for visual
|
// the lists are passed through as static data — that's fine for visual
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue