Thread cacheMode through managed list refresh via SDK cache-aware verbs (#1721) (#1723)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* Thread cacheMode through managed list refresh via SDK cache-aware verbs (#1721) Manual/auto refresh of tools/resources/prompts now forces a cache-bypassing round trip on modern servers, delivering the raw-request cacheMode control deferred from #1628. The InspectorClient list/read path uses the SDK's single-page `client.request` verbs (for pagination debugging), which never consult the SDK response cache — so `cacheMode` was inert there. This adds a separate aggregate path that routes through the SDK's cache-aware high-level verbs, where `cacheMode` is honored: - InspectorClient: new `listAllTools/Prompts/Resources/ResourceTemplates( { cacheMode, metadata })` that call `client.listTools()` etc. (all-page aggregate + SEP-2243 x-mcp-header exclusion + cache consult/write). A shared `getCacheableRequestOptions` / `aggregateListParams` back all four. - InspectorClientProtocol: the four `listAll*` methods added. - ManagedListState: delegates page walking to the SDK verb (config `fetchPage` loop → `fetchAll`); `refresh(metadata?, cacheMode?)` threads cacheMode. A `list_changed` auto-refresh bypasses the cache (`cacheMode: "refresh"`). - Managed hooks: user-initiated `refresh()` passes `cacheMode: "refresh"`, so the existing per-tab Refresh buttons become genuinely cache-bypassing — no App wiring change. Connect-time load stays default (`'use'`). The single-page `listTools(cursor)` primitive is unchanged; the paged hooks still surface per-page `nextCursor`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Single-page list pagination: sidebar toggle + server setting (#1721) Delivers the deferred single-page fetch UI: Tools/Resources/Prompts can be loaded one page at a time instead of auto-aggregating every page — a defensive option for servers with very large lists. - New `singlePageLists` server setting (persisted, server-wide), plumbed through ServerConfig/InspectorServerSettings, serverList convert/default, the node + remote settings paths, and the Server Settings form ("Fetch Lists One Page at a Time"). - New `ListPaginationControls` element (a "Single page" switch + "Load next page" button + "N pages loaded" status) rendered in each list sidebar; the toggle edits the setting live (optimistic) and persists it. - The paged state stores (PagedTools/Prompts/ResourcesState) now own loading: they auto-load page 1 on connect in single-page mode and track nextCursor + page count as observable state; the managed stores skip their all-page walk in single-page mode (so nothing is over-fetched) while still lighting the list-changed indicator. `usePaginatedList` selects the display source and pagination controls per mode — pure, no data-loading in React effects. - Refresh is mode-aware: reload page 1 in single-page mode, refetch the whole aggregate otherwise. Sidebars widened 340→360 so the controls don't crowd list entries. - Test server: `test-servers/configs/pagination-http.json` (12 tools/resources/ prompts, page size 4 → 3 pages each) exercises the flow end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Add pr-screenshots proving single-page pagination (#1721) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Address @claude review: template load, double-click guard, auth recovery (#1721) - Fix: resource templates never loaded on connect in single-page mode. The connect-time all-page skip now applies only to lists with a paged counterpart (new `deferInSinglePage` flag on ManagedListConfig; false for resource templates), so templates still aggregate on connect regardless of the setting. - Guard `PagedTools/Prompts/ResourcesState.loadPage` against a concurrent load (fast double-click on "Load next page") so the same page can't be appended twice; the in-flight load owns the cursor and the second call is a no-op. - Thread ambient auth recovery through the single-page refresh, Load-next-page, and mode-switch loads (they previously bypassed it) — usePaginatedList's onRefresh/onLoadMore now return their promises so App can wrap them. - Drop the misleading "aggregated list" wording from the paged-store docs. Tests added for each; npm run coverage green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Review round 2: revert optimistic toggle when persisting the setting fails (#1721) Non-blocking review follow-up. If updateServerSettings rejects, the optimistic `singlePageListsOverride` is now cleared and the live client setting rolled back (the effect only clears the override when the persisted value changes, which doesn't happen on failure), so the UI and client reflect what's on disk rather than the failed edit. Added an App test for the revert path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Fix single-page Refresh not clearing list-changed indicator + UI polish (#1721) Round-3 review fix (blocking): in single-page mode a `list_changed` lights the list-changed indicator, but the single-page Refresh reloads page 1 of the paged store and bypassed `useManaged*.refresh()`, so the indicator was never cleared. Expose `clearListChanged` from useManagedTools/Prompts/Resources and call it in the single-page branch of onRefreshTools/Prompts/Resources. Tests added. UI polish (user feedback on the sidebar control): - Rename the switch label "Single page" → "Paginated". - Put "Load next page" on the same row as the switch, space-between (switch fully left, button fully right). - Move the "N pages loaded" status to its own line below the row, centered. - Hide the whole control once the list is known to be a single page (single-page mode, page 1 loaded, no nextCursor) — nothing to paginate. pr-screenshots refreshed to the new layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Rename the singlePageLists setting to paginatedLists for clarity (#1721) `singlePageLists` read backwards from the "Paginated" switch label (it sounds like paginated=false). Rename the server setting to `paginatedLists` with the same value meaning — true = paginated/one-page-at-a-time, false = aggregate-all — and follow through on the related identifiers for consistency: - Setting field `paginatedLists` across ServerConfig / InspectorServerSettings, serverList convert/default, node + remote settings paths, and the form/modal callbacks (onPaginatedListsChange). - `usePaginatedList` / `ListPaginationControls` prop `singlePage` → `paginated`, `onSinglePageChange` → `onPaginatedChange`. - `ManagedListConfig.deferInSinglePage` → `deferWhenPaginated`. - App state (paginatedListsOverride, onTogglePaginatedLists, …) and all tests/fixtures + mode-referring comments. "single-page" wording that denotes the SDK's one-page `client.request` verb (inspectorClient) is intentionally left as-is. Purely a rename — no behavior change; unit + integration + cli + tui + coverage all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Format App.test.tsx (prettier) after the paginatedLists rename (#1721) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d552564966
commit
b3b04c6939
81 changed files with 2494 additions and 247 deletions
|
|
@ -114,6 +114,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era**
|
|||
|
||||
`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active.
|
||||
|
||||
`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an *N pages loaded* status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -187,12 +187,23 @@ const SERVER_A = {
|
|||
connection: { status: "disconnected" },
|
||||
};
|
||||
|
||||
// Stable spy so tests can assert the sidebar paginated toggle persisted the
|
||||
// `paginatedLists` setting (#1721). `vi.hoisted` so it exists when the hoisted
|
||||
// `vi.mock` factory closes over it.
|
||||
const { updateServerSettingsSpy } = vi.hoisted(() => ({
|
||||
updateServerSettingsSpy: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// Stable spy for the tools list-changed acknowledgement, so a test can assert
|
||||
// the paginated Refresh clears the indicator (#1721).
|
||||
const { clearToolsListChangedSpy } = vi.hoisted(() => ({
|
||||
clearToolsListChangedSpy: vi.fn(),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useServers.js", () => ({
|
||||
useServers: vi.fn(() => ({
|
||||
servers: [SERVER_A],
|
||||
addServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
updateServerSettings: vi.fn(),
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
|
@ -211,13 +222,22 @@ vi.mock("@inspector/core/react/useManagedTools.js", () => ({
|
|||
useManagedTools: vi.fn(() => ({
|
||||
tools: [{ name: "get_acts", inputSchema: { type: "object" } }],
|
||||
refresh: vi.fn(),
|
||||
clearListChanged: clearToolsListChangedSpy,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({
|
||||
useManagedPrompts: vi.fn(() => ({ prompts: [], refresh: vi.fn() })),
|
||||
useManagedPrompts: vi.fn(() => ({
|
||||
prompts: [],
|
||||
refresh: vi.fn(),
|
||||
clearListChanged: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedResources.js", () => ({
|
||||
useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn() })),
|
||||
useManagedResources: vi.fn(() => ({
|
||||
resources: [],
|
||||
refresh: vi.fn(),
|
||||
clearListChanged: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
|
||||
useManagedResourceTemplates: vi.fn(() => ({
|
||||
|
|
@ -225,6 +245,58 @@ vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({
|
|||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
// Paged (paginated) hooks + state managers (#1721). Mirrors the managed
|
||||
// mocks: the hooks return an empty accumulated list and a resolving loadPage so
|
||||
// usePaginatedList runs without a real transport; the state classes are no-op
|
||||
// constructors App still instantiates/destroys per connect.
|
||||
vi.mock("@inspector/core/react/usePagedTools.js", () => ({
|
||||
usePagedTools: vi.fn(() => ({
|
||||
tools: [],
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
loadPage: vi.fn(() =>
|
||||
Promise.resolve({ tools: [], nextCursor: undefined }),
|
||||
),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/usePagedPrompts.js", () => ({
|
||||
usePagedPrompts: vi.fn(() => ({
|
||||
prompts: [],
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
loadPage: vi.fn(() =>
|
||||
Promise.resolve({ prompts: [], nextCursor: undefined }),
|
||||
),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/usePagedResources.js", () => ({
|
||||
usePagedResources: vi.fn(() => ({
|
||||
resources: [],
|
||||
nextCursor: undefined,
|
||||
pageCount: 0,
|
||||
loadPage: vi.fn(() =>
|
||||
Promise.resolve({ resources: [], nextCursor: undefined }),
|
||||
),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/pagedToolsState.js", () => ({
|
||||
PagedToolsState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/pagedPromptsState.js", () => ({
|
||||
PagedPromptsState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/mcp/state/pagedResourcesState.js", () => ({
|
||||
PagedResourcesState: vi.fn(function () {
|
||||
return { destroy: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({
|
||||
useManagedRequestorTasks: vi.fn(() => ({
|
||||
tasks: [],
|
||||
|
|
@ -313,6 +385,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
onReplayProtocol: (id: string) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
pinnedProtocolIds?: Set<string>;
|
||||
onRefreshTools: () => void;
|
||||
toolsPagination: {
|
||||
paginated: boolean;
|
||||
canLoadMore: boolean;
|
||||
loadedPages: number;
|
||||
onPaginatedChange: (v: boolean) => void;
|
||||
onLoadMore: () => void;
|
||||
};
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="tool-status">
|
||||
|
|
@ -428,6 +508,22 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
replay-history
|
||||
</button>
|
||||
<button onClick={() => props.onClearProtocol()}>clear-history</button>
|
||||
<span data-testid="tools-paginated">
|
||||
{String(props.toolsPagination.paginated)}
|
||||
</span>
|
||||
<span data-testid="tools-loaded-pages">
|
||||
{props.toolsPagination.loadedPages}
|
||||
</span>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(true)}>
|
||||
paginated-on
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onPaginatedChange(false)}>
|
||||
paginated-off
|
||||
</button>
|
||||
<button onClick={() => props.toolsPagination.onLoadMore()}>
|
||||
load-more-tools
|
||||
</button>
|
||||
<button onClick={() => props.onRefreshTools()}>refresh-tools</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
|
@ -1889,5 +1985,100 @@ describe("App OAuth resume lifecycle", () => {
|
|||
INSPECTOR_SERVERS_TAB,
|
||||
),
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("active-tab")).toHaveTextContent(
|
||||
INSPECTOR_SERVERS_TAB,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App paginated list pagination toggle (#1721)", () => {
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
updateServerSettingsSpy.mockClear();
|
||||
});
|
||||
|
||||
it("persists and live-pushes paginatedLists when the sidebar toggle flips", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false");
|
||||
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
|
||||
// Optimistic UI flip is immediate.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
// Persisted to the server settings (survives reconnects).
|
||||
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
|
||||
"A",
|
||||
expect.objectContaining({ paginatedLists: true }),
|
||||
);
|
||||
// Live-pushed to the client so the managed state's gating reads it now.
|
||||
const client = clientInstances[0] as unknown as {
|
||||
setServerSettings: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
expect(client.setServerSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paginatedLists: true }),
|
||||
);
|
||||
|
||||
// Toggling back off persists false.
|
||||
await user.click(screen.getByText("paginated-off"));
|
||||
await waitFor(() =>
|
||||
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
|
||||
"A",
|
||||
expect.objectContaining({ paginatedLists: false }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Refresh and Load-next-page in paginated mode and clears the indicator", async () => {
|
||||
const user = userEvent.setup();
|
||||
clearToolsListChangedSpy.mockClear();
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
// Exercise the mode-aware Refresh (paginated → reload page 1) and the
|
||||
// Load-next-page control; both should run without error.
|
||||
await user.click(screen.getByText("refresh-tools"));
|
||||
await user.click(screen.getByText("load-more-tools"));
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true");
|
||||
// The paginated Refresh must acknowledge the managed list-changed
|
||||
// indicator (the paged reload bypasses the managed hook's refresh) (#1721).
|
||||
expect(clearToolsListChangedSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reverts the optimistic toggle when persisting the setting fails (#1721)", async () => {
|
||||
const user = userEvent.setup();
|
||||
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
// The optimistic flip is rolled back once the persist rejects, so the UI
|
||||
// reflects the (unchanged) persisted value rather than the failed edit.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"),
|
||||
);
|
||||
// The live client setting was rolled back too (last call reverts it).
|
||||
const client = clientInstances[0] as unknown as {
|
||||
setServerSettings: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
|
||||
paginatedLists?: boolean;
|
||||
};
|
||||
expect(lastPush?.paginatedLists).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ import {
|
|||
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
|
||||
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js";
|
||||
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
|
||||
import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState.js";
|
||||
import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState.js";
|
||||
import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState.js";
|
||||
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
|
||||
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
|
||||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||
|
|
@ -91,6 +94,11 @@ import { getWebRemoteOAuthStorage } from "./lib/remoteOAuthStorage";
|
|||
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
||||
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
|
||||
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
||||
import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
|
||||
import { usePagedPrompts } from "@inspector/core/react/usePagedPrompts.js";
|
||||
import { usePagedResources } from "@inspector/core/react/usePagedResources.js";
|
||||
import { usePaginatedList } from "./hooks/usePaginatedList";
|
||||
import type { ListPaginationControlsProps } from "./components/elements/ListPaginationControls/ListPaginationControls";
|
||||
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
|
||||
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
||||
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
|
||||
|
|
@ -361,6 +369,7 @@ const EMPTY_SETTINGS: InspectorServerSettings = {
|
|||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
paginatedLists: false,
|
||||
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
||||
roots: [],
|
||||
};
|
||||
|
|
@ -745,6 +754,14 @@ function App() {
|
|||
useState<ManagedPromptsState | null>(null);
|
||||
const [managedResourcesState, setManagedResourcesState] =
|
||||
useState<ManagedResourcesState | null>(null);
|
||||
// Paged (paginated) counterparts, used when the `paginatedLists` setting
|
||||
// is on (#1721). Created/destroyed alongside the managed states.
|
||||
const [pagedToolsState, setPagedToolsState] =
|
||||
useState<PagedToolsState | null>(null);
|
||||
const [pagedPromptsState, setPagedPromptsState] =
|
||||
useState<PagedPromptsState | null>(null);
|
||||
const [pagedResourcesState, setPagedResourcesState] =
|
||||
useState<PagedResourcesState | null>(null);
|
||||
const [managedResourceTemplatesState, setManagedResourceTemplatesState] =
|
||||
useState<ManagedResourceTemplatesState | null>(null);
|
||||
const [managedRequestorTasksState, setManagedRequestorTasksState] =
|
||||
|
|
@ -912,22 +929,93 @@ function App() {
|
|||
lastError,
|
||||
} = useInspectorClient(inspectorClient);
|
||||
const {
|
||||
tools,
|
||||
tools: managedTools,
|
||||
listChanged: toolsListChanged,
|
||||
refresh: refreshTools,
|
||||
clearListChanged: clearToolsListChanged,
|
||||
} = useManagedTools(inspectorClient, managedToolsState);
|
||||
const {
|
||||
prompts,
|
||||
prompts: managedPrompts,
|
||||
listChanged: promptsListChanged,
|
||||
refresh: refreshPrompts,
|
||||
clearListChanged: clearPromptsListChanged,
|
||||
} = useManagedPrompts(inspectorClient, managedPromptsState);
|
||||
const {
|
||||
resources,
|
||||
resources: managedResources,
|
||||
listChanged: resourcesListChanged,
|
||||
refresh: refreshResources,
|
||||
clearListChanged: clearResourcesListChanged,
|
||||
} = useManagedResources(inspectorClient, managedResourcesState);
|
||||
const { resourceTemplates, refresh: refreshResourceTemplates } =
|
||||
useManagedResourceTemplates(inspectorClient, managedResourceTemplatesState);
|
||||
// Paged (paginated) list sources. When `paginatedLists` is on the managed
|
||||
// states skip their all-page walk and these drive the sidebar instead (#1721).
|
||||
const {
|
||||
tools: pagedTools,
|
||||
nextCursor: pagedToolsCursor,
|
||||
pageCount: pagedToolsPageCount,
|
||||
loadPage: loadToolsPage,
|
||||
} = usePagedTools(inspectorClient, pagedToolsState);
|
||||
const {
|
||||
prompts: pagedPrompts,
|
||||
nextCursor: pagedPromptsCursor,
|
||||
pageCount: pagedPromptsPageCount,
|
||||
loadPage: loadPromptsPage,
|
||||
} = usePagedPrompts(inspectorClient, pagedPromptsState);
|
||||
const {
|
||||
resources: pagedResources,
|
||||
nextCursor: pagedResourcesCursor,
|
||||
pageCount: pagedResourcesPageCount,
|
||||
loadPage: loadResourcesPage,
|
||||
} = usePagedResources(inspectorClient, pagedResourcesState);
|
||||
// The active server's persisted paginated setting drives the display mode.
|
||||
// The sidebar toggle edits it (optimistically, below) and persists it.
|
||||
const persistedPaginatedLists =
|
||||
servers.find((s) => s.id === activeServerId)?.settings?.paginatedLists ??
|
||||
false;
|
||||
const [paginatedListsOverride, setPaginatedListsOverride] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
// Drop the optimistic override once the persisted value catches up (or the
|
||||
// active server changes), so the persisted setting is the resting source.
|
||||
useEffect(() => {
|
||||
setPaginatedListsOverride(null);
|
||||
}, [persistedPaginatedLists, activeServerId]);
|
||||
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
|
||||
const connected = connectionStatus === "connected";
|
||||
const toolsPagination = usePaginatedList({
|
||||
connected,
|
||||
paginated: paginatedLists,
|
||||
managedItems: managedTools,
|
||||
managedRefresh: refreshTools,
|
||||
pagedItems: pagedTools,
|
||||
pagedNextCursor: pagedToolsCursor,
|
||||
pagedPageCount: pagedToolsPageCount,
|
||||
loadPage: loadToolsPage,
|
||||
});
|
||||
const promptsPagination = usePaginatedList({
|
||||
connected,
|
||||
paginated: paginatedLists,
|
||||
managedItems: managedPrompts,
|
||||
managedRefresh: refreshPrompts,
|
||||
pagedItems: pagedPrompts,
|
||||
pagedNextCursor: pagedPromptsCursor,
|
||||
pagedPageCount: pagedPromptsPageCount,
|
||||
loadPage: loadPromptsPage,
|
||||
});
|
||||
const resourcesPagination = usePaginatedList({
|
||||
connected,
|
||||
paginated: paginatedLists,
|
||||
managedItems: managedResources,
|
||||
managedRefresh: refreshResources,
|
||||
pagedItems: pagedResources,
|
||||
pagedNextCursor: pagedResourcesCursor,
|
||||
pagedPageCount: pagedResourcesPageCount,
|
||||
loadPage: loadResourcesPage,
|
||||
});
|
||||
const tools = toolsPagination.items;
|
||||
const prompts = promptsPagination.items;
|
||||
const resources = resourcesPagination.items;
|
||||
const {
|
||||
tasks,
|
||||
refresh: refreshTasks,
|
||||
|
|
@ -2067,6 +2155,9 @@ function App() {
|
|||
managedToolsState?.destroy();
|
||||
managedPromptsState?.destroy();
|
||||
managedResourcesState?.destroy();
|
||||
pagedToolsState?.destroy();
|
||||
pagedPromptsState?.destroy();
|
||||
pagedResourcesState?.destroy();
|
||||
managedResourceTemplatesState?.destroy();
|
||||
managedRequestorTasksState?.destroy();
|
||||
resourceSubscriptionsState?.destroy();
|
||||
|
|
@ -2166,6 +2257,9 @@ function App() {
|
|||
|
||||
setInspectorClient(client);
|
||||
setManagedToolsState(new ManagedToolsState(client));
|
||||
setPagedToolsState(new PagedToolsState(client));
|
||||
setPagedPromptsState(new PagedPromptsState(client));
|
||||
setPagedResourcesState(new PagedResourcesState(client));
|
||||
setManagedPromptsState(new ManagedPromptsState(client));
|
||||
const nextResourcesState = new ManagedResourcesState(client);
|
||||
setManagedResourcesState(nextResourcesState);
|
||||
|
|
@ -2204,6 +2298,9 @@ function App() {
|
|||
managedToolsState,
|
||||
managedPromptsState,
|
||||
managedResourcesState,
|
||||
pagedToolsState,
|
||||
pagedPromptsState,
|
||||
pagedResourcesState,
|
||||
managedResourceTemplatesState,
|
||||
managedRequestorTasksState,
|
||||
resourceSubscriptionsState,
|
||||
|
|
@ -3146,18 +3243,189 @@ function App() {
|
|||
[inspectorClient, runWithCommandAuthRecovery],
|
||||
);
|
||||
|
||||
// Refresh acts per pagination mode: in paginated mode reload page 1 (the
|
||||
// paged state); in all-pages mode re-fetch the whole aggregate with auth
|
||||
// recovery (the pre-existing path). See usePaginatedList / #1721.
|
||||
const onRefreshTools = useCallback(() => {
|
||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
||||
}, [refreshTools, runWithCommandAuthRecovery]);
|
||||
if (paginatedLists) {
|
||||
// Paginated refresh reloads page 1 of the paged store, bypassing the
|
||||
// managed hook's refresh — so acknowledge the list-changed indicator here
|
||||
// (the managed state lit it on `list_changed`; nothing else clears it in
|
||||
// paginated mode) (#1721).
|
||||
clearToolsListChanged();
|
||||
void runWithCommandAuthRecovery(
|
||||
() => toolsPagination.onRefresh(),
|
||||
"ambient",
|
||||
);
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
||||
}
|
||||
}, [
|
||||
paginatedLists,
|
||||
toolsPagination,
|
||||
refreshTools,
|
||||
clearToolsListChanged,
|
||||
runWithCommandAuthRecovery,
|
||||
]);
|
||||
const onRefreshPrompts = useCallback(() => {
|
||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
||||
}, [refreshPrompts, runWithCommandAuthRecovery]);
|
||||
if (paginatedLists) {
|
||||
clearPromptsListChanged();
|
||||
void runWithCommandAuthRecovery(
|
||||
() => promptsPagination.onRefresh(),
|
||||
"ambient",
|
||||
);
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
||||
}
|
||||
}, [
|
||||
paginatedLists,
|
||||
promptsPagination,
|
||||
refreshPrompts,
|
||||
clearPromptsListChanged,
|
||||
runWithCommandAuthRecovery,
|
||||
]);
|
||||
const onRefreshResources = useCallback(() => {
|
||||
void runWithCommandAuthRecovery(async () => {
|
||||
await refreshResources();
|
||||
await refreshResourceTemplates();
|
||||
}, "ambient");
|
||||
}, [refreshResources, refreshResourceTemplates, runWithCommandAuthRecovery]);
|
||||
if (paginatedLists) {
|
||||
clearResourcesListChanged();
|
||||
void runWithCommandAuthRecovery(
|
||||
() => resourcesPagination.onRefresh(),
|
||||
"ambient",
|
||||
);
|
||||
// Resource templates always use the managed (aggregate) path.
|
||||
void runWithCommandAuthRecovery(
|
||||
() => refreshResourceTemplates(),
|
||||
"ambient",
|
||||
);
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(async () => {
|
||||
await refreshResources();
|
||||
await refreshResourceTemplates();
|
||||
}, "ambient");
|
||||
}
|
||||
}, [
|
||||
paginatedLists,
|
||||
resourcesPagination,
|
||||
refreshResources,
|
||||
refreshResourceTemplates,
|
||||
clearResourcesListChanged,
|
||||
runWithCommandAuthRecovery,
|
||||
]);
|
||||
// The per-list sidebar toggle edits the server-wide `paginatedLists` setting:
|
||||
// optimistic override for an instant flip, live push so the managed state's
|
||||
// gating reads it now, and a persisted PUT so it survives reconnects (#1721).
|
||||
const onTogglePaginatedLists = useCallback(
|
||||
(value: boolean) => {
|
||||
setPaginatedListsOverride(value);
|
||||
const current = servers.find((s) => s.id === activeServerId);
|
||||
if (!current || activeServerId === undefined) return;
|
||||
const prevSettings = current.settings ?? EMPTY_SETTINGS;
|
||||
const next: InspectorServerSettings = {
|
||||
...prevSettings,
|
||||
paginatedLists: value,
|
||||
};
|
||||
inspectorClient?.setServerSettings(next);
|
||||
// Drive the load that the mode change implies (data-loading stays out of
|
||||
// React effects; the paged stores own only the connect-time load). To
|
||||
// paginated: pull page 1 into each paged store. To all-pages: refetch
|
||||
// each managed aggregate that was gated off. Only when connected.
|
||||
if (connected) {
|
||||
// Wrap in ambient auth recovery so a mid-session 401 triggers re-auth
|
||||
// rather than surfacing raw, matching the all-pages refresh path.
|
||||
if (value) {
|
||||
void runWithCommandAuthRecovery(
|
||||
() => loadToolsPage(undefined),
|
||||
"ambient",
|
||||
);
|
||||
void runWithCommandAuthRecovery(
|
||||
() => loadPromptsPage(undefined),
|
||||
"ambient",
|
||||
);
|
||||
void runWithCommandAuthRecovery(
|
||||
() => loadResourcesPage(undefined),
|
||||
"ambient",
|
||||
);
|
||||
} else {
|
||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
||||
void runWithCommandAuthRecovery(() => refreshResources(), "ambient");
|
||||
}
|
||||
}
|
||||
void updateServerSettings(activeServerId, next).catch((err: unknown) => {
|
||||
// Persist failed: revert the optimistic override (the effect only
|
||||
// clears it when the persisted value changes, which won't happen here)
|
||||
// and roll the live client setting back, so the UI and client reflect
|
||||
// the value that's actually on disk rather than the failed edit (#1721).
|
||||
setPaginatedListsOverride(null);
|
||||
inspectorClient?.setServerSettings(prevSettings);
|
||||
notifications.show({
|
||||
title: "Failed to save pagination setting",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
servers,
|
||||
activeServerId,
|
||||
inspectorClient,
|
||||
updateServerSettings,
|
||||
connected,
|
||||
loadToolsPage,
|
||||
loadPromptsPage,
|
||||
loadResourcesPage,
|
||||
refreshTools,
|
||||
refreshPrompts,
|
||||
refreshResources,
|
||||
runWithCommandAuthRecovery,
|
||||
],
|
||||
);
|
||||
// Wrap Load-next-page in ambient auth recovery too, so a paginated
|
||||
// paginated fetch that hits a 401 recovers like the all-pages path (#1721).
|
||||
const onLoadMoreTools = useCallback(
|
||||
() =>
|
||||
void runWithCommandAuthRecovery(
|
||||
() => toolsPagination.onLoadMore(),
|
||||
"ambient",
|
||||
),
|
||||
[toolsPagination, runWithCommandAuthRecovery],
|
||||
);
|
||||
const onLoadMorePrompts = useCallback(
|
||||
() =>
|
||||
void runWithCommandAuthRecovery(
|
||||
() => promptsPagination.onLoadMore(),
|
||||
"ambient",
|
||||
),
|
||||
[promptsPagination, runWithCommandAuthRecovery],
|
||||
);
|
||||
const onLoadMoreResources = useCallback(
|
||||
() =>
|
||||
void runWithCommandAuthRecovery(
|
||||
() => resourcesPagination.onLoadMore(),
|
||||
"ambient",
|
||||
),
|
||||
[resourcesPagination, runWithCommandAuthRecovery],
|
||||
);
|
||||
const toolsPaginationControls: ListPaginationControlsProps = {
|
||||
paginated: toolsPagination.paginated,
|
||||
onPaginatedChange: onTogglePaginatedLists,
|
||||
canLoadMore: toolsPagination.canLoadMore,
|
||||
loadedPages: toolsPagination.loadedPages,
|
||||
onLoadMore: onLoadMoreTools,
|
||||
};
|
||||
const promptsPaginationControls: ListPaginationControlsProps = {
|
||||
paginated: promptsPagination.paginated,
|
||||
onPaginatedChange: onTogglePaginatedLists,
|
||||
canLoadMore: promptsPagination.canLoadMore,
|
||||
loadedPages: promptsPagination.loadedPages,
|
||||
onLoadMore: onLoadMorePrompts,
|
||||
};
|
||||
const resourcesPaginationControls: ListPaginationControlsProps = {
|
||||
paginated: resourcesPagination.paginated,
|
||||
onPaginatedChange: onTogglePaginatedLists,
|
||||
canLoadMore: resourcesPagination.canLoadMore,
|
||||
loadedPages: resourcesPagination.loadedPages,
|
||||
onLoadMore: onLoadMoreResources,
|
||||
};
|
||||
const onRefreshTasks = useCallback(() => {
|
||||
void runWithCommandAuthRecovery(() => refreshTasks(), "ambient")?.catch(
|
||||
(err: unknown) => {
|
||||
|
|
@ -3339,6 +3607,9 @@ function App() {
|
|||
managedToolsState?.destroy();
|
||||
managedPromptsState?.destroy();
|
||||
managedResourcesState?.destroy();
|
||||
pagedToolsState?.destroy();
|
||||
pagedPromptsState?.destroy();
|
||||
pagedResourcesState?.destroy();
|
||||
managedResourceTemplatesState?.destroy();
|
||||
managedRequestorTasksState?.destroy();
|
||||
resourceSubscriptionsState?.destroy();
|
||||
|
|
@ -3349,6 +3620,9 @@ function App() {
|
|||
setManagedToolsState(null);
|
||||
setManagedPromptsState(null);
|
||||
setManagedResourcesState(null);
|
||||
setPagedToolsState(null);
|
||||
setPagedPromptsState(null);
|
||||
setPagedResourcesState(null);
|
||||
setManagedResourceTemplatesState(null);
|
||||
setManagedRequestorTasksState(null);
|
||||
setResourceSubscriptionsState(null);
|
||||
|
|
@ -3366,6 +3640,9 @@ function App() {
|
|||
managedToolsState,
|
||||
managedPromptsState,
|
||||
managedResourcesState,
|
||||
pagedToolsState,
|
||||
pagedPromptsState,
|
||||
pagedResourcesState,
|
||||
managedResourceTemplatesState,
|
||||
managedRequestorTasksState,
|
||||
resourceSubscriptionsState,
|
||||
|
|
@ -3962,6 +4239,9 @@ function App() {
|
|||
onClearToolResult={onClearToolResult}
|
||||
onReadResourceContents={onReadResourceContents}
|
||||
onRefreshTools={onRefreshTools}
|
||||
toolsPagination={toolsPaginationControls}
|
||||
promptsPagination={promptsPaginationControls}
|
||||
resourcesPagination={resourcesPaginationControls}
|
||||
onPromptsUiChange={setPromptsUi}
|
||||
onGetPrompt={(name, args) => {
|
||||
void onGetPrompt(name, args);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { ListPaginationControls } from "./ListPaginationControls";
|
||||
|
||||
const meta: Meta<typeof ListPaginationControls> = {
|
||||
title: "Elements/ListPaginationControls",
|
||||
component: ListPaginationControls,
|
||||
args: {
|
||||
onPaginatedChange: fn(),
|
||||
onLoadMore: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ListPaginationControls>;
|
||||
|
||||
export const AllPages: Story = {
|
||||
args: {
|
||||
paginated: false,
|
||||
canLoadMore: false,
|
||||
loadedPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const SinglePageWithMore: Story = {
|
||||
args: {
|
||||
paginated: true,
|
||||
canLoadMore: true,
|
||||
loadedPages: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export const SinglePageAtEnd: Story = {
|
||||
args: {
|
||||
paginated: true,
|
||||
canLoadMore: false,
|
||||
loadedPages: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const SinglePageFirstPage: Story = {
|
||||
args: {
|
||||
paginated: true,
|
||||
canLoadMore: true,
|
||||
loadedPages: 1,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ListPaginationControls } from "./ListPaginationControls";
|
||||
|
||||
const baseProps = {
|
||||
paginated: false,
|
||||
onPaginatedChange: () => {},
|
||||
canLoadMore: false,
|
||||
loadedPages: 0,
|
||||
onLoadMore: () => {},
|
||||
};
|
||||
|
||||
describe("ListPaginationControls", () => {
|
||||
it("renders only the 'Paginated' switch in all-pages mode (no load-more)", () => {
|
||||
renderWithMantine(<ListPaginationControls {...baseProps} />);
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(screen.getByText("Paginated")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Load next page" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles paginated mode via the switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPaginatedChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
onPaginatedChange={onPaginatedChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("switch"));
|
||||
expect(onPaginatedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows the load-more button and page count in paginated mode", () => {
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
paginated
|
||||
canLoadMore
|
||||
loadedPages={2}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("switch")).toBeChecked();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load next page" }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByText("2 pages loaded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the singular label for one page (while more remain)", () => {
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
paginated
|
||||
canLoadMore
|
||||
loadedPages={1}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("1 page loaded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the whole control when the list is a single page", () => {
|
||||
// Paginated mode, page 1 loaded, no next cursor → nothing to paginate.
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
paginated
|
||||
canLoadMore={false}
|
||||
loadedPages={1}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Load next page" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still shows the switch while a paginated load is pending (0 pages)", () => {
|
||||
// loadedPages 0 (page 1 not yet loaded) must not trip the paginated hide.
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
paginated
|
||||
canLoadMore={false}
|
||||
loadedPages={0}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the end when there are multiple pages and no more to load", () => {
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
paginated
|
||||
canLoadMore={false}
|
||||
loadedPages={3}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load next page" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText("3 pages loaded · end")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onLoadMore when the button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onLoadMore = vi.fn();
|
||||
renderWithMantine(
|
||||
<ListPaginationControls
|
||||
{...baseProps}
|
||||
paginated
|
||||
canLoadMore
|
||||
loadedPages={1}
|
||||
onLoadMore={onLoadMore}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Load next page" }));
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
|
||||
|
||||
export interface ListPaginationControlsProps {
|
||||
/**
|
||||
* True when the list is fetched one page at a time (backed by the server's
|
||||
* `paginatedLists` setting). False = auto-aggregate every page on load.
|
||||
*/
|
||||
paginated: boolean;
|
||||
/** Toggle paginated mode. Wired to write the `paginatedLists` setting. */
|
||||
onPaginatedChange: (paginated: boolean) => void;
|
||||
/** Paginated mode only: the server returned a `nextCursor` to load. */
|
||||
canLoadMore: boolean;
|
||||
/** Paginated mode only: number of pages loaded so far (status label). */
|
||||
loadedPages: number;
|
||||
/** Paginated mode only: fetch the next page. */
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
// Switch fully left, "Load next page" fully right; the page-count sits on its
|
||||
// own line under the row.
|
||||
const ControlsRow = Group.withProps({
|
||||
gap: "sm",
|
||||
align: "center",
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const ModeSwitch = Switch.withProps({
|
||||
size: "sm",
|
||||
"aria-label": "Fetch lists one page at a time",
|
||||
});
|
||||
|
||||
const LoadMoreButton = Button.withProps({
|
||||
size: "compact-sm",
|
||||
variant: "light",
|
||||
});
|
||||
|
||||
const StatusText = Text.withProps({
|
||||
size: "xs",
|
||||
ta: "center",
|
||||
c: "var(--inspector-text-secondary)",
|
||||
});
|
||||
|
||||
/**
|
||||
* Sidebar control for a paginated list (Tools/Resources/Prompts). A "Paginated"
|
||||
* switch toggles between auto-aggregating every page and fetching one page at a
|
||||
* time; in paginated mode a "Load next page" button (to the right of the
|
||||
* switch) surfaces the server's `nextCursor` and a status shows how many pages
|
||||
* are loaded. Hidden entirely once the list is known to be a single page, since
|
||||
* there's nothing to paginate.
|
||||
*/
|
||||
export function ListPaginationControls({
|
||||
paginated,
|
||||
onPaginatedChange,
|
||||
canLoadMore,
|
||||
loadedPages,
|
||||
onLoadMore,
|
||||
}: ListPaginationControlsProps) {
|
||||
// The list turned out to be a single page (loaded page 1, no `nextCursor`):
|
||||
// pagination is moot, so hide the whole control rather than show a useless
|
||||
// toggle + disabled button (#1721).
|
||||
if (paginated && !canLoadMore && loadedPages === 1) return null;
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<ControlsRow>
|
||||
<ModeSwitch
|
||||
label="Paginated"
|
||||
checked={paginated}
|
||||
onChange={(e) => onPaginatedChange(e.currentTarget.checked)}
|
||||
/>
|
||||
{paginated ? (
|
||||
<LoadMoreButton disabled={!canLoadMore} onClick={onLoadMore}>
|
||||
Load next page
|
||||
</LoadMoreButton>
|
||||
) : null}
|
||||
</ControlsRow>
|
||||
{paginated ? (
|
||||
<StatusText>
|
||||
{loadedPages} {loadedPages === 1 ? "page" : "pages"} loaded
|
||||
{canLoadMore ? "" : " · end"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import { fn } from "storybook/test";
|
||||
import { PromptControls } from "./PromptControls";
|
||||
|
|
@ -7,6 +8,7 @@ const meta: Meta<typeof PromptControls> = {
|
|||
title: "Groups/PromptControls",
|
||||
component: PromptControls,
|
||||
args: {
|
||||
pagination: noopPagination,
|
||||
onRefreshList: fn(),
|
||||
onSelectPrompt: fn(),
|
||||
onSearchChange: fn(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { PromptControls, type PromptControlsProps } from "./PromptControls";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
|
||||
const samplePrompts: Prompt[] = [
|
||||
{
|
||||
|
|
@ -25,6 +26,7 @@ const baseProps = {
|
|||
onRefreshList: vi.fn(),
|
||||
onSelectPrompt: vi.fn(),
|
||||
onSearchChange: vi.fn(),
|
||||
pagination: noopPagination,
|
||||
};
|
||||
|
||||
// The search box is controlled: typing fires onSearchChange but does not
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
|
|||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import {
|
||||
ListPaginationControls,
|
||||
type ListPaginationControlsProps,
|
||||
} from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import { PromptListItem } from "../PromptListItem/PromptListItem";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
|
|
@ -13,6 +17,8 @@ export interface PromptControlsProps {
|
|||
searchText?: string;
|
||||
listChanged: boolean;
|
||||
onRefreshList: () => void;
|
||||
/** Pagination controls (#1721). */
|
||||
pagination: ListPaginationControlsProps;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectPrompt: (name: string) => void;
|
||||
}
|
||||
|
|
@ -23,6 +29,7 @@ export function PromptControls({
|
|||
searchText = "",
|
||||
listChanged,
|
||||
onRefreshList,
|
||||
pagination,
|
||||
onSearchChange,
|
||||
onSelectPrompt,
|
||||
}: PromptControlsProps) {
|
||||
|
|
@ -53,6 +60,7 @@ export function PromptControls({
|
|||
searchText ? <ClearButton onClick={() => onSearchChange("")} /> : null
|
||||
}
|
||||
/>
|
||||
<ListPaginationControls {...pagination} />
|
||||
<ScrollArea viewportRef={viewportRef} flex={1} mih={0}>
|
||||
<Stack gap="xs">
|
||||
{filteredPrompts.map((prompt) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
import type {
|
||||
Resource,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
|
|
@ -26,6 +27,7 @@ const meta: Meta<typeof ResourceControls> = {
|
|||
title: "Groups/ResourceControls",
|
||||
component: ResourceControls,
|
||||
args: {
|
||||
pagination: noopPagination,
|
||||
onRefreshList: fn(),
|
||||
onSelectUri: fn(),
|
||||
onSelectTemplate: fn(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ResourceControls,
|
||||
type ResourceControlsProps,
|
||||
} from "./ResourceControls";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
|
||||
const sampleResources: Resource[] = [
|
||||
{ name: "config.json", uri: "file:///config.json" },
|
||||
|
|
@ -41,6 +42,7 @@ const baseProps = {
|
|||
onUnsubscribeResource: vi.fn(),
|
||||
compact: false,
|
||||
onCompactChange: vi.fn(),
|
||||
pagination: noopPagination,
|
||||
};
|
||||
|
||||
// ResourceControls is controlled: search text + accordion open-sections live in
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js";
|
||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import {
|
||||
ListPaginationControls,
|
||||
type ListPaginationControlsProps,
|
||||
} from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { ResourceListItem } from "../ResourceListItem/ResourceListItem";
|
||||
import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem";
|
||||
|
|
@ -32,6 +36,8 @@ export interface ResourceControlsProps {
|
|||
openSections?: string[];
|
||||
listChanged: boolean;
|
||||
onRefreshList: () => void;
|
||||
/** Pagination controls for the Resources list (#1721). */
|
||||
pagination: ListPaginationControlsProps;
|
||||
onSearchChange: (value: string) => void;
|
||||
onOpenSectionsChange: (value: string[]) => void;
|
||||
onSelectUri: (uri: string) => void;
|
||||
|
|
@ -71,6 +77,7 @@ export function ResourceControls({
|
|||
openSections: controlledOpenSections,
|
||||
listChanged,
|
||||
onRefreshList,
|
||||
pagination,
|
||||
onSearchChange,
|
||||
onOpenSectionsChange,
|
||||
onSelectUri,
|
||||
|
|
@ -186,6 +193,7 @@ export function ResourceControls({
|
|||
/>
|
||||
<ListToggle compact={!allExpanded} onToggle={handleToggleList} />
|
||||
</Group>
|
||||
<ListPaginationControls {...pagination} />
|
||||
<Accordion
|
||||
multiple
|
||||
variant="disclosure"
|
||||
|
|
|
|||
|
|
@ -131,6 +131,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
|||
settings: { ...args.settings, autoRefreshOnListChanged: value },
|
||||
});
|
||||
}}
|
||||
onPaginatedListsChange={(value) => {
|
||||
args.onPaginatedListsChange(value);
|
||||
updateArgs({
|
||||
settings: { ...args.settings, paginatedLists: value },
|
||||
});
|
||||
}}
|
||||
onMaxFetchRequestsChange={(value) => {
|
||||
args.onMaxFetchRequestsChange(value);
|
||||
updateArgs({
|
||||
|
|
@ -178,6 +184,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
|||
onMetadataChange: fn(),
|
||||
onTimeoutChange: fn(),
|
||||
onAutoRefreshChange: fn(),
|
||||
onPaginatedListsChange: fn(),
|
||||
onMaxFetchRequestsChange: fn(),
|
||||
onProtocolEraChange: fn(),
|
||||
onOAuthChange: fn(),
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const baseHandlers = {
|
|||
onMetadataChange: vi.fn(),
|
||||
onTimeoutChange: vi.fn(),
|
||||
onAutoRefreshChange: vi.fn(),
|
||||
onPaginatedListsChange: vi.fn(),
|
||||
onMaxFetchRequestsChange: vi.fn(),
|
||||
onProtocolEraChange: vi.fn(),
|
||||
onOAuthChange: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export interface ServerSettingsFormProps {
|
|||
value: number,
|
||||
) => void;
|
||||
onAutoRefreshChange: (value: boolean) => void;
|
||||
onPaginatedListsChange: (value: boolean) => void;
|
||||
onMaxFetchRequestsChange: (value: number) => void;
|
||||
onProtocolEraChange: (value: ServerProtocolEra) => void;
|
||||
onOAuthChange: (oauth: OAuthSettings) => void;
|
||||
|
|
@ -239,6 +240,7 @@ export function ServerSettingsForm({
|
|||
onMetadataChange,
|
||||
onTimeoutChange,
|
||||
onAutoRefreshChange,
|
||||
onPaginatedListsChange,
|
||||
onMaxFetchRequestsChange,
|
||||
onProtocolEraChange,
|
||||
onOAuthChange,
|
||||
|
|
@ -314,6 +316,12 @@ export function ServerSettingsForm({
|
|||
checked={settings.autoRefreshOnListChanged ?? false}
|
||||
onChange={(e) => onAutoRefreshChange(e.currentTarget.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Fetch Lists One Page at a Time"
|
||||
description="When checked, the Tools, Resources, and Prompts lists load a single page and reveal a “Load next page” control instead of auto-loading every page. Useful defensively for servers with very large lists. The per-list sidebar toggle sets this too."
|
||||
checked={settings.paginatedLists ?? false}
|
||||
onChange={(e) => onPaginatedListsChange(e.currentTarget.checked)}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Network Log Size"
|
||||
description="Maximum number of HTTP requests kept in the Network log for this server. Older entries rotate out past this limit; a response body that arrives after its entry rotated out is dropped. Use 0 for unlimited (not recommended). Applies immediately to the active connection."
|
||||
|
|
|
|||
|
|
@ -162,6 +162,10 @@ export function ServerSettingsModal({
|
|||
onSettingsChange({ ...settings, autoRefreshOnListChanged: value });
|
||||
}
|
||||
|
||||
function handlePaginatedListsChange(value: boolean) {
|
||||
onSettingsChange({ ...settings, paginatedLists: value });
|
||||
}
|
||||
|
||||
function handleMaxFetchRequestsChange(value: number) {
|
||||
onSettingsChange({ ...settings, maxFetchRequests: value });
|
||||
}
|
||||
|
|
@ -232,6 +236,7 @@ export function ServerSettingsModal({
|
|||
onMetadataChange={handleMetadataChange}
|
||||
onTimeoutChange={handleTimeoutChange}
|
||||
onAutoRefreshChange={handleAutoRefreshChange}
|
||||
onPaginatedListsChange={handlePaginatedListsChange}
|
||||
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
|
||||
onProtocolEraChange={handleProtocolEraChange}
|
||||
onOAuthChange={handleOAuthChange}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn, userEvent, within } from "storybook/test";
|
||||
|
|
@ -23,6 +24,7 @@ const meta: Meta<typeof ToolControls> = {
|
|||
title: "Groups/ToolControls",
|
||||
component: ToolControls,
|
||||
args: {
|
||||
pagination: noopPagination,
|
||||
onRefreshList: fn(),
|
||||
onSelectTool: fn(),
|
||||
onSearchChange: fn(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ToolControls, type ToolControlsProps } from "./ToolControls";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
|
||||
const sampleTools: Tool[] = [
|
||||
{ name: "list_files", title: "List Files", inputSchema: { type: "object" } },
|
||||
|
|
@ -22,6 +23,7 @@ const baseProps = {
|
|||
onRefreshList: vi.fn(),
|
||||
onSelectTool: vi.fn(),
|
||||
onSearchChange: vi.fn(),
|
||||
pagination: noopPagination,
|
||||
};
|
||||
|
||||
// The search box is controlled: typing fires onSearchChange but does not
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
|
|||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import {
|
||||
ListPaginationControls,
|
||||
type ListPaginationControlsProps,
|
||||
} from "../../elements/ListPaginationControls/ListPaginationControls";
|
||||
import { ToolListItem } from "../ToolListItem/ToolListItem";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
|
|
@ -13,6 +17,8 @@ export interface ToolControlsProps {
|
|||
searchText?: string;
|
||||
listChanged: boolean;
|
||||
onRefreshList: () => void;
|
||||
/** Pagination controls (#1721). */
|
||||
pagination: ListPaginationControlsProps;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectTool: (name: string) => void;
|
||||
}
|
||||
|
|
@ -23,6 +29,7 @@ export function ToolControls({
|
|||
searchText = "",
|
||||
listChanged,
|
||||
onRefreshList,
|
||||
pagination,
|
||||
onSearchChange,
|
||||
onSelectTool,
|
||||
}: ToolControlsProps) {
|
||||
|
|
@ -61,6 +68,7 @@ export function ToolControls({
|
|||
searchText ? <ClearButton onClick={() => onSearchChange("")} /> : null
|
||||
}
|
||||
/>
|
||||
<ListPaginationControls {...pagination} />
|
||||
<ScrollArea viewportRef={viewportRef} flex={1} mih={0}>
|
||||
<Stack gap="xs">
|
||||
{filteredTools.map((tool) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { noopPagination } from "../../../test/fixtures/pagination";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
|
|
@ -21,6 +22,7 @@ const meta: Meta<typeof PromptsScreen> = {
|
|||
component: PromptsScreen,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
pagination: noopPagination,
|
||||
ui: EMPTY_PROMPTS_UI,
|
||||
onUiChange: fn(),
|
||||
onRefreshList: fn(),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue