diff --git a/ui/src/components/app-sidebar-session-catalog-state.ts b/ui/src/components/app-sidebar-session-catalog-state.ts new file mode 100644 index 000000000000..87df3f2ec67e --- /dev/null +++ b/ui/src/components/app-sidebar-session-catalog-state.ts @@ -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; + +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>; +}): { 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, + }; +} diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index da20bbb2d9c7..537fb05fea51 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -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('[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('[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 { diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index c7819afa35a4..f92da695fefc 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -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); diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index bae01ee70703..feafeb530e39 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -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, {