mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 21:46:04 +00:00
fix: show catalog pagination failures (#106518)
* fix(ui): surface catalog pagination failures * test(ui): scope catalog retry assertion
This commit is contained in:
committed by
GitHub
parent
11cd600c2d
commit
3ffd0ac8d8
79
ui/src/components/app-sidebar-session-catalog-state.ts
Normal file
79
ui/src/components/app-sidebar-session-catalog-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user