From 41f4b5f6736fd2e2cb9077dd128dd67a74b311b0 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Wed, 12 Aug 2026 17:25:38 -0700 Subject: [PATCH] fix(ui): archived sessions leave sidebar after navigation (#122876) * oc-ad0.1: fix archived child session retention * oc-37c: evict archived child on navigation --- .../app-sidebar-child-session-data.ts | 10 +++++- .../session-management.archive.e2e.test.ts | 36 +++++++++++++------ ui/src/e2e/session-management.test-support.ts | 9 +---- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/ui/src/components/app-sidebar-child-session-data.ts b/ui/src/components/app-sidebar-child-session-data.ts index f49c924aa902..8c69d19695c7 100644 --- a/ui/src/components/app-sidebar-child-session-data.ts +++ b/ui/src/components/app-sidebar-child-session-data.ts @@ -110,7 +110,7 @@ export function preserveActiveSessionLineageRows( if (!parent) { break; } - preserved[parent[0]] = parent[1]; + preserved[parent[0]] = parent[1].filter((row) => areUiSessionKeysEquivalent(row.key, childKey)); childKey = parent[0]; } return preserved; @@ -205,6 +205,14 @@ export function evictArchivedSessionLineage( row != null && areUiSessionKeysEquivalent(row.key, sessionKey), ); if (selectedRow?.archived === true) { + // Navigation has ended the archived row's temporary presentation lease. + // Remove it from the child cache before the next canonical list refresh. + owner.childSessionRowsByParent = Object.fromEntries( + Object.entries(owner.childSessionRowsByParent).map(([parentKey, rows]) => [ + parentKey, + rows.filter((row) => !areUiSessionKeysEquivalent(row.key, sessionKey)), + ]), + ); owner.context?.sessions.reconcile(selectedRow, owner.sessionsResult?.defaults, { archivedFilter: "active", }); diff --git a/ui/src/e2e/session-management.archive.e2e.test.ts b/ui/src/e2e/session-management.archive.e2e.test.ts index b85868f38289..b80e2531e63e 100644 --- a/ui/src/e2e/session-management.archive.e2e.test.ts +++ b/ui/src/e2e/session-management.archive.e2e.test.ts @@ -7,7 +7,6 @@ import { controlUiSessionUrl, createSessionManagementE2eSuite, installMockGateway, - navigateToControlUiSession, requireRecord, sessionRow, sessionsListResponse, @@ -564,13 +563,27 @@ suite.define(() => { const page = await context.newPage(); const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); const main = sessionRow("agent:main:main", "Main", baseTime); + const target = { + ...sessionRow( + "agent:main:dashboard:navigation-target", + "Navigation target", + baseTime - 1_000, + ), + parentSessionKey: main.key, + sessionId: "navigation-target", + }; const archived = { - ...sessionRow("agent:main:navigation-archive", "Navigation archive", baseTime - 1_000), + ...sessionRow( + "agent:main:dashboard:navigation-archive", + "Navigation archive", + baseTime - 2_000, + ), + parentSessionKey: main.key, sessionId: "navigation-archive", }; const gateway = await installMockGateway(page, { methodResponses: { - "sessions.list": sessionsListResponse([main, archived]), + "sessions.list": sessionsListResponse([main, target, archived]), "sessions.patch": {}, }, sessionKey: main.key, @@ -599,35 +612,36 @@ suite.define(() => { .locator(".agent-chat__disabled-banner"); await archivedNotice.waitFor({ state: "visible", timeout: 10_000 }); - await navigateToControlUiSession(page, main.key); - await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(main.key)); + await rowFor(target.key).click(); + await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath(target.key)); + await archivedRow.waitFor({ state: "detached", timeout: 10_000 }); - await gateway.setMethodResponse("sessions.list", sessionsListResponse([main])); + await gateway.setMethodResponse("sessions.list", sessionsListResponse([main, target])); let listRequestCount = (await gateway.getRequests("sessions.list")).length; await gateway.emitGatewayEvent("sessions.changed", { - ...main, + ...target, updatedAt: baseTime + 1_000, reason: "update", - sessionKey: main.key, + sessionKey: target.key, }); await expect .poll(async () => (await gateway.getRequests("sessions.list")).length) .toBeGreaterThan(listRequestCount); - await archivedRow.waitFor({ state: "detached", timeout: 10_000 }); await gateway.setMethodResponse( "sessions.list", sessionsListResponse([ { ...main, updatedAt: baseTime + 2_000 }, + { ...target, updatedAt: baseTime + 3_000 }, { ...archived, archived: false, updatedAt: baseTime + 3_000 }, ]), ); listRequestCount = (await gateway.getRequests("sessions.list")).length; await gateway.emitGatewayEvent("sessions.changed", { - ...main, + ...target, updatedAt: baseTime + 2_000, reason: "update", - sessionKey: main.key, + sessionKey: target.key, }); await expect .poll(async () => (await gateway.getRequests("sessions.list")).length) diff --git a/ui/src/e2e/session-management.test-support.ts b/ui/src/e2e/session-management.test-support.ts index cc45f98df413..64c489d3e6ab 100644 --- a/ui/src/e2e/session-management.test-support.ts +++ b/ui/src/e2e/session-management.test-support.ts @@ -7,20 +7,13 @@ import { controlUiSessionPath, controlUiSessionUrl, installMockGateway, - navigateToControlUiSession, waitForConfirmModal, type MockGatewayControls, type MockGatewayRequest, } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; -export { - controlUiSessionPath, - controlUiSessionUrl, - installMockGateway, - navigateToControlUiSession, - waitForConfirmModal, -}; +export { controlUiSessionPath, controlUiSessionUrl, installMockGateway, waitForConfirmModal }; export const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections"; export const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";