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:
Cliff Hall 2026-06-14 12:54:19 -04:00 • committed by GitHub
commit ef921ba052
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 346 additions and 47 deletions

View file

@ -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`
// height split, URIs scrolled while the others left their share unused. Now the
// sections size to content inside one bounded scroll region.

View file

@ -267,6 +267,58 @@ describe("ResourceControls", () => {
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 () => {
const user = userEvent.setup();
const resourcesWithTitle: Resource[] = [

View file

@ -21,6 +21,13 @@ export interface ResourceControlsProps {
resources: Resource[];
templates: ResourceTemplate[];
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;
selectedTemplateUri?: string;
// Search text + accordion open-sections are controlled by the parent (App,
@ -63,6 +70,7 @@ export function ResourceControls({
resources,
templates,
subscriptions,
subscriptionsSupported = true,
selectedUri,
selectedTemplateUri,
searchText = "",
@ -97,15 +105,25 @@ export function ResourceControls({
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
// 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
// persisted preference.
const openSections =
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
// out of the accordion's open set — they render collapsed (chevron points
@ -118,15 +136,20 @@ export function ResourceControls({
subscriptions: filteredSubscriptions.length,
};
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
// `value`). Mantine derives the next open-array by toggling the clicked
// section against the `value` we hand it, which omits these — so without
// 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.
// 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(
(section) => !visibleOpenSections.includes(section),
(section) =>
allSections.includes(section) && !visibleOpenSections.includes(section),
);
function handleOpenSectionsChange(next: string[]) {
// Safe to append unconditionally: empty-section controls are `disabled`, so
@ -240,28 +263,35 @@ export function ResourceControls({
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item
value="subscriptions"
flex={sectionFlex(
visibleOpenSections.includes("subscriptions"),
filteredSubscriptions.length,
)}
>
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
{formatSectionCount("Subscriptions", filteredSubscriptions.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredSubscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.resource.uri}
subscription={sub}
onUnsubscribe={() => onUnsubscribeResource(sub.resource.uri)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
{subscriptionsSupported && (
<Accordion.Item
value="subscriptions"
flex={sectionFlex(
visibleOpenSections.includes("subscriptions"),
filteredSubscriptions.length,
)}
>
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
{formatSectionCount(
"Subscriptions",
filteredSubscriptions.length,
)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredSubscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.resource.uri}
subscription={sub}
onUnsubscribe={() =>
onUnsubscribeResource(sub.resource.uri)
}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
</Stack>
);

View file

@ -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 = {
args: {
resource: {

View file

@ -109,6 +109,30 @@ describe("ResourcePreviewPanel", () => {
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 () => {
const user = userEvent.setup();
const onRefresh = vi.fn();

View file

@ -24,6 +24,12 @@ export interface ResourcePreviewPanelProps {
contents: (TextResourceContents | BlobResourceContents)[];
lastUpdated?: Date;
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;
onSubscribe: () => void;
onUnsubscribe: () => void;
@ -168,6 +174,7 @@ export function ResourcePreviewPanel({
contents,
lastUpdated,
isSubscribed,
subscriptionsSupported = true,
onRefresh,
onSubscribe,
onUnsubscribe,
@ -223,10 +230,12 @@ export function ResourcePreviewPanel({
<Button variant="subtle" size="sm" onClick={onRefresh}>
Refresh
</Button>
<SubscribeButton
subscribed={isSubscribed}
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
/>
{subscriptionsSupported && (
<SubscribeButton
subscribed={isSubscribed}
onToggle={isSubscribed ? onUnsubscribe : onSubscribe}
/>
)}
</ActionGroup>
</FooterRow>
</PanelStack>