diff --git a/ui/src/components/session-data-controller-pagination.ts b/ui/src/components/session-data-controller-pagination.ts index e01f814b0fce..dfe1fee3a470 100644 --- a/ui/src/components/session-data-controller-pagination.ts +++ b/ui/src/components/session-data-controller-pagination.ts @@ -1,6 +1,7 @@ import type { SessionsListResult } from "../api/types.ts"; import type { RouteId } from "../app-route-paths.ts"; import type { ApplicationContext } from "../app/context.ts"; +import { appendSessionResults } from "../lib/sessions/reconcile.ts"; import { normalizeAgentId } from "../lib/sessions/session-key.ts"; import { SIDEBAR_AGENT_SESSION_LIST_LIMIT, @@ -56,34 +57,6 @@ function publishSidebarSessionResult( owner.requestSessionDataUpdate(); } -function appendSidebarSessionResults( - previous: SessionsListResult, - page: SessionsListResult, -): SessionsListResult { - const seen = new Set(); - const sessions = [...previous.sessions, ...page.sessions].filter((row) => { - if (!row.key || seen.has(row.key)) { - return false; - } - seen.add(row.key); - return true; - }); - const totalCount = page.totalCount ?? previous.totalCount; - const hasMore = - page.hasMore ?? - (typeof totalCount === "number" && Number.isFinite(totalCount) - ? sessions.length < totalCount - : false); - return { - ...page, - count: sessions.length, - totalCount, - hasMore, - nextOffset: page.nextOffset ?? (hasMore ? sessions.length : null), - sessions, - }; -} - export async function refreshSidebarSessions( owner: SidebarSessionPaginationOwner, agentId: string, @@ -228,7 +201,7 @@ export async function loadMoreSidebarSessions( publishSidebarSessionResult( owner, agentId, - appendSidebarSessionResults(previous, page), + appendSessionResults(previous, page), archivedFilter, generation, ); 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 ddf97810e0d7..134274596773 100644 --- a/ui/src/components/session-data-controller.event-refresh.test.ts +++ b/ui/src/components/session-data-controller.event-refresh.test.ts @@ -230,6 +230,39 @@ describe("filtered sidebar session event refresh", () => { controller.hostDisconnected(); }); + it("retires an in-flight child snapshot when the status filter changes", async () => { + const { controller, list, selectStatusFilter } = createFilteredSessionController("archived"); + controller.hostConnected(); + await Promise.resolve(); + await Promise.resolve(); + let resolveChildPage!: (value: Awaited>) => void; + const childPage = new Promise>>((resolve) => { + resolveChildPage = resolve; + }); + list.mockImplementationOnce(async () => await childPage); + + const pendingChildren = controller.loadChildSessions("agent:main:parent"); + expect(controller.loadingChildSessionKeys.has("agent:main:parent")).toBe(true); + + selectStatusFilter("all"); + resolveChildPage({ + ts: 2, + path: "", + count: 1, + totalCount: 1, + nextOffset: null, + hasMore: false, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key: "agent:main:stale-child", kind: "direct", updatedAt: 2 }], + }); + await pendingChildren; + + expect(controller.childSessionRowsByParent).toEqual({}); + expect(controller.loadedChildSessionKeys.has("agent:main:parent")).toBe(false); + expect(controller.loadingChildSessionKeys.has("agent:main:parent")).toBe(false); + controller.hostDisconnected(); + }); + it("bounds refresh latency while same-agent events continue arriving", async () => { vi.useFakeTimers(); const { controller, list, publishSessionChanged } = createFilteredSessionController("all"); diff --git a/ui/src/components/session-data-controller.ts b/ui/src/components/session-data-controller.ts index 4854acd77bdd..e33848e5ef97 100644 --- a/ui/src/components/session-data-controller.ts +++ b/ui/src/components/session-data-controller.ts @@ -399,24 +399,28 @@ export class SessionDataController implements ReactiveController, SessionCatalog } } + private resetChildSessionState(): void { + this.childSessionGeneration += 1; + this.childSessionRowsByParent = {}; + this.loadedChildSessionKeys = new Set(); + this.failedChildSessionKeys = new Set(); + this.loadingChildSessionKeys = new Set(); + this.activeSessionLineageRoot = null; + this.activeSessionLineageRouteKey = null; + this.activeSessionLineageLoaded = false; + this.activeSessionLineageRequestToken = null; + if (this.activeSessionLineageRetryTimer) { + globalThis.clearTimeout(this.activeSessionLineageRetryTimer); + this.activeSessionLineageRetryTimer = null; + } + } + private readonly updateSessions = (sessions: SessionCapability) => { if (this.childSessionCanonicalListRevision !== sessions.canonicalListRevision) { this.childSessionCanonicalListRevision = sessions.canonicalListRevision; // The canonical root list advances after session events, but excludes hidden children. // Drop child snapshots so expanded parents refetch live terminal state. - this.childSessionGeneration += 1; - this.childSessionRowsByParent = {}; - this.loadedChildSessionKeys = new Set(); - this.failedChildSessionKeys = new Set(); - this.loadingChildSessionKeys = new Set(); - this.activeSessionLineageRoot = null; - this.activeSessionLineageRouteKey = null; - this.activeSessionLineageLoaded = false; - this.activeSessionLineageRequestToken = null; - if (this.activeSessionLineageRetryTimer) { - globalThis.clearTimeout(this.activeSessionLineageRetryTimer); - this.activeSessionLineageRetryTimer = null; - } + this.resetChildSessionState(); this.notify(); } const snapshot = sessions.state; @@ -512,24 +516,12 @@ export class SessionDataController implements ReactiveController, SessionCatalog } private clearSessionCache(): void { - this.childSessionGeneration += 1; this.childSessionCanonicalListRevision = null; this.reconnectListRevision = null; this.sessionsResult = null; this.sessionsAgentId = null; this.sessionRowsByAgent = {}; - this.childSessionRowsByParent = {}; - this.loadedChildSessionKeys = new Set(); - this.failedChildSessionKeys = new Set(); - this.loadingChildSessionKeys = new Set(); - this.activeSessionLineageRoot = null; - this.activeSessionLineageRouteKey = null; - this.activeSessionLineageLoaded = false; - this.activeSessionLineageRequestToken = null; - if (this.activeSessionLineageRetryTimer) { - globalThis.clearTimeout(this.activeSessionLineageRetryTimer); - this.activeSessionLineageRetryTimer = null; - } + this.resetChildSessionState(); this.sessionCreatedOrder.clear(); this.visibleSessionLimits.clear(); this.notify(); @@ -680,10 +672,9 @@ export class SessionDataController implements ReactiveController, SessionCatalog this.sidebarSessionPaginationState.loadedScope = undefined; this.sessionsLoading = false; this.visibleSessionLimits.clear(); - this.childSessionRowsByParent = {}; - this.loadedChildSessionKeys = new Set(); - this.failedChildSessionKeys = new Set(); - this.loadingChildSessionKeys = new Set(); + // A filter transition owns a new child/lineage generation; otherwise a + // pending request from the retired view can repopulate its cleared rows. + this.resetChildSessionState(); this.sessionRowsByAgent = {}; if (statusFilter === "active" && this.context) { this.sessionsResult = this.context.sessions.state.result; diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts index 8a6455961479..0814c20ccd71 100644 --- a/ui/src/lib/sessions/reconcile.ts +++ b/ui/src/lib/sessions/reconcile.ts @@ -41,6 +41,35 @@ export type SessionRunTerminal = { endedAt: number; }; +/** Merge canonical and filtered pages with the same cursor/deduplication contract. */ +export function appendSessionResults( + previous: SessionsListResult, + page: SessionsListResult, +): SessionsListResult { + const seen = new Set(); + const sessions = [...previous.sessions, ...page.sessions].filter((row) => { + if (!row.key || seen.has(row.key)) { + return false; + } + seen.add(row.key); + return true; + }); + const totalCount = page.totalCount ?? previous.totalCount; + const hasMore = + page.hasMore ?? + (typeof totalCount === "number" && Number.isFinite(totalCount) + ? sessions.length < totalCount + : false); + return { + ...page, + count: sessions.length, + totalCount, + hasMore, + nextOffset: page.nextOffset ?? (hasMore ? sessions.length : null), + sessions, + }; +} + type SessionChangedEventInfo = { key: string; agentId: string | null; diff --git a/ui/src/lib/sessions/session-roster-refresh.ts b/ui/src/lib/sessions/session-roster-refresh.ts index f32208498c23..4a4b5e64ded5 100644 --- a/ui/src/lib/sessions/session-roster-refresh.ts +++ b/ui/src/lib/sessions/session-roster-refresh.ts @@ -1,5 +1,6 @@ import type { SessionsListResult } from "../../api/types.ts"; import { createSessionEventRefreshCoordinator } from "./event-refresh-coordinator.ts"; +import { appendSessionResults } from "./reconcile.ts"; import type { SessionConnectionOwner, SessionGateway, @@ -26,34 +27,6 @@ type SessionRosterRefreshHost = { onCanonicalList: (result: SessionsListResult | null) => void; }; -function appendSessionResults( - previous: SessionsListResult, - page: SessionsListResult, -): SessionsListResult { - const seen = new Set(); - const sessions = [...previous.sessions, ...page.sessions].filter((row) => { - if (!row.key || seen.has(row.key)) { - return false; - } - seen.add(row.key); - return true; - }); - const totalCount = page.totalCount ?? previous.totalCount; - const hasMore = - page.hasMore ?? - (typeof totalCount === "number" && Number.isFinite(totalCount) - ? sessions.length < totalCount - : false); - return { - ...page, - count: sessions.length, - totalCount, - hasMore, - nextOffset: page.nextOffset ?? (hasMore ? sessions.length : null), - sessions, - }; -} - export function createSessionRosterRefresh(host: SessionRosterRefreshHost) { let inFlight: Promise | null = null; let queuedExplicitRefresh: SessionRefreshOptions | null = null;