mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(ui): prevent stale sidebar sessions after switching filters (#117090)
This commit is contained in:
committed by
GitHub
parent
89aadef6ef
commit
8ea6c43754
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user