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
This commit is contained in:
Peter Steinberger
2026-08-26 05:08:59 -07:00
committed by GitHub
parent 5c8dbdcabf
commit f6c15df332
4 changed files with 207 additions and 9 deletions
@@ -20,6 +20,24 @@ type SidebarSessionListOwner = {
requestSessionDataUpdate(): void;
};
const sidebarSessionErrorOwners = new WeakMap<SidebarSessionListOwner, "list" | "action">();
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(
@@ -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<Awaited<ReturnType<typeof list>>>((_, 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");
+5 -5
View File
@@ -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();
}
}
@@ -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();
}
},
);
});