Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +70,7 @@ export function ResourceControls({
resources,
templates,
subscriptions,
subscriptionsSupported = true,
selectedUri,
selectedTemplateUri,
searchText = "",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -168,6 +174,7 @@ export function ResourcePreviewPanel({
contents,
lastUpdated,
isSubscribed,
subscriptionsSupported = true,
onRefresh,
onSubscribe,
onUnsubscribe,
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading
Loading