diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 3489f6fe..a482bee5 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2316,6 +2316,7 @@ function App() { onRefreshResources={onRefreshResources} onCompleteArgument={onCompleteArgument} completionsSupported={capabilities?.completions !== undefined} + subscriptionsSupported={capabilities?.resources?.subscribe === true} onTasksUiChange={setTasksUi} onCancelTask={(taskId) => { void onCancelTask(taskId); diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx index a35420d8..81a581b0 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx @@ -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. diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx index 261833d4..c56e231a 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx @@ -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( + , + ); + 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(); + 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( + , + ); + 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( + , + ); + 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[] = [ diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 108aff35..7c71a0db 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -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({ - - - {formatSectionCount("Subscriptions", filteredSubscriptions.length)} - - - - {filteredSubscriptions.map((sub) => ( - onUnsubscribeResource(sub.resource.uri)} - /> - ))} - - - + {subscriptionsSupported && ( + + + {formatSectionCount( + "Subscriptions", + filteredSubscriptions.length, + )} + + + + {filteredSubscriptions.map((sub) => ( + + onUnsubscribeResource(sub.resource.uri) + } + /> + ))} + + + + )} ); diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx index b4ce17e8..84b83209 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx @@ -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: { diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx index 82528b79..75ae8e87 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx @@ -109,6 +109,30 @@ describe("ResourcePreviewPanel", () => { expect(onUnsubscribe).toHaveBeenCalledTimes(1); }); + it("hides the Subscribe button when subscriptionsSupported is false", () => { + renderWithMantine( + , + ); + 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( + , + ); + expect( + screen.queryByRole("button", { name: "Unsubscribe" }), + ).not.toBeInTheDocument(); + }); + it("invokes onRefresh when Refresh is clicked", async () => { const user = userEvent.setup(); const onRefresh = vi.fn(); diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx index 181cd1d8..7c5ce5f7 100644 --- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx +++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx @@ -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({ - + {subscriptionsSupported && ( + + )} diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index f60d57b4..127f43c6 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -63,7 +63,7 @@ export interface AppsUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", align: "flex-start", diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx index a9199587..65fd4407 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx @@ -39,7 +39,7 @@ export interface HistoryUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index 284043d6..60754e3f 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -27,7 +27,7 @@ export interface LogsUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx index ebc0996f..65b88df2 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -32,7 +32,7 @@ export interface NetworkUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index d10f1686..aa0ff10b 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -59,7 +59,7 @@ export interface PromptsUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); @@ -104,7 +104,7 @@ const EmptyState = Text.withProps({ }); 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 { return !!prompt.arguments && prompt.arguments.length > 0; diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index 6d0095a4..b7da682e 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -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) => ({ name: `resource-${String(i + 1).padStart(2, "0")}.wav`, uri: `file:///kit/resource-${i + 1}.wav`, diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx index ebc4b5bf..dd88e908 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx @@ -317,4 +317,27 @@ describe("ResourcesScreen", () => { await user.click(screen.getByRole("button", { name: "Unsubscribe" })); expect(onUnsubscribeResource).toHaveBeenCalledWith("file:///x"); }); + + it("hides the Subscriptions section and Subscribe button when subscriptionsSupported is false", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + // 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(); + }); }); diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index d98bed36..fc38f38c 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -35,6 +35,13 @@ export interface ResourcesScreenProps { ui: ResourcesUiState; listChanged: 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; onRefreshList: () => void; onReadResource: (uri: string) => void; @@ -67,7 +74,7 @@ export interface ResourcesUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); @@ -119,7 +126,7 @@ const EmptyState = Text.withProps({ }); 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({ resources, @@ -129,6 +136,7 @@ export function ResourcesScreen({ ui, listChanged, completionsSupported, + subscriptionsSupported = true, onUiChange, onRefreshList, onReadResource, @@ -255,6 +263,7 @@ export function ResourcesScreen({ contents={readState.result.contents} lastUpdated={readState.lastUpdated} isSubscribed={readState.isSubscribed ?? false} + subscriptionsSupported={subscriptionsSupported} onRefresh={() => handleReadResource(readResource.uri)} onSubscribe={() => onSubscribeResource(readResource.uri)} onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} @@ -275,6 +284,7 @@ export function ResourcesScreen({ resources={resources} templates={templates} subscriptions={subscriptions} + subscriptionsSupported={subscriptionsSupported} selectedUri={selectedResourceUri} selectedTemplateUri={selectedTemplateUri} searchText={search} diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx index 8173df7e..b69fba59 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type { ServerEntry } from "@inspector/core/mcp/types.js"; +import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions"; import { ServerListScreen } from "./ServerListScreen"; const meta: Meta = { @@ -79,6 +80,11 @@ export const MultipleServers: Story = { args: { 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 = { diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index 393d1264..b0504149 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -137,7 +137,15 @@ export function ServerListScreen({ onExport={onExport} /> - + {servers.length === 0 ? ( No servers configured. Add a server to get started. diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx index a027f459..379fc7a5 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx @@ -23,7 +23,7 @@ export interface TasksUiState { const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 32f006e6..698d4753 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -61,7 +61,7 @@ export interface ToolsScreenProps { // viewport minus the app-shell header and the screen's top+bottom xl padding, // leaving the bottom margin the overflow used to eat. 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 // 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). const ScreenLayout = Flex.withProps({ variant: "screen", - h: "calc(100vh - var(--app-shell-header-height, 0px))", + h: "calc(100dvh - var(--app-shell-header-height, 0px))", gap: "md", p: "xl", }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index c48f32bc..af95fc26 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -14,7 +14,7 @@ import type { ServerEntry, } from "@inspector/core/mcp/types.js"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { expect, fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; import { EMPTY_TOOLS_UI, @@ -397,7 +397,24 @@ const meta: Meta = { export default meta; type Story = StoryObj; -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 = { 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 // mode). The other tabs still render their disconnected fixtures because // the lists are passed through as static data — that's fine for visual diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index fd06cad7..fbb99ed8 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -310,6 +310,12 @@ export interface InspectorViewProps { context: Record, ) => Promise; completionsSupported?: boolean; + /** + * Whether the connected server advertises the `resources.subscribe` + * capability. When false, the Resources screen hides the Subscribe/ + * Unsubscribe button and the Subscriptions accordion section. + */ + subscriptionsSupported?: boolean; onTasksUiChange: (next: TasksUiState) => void; onCancelTask: (taskId: string) => void; @@ -407,6 +413,7 @@ export function InspectorView({ onRefreshResources, onCompleteArgument, completionsSupported, + subscriptionsSupported, onTasksUiChange, onCancelTask, onClearCompletedTasks, @@ -554,7 +561,13 @@ export function InspectorView({ ); return ( - + // padding={0}: each screen fills `calc(100dvh - header)` and supplies its + // own `xl` padding, so Main must contribute only the fixed-header offset. + // Mantine's default `padding="md"` added an extra inset that pushed content + // past the viewport and made the whole InspectorView scroll — the theme's + // Main-slot height clamp + overflow:hidden keep that scroll on the inner + // ScrollArea regions only. + {connectionStatus === "connected" && initializeResult ? (