From f6c15df3329d2748a1da3fc2f222503579a91883 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 05:08:59 -0700 Subject: [PATCH] fix(ui): clear stale session-list errors after Gateway recovery (#130004) * fix(ui): clear stale filtered-session errors after recovery * fix(ui): keep session error owners within lint boundaries --- .../session-data-controller-events.ts | 27 +++++- ...sion-data-controller.event-refresh.test.ts | 91 +++++++++++++++++++ ui/src/components/session-data-controller.ts | 10 +- ...ion-management.filtered-errors.e2e.test.ts | 88 ++++++++++++++++++ 4 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 ui/src/e2e/session-management.filtered-errors.e2e.test.ts diff --git a/ui/src/components/session-data-controller-events.ts b/ui/src/components/session-data-controller-events.ts index c48cc8afc3a7..d1b6439e7574 100644 --- a/ui/src/components/session-data-controller-events.ts +++ b/ui/src/components/session-data-controller-events.ts @@ -20,6 +20,24 @@ type SidebarSessionListOwner = { requestSessionDataUpdate(): void; }; +const sidebarSessionErrorOwners = new WeakMap(); + +export function publishSidebarSessionError( + owner: SidebarSessionListOwner, + error: string | null, + source: "list" | "action", +): void { + if (source === "list" && sidebarSessionErrorOwners.get(owner) === "action") { + return; + } + owner.sessionMutationError = error; + if (error === null) { + sidebarSessionErrorOwners.delete(owner); + } else { + sidebarSessionErrorOwners.set(owner, source); + } +} + function pruneSidebarAgentSessionCaches( owner: SidebarSessionListOwner, agentIds: readonly string[], @@ -97,14 +115,15 @@ export function subscribeFilteredSidebarSessions( } publishSidebarSessionList(owner, snapshot); owner.sessionsLoading = snapshot.loading; - if (snapshot.error) { - owner.sessionMutationError = snapshot.error; - } + publishSidebarSessionError(owner, snapshot.error, "list"); owner.requestSessionDataUpdate(); }; const unsubscribe = sessions.subscribeList(scope, apply); apply(sessions.listSnapshot(scope)); - return unsubscribe; + return () => { + unsubscribe(); + publishSidebarSessionError(owner, null, "list"); + }; } export function refreshSidebarSessionList( diff --git a/ui/src/components/session-data-controller.event-refresh.test.ts b/ui/src/components/session-data-controller.event-refresh.test.ts index 7be56f6df6eb..fc94877f2b50 100644 --- a/ui/src/components/session-data-controller.event-refresh.test.ts +++ b/ui/src/components/session-data-controller.event-refresh.test.ts @@ -180,6 +180,97 @@ function createFilteredSessionController(statusFilter: "archived" | "all", rowCo } describe("filtered sidebar session event refresh", () => { + it.each(["archived", "all"] as const)( + "clears a recovered %s list failure without erasing a same-text action failure", + async (statusFilter) => { + const { controller, list, selectStatusFilter } = + createFilteredSessionController(statusFilter); + controller.hostConnected(); + list.mockRejectedValueOnce(new Error("Session request failed")); + + await controller.refreshSidebarSessions(); + + expect(controller.sessionMutationError).toBe("Session request failed"); + + await controller.refreshSidebarSessions(); + + expect(controller.sessionMutationError).toBeNull(); + expect(controller.sessionsResult?.sessions).toHaveLength(1); + + list.mockRejectedValueOnce(new Error("Session request failed")); + await controller.refreshSidebarSessions(); + + const mutation = controller.beginSessionMutation(); + expect(mutation).not.toBeNull(); + controller.publishSessionMutationError(mutation!, new Error("Session request failed")); + + list.mockRejectedValueOnce(new Error("Background session list failed")); + await controller.refreshSidebarSessions(); + expect(controller.sessionMutationError).toBe("Session request failed"); + + await controller.refreshSidebarSessions(); + expect(controller.sessionMutationError).toBe("Session request failed"); + + selectStatusFilter(statusFilter === "archived" ? "all" : "archived"); + expect(controller.sessionMutationError).toBe("Session request failed"); + + controller.hostDisconnected(); + expect(controller.sessionMutationError).toBeNull(); + }, + ); + + it.each(["archived", "all"] as const)( + "retires the %s list failure when its selected filter changes", + async (statusFilter) => { + const { controller, list, selectStatusFilter } = + createFilteredSessionController(statusFilter); + controller.hostConnected(); + list.mockRejectedValueOnce(new Error("Retired session list failed")); + + await controller.refreshSidebarSessions(); + expect(controller.sessionMutationError).toBe("Retired session list failed"); + + selectStatusFilter(statusFilter === "archived" ? "all" : "archived"); + + expect(controller.sessionMutationError).toBeNull(); + controller.hostDisconnected(); + }, + ); + + it("dismisses a filtered list failure without restoring it on recovery", async () => { + const { controller, list } = createFilteredSessionController("archived"); + controller.hostConnected(); + list.mockRejectedValueOnce(new Error("Dismissed session list failed")); + + await controller.refreshSidebarSessions(); + expect(controller.sessionMutationError).toBe("Dismissed session list failed"); + + controller.dismissSessionMutationError(); + expect(controller.sessionMutationError).toBeNull(); + + await controller.refreshSidebarSessions(); + expect(controller.sessionMutationError).toBeNull(); + controller.hostDisconnected(); + }); + + it("ignores a retired filter's delayed failure after the replacement scope binds", async () => { + const { controller, list, selectStatusFilter } = createFilteredSessionController("archived"); + controller.hostConnected(); + let rejectList!: (error: Error) => void; + const delayedList = new Promise>>((_, reject) => { + rejectList = reject; + }); + list.mockImplementationOnce(async () => await delayedList); + + const retiredRefresh = controller.refreshSidebarSessions(); + selectStatusFilter("all"); + rejectList(new Error("Retired archived request failed")); + await retiredRefresh; + + expect(controller.sessionMutationError).toBeNull(); + controller.hostDisconnected(); + }); + it("evicts cached sessions when an agent leaves the authoritative roster", () => { const { controller, publishAgentRoster, resultForKeys } = createFilteredSessionController("all"); diff --git a/ui/src/components/session-data-controller.ts b/ui/src/components/session-data-controller.ts index 6e021b8024a0..9e6b4d130468 100644 --- a/ui/src/components/session-data-controller.ts +++ b/ui/src/components/session-data-controller.ts @@ -46,6 +46,7 @@ import { updateSessionCatalogData as updateSessionCatalogDataForHost, } from "./session-data-controller-catalog.ts"; import { + publishSidebarSessionError, publishSidebarSessionList, refreshSidebarSessionList, subscribeSidebarAgentSessionCaches, @@ -591,7 +592,6 @@ export class SessionDataController implements ReactiveController, SessionCatalog } this.childSessionRowsByParent = { ...this.childSessionRowsByParent, [parentKey]: rows }; this.loadedChildSessionKeys = new Set([...this.loadedChildSessionKeys, parentKey]); - this.notify(); } catch (error) { if (generation !== this.childSessionGeneration || sessions !== this.context?.sessions) { return; @@ -687,7 +687,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog } dismissSessionMutationError(): void { - this.sessionMutationError = null; + publishSidebarSessionError(this, null, "action"); this.notify(); } @@ -732,7 +732,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog private invalidateSessionMutations(): void { this.sessionMutationEpoch += 1; - this.sessionMutationError = null; + publishSidebarSessionError(this, null, "action"); // Dismiss any confirm dialog still open under the retired epoch before a // new one can be issued; otherwise it stays modal until manually closed. this.sessionMutationAbortController.abort(); @@ -750,7 +750,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog if (gateway.snapshot.phase !== "connected" || !client) { return null; } - this.sessionMutationError = null; + publishSidebarSessionError(this, null, "action"); this.notify(); return { epoch: this.sessionMutationEpoch, @@ -779,7 +779,7 @@ export class SessionDataController implements ReactiveController, SessionCatalog publishSessionMutationError(scope: SidebarSessionMutationScope, error: unknown): void { if (this.isSessionMutationScopeCurrent(scope)) { - this.sessionMutationError = formatUiError(error); + publishSidebarSessionError(this, formatUiError(error), "action"); this.notify(); } } diff --git a/ui/src/e2e/session-management.filtered-errors.e2e.test.ts b/ui/src/e2e/session-management.filtered-errors.e2e.test.ts new file mode 100644 index 000000000000..0f9ab87afb08 --- /dev/null +++ b/ui/src/e2e/session-management.filtered-errors.e2e.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from "vitest"; +import { + captureUiProof, + createSessionManagementE2eSuite, + installMockGateway, + sessionRow, + sessionsListResponse, +} from "./session-management.test-support.ts"; + +const suite = createSessionManagementE2eSuite(); + +suite.define(() => { + it.each(["Archived", "All"] as const)( + "clears the visible %s sidebar error after its failed roster recovers or retires", + async (statusFilter) => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const updatedAt = Date.parse("2026-07-01T16:00:00.000Z"); + const main = sessionRow("agent:main:main", "Main", updatedAt); + const archived = sessionRow("agent:main:archived", "Archived planning", updatedAt - 1, { + archived: true, + }); + const healthy = sessionsListResponse([main, archived]); + const gateway = await installMockGateway(page, { + methodResponses: { "sessions.list": healthy }, + sessionArchiveFiltering: true, + sessionKey: main.key, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const selectFilter = async (label: "Archived" | "All" | "Active") => { + await page.getByRole("button", { name: "Filter & sort" }).click(); + await page + .locator(".sidebar-session-sort-menu") + .getByRole("menuitemradio", { name: label, exact: true }) + .click(); + }; + await selectFilter(statusFilter); + await page.getByText("Archived planning", { exact: true }).first().waitFor(); + + const failure = { + __mockError: { code: "UNAVAILABLE", message: "Session list temporarily unavailable" }, + }; + const refresh = () => + gateway.emitGatewayEvent("sessions.changed", { + ...archived, + agentId: "main", + reason: "update", + sessionKey: archived.key, + }); + const alert = page.locator("[data-sidebar-session-error]"); + + await gateway.setMethodResponse("sessions.list", failure); + await refresh(); + await expect + .poll(() => alert.textContent()) + .toContain("Session list temporarily unavailable"); + if (statusFilter === "Archived") { + await captureUiProof(page, "filtered-session-error-recovery-before.png"); + } + + await gateway.setMethodResponse("sessions.list", healthy); + await refresh(); + await expect.poll(() => alert.count()).toBe(0); + await page.getByText("Archived planning", { exact: true }).first().waitFor(); + if (statusFilter === "Archived") { + await captureUiProof(page, "filtered-session-error-recovery-after.png"); + } + + await gateway.setMethodResponse("sessions.list", failure); + await refresh(); + await expect + .poll(() => alert.textContent()) + .toContain("Session list temporarily unavailable"); + + await selectFilter("Active"); + await expect.poll(() => alert.count()).toBe(0); + } finally { + await context.close(); + } + }, + ); +});