mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
5c8dbdcabf
commit
f6c15df332
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user