fix(ui): prevent stale sidebar sessions after switching filters (#117090)

This commit is contained in:
Peter Steinberger
2026-07-31 17:05:16 -07:00
committed by GitHub
parent 89aadef6ef
commit 8ea6c43754
5 changed files with 86 additions and 87 deletions
@@ -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<string>();
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,
);
@@ -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<ReturnType<typeof list>>) => void;
const childPage = new Promise<Awaited<ReturnType<typeof list>>>((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");
+21 -30
View File
@@ -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;
+29
View File
@@ -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<string>();
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;
+1 -28
View File
@@ -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<string>();
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<void> | null = null;
let queuedExplicitRefresh: SessionRefreshOptions | null = null;