fix: show catalog pagination failures (#106518)

* fix(ui): surface catalog pagination failures

* test(ui): scope catalog retry assertion
This commit is contained in:
Peter Steinberger
2026-07-13 09:32:52 -07:00
committed by GitHub
parent 11cd600c2d
commit 3ffd0ac8d8
4 changed files with 306 additions and 70 deletions

View File

@@ -0,0 +1,79 @@
import type {
SessionCatalog,
SessionCatalogHost,
SessionCatalogSession,
} from "../../../packages/gateway-protocol/src/index.ts";
import { GatewayRequestError } from "../api/gateway.ts";
type SessionCatalogError = NonNullable<SessionCatalog["error"]>;
export function sessionCatalogRequestError(error: unknown): SessionCatalogError {
return {
code: error instanceof GatewayRequestError ? error.gatewayCode : "UNAVAILABLE",
message: error instanceof Error ? error.message : String(error),
};
}
export function mergeCatalogSessionRows(
first: readonly SessionCatalogSession[],
second: readonly SessionCatalogSession[],
): SessionCatalogSession[] {
const seen = new Set(first.map((session) => session.threadId));
return [...first, ...second.filter((session) => !seen.has(session.threadId))];
}
export function preserveExpandedCatalogHost(
freshHost: SessionCatalogHost,
previous: SessionCatalogHost | undefined,
): SessionCatalogHost {
if (!previous) {
return freshHost;
}
const { sessions, nextCursor, ...previousDetails } = previous;
const { sessions: _freshSessions, nextCursor: _freshNextCursor, ...freshDetails } = freshHost;
return {
...previousDetails,
...freshDetails,
sessions,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
export function mergeSessionCatalogPage(params: {
current: SessionCatalog;
page: SessionCatalog;
cursors: Readonly<Record<string, string>>;
}): { catalog: SessionCatalog; advancedHostIds: string[] } {
const pageHosts = new Map(params.page.hosts.map((host) => [host.hostId, host]));
const advancedHostIds: string[] = [];
const hosts = params.current.hosts.map((host) => {
const requestedCursor = params.cursors[host.hostId];
const pageHost = pageHosts.get(host.hostId);
if (requestedCursor === undefined || host.nextCursor !== requestedCursor || !pageHost) {
return host;
}
if (pageHost.error) {
return preserveExpandedCatalogHost(pageHost, host);
}
advancedHostIds.push(host.hostId);
const { nextCursor, sessions, error: _pageError, ...pageHostDetails } = pageHost;
const { nextCursor: _currentCursor, error: _currentError, ...currentHost } = host;
return {
...currentHost,
...pageHostDetails,
sessions: mergeCatalogSessionRows(host.sessions, sessions),
...(nextCursor ? { nextCursor } : {}),
};
});
const { hosts: _currentHosts, error: _currentError, ...currentDetails } = params.current;
const { hosts: _pageHosts, error: pageError, ...pageDetails } = params.page;
return {
catalog: {
...currentDetails,
...pageDetails,
hosts,
...(pageError ? { error: pageError } : {}),
},
advancedHostIds,
};
}

View File

@@ -7,7 +7,7 @@ import type {
SessionCatalog,
SessionsCatalogListResult,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { GatewayRequestError, type GatewayBrowserClient } from "../api/gateway.ts";
import type { AgentsListResult, SessionsListResult } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
import {
@@ -1095,6 +1095,127 @@ describe("AppSidebar session catalog pagination", () => {
}
});
it("shows a rejected load-more request and clears it after a successful retry", async () => {
vi.useFakeTimers();
try {
const request = vi
.fn()
.mockResolvedValueOnce(catalogPage([{ threadId: "thread-1", name: "Newest" }], "page-2"))
.mockRejectedValueOnce(
new GatewayRequestError({ code: "UNAVAILABLE", message: "Second page unavailable" }),
)
.mockResolvedValueOnce(catalogPage([{ threadId: "thread-2", name: "Older" }]));
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
gateway.gateway,
createSessions("main", ["agent:main:main"]),
);
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
const section = () => sidebar.querySelector('[data-session-section="catalog:codex"]');
const loadMore = () =>
sidebar.querySelector<HTMLButtonElement>('[data-session-catalog-load-more="codex"]');
loadMore()?.click();
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(section()?.querySelector('[data-session-catalog-error="codex"]')).not.toBeNull();
expect(
section()?.querySelector(".sidebar-session-group-toggle")?.getAttribute("aria-label"),
).toContain("Second page unavailable");
expect(sidebar.sessionCatalogs[0]?.error?.code).toBe("UNAVAILABLE");
expect(sidebar.sessionCatalogs[0]?.hosts[0]?.nextCursor).toBe("page-2");
expect(loadMore()?.disabled).toBe(false);
loadMore()?.click();
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(request).toHaveBeenNthCalledWith(3, "sessions.catalog.list", {
agentId: "main",
catalogId: "codex",
cursors: { "gateway:local": "page-2" },
});
expect(section()?.querySelector('[data-session-catalog-error="codex"]')).toBeNull();
expect(sidebar.textContent).toContain("Older");
expect(loadMore()).toBeNull();
} finally {
vi.useRealTimers();
}
});
it.each(["catalog", "host"] as const)(
"preserves the current page while exposing a structured %s load-more error",
async (errorOwner) => {
vi.useFakeTimers();
try {
const structuredError =
errorOwner === "catalog"
? {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [],
error: { code: "catalog_error", message: "Catalog page failed" },
},
],
}
: catalogErrorPage("Host page failed");
const request = vi
.fn()
.mockResolvedValueOnce(catalogPage([{ threadId: "thread-1", name: "Newest" }], "page-2"))
.mockResolvedValueOnce(structuredError)
.mockResolvedValueOnce(catalogPage([{ threadId: "thread-2", name: "Older" }]));
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
gateway.publish({
hello: {
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
gateway.gateway,
createSessions("main", ["agent:main:main"]),
);
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
const loadMore = () =>
sidebar.querySelector<HTMLButtonElement>('[data-session-catalog-load-more="codex"]');
loadMore()?.click();
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
const section = sidebar.querySelector('[data-session-section="catalog:codex"]');
expect(section?.querySelector('[data-session-catalog-error="codex"]')).not.toBeNull();
expect(
section?.querySelector(".sidebar-session-group-toggle")?.getAttribute("aria-label"),
).toContain(errorOwner === "catalog" ? "Catalog page failed" : "Host page failed");
expect(sidebar.textContent).toContain("Newest");
expect(sidebar.sessionCatalogs[0]?.hosts[0]?.nextCursor).toBe("page-2");
loadMore()?.click();
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.querySelector('[data-session-catalog-error="codex"]')).toBeNull();
expect(sidebar.textContent).toContain("Older");
} finally {
vi.useRealTimers();
}
},
);
it("appends host pages and keeps them through the next poll refresh", async () => {
vi.useFakeTimers();
try {

View File

@@ -4,8 +4,6 @@ import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import type {
SessionCatalog,
SessionCatalogHost,
SessionCatalogSession,
SessionsCatalogListResult,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../api/gateway.ts";
@@ -96,6 +94,12 @@ import {
sidebarMoreMenuHoldsActiveRoute,
sidebarPluginTabs,
} from "./app-sidebar-nav-menus.ts";
import {
mergeCatalogSessionRows,
mergeSessionCatalogPage,
preserveExpandedCatalogHost,
sessionCatalogRequestError,
} from "./app-sidebar-session-catalog-state.ts";
import {
adoptedCatalogSessionKeys,
bindAdoptedCatalogSession,
@@ -234,31 +238,6 @@ function sessionCatalogHostKey(catalogId: string, hostId: string): string {
return `${catalogId}\u0000${hostId}`;
}
function mergeCatalogSessionRows(
first: readonly SessionCatalogSession[],
second: readonly SessionCatalogSession[],
): SessionCatalogSession[] {
const seen = new Set(first.map((session) => session.threadId));
return [...first, ...second.filter((session) => !seen.has(session.threadId))];
}
function preserveExpandedCatalogHost(
freshHost: SessionCatalogHost,
previous: SessionCatalogHost | undefined,
): SessionCatalogHost {
if (!previous) {
return freshHost;
}
const { sessions, nextCursor, ...previousDetails } = previous;
const { sessions: _freshSessions, nextCursor: _freshNextCursor, ...freshDetails } = freshHost;
return {
...previousDetails,
...freshDetails,
sessions,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
class AppSidebar extends OpenClawLightDomContentsElement {
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@@ -658,49 +637,37 @@ class AppSidebar extends OpenClawLightDomContentsElement {
if (!page) {
return;
}
const pageHosts = new Map(page.hosts.map((host) => [host.hostId, host]));
const requestedHosts = new Set(Object.keys(cursors));
let updated = false;
const catalogs = this.sessionCatalogs.map((currentCatalog) => {
if (currentCatalog.id !== catalogId) {
return currentCatalog;
}
return {
...currentCatalog,
hosts: currentCatalog.hosts.map((host) => {
const pageHost = pageHosts.get(host.hostId);
if (
!requestedHosts.has(host.hostId) ||
host.nextCursor !== cursors[host.hostId] ||
!pageHost ||
pageHost.error
) {
return host;
}
updated = true;
const key = sessionCatalogHostKey(catalogId, host.hostId);
this.sessionCatalogPageDepths.set(
key,
(this.sessionCatalogPageDepths.get(key) ?? 0) + 1,
);
const { nextCursor, sessions, ...pageHostDetails } = pageHost;
const { nextCursor: _currentCursor, ...currentHost } = host;
return {
...currentHost,
...pageHostDetails,
sessions: mergeCatalogSessionRows(host.sessions, sessions),
...(nextCursor ? { nextCursor } : {}),
};
}),
};
});
if (updated) {
this.sessionCatalogs = catalogs;
this.sessionCatalogRevisions.set(catalogId, revision + 1);
this.sessionCatalogRevision += 1;
const current = this.sessionCatalogs.find((candidate) => candidate.id === catalogId);
if (!current) {
return;
}
} catch {
// Keep the current cursor so the user can retry the same page.
const merged = mergeSessionCatalogPage({ current, page, cursors });
for (const hostId of merged.advancedHostIds) {
const key = sessionCatalogHostKey(catalogId, hostId);
this.sessionCatalogPageDepths.set(key, (this.sessionCatalogPageDepths.get(key) ?? 0) + 1);
}
this.sessionCatalogs = this.sessionCatalogs.map((candidate) =>
candidate.id === catalogId ? merged.catalog : candidate,
);
this.sessionCatalogRevisions.set(catalogId, revision + 1);
this.sessionCatalogRevision += 1;
} catch (error) {
if (
generation !== this.sessionCatalogGeneration ||
revision !== (this.sessionCatalogRevisions.get(catalogId) ?? 0) ||
client !== this.gatewayClient
) {
return;
}
// Preserve rows and cursors: the visible error explains that retrying
// Load More will request the same page rather than advancing past it.
this.sessionCatalogs = this.sessionCatalogs.map((candidate) =>
candidate.id === catalogId
? { ...candidate, error: sessionCatalogRequestError(error) }
: candidate,
);
this.sessionCatalogRevisions.set(catalogId, revision + 1);
this.sessionCatalogRevision += 1;
} finally {
if (generation === this.sessionCatalogGeneration) {
const loading = new Set(this.loadingMoreSessionCatalogIds);

View File

@@ -79,6 +79,75 @@ suite("Codex native session catalog", () => {
await page.close();
});
it("shows a catalog Load More rejection without losing the retry cursor", async () => {
const page = await browser.newPage();
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.catalog.list": {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:codex",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "thread-1",
name: "Newest session",
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
nextCursor: "page-2",
},
],
},
],
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBe(1);
const loadMore = page.locator('[data-session-catalog-load-more="codex"]');
await loadMore.waitFor({ state: "visible" });
await gateway.deferNext("sessions.catalog.list");
await loadMore.click();
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBe(2);
await gateway.rejectDeferred("sessions.catalog.list", {
code: "UNAVAILABLE",
message: "Second catalog page unavailable",
});
const section = page.locator('[data-session-section="catalog:codex"]');
await section.locator('[data-session-catalog-error="codex"]').waitFor({ state: "visible" });
await expect
.poll(() => section.locator(".sidebar-session-group-toggle").getAttribute("aria-label"))
.toContain("Second catalog page unavailable");
await expect.poll(() => loadMore.getAttribute("aria-busy")).toBe("false");
expect(await loadMore.isEnabled()).toBe(true);
expect(await page.getByText("Newest session", { exact: true }).count()).toBe(1);
expect(pageErrors).toEqual([]);
} finally {
await page.close();
}
});
it("adopts from the native chat composer, navigates, and auto-sends", async () => {
const page = await browser.newPage();
const gateway = await installMockGateway(page, {