fix: surface list-fetch failures in paginated mode (#1998)
#1954 wired the list-load error off the aggregate managed stores only. With `paginatedLists` on, those stores deliberately skip their all-page walk, so their error is permanently null — while the paged stores that actually drive the sidebar had no error state at all and `loadPage` had a try/finally with no catch. A failing list showed an empty panel with no alert and no Retry, and the connect-time `void loadPage(undefined)` left an unhandled rejection. - Give PagedTools/Prompts/ResourcesState observable error state: an `errorChange` event plus `getError()`, set in a new catch in `loadPage`, cleared on the next success and on disconnect. The rejection is still re-thrown, matching ManagedListState — callers' auth-recovery wrappers key off it to detect a 401. - Catch the connect-time load's rejection (`.catch(() => {})`) now that the failure is recorded as state rather than lost. - Select the error by mode in `usePaginatedList`, the same way `items` already is, and read it in App.tsx from the pagination model. - Rename `useManagedListError` to `useListError`: both store families expose an identical `errorChange`, so the hook declares that one-event contract itself instead of importing ManagedListEventMap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
885228699e
commit
de60983ccc
21 changed files with 523 additions and 42 deletions
|
|
@ -1031,18 +1031,21 @@ function App() {
|
|||
tools: pagedTools,
|
||||
nextCursor: pagedToolsCursor,
|
||||
pageCount: pagedToolsPageCount,
|
||||
error: pagedToolsLoadError,
|
||||
loadPage: loadToolsPage,
|
||||
} = usePagedTools(inspectorClient, pagedToolsState);
|
||||
const {
|
||||
prompts: pagedPrompts,
|
||||
nextCursor: pagedPromptsCursor,
|
||||
pageCount: pagedPromptsPageCount,
|
||||
error: pagedPromptsLoadError,
|
||||
loadPage: loadPromptsPage,
|
||||
} = usePagedPrompts(inspectorClient, pagedPromptsState);
|
||||
const {
|
||||
resources: pagedResources,
|
||||
nextCursor: pagedResourcesCursor,
|
||||
pageCount: pagedResourcesPageCount,
|
||||
error: pagedResourcesLoadError,
|
||||
loadPage: loadResourcesPage,
|
||||
} = usePagedResources(inspectorClient, pagedResourcesState);
|
||||
// The active server's persisted paginated setting drives the display mode.
|
||||
|
|
@ -1073,9 +1076,11 @@ function App() {
|
|||
paginated: paginatedLists,
|
||||
managedItems: managedTools,
|
||||
managedRefresh: refreshTools,
|
||||
managedError: toolsLoadError,
|
||||
pagedItems: pagedTools,
|
||||
pagedNextCursor: pagedToolsCursor,
|
||||
pagedPageCount: pagedToolsPageCount,
|
||||
pagedError: pagedToolsLoadError,
|
||||
loadPage: loadToolsPage,
|
||||
});
|
||||
const promptsPagination = usePaginatedList({
|
||||
|
|
@ -1083,9 +1088,11 @@ function App() {
|
|||
paginated: paginatedLists,
|
||||
managedItems: managedPrompts,
|
||||
managedRefresh: refreshPrompts,
|
||||
managedError: promptsLoadError,
|
||||
pagedItems: pagedPrompts,
|
||||
pagedNextCursor: pagedPromptsCursor,
|
||||
pagedPageCount: pagedPromptsPageCount,
|
||||
pagedError: pagedPromptsLoadError,
|
||||
loadPage: loadPromptsPage,
|
||||
});
|
||||
const resourcesPagination = usePaginatedList({
|
||||
|
|
@ -1093,9 +1100,11 @@ function App() {
|
|||
paginated: paginatedLists,
|
||||
managedItems: managedResources,
|
||||
managedRefresh: refreshResources,
|
||||
managedError: resourcesLoadError,
|
||||
pagedItems: pagedResources,
|
||||
pagedNextCursor: pagedResourcesCursor,
|
||||
pagedPageCount: pagedResourcesPageCount,
|
||||
pagedError: pagedResourcesLoadError,
|
||||
loadPage: loadResourcesPage,
|
||||
});
|
||||
const tools = toolsPagination.items;
|
||||
|
|
@ -4407,9 +4416,11 @@ function App() {
|
|||
toolsListChanged={toolsListChanged}
|
||||
promptsListChanged={promptsListChanged}
|
||||
resourcesListChanged={resourcesListChanged}
|
||||
toolsLoadError={toolsLoadError}
|
||||
promptsLoadError={promptsLoadError}
|
||||
resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError}
|
||||
toolsLoadError={toolsPagination.error}
|
||||
promptsLoadError={promptsPagination.error}
|
||||
resourcesLoadError={
|
||||
resourcesPagination.error ?? resourceTemplatesLoadError
|
||||
}
|
||||
subscriptions={subscriptions}
|
||||
subscriptionStreamState={subscriptionStreamState}
|
||||
logs={logs}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ interface Params {
|
|||
paginated: boolean;
|
||||
managedItems: string[];
|
||||
managedRefresh: () => Promise<unknown>;
|
||||
managedError: Error | null;
|
||||
pagedItems: string[];
|
||||
pagedNextCursor?: string;
|
||||
pagedPageCount: number;
|
||||
pagedError: Error | null;
|
||||
loadPage: (cursor?: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
|
|
@ -19,9 +21,11 @@ function makeParams(over: Partial<Params> = {}): Params {
|
|||
paginated: false,
|
||||
managedItems: ["m1", "m2"],
|
||||
managedRefresh: vi.fn(async () => []),
|
||||
managedError: null,
|
||||
pagedItems: ["p1"],
|
||||
pagedNextCursor: undefined,
|
||||
pagedPageCount: 0,
|
||||
pagedError: null,
|
||||
loadPage: vi.fn(async () => ({})),
|
||||
...over,
|
||||
};
|
||||
|
|
@ -88,6 +92,35 @@ describe("usePaginatedList", () => {
|
|||
expect(loadPage).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
// #1998: the managed store deliberately never fetches in paginated mode, so
|
||||
// reading its (permanently null) error there would leave a failed page load
|
||||
// showing an empty panel with no alert and no Retry.
|
||||
it("reports the managed error in all-pages mode", () => {
|
||||
const managedError = new Error("aggregate failed");
|
||||
const pagedError = new Error("page failed");
|
||||
const params = makeParams({ managedError, pagedError });
|
||||
const { result } = renderHook(() => usePaginatedList(params));
|
||||
expect(result.current.error).toBe(managedError);
|
||||
});
|
||||
|
||||
it("reports the paged error in paginated mode", () => {
|
||||
const managedError = new Error("aggregate failed");
|
||||
const pagedError = new Error("page failed");
|
||||
const params = makeParams({ paginated: true, managedError, pagedError });
|
||||
const { result } = renderHook(() => usePaginatedList(params));
|
||||
expect(result.current.error).toBe(pagedError);
|
||||
});
|
||||
|
||||
it("reports no error when the active source succeeded", () => {
|
||||
const params = makeParams({
|
||||
paginated: true,
|
||||
managedError: new Error("aggregate failed"),
|
||||
pagedError: null,
|
||||
});
|
||||
const { result } = renderHook(() => usePaginatedList(params));
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("onRefresh re-fetches the aggregate in all-pages mode", () => {
|
||||
const managedRefresh = vi.fn(async () => []);
|
||||
const params = makeParams({ managedRefresh });
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ export interface PaginatedListModel<T> {
|
|||
* promise so the caller can wrap it in auth recovery.
|
||||
*/
|
||||
onLoadMore: () => Promise<unknown>;
|
||||
/**
|
||||
* The active source's last load failure, or `null`. Selected by mode for the
|
||||
* same reason `items` is: in paginated mode the managed store never fetches,
|
||||
* so its error is permanently `null` and reading it would leave a failed page
|
||||
* showing an empty panel with no alert and no Retry (#1998).
|
||||
*/
|
||||
error: Error | null;
|
||||
/**
|
||||
* Refresh the list: reload page 1 in paginated mode, or re-fetch the whole
|
||||
* aggregate in all-pages mode. This is what the list-changed indicator's
|
||||
|
|
@ -37,12 +44,16 @@ export interface UsePaginatedListParams<T> {
|
|||
managedItems: T[];
|
||||
/** Re-fetch the whole aggregate (all-pages mode Refresh). */
|
||||
managedRefresh: () => Promise<unknown>;
|
||||
/** The aggregate store's last-fetch error (all-pages mode). */
|
||||
managedError: Error | null;
|
||||
/** The accumulated paged list (paginated mode display source). */
|
||||
pagedItems: T[];
|
||||
/** The paged store's current `nextCursor` (undefined = at the end). */
|
||||
pagedNextCursor?: string;
|
||||
/** The paged store's page count (page 1 = 1). */
|
||||
pagedPageCount: number;
|
||||
/** The paged store's last page-load error (paginated mode). */
|
||||
pagedError: Error | null;
|
||||
/** Fetch one page; `undefined` cursor = page 1 (replaces the paged list). */
|
||||
loadPage: (cursor?: string) => Promise<unknown>;
|
||||
}
|
||||
|
|
@ -64,9 +75,11 @@ export function usePaginatedList<T>({
|
|||
paginated,
|
||||
managedItems,
|
||||
managedRefresh,
|
||||
managedError,
|
||||
pagedItems,
|
||||
pagedNextCursor,
|
||||
pagedPageCount,
|
||||
pagedError,
|
||||
loadPage,
|
||||
}: UsePaginatedListParams<T>): PaginatedListModel<T> {
|
||||
const onLoadMore = useCallback((): Promise<unknown> => {
|
||||
|
|
@ -80,6 +93,7 @@ export function usePaginatedList<T>({
|
|||
|
||||
return {
|
||||
items: paginated ? pagedItems : managedItems,
|
||||
error: paginated ? pagedError : managedError,
|
||||
paginated,
|
||||
// Masked by `connected`: while disconnected there is no page to load and no
|
||||
// meaningful page count (the store resets on disconnect).
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ function waitForChange(state: PagedPromptsState): Promise<Prompt[]> {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForError(state: PagedPromptsState): Promise<Error | null> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("errorChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedPromptsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedPromptsState;
|
||||
|
|
@ -201,4 +209,61 @@ describe("PagedPromptsState", () => {
|
|||
expect(client.listPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #1998: in paginated mode this store — not the managed one — drives the
|
||||
// sidebar, so a failed page must surface here or the panel renders empty
|
||||
// with no alert and no Retry.
|
||||
describe("load errors (#1998)", () => {
|
||||
it("records the failure, dispatches errorChange, and re-throws", async () => {
|
||||
client.setStatus("connected");
|
||||
const boom = new Error("list failed");
|
||||
client.listPrompts.mockRejectedValueOnce(boom);
|
||||
const errorPromise = waitForError(state);
|
||||
await expect(state.loadPage()).rejects.toThrow("list failed");
|
||||
expect(await errorPromise).toBe(boom);
|
||||
expect(state.getError()).toBe(boom);
|
||||
});
|
||||
|
||||
it("wraps a non-Error rejection", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listPrompts.mockRejectedValueOnce("plain string");
|
||||
await expect(state.loadPage()).rejects.toBe("plain string");
|
||||
expect(state.getError()?.message).toBe("plain string");
|
||||
});
|
||||
|
||||
it("clears the error on the next successful load", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listPrompts.mockRejectedValueOnce(new Error("list failed"));
|
||||
await expect(state.loadPage()).rejects.toThrow();
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
const cleared = waitForError(state);
|
||||
await state.loadPage();
|
||||
expect(await cleared).toBeNull();
|
||||
expect(state.getError()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the error on disconnect", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listPrompts.mockRejectedValueOnce(new Error("list failed"));
|
||||
await expect(state.loadPage()).rejects.toThrow();
|
||||
const cleared = waitForError(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await cleared).toBeNull();
|
||||
});
|
||||
|
||||
it("records a connect-time auto-load failure instead of floating it", async () => {
|
||||
const spClient = new FakeInspectorClient({
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new PagedPromptsState(spClient);
|
||||
const boom = new Error("connect-time list failed");
|
||||
spClient.listPrompts.mockRejectedValueOnce(boom);
|
||||
const errored = waitForError(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect(await errored).toBe(boom);
|
||||
expect(spState.getError()).toBe(boom);
|
||||
spState.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ function waitForChange(state: PagedResourcesState): Promise<Resource[]> {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForError(state: PagedResourcesState): Promise<Error | null> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("errorChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedResourcesState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedResourcesState;
|
||||
|
|
@ -206,4 +214,61 @@ describe("PagedResourcesState", () => {
|
|||
expect(client.listResources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #1998: in paginated mode this store — not the managed one — drives the
|
||||
// sidebar, so a failed page must surface here or the panel renders empty
|
||||
// with no alert and no Retry.
|
||||
describe("load errors (#1998)", () => {
|
||||
it("records the failure, dispatches errorChange, and re-throws", async () => {
|
||||
client.setStatus("connected");
|
||||
const boom = new Error("list failed");
|
||||
client.listResources.mockRejectedValueOnce(boom);
|
||||
const errorPromise = waitForError(state);
|
||||
await expect(state.loadPage()).rejects.toThrow("list failed");
|
||||
expect(await errorPromise).toBe(boom);
|
||||
expect(state.getError()).toBe(boom);
|
||||
});
|
||||
|
||||
it("wraps a non-Error rejection", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listResources.mockRejectedValueOnce("plain string");
|
||||
await expect(state.loadPage()).rejects.toBe("plain string");
|
||||
expect(state.getError()?.message).toBe("plain string");
|
||||
});
|
||||
|
||||
it("clears the error on the next successful load", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listResources.mockRejectedValueOnce(new Error("list failed"));
|
||||
await expect(state.loadPage()).rejects.toThrow();
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
const cleared = waitForError(state);
|
||||
await state.loadPage();
|
||||
expect(await cleared).toBeNull();
|
||||
expect(state.getError()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the error on disconnect", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listResources.mockRejectedValueOnce(new Error("list failed"));
|
||||
await expect(state.loadPage()).rejects.toThrow();
|
||||
const cleared = waitForError(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await cleared).toBeNull();
|
||||
});
|
||||
|
||||
it("records a connect-time auto-load failure instead of floating it", async () => {
|
||||
const spClient = new FakeInspectorClient({
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new PagedResourcesState(spClient);
|
||||
const boom = new Error("connect-time list failed");
|
||||
spClient.listResources.mockRejectedValueOnce(boom);
|
||||
const errored = waitForError(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect(await errored).toBe(boom);
|
||||
expect(spState.getError()).toBe(boom);
|
||||
spState.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ function waitForChange(state: PagedToolsState): Promise<Tool[]> {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForError(state: PagedToolsState): Promise<Error | null> {
|
||||
return new Promise((resolve) => {
|
||||
state.addEventListener("errorChange", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("PagedToolsState", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: PagedToolsState;
|
||||
|
|
@ -238,4 +246,61 @@ describe("PagedToolsState", () => {
|
|||
expect(state.getTools()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// #1998: in paginated mode this store — not the managed one — drives the
|
||||
// sidebar, so a failed page must surface here or the panel renders empty
|
||||
// with no alert and no Retry.
|
||||
describe("load errors (#1998)", () => {
|
||||
it("records the failure, dispatches errorChange, and re-throws", async () => {
|
||||
client.setStatus("connected");
|
||||
const boom = new Error("list failed");
|
||||
client.listTools.mockRejectedValueOnce(boom);
|
||||
const errorPromise = waitForError(state);
|
||||
await expect(state.loadPage()).rejects.toThrow("list failed");
|
||||
expect(await errorPromise).toBe(boom);
|
||||
expect(state.getError()).toBe(boom);
|
||||
});
|
||||
|
||||
it("wraps a non-Error rejection", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listTools.mockRejectedValueOnce("plain string");
|
||||
await expect(state.loadPage()).rejects.toBe("plain string");
|
||||
expect(state.getError()?.message).toBe("plain string");
|
||||
});
|
||||
|
||||
it("clears the error on the next successful load", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listTools.mockRejectedValueOnce(new Error("list failed"));
|
||||
await expect(state.loadPage()).rejects.toThrow();
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
const cleared = waitForError(state);
|
||||
await state.loadPage();
|
||||
expect(await cleared).toBeNull();
|
||||
expect(state.getError()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the error on disconnect", async () => {
|
||||
client.setStatus("connected");
|
||||
client.listTools.mockRejectedValueOnce(new Error("list failed"));
|
||||
await expect(state.loadPage()).rejects.toThrow();
|
||||
const cleared = waitForError(state);
|
||||
client.setStatus("disconnected");
|
||||
expect(await cleared).toBeNull();
|
||||
});
|
||||
|
||||
it("records a connect-time auto-load failure instead of floating it", async () => {
|
||||
const spClient = new FakeInspectorClient({
|
||||
serverSettings: PAGINATED_SETTINGS,
|
||||
});
|
||||
spClient.setStatus("connected");
|
||||
const spState = new PagedToolsState(spClient);
|
||||
const boom = new Error("connect-time list failed");
|
||||
spClient.listTools.mockRejectedValueOnce(boom);
|
||||
const errored = waitForError(spState);
|
||||
spClient.dispatchTypedEvent("connect");
|
||||
expect(await errored).toBe(boom);
|
||||
expect(spState.getError()).toBe(boom);
|
||||
spState.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
112
clients/web/src/test/core/react/useListError.test.tsx
Normal file
112
clients/web/src/test/core/react/useListError.test.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
|
||||
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
|
||||
import { useListError } from "@inspector/core/react/useListError";
|
||||
|
||||
// The shared subscription behind the four `useManaged*` hooks' `error` field
|
||||
// (#1953). Exercised through ManagedToolsState — any managed list would do,
|
||||
// since the error lives entirely in the shared base.
|
||||
describe("useListError", () => {
|
||||
let client: FakeInspectorClient;
|
||||
let state: ManagedToolsState;
|
||||
const boom = new Error("Invalid result for tools/list: ttlMs required");
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({
|
||||
status: "connected",
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
state = new ManagedToolsState(client, 0);
|
||||
});
|
||||
|
||||
it("returns null when there is no state", () => {
|
||||
const { result } = renderHook(() => useListError(null));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null before any load fails", () => {
|
||||
const { result } = renderHook(() => useListError(state));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds from an error the state already holds", async () => {
|
||||
client.listAllTools.mockRejectedValueOnce(boom);
|
||||
await expect(state.refresh()).rejects.toThrow(boom);
|
||||
|
||||
const { result } = renderHook(() => useListError(state));
|
||||
expect(result.current).toBe(boom);
|
||||
});
|
||||
|
||||
it("updates when the state dispatches errorChange", async () => {
|
||||
const { result } = renderHook(() => useListError(state));
|
||||
|
||||
client.listAllTools.mockRejectedValueOnce(boom);
|
||||
await act(async () => {
|
||||
await expect(state.refresh()).rejects.toThrow(boom);
|
||||
});
|
||||
expect(result.current).toBe(boom);
|
||||
});
|
||||
|
||||
it("clears when a later load succeeds", async () => {
|
||||
client.listAllTools.mockRejectedValueOnce(boom);
|
||||
await expect(state.refresh()).rejects.toThrow(boom);
|
||||
const { result } = renderHook(() => useListError(state));
|
||||
expect(result.current).toBe(boom);
|
||||
|
||||
await act(async () => {
|
||||
await state.refresh();
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("resets to null when the state becomes null", async () => {
|
||||
client.listAllTools.mockRejectedValueOnce(boom);
|
||||
await expect(state.refresh()).rejects.toThrow(boom);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedToolsState | null }) => useListError(s),
|
||||
{ initialProps: { s: state as ManagedToolsState | null } },
|
||||
);
|
||||
expect(result.current).toBe(boom);
|
||||
|
||||
rerender({ s: null });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", async () => {
|
||||
const { unmount } = renderHook(() => useListError(state));
|
||||
unmount();
|
||||
|
||||
client.listAllTools.mockRejectedValueOnce(boom);
|
||||
// No act() wrapper: a listener still attached would warn about an update
|
||||
// outside act, and the assertion below would be the only other signal.
|
||||
await expect(state.refresh()).rejects.toThrow(boom);
|
||||
expect(state.getError()).toBe(boom);
|
||||
});
|
||||
|
||||
// The useState+useEffect subscribe pattern would render one frame carrying
|
||||
// the PREVIOUS store's error here, before the effect re-synced. With
|
||||
// useSyncExternalStore the snapshot is read during render, so the swap lands
|
||||
// in the same frame — asserted immediately after rerender, with no waitFor
|
||||
// and no act() flush, which is what makes it a regression test rather than a
|
||||
// restatement of eventual consistency.
|
||||
it("reflects a store swap in the same frame", async () => {
|
||||
client.listAllTools.mockRejectedValueOnce(boom);
|
||||
await expect(state.refresh()).rejects.toThrow(boom);
|
||||
|
||||
const other = new ManagedToolsState(client, 0);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ s }: { s: ManagedToolsState }) => useListError(s),
|
||||
{ initialProps: { s: state } },
|
||||
);
|
||||
expect(result.current).toBe(boom);
|
||||
|
||||
rerender({ s: other });
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
rerender({ s: state });
|
||||
expect(result.current).toBe(boom);
|
||||
});
|
||||
});
|
||||
|
|
@ -118,4 +118,31 @@ describe("usePagedPrompts", () => {
|
|||
await state.loadPage();
|
||||
expect(result.current.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
// #1998: paginated mode renders this store, so its load failure is what the
|
||||
// panel's alert + Retry key off.
|
||||
it("exposes the state's last load error and clears it on success", async () => {
|
||||
const { result } = renderHook(() => usePagedPrompts(client, state));
|
||||
expect(result.current.error).toBeNull();
|
||||
const boom = new Error("list failed");
|
||||
client.listPrompts.mockRejectedValueOnce(boom);
|
||||
await act(async () => {
|
||||
await expect(result.current.loadPage()).rejects.toThrow("list failed");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(boom);
|
||||
});
|
||||
client.queuePromptPages({ prompts: [prompt("a")] });
|
||||
await act(async () => {
|
||||
await result.current.loadPage();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no error when state is null", () => {
|
||||
const { result } = renderHook(() => usePagedPrompts(client, null));
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -125,4 +125,31 @@ describe("usePagedResources", () => {
|
|||
await state.loadPage();
|
||||
expect(result.current.resources).toEqual([]);
|
||||
});
|
||||
|
||||
// #1998: paginated mode renders this store, so its load failure is what the
|
||||
// panel's alert + Retry key off.
|
||||
it("exposes the state's last load error and clears it on success", async () => {
|
||||
const { result } = renderHook(() => usePagedResources(client, state));
|
||||
expect(result.current.error).toBeNull();
|
||||
const boom = new Error("list failed");
|
||||
client.listResources.mockRejectedValueOnce(boom);
|
||||
await act(async () => {
|
||||
await expect(result.current.loadPage()).rejects.toThrow("list failed");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(boom);
|
||||
});
|
||||
client.queueResourcePages({ resources: [resource("a://1")] });
|
||||
await act(async () => {
|
||||
await result.current.loadPage();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no error when state is null", () => {
|
||||
const { result } = renderHook(() => usePagedResources(client, null));
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -125,4 +125,31 @@ describe("usePagedTools", () => {
|
|||
await state.loadPage();
|
||||
expect(result.current.tools).toEqual([]);
|
||||
});
|
||||
|
||||
// #1998: paginated mode renders this store, so its load failure is what the
|
||||
// panel's alert + Retry key off.
|
||||
it("exposes the state's last load error and clears it on success", async () => {
|
||||
const { result } = renderHook(() => usePagedTools(client, state));
|
||||
expect(result.current.error).toBeNull();
|
||||
const boom = new Error("list failed");
|
||||
client.listTools.mockRejectedValueOnce(boom);
|
||||
await act(async () => {
|
||||
await expect(result.current.loadPage()).rejects.toThrow("list failed");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(boom);
|
||||
});
|
||||
client.queueToolPages({ tools: [tool("a")] });
|
||||
await act(async () => {
|
||||
await result.current.loadPage();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no error when state is null", () => {
|
||||
const { result } = renderHook(() => usePagedTools(client, null));
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import type { PagePaginationState } from "./pagedToolsState.js";
|
|||
export interface PagedPromptsStateEventMap {
|
||||
promptsChange: Prompt[];
|
||||
paginationChange: PagePaginationState;
|
||||
/** The last page load's failure, or `null` once a load succeeds. */
|
||||
errorChange: Error | null;
|
||||
}
|
||||
|
||||
export interface LoadPageResult {
|
||||
|
|
@ -35,6 +37,11 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
// Double-click guard: a load in flight makes the next `loadPage` a no-op so
|
||||
// the same page can't be appended twice (#1721).
|
||||
private loading = false;
|
||||
// The last page load's failure, kept as observable state so a failing page
|
||||
// renders an alert with a Retry instead of an empty sidebar. The aggregate
|
||||
// stores carry the same state, but they are not the display source in
|
||||
// paginated mode, so their error never fires there (#1998).
|
||||
private error: Error | null = null;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
|
|
@ -43,7 +50,11 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
this.client = client;
|
||||
const onConnect = (): void => {
|
||||
if (this.client?.getServerSettings()?.paginatedLists) {
|
||||
void this.loadPage(undefined);
|
||||
// No caller to await this one, so its rejection is caught here rather
|
||||
// than left to become an unhandled rejection. Not a swallow:
|
||||
// `loadPage` has already recorded the failure via `setError`, and the
|
||||
// list panel renders it (#1998).
|
||||
void this.loadPage(undefined).catch(() => {});
|
||||
}
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
|
|
@ -70,6 +81,20 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
/** The last page load's failure, or `null` when the last load succeeded. */
|
||||
getError(): Error | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
// Compared by identity rather than message: two distinct failures with the
|
||||
// same text are still two events, and a re-render on a repeat failure is
|
||||
// cheap next to silently coalescing them.
|
||||
private setError(value: Error | null): void {
|
||||
if (this.error === value) return;
|
||||
this.error = value;
|
||||
this.dispatchTypedEvent("errorChange", value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
|
@ -80,6 +105,9 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("promptsChange", this.prompts);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
// A disconnect ends the session the error belonged to — a stale
|
||||
// "couldn't load" must not outlive it into the next connect.
|
||||
this.setError(null);
|
||||
}
|
||||
|
||||
async loadPage(
|
||||
|
|
@ -102,9 +130,17 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
: [...this.prompts, ...result.prompts];
|
||||
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
|
||||
this.nextCursor = result.nextCursor;
|
||||
this.setError(null);
|
||||
this.dispatchTypedEvent("promptsChange", this.prompts);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
return { prompts: result.prompts, nextCursor: result.nextCursor };
|
||||
} catch (err) {
|
||||
// Recorded as observable state AND re-thrown: the state drives the
|
||||
// panel's alert, while the rejection is what a caller's auth-recovery
|
||||
// wrapper keys off to detect a 401 and start a re-authorization. The
|
||||
// connect-time load, which has no such caller, catches it above.
|
||||
this.setError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
|
@ -116,5 +152,6 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
this.prompts = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import type { PagePaginationState } from "./pagedToolsState.js";
|
|||
export interface PagedResourcesStateEventMap {
|
||||
resourcesChange: Resource[];
|
||||
paginationChange: PagePaginationState;
|
||||
/** The last page load's failure, or `null` once a load succeeds. */
|
||||
errorChange: Error | null;
|
||||
}
|
||||
|
||||
export interface LoadPageResult {
|
||||
|
|
@ -36,6 +38,11 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
// Double-click guard: a load in flight makes the next `loadPage` a no-op so
|
||||
// the same page can't be appended twice (#1721).
|
||||
private loading = false;
|
||||
// The last page load's failure, kept as observable state so a failing page
|
||||
// renders an alert with a Retry instead of an empty sidebar. The aggregate
|
||||
// stores carry the same state, but they are not the display source in
|
||||
// paginated mode, so their error never fires there (#1998).
|
||||
private error: Error | null = null;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
|
|
@ -44,7 +51,11 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
this.client = client;
|
||||
const onConnect = (): void => {
|
||||
if (this.client?.getServerSettings()?.paginatedLists) {
|
||||
void this.loadPage(undefined);
|
||||
// No caller to await this one, so its rejection is caught here rather
|
||||
// than left to become an unhandled rejection. Not a swallow:
|
||||
// `loadPage` has already recorded the failure via `setError`, and the
|
||||
// list panel renders it (#1998).
|
||||
void this.loadPage(undefined).catch(() => {});
|
||||
}
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
|
|
@ -71,6 +82,20 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
/** The last page load's failure, or `null` when the last load succeeded. */
|
||||
getError(): Error | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
// Compared by identity rather than message: two distinct failures with the
|
||||
// same text are still two events, and a re-render on a repeat failure is
|
||||
// cheap next to silently coalescing them.
|
||||
private setError(value: Error | null): void {
|
||||
if (this.error === value) return;
|
||||
this.error = value;
|
||||
this.dispatchTypedEvent("errorChange", value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
|
@ -81,6 +106,9 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("resourcesChange", this.resources);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
// A disconnect ends the session the error belonged to — a stale
|
||||
// "couldn't load" must not outlive it into the next connect.
|
||||
this.setError(null);
|
||||
}
|
||||
|
||||
async loadPage(
|
||||
|
|
@ -103,9 +131,17 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
: [...this.resources, ...result.resources];
|
||||
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
|
||||
this.nextCursor = result.nextCursor;
|
||||
this.setError(null);
|
||||
this.dispatchTypedEvent("resourcesChange", this.resources);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
return { resources: result.resources, nextCursor: result.nextCursor };
|
||||
} catch (err) {
|
||||
// Recorded as observable state AND re-thrown: the state drives the
|
||||
// panel's alert, while the rejection is what a caller's auth-recovery
|
||||
// wrapper keys off to detect a 401 and start a re-authorization. The
|
||||
// connect-time load, which has no such caller, catches it above.
|
||||
this.setError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
|
@ -117,5 +153,6 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
this.resources = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface PagePaginationState {
|
|||
export interface PagedToolsStateEventMap {
|
||||
toolsChange: Tool[];
|
||||
paginationChange: PagePaginationState;
|
||||
/** The last page load's failure, or `null` once a load succeeds. */
|
||||
errorChange: Error | null;
|
||||
}
|
||||
|
||||
export interface LoadPageResult {
|
||||
|
|
@ -45,6 +47,11 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
// page"): both calls would otherwise read the same cursor and append the
|
||||
// same page twice. A load in flight makes the next `loadPage` a no-op (#1721).
|
||||
private loading = false;
|
||||
// The last page load's failure, kept as observable state so a failing page
|
||||
// renders an alert with a Retry instead of an empty sidebar. The aggregate
|
||||
// stores carry the same state, but they are not the display source in
|
||||
// paginated mode, so their error never fires there (#1998).
|
||||
private error: Error | null = null;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
|
|
@ -55,7 +62,11 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
// Auto-load page 1 only in paginated mode — otherwise the managed
|
||||
// (aggregate) state is the display source and this stays idle (#1721).
|
||||
if (this.client?.getServerSettings()?.paginatedLists) {
|
||||
void this.loadPage(undefined);
|
||||
// No caller to await this one, so its rejection is caught here rather
|
||||
// than left to become an unhandled rejection. Not a swallow:
|
||||
// `loadPage` has already recorded the failure via `setError`, and the
|
||||
// list panel renders it (#1998).
|
||||
void this.loadPage(undefined).catch(() => {});
|
||||
}
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
|
|
@ -82,6 +93,20 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
/** The last page load's failure, or `null` when the last load succeeded. */
|
||||
getError(): Error | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
// Compared by identity rather than message: two distinct failures with the
|
||||
// same text are still two events, and a re-render on a repeat failure is
|
||||
// cheap next to silently coalescing them.
|
||||
private setError(value: Error | null): void {
|
||||
if (this.error === value) return;
|
||||
this.error = value;
|
||||
this.dispatchTypedEvent("errorChange", value);
|
||||
}
|
||||
|
||||
/** Clear the accumulated list and pagination progress. */
|
||||
clear(): void {
|
||||
this.reset();
|
||||
|
|
@ -93,6 +118,9 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("toolsChange", this.tools);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
// A disconnect ends the session the error belonged to — a stale
|
||||
// "couldn't load" must not outlive it into the next connect.
|
||||
this.setError(null);
|
||||
}
|
||||
|
||||
async loadPage(cursor?: string): Promise<LoadPageResult> {
|
||||
|
|
@ -116,9 +144,17 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
: [...this.tools, ...result.tools];
|
||||
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
|
||||
this.nextCursor = result.nextCursor;
|
||||
this.setError(null);
|
||||
this.dispatchTypedEvent("toolsChange", this.tools);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
return { tools: result.tools, nextCursor: result.nextCursor };
|
||||
} catch (err) {
|
||||
// Recorded as observable state AND re-thrown: the state drives the
|
||||
// panel's alert, while the rejection is what a caller's auth-recovery
|
||||
// wrapper keys off to detect a 401 and start a re-authorization. The
|
||||
// connect-time load, which has no such caller, catches it above.
|
||||
this.setError(err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
|
@ -130,5 +166,6 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
this.tools = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
core/react/useListError.ts
Normal file
77
core/react/useListError.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
|
||||
/**
|
||||
* The single event both list-state families expose for this. Declared here
|
||||
* rather than imported from either so the hook depends on neither: the managed
|
||||
* (aggregate) states and the paged states each carry an `errorChange` of this
|
||||
* shape, and this is the whole contract the hook needs.
|
||||
*/
|
||||
interface ListErrorEventMap {
|
||||
errorChange: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a list state this hook needs. Declared structurally rather than
|
||||
* as a concrete state class so every list hook can share it without threading
|
||||
* its item type through — the error is the same shape for all of them.
|
||||
*/
|
||||
export interface ListErrorSource {
|
||||
getError(): Error | null;
|
||||
addEventListener(
|
||||
type: "errorChange",
|
||||
listener: (
|
||||
event: TypedEventGeneric<ListErrorEventMap, "errorChange">,
|
||||
) => void,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "errorChange",
|
||||
listener: (
|
||||
event: TypedEventGeneric<ListErrorEventMap, "errorChange">,
|
||||
) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a list state's last-fetch error (#1953, #1998).
|
||||
*
|
||||
* Shared by the four `useManaged*` hooks and the three `usePaged*` hooks so a
|
||||
* list load that fails — including the connect-time one, which has no caller
|
||||
* to await it — reaches the UI instead of only the console. `null` means the
|
||||
* last fetch succeeded. Both families need it because the paged stores are the
|
||||
* display source in paginated mode, where the managed stores deliberately
|
||||
* never fetch (#1998).
|
||||
*
|
||||
* Built on `useSyncExternalStore` rather than the `useState` + `useEffect`
|
||||
* subscribe pattern the sibling hooks use. Re-syncing state from the `state`
|
||||
* prop inside an effect would render one frame carrying the PREVIOUS store's
|
||||
* error after `state` changes (switching servers) before the effect corrects
|
||||
* it — the "don't derive state from props in an effect" rule in AGENTS.md.
|
||||
* `useSyncExternalStore` has no such window: the snapshot is read during
|
||||
* render, so a store swap is reflected in the same frame, and it also closes
|
||||
* the gap where an error recorded between render and subscribe would be missed.
|
||||
*
|
||||
* The snapshot must be referentially stable across reads that mean "no change",
|
||||
* which it is: it returns the stored `Error` instance itself (or `null`), never
|
||||
* a fresh object.
|
||||
*/
|
||||
export function useListError(state: ListErrorSource | null): Error | null {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
if (!state) return () => {};
|
||||
const listener = () => onStoreChange();
|
||||
state.addEventListener("errorChange", listener);
|
||||
return () => {
|
||||
state.removeEventListener("errorChange", listener);
|
||||
};
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => state?.getError() ?? null, [state]);
|
||||
|
||||
// Server snapshot: same read. The stores are browser/Node runtime objects
|
||||
// with no SSR path, and passing the same getter keeps hydration consistent
|
||||
// rather than throwing on a server render.
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from "../mcp/state/managedPromptsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
|
||||
export interface UseManagedPromptsResult {
|
||||
/**
|
||||
|
|
@ -72,7 +72,7 @@ export function useManagedPrompts(
|
|||
};
|
||||
}, [managedPromptsState]);
|
||||
|
||||
const error = useManagedListError(managedPromptsState);
|
||||
const error = useListError(managedPromptsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Prompt[]> => {
|
||||
if (!managedPromptsState || !client) return [];
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from "../mcp/state/managedResourceTemplatesState.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
|
||||
export interface UseManagedResourceTemplatesResult {
|
||||
/**
|
||||
|
|
@ -57,7 +57,7 @@ export function useManagedResourceTemplates(
|
|||
};
|
||||
}, [managedResourceTemplatesState]);
|
||||
|
||||
const error = useManagedListError(managedResourceTemplatesState);
|
||||
const error = useListError(managedResourceTemplatesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<ResourceTemplate[]> => {
|
||||
if (!managedResourceTemplatesState || !client) return [];
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from "../mcp/state/managedResourcesState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
|
||||
export interface UseManagedResourcesResult {
|
||||
/**
|
||||
|
|
@ -83,7 +83,7 @@ export function useManagedResources(
|
|||
};
|
||||
}, [managedResourcesState]);
|
||||
|
||||
const error = useManagedListError(managedResourcesState);
|
||||
const error = useListError(managedResourcesState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Resource[]> => {
|
||||
if (!managedResourcesState || !client) return [];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { ManagedToolsState } from "../mcp/state/managedToolsState.js";
|
|||
import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useManagedListError } from "./useManagedListError.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
|
||||
export interface UseManagedToolsResult {
|
||||
/**
|
||||
|
|
@ -72,7 +72,7 @@ export function useManagedTools(
|
|||
};
|
||||
}, [managedToolsState]);
|
||||
|
||||
const error = useManagedListError(managedToolsState);
|
||||
const error = useListError(managedToolsState);
|
||||
|
||||
const refresh = useCallback(async (): Promise<Tool[]> => {
|
||||
if (!managedToolsState || !client) return [];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
} from "../mcp/state/pagedPromptsState.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
|
||||
export interface UsePagedPromptsResult {
|
||||
prompts: Prompt[];
|
||||
|
|
@ -14,6 +15,12 @@ export interface UsePagedPromptsResult {
|
|||
nextCursor?: string;
|
||||
/** Pages loaded since the last reset (page 1 = 1). */
|
||||
pageCount: number;
|
||||
/**
|
||||
* The last page load's failure, or `null` when it succeeded. In paginated
|
||||
* mode this store is the display source, so this — not the managed
|
||||
* store's error — is what the panel renders (#1998).
|
||||
*/
|
||||
error: Error | null;
|
||||
loadPage: (
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
|
|
@ -72,6 +79,8 @@ export function usePagedPrompts(
|
|||
};
|
||||
}, [pagedPromptsState]);
|
||||
|
||||
const error = useListError(pagedPromptsState);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (
|
||||
cursor?: string,
|
||||
|
|
@ -89,5 +98,5 @@ export function usePagedPrompts(
|
|||
pagedPromptsState?.clear();
|
||||
}, [pagedPromptsState]);
|
||||
|
||||
return { prompts, nextCursor, pageCount, loadPage, clear };
|
||||
return { prompts, nextCursor, pageCount, error, loadPage, clear };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
} from "../mcp/state/pagedResourcesState.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
||||
import { useListError } from "./useListError.js";
|
||||
|
||||
export interface UsePagedResourcesResult {
|
||||
resources: Resource[];
|
||||
|
|
@ -14,6 +15,12 @@ export interface UsePagedResourcesResult {
|
|||
nextCursor?: string;
|
||||
/** Pages loaded since the last reset (page 1 = 1). */
|
||||
pageCount: number;
|
||||
/**
|
||||
* The last page load's failure, or `null` when it succeeded. In paginated
|
||||
* mode this store is the display source, so this — not the managed
|
||||
* store's error — is what the panel renders (#1998).
|
||||
*/
|
||||
error: Error | null;
|
||||
loadPage: (
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
|
|
@ -78,6 +85,8 @@ export function usePagedResources(
|
|||
};
|
||||
}, [pagedResourcesState]);
|
||||
|
||||
const error = useListError(pagedResourcesState);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (
|
||||
cursor?: string,
|
||||
|
|
@ -95,5 +104,5 @@ export function usePagedResources(
|
|||
pagedResourcesState?.clear();
|
||||
}, [pagedResourcesState]);
|
||||
|
||||
return { resources, nextCursor, pageCount, loadPage, clear };
|
||||
return { resources, nextCursor, pageCount, error, loadPage, clear };
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue