fix(ui): stop duplicate Sessions page queries (#125018)

Make the Sessions route and page share the managed session-list owner so startup hydration, reconnects, filters, and mutations cannot schedule duplicate raw roster requests. Preserve last-good rows and retire stale query epochs.
This commit is contained in:
Peter Steinberger
2026-08-16 20:54:40 -07:00
committed by GitHub
parent dd1ee7b375
commit 77ececee54
11 changed files with 901 additions and 337 deletions
@@ -90,6 +90,96 @@ suite.define(() => {
expect(await currentPage.getByText(hiddenLabel, { exact: true }).count()).toBe(0);
});
it("keeps the Sessions page query stable when the startup roster completes", async () => {
const visibleLabel = "Visible page-owned session";
const pageQueryParams = {
agentId: "main",
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: false,
limit: 50,
};
const visibleResponse = {
count: 1,
defaults: { contextTokens: null, model: null, modelProvider: null },
path: "",
sessions: [
{
key: "agent:main:page-owned",
kind: "direct",
label: visibleLabel,
updatedAt: 1,
},
],
ts: 1,
};
const context = await suite.browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, {
deferredMethods: ["sessions.list"],
sessionKey: "agent:main:main",
methodResponses: {
"sessions.list": {
cases: [
{ match: pageQueryParams, response: visibleResponse },
{
response: {
count: 0,
defaults: visibleResponse.defaults,
path: "",
sessions: [],
ts: 1,
},
},
],
},
},
});
const exactPageQueries = async () =>
(await gateway.getRequests("sessions.list")).filter((request) => {
const params = request.params;
if (!params || typeof params !== "object" || Array.isArray(params)) {
return false;
}
const record = params as Record<string, unknown>;
const entries = Object.entries(pageQueryParams);
return (
Object.keys(record).length === entries.length &&
entries.every(([key, value]) => record[key] === value)
);
});
await currentPage.goto(`${suite.server.baseUrl}sessions`);
const visibleRow = currentPage.getByText(visibleLabel, { exact: true }).first();
await visibleRow.waitFor({ timeout: 10_000 });
const startupAndPageRequests = await gateway.getRequests("sessions.list");
expect(startupAndPageRequests[0]?.params).toEqual({
agentId: "main",
configuredAgentsOnly: true,
includeDerivedTitles: true,
includeGlobal: true,
includeLastMessage: true,
includeUnknown: true,
limit: 50,
});
expect
.soft((await exactPageQueries()).map((request) => request.params))
.toEqual([pageQueryParams]);
await gateway.resolveDeferred("sessions.list", visibleResponse);
await visibleRow.waitFor();
const stabilityDeadline = Date.now() + 500;
do {
expect(await exactPageQueries()).toHaveLength(1);
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
} while (Date.now() < stabilityDeadline);
});
it("keeps older Gateway sessions consistent between the sidebar and Sessions page", async () => {
const sessionKey = "agent:main:older-stored";
const sessionLabel = "Older stored session";
+34
View File
@@ -205,6 +205,40 @@ describe("session list requests", () => {
sessions.dispose();
});
it("keeps explicit unenriched page queries independent from the primary roster", async () => {
const request = vi
.fn()
.mockResolvedValueOnce(listResult(["agent:main:primary"]))
.mockResolvedValueOnce(listResult(["agent:main:page"]));
const { sessions } = sessionHarness(request);
const pageQuery = {
agentId: "main",
limit: 50,
includeGlobal: true,
includeUnknown: true,
configuredAgentsOnly: true,
includeDerivedTitles: false,
includeLastMessage: false,
};
const unsubscribe = sessions.subscribeList(pageQuery, () => undefined);
await sessions.refreshList({ agentId: "main", limit: 50, force: true });
const primaryResult = sessions.state.result;
await sessions.refreshList({ ...pageQuery, force: true });
expect(sessions.state.result).toBe(primaryResult);
expect(sessions.listSnapshot(pageQuery).result?.sessions[0]?.key).toBe("agent:main:page");
expect(request.mock.calls[1]?.[1]).toEqual({
agentId: "main",
configuredAgentsOnly: true,
includeGlobal: true,
includeUnknown: true,
limit: 50,
});
unsubscribe();
sessions.dispose();
});
it("retires stale filtered snapshots across same-client reconnects without losing subscribers", async () => {
let resolveStale!: (result: SessionsListResult) => void;
const staleResult = new Promise<SessionsListResult>((resolve) => {
@@ -68,6 +68,9 @@ function managedSessionListAgentId(entry: ManagedSessionList): string | undefine
}
function isPrimarySessionListQuery(options: SessionListScope): boolean {
if (options.includeDerivedTitles === false || options.includeLastMessage === false) {
return false;
}
const query = normalizeManagedSessionListQuery(options);
return (
query.archived === undefined &&
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../app/context.ts";
import { waitForFast } from "../test-helpers/wait-for.ts";
import type { SessionsRouteData } from "./sessions/sessions-page.ts";
import type { SessionsRouteData } from "./sessions/route.ts";
import type { SkillsRouteData } from "./skills/skills-page.ts";
import type { UsageRefreshPolicy } from "./usage/refresh-policy.ts";
import type { UsageRouteData } from "./usage/usage-page.ts";
@@ -105,6 +105,9 @@ function contextWithClient(
sessions: {
state: { result: null, loading: false },
list: vi.fn(async () => null),
listSnapshot: () => ({ result: null, agentId: null, loading: false, error: null }),
subscribeList: () => () => undefined,
refreshList: vi.fn(async () => undefined),
subscribe,
},
workboard: { subscribe },
@@ -169,7 +172,9 @@ describe("gateway source replacement across reconnect with a reused client", ()
const routeData = {
gateway: context.gateway,
gatewaySnapshot: context.gateway.snapshot,
sessions: context.sessions,
result: { count: 1, sessions: [{ key: "old" }] },
loading: false,
error: null,
expandedSessionKey: null,
statusFilter: "active",
+8 -24
View File
@@ -10,8 +10,7 @@ import type { ModelProvidersRouteData } from "./model-providers/model-providers-
import { page as modelProvidersPage } from "./model-providers/route.ts";
import type { PluginsRouteData } from "./plugins/plugins-page.ts";
import { page as pluginsPage } from "./plugins/route.ts";
import { page as sessionsPage } from "./sessions/route.ts";
import type { SessionsRouteData } from "./sessions/sessions-page.ts";
import { page as sessionsPage, type SessionsRouteData } from "./sessions/route.ts";
import { page as skillsPage } from "./skills/route.ts";
import type { SkillsRouteData } from "./skills/skills-page.ts";
import { page as usagePage } from "./usage/route.ts";
@@ -103,41 +102,26 @@ describe("route preload gateway provenance", () => {
const originalSnapshot = snapshot(client, true);
const mutable = mutableGateway(originalSnapshot);
const gateway = mutable.gateway;
const list = deferred<null>();
const refresh = deferred<void>();
const managedSnapshot = { result: null, agentId: null, loading: false, error: null };
const request = loadRoute<SessionsRouteData>(sessionsPage, {
gateway,
sessions: { list: vi.fn(() => list.promise) },
sessions: {
listSnapshot: vi.fn(() => managedSnapshot),
refreshList: vi.fn(() => refresh.promise),
},
runtimeConfig: { ensureLoaded: vi.fn(async () => undefined) },
agentSelection: { state: { selectedId: null, scopeId: null } },
} as unknown as ApplicationContext);
mutable.replaceSnapshot(snapshot(client, false));
list.resolve(null);
refresh.resolve();
const data = await request;
expect(data.gateway).toBe(gateway);
expect(data.gatewaySnapshot).toBe(originalSnapshot);
});
it("preloads a bounded session roster without an implicit recency filter", async () => {
const list = vi.fn(async (_options: unknown) => null);
await loadRoute<SessionsRouteData>(sessionsPage, {
gateway: mutableGateway(snapshot(null, false)).gateway,
sessions: { list },
runtimeConfig: { ensureLoaded: vi.fn(async () => undefined) },
agentSelection: { state: { selectedId: null, scopeId: null } },
} as unknown as ApplicationContext);
expect(list).toHaveBeenCalledWith({
limit: 50,
search: undefined,
includeGlobal: true,
includeUnknown: false,
archivedFilter: "active",
});
expect(list.mock.calls[0]?.[0]).not.toHaveProperty("activeMinutes");
});
it("keeps usage provenance from before its async preload", async () => {
const client = {
request: vi.fn(async () => ({})),
+118
View File
@@ -0,0 +1,118 @@
// @vitest-environment node
import type { RouteLoaderOptions } from "@openclaw/uirouter";
import { describe, expect, it, vi } from "vitest";
import type { SessionsListResult } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import type { SessionListOptions, SessionListSnapshot } from "../../lib/sessions/index.ts";
import { page, type SessionsRouteData } from "./route.ts";
const result: SessionsListResult = {
ts: 1,
path: "",
count: 0,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [],
};
async function loadSessionsRoute(options: {
search: string;
scopeId: string | null;
expectedQuery: SessionListOptions;
}) {
let snapshot: SessionListSnapshot = {
result: null,
agentId: null,
loading: false,
error: null,
};
const list = vi.fn();
const listSnapshot = vi.fn(() => snapshot);
const refreshList = vi.fn(async () => {
snapshot = { result, agentId: options.scopeId, loading: false, error: null };
});
const context = {
gateway: { snapshot: { phase: "connected", client: {} } },
sessions: { list, listSnapshot, refreshList },
runtimeConfig: { ensureLoaded: vi.fn(async () => undefined) },
agentSelection: { state: { selectedId: options.scopeId, scopeId: options.scopeId } },
} as unknown as ApplicationContext;
const loaderOptions: RouteLoaderOptions = {
signal: new AbortController().signal,
shouldRun: () => true,
revalidating: false,
location: { pathname: "/sessions", search: options.search, hash: "" },
deps: "",
cause: "navigation",
};
const data = (await page.loader?.(context, loaderOptions)) as SessionsRouteData;
expect(refreshList).toHaveBeenCalledWith({ ...options.expectedQuery, force: true });
expect(listSnapshot).toHaveBeenLastCalledWith(options.expectedQuery);
expect(list).not.toHaveBeenCalled();
expect(data).toMatchObject({ result, loading: false, error: null });
}
describe("sessions route", () => {
it.each([
{
name: "default selected-agent roster",
search: "",
scopeId: "writer",
expectedQuery: {
limit: 50,
includeGlobal: true,
includeUnknown: false,
includeDerivedTitles: false,
includeLastMessage: false,
archivedFilter: "active" as const,
agentId: "writer",
},
},
{
name: "archived all-agent roster",
search: "?status=archived",
scopeId: null,
expectedQuery: {
limit: 50,
includeGlobal: true,
includeUnknown: false,
includeDerivedTitles: false,
includeLastMessage: false,
archivedFilter: "archived" as const,
},
},
{
name: "all-status selected-agent roster",
search: "?status=all",
scopeId: "main",
expectedQuery: {
limit: 50,
includeGlobal: true,
includeUnknown: false,
includeDerivedTitles: false,
includeLastMessage: false,
archivedFilter: "all" as const,
agentId: "main",
},
},
{
name: "deep link owned by a different agent",
search: "?session=agent%3Aresearch%3Alinked",
scopeId: "main",
expectedQuery: {
limit: 50,
search: "agent:research:linked",
includeGlobal: true,
includeUnknown: true,
includeDerivedTitles: false,
includeLastMessage: false,
archivedFilter: "active" as const,
agentId: "research",
},
},
])("loads the managed $name without a raw list", async (testCase) => {
await loadSessionsRoute(testCase);
});
});
+65 -21
View File
@@ -3,18 +3,39 @@ import { definePage } from "@openclaw/uirouter";
import { html } from "lit";
import { routePageSpec } from "../../app-route-paths.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { formatUiError } from "../../lib/format-error.ts";
import {
DEFAULT_SESSION_LIST_QUERY,
type SessionArchivedFilter,
type SessionListOptions,
type SessionListSnapshot,
} from "../../lib/sessions/index.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
import type { SessionsRouteData } from "./sessions-page.ts";
export type SessionsRouteData = {
// Client identity alone cannot distinguish provider replacement or reconnect epochs.
gateway: ApplicationContext["gateway"];
gatewaySnapshot: ApplicationContext["gateway"]["snapshot"];
sessions: ApplicationContext["sessions"];
result: SessionListSnapshot["result"];
loading: boolean;
error: string | null;
expandedSessionKey: string | null;
statusFilter: SessionArchivedFilter;
};
type SessionsPageListFilters = {
activeMinutes?: number;
limit?: number;
includeGlobal: boolean;
includeUnknown: boolean;
statusFilter: SessionArchivedFilter;
deepLinkSessionKey?: string | null;
};
function routeOptions(location: RouteLocation) {
const search = new URLSearchParams(location.search);
const expandedSessionKey = search.get("session")?.trim() || null;
// The retired internal `showArchived` param is deliberately not read; dashboard
// The retired internal `showArchived` param is deliberately not read; Sessions
// URLs are not a shipped contract and stale links fall back to the Active view.
const requestedStatus = search.get("status");
const statusFilter: SessionArchivedFilter =
@@ -22,36 +43,59 @@ function routeOptions(location: RouteLocation) {
return { expandedSessionKey, statusFilter };
}
export function sessionsPageListQuery(
context: ApplicationContext,
filters: SessionsPageListFilters,
): SessionListOptions {
const deepLinkSessionKey = filters.deepLinkSessionKey?.trim() || null;
const scopeAgentId =
parseAgentSessionKey(deepLinkSessionKey)?.agentId ??
context.agentSelection.state.scopeId?.trim();
const activeMinutes =
!deepLinkSessionKey && filters.statusFilter === "active" ? filters.activeMinutes : undefined;
return {
limit: deepLinkSessionKey ? DEFAULT_SESSION_LIST_QUERY.limit : filters.limit,
...(activeMinutes ? { activeMinutes } : {}),
...(deepLinkSessionKey ? { search: deepLinkSessionKey } : {}),
includeGlobal: deepLinkSessionKey ? true : filters.includeGlobal,
includeUnknown: deepLinkSessionKey ? true : filters.includeUnknown,
includeDerivedTitles: false,
includeLastMessage: false,
archivedFilter: filters.statusFilter,
...(scopeAgentId ? { agentId: scopeAgentId } : {}),
};
}
async function loadSessionsRoute(
context: ApplicationContext,
location: RouteLocation,
): Promise<SessionsRouteData> {
const gateway = context.gateway;
const gatewaySnapshot = gateway.snapshot;
const sessions = context.sessions;
const options = routeOptions(location);
const checkpointAgentId = parseAgentSessionKey(options.expandedSessionKey)?.agentId;
const scopeAgentId = checkpointAgentId ?? context.agentSelection.state.scopeId;
const [sessions] = await Promise.all([
context.sessions
.list({
...DEFAULT_SESSION_LIST_QUERY,
search: options.expandedSessionKey ?? undefined,
includeGlobal: true,
includeUnknown: Boolean(options.expandedSessionKey),
archivedFilter: options.statusFilter,
...(scopeAgentId ? { agentId: scopeAgentId } : {}),
})
.then(
(result) => ({ result, error: null }),
(error: unknown) => ({ result: null, error: formatUiError(error) }),
),
const query = sessionsPageListQuery(context, {
limit: DEFAULT_SESSION_LIST_QUERY.limit,
includeGlobal: true,
includeUnknown: false,
statusFilter: options.statusFilter,
deepLinkSessionKey: options.expandedSessionKey,
});
let snapshot = sessions.listSnapshot(query);
await Promise.all([
!snapshot.result && !snapshot.loading
? sessions.refreshList({ ...query, force: true })
: undefined,
context.runtimeConfig.ensureLoaded().catch(() => undefined),
]);
snapshot = sessions.listSnapshot(query);
return {
gateway,
gatewaySnapshot,
result: sessions.result,
error: sessions.error,
sessions,
result: snapshot.result,
loading: snapshot.loading,
error: snapshot.error,
...options,
};
}
@@ -0,0 +1,332 @@
/* @vitest-environment jsdom */
import { nothing } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SessionCompactionCheckpoint, SessionsListResult } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { showConfirmDialog } from "../../components/confirm-dialog.ts";
import {
createContext,
createGateway,
createManagedSessions,
createRenderedPage,
type TestSessionsPage,
} from "./sessions-page.test-support.ts";
vi.mock("../../components/confirm-dialog.ts", () => ({ showConfirmDialog: vi.fn() }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
async function createPage(context: ApplicationContext): Promise<TestSessionsPage> {
const page = document.createElement("openclaw-sessions-page") as TestSessionsPage;
page.context = context;
page.render = () => nothing;
document.body.append(page);
await page.updateComplete;
return page;
}
afterEach(() => {
document.body.replaceChildren();
vi.mocked(showConfirmDialog).mockReset();
vi.restoreAllMocks();
});
describe("sessions page managed roster", () => {
it("rejects route data from an earlier same-client connection epoch", async () => {
const client = {} as GatewayBrowserClient;
const mutableGateway = createGateway(client);
const staleGatewaySnapshot = mutableGateway.gateway.snapshot;
const refresh = deferred<void>();
const refreshList = vi.fn(() => refresh.promise);
const managed = createManagedSessions({ refreshList });
const context = createContext(mutableGateway.gateway, managed.sessions);
const page = document.createElement("openclaw-sessions-page") as TestSessionsPage;
page.context = context;
page.render = () => nothing;
page.routeData = {
gateway: mutableGateway.gateway,
gatewaySnapshot: staleGatewaySnapshot,
sessions: managed.sessions,
result: { count: 1, sessions: [{ key: "stale" }] } as SessionsListResult,
loading: false,
error: null,
expandedSessionKey: null,
statusFilter: "active",
};
mutableGateway.emit({ phase: "reconnecting", client });
mutableGateway.emit({ phase: "connected", client });
document.body.append(page);
await page.updateComplete;
await vi.waitFor(() => expect(refreshList).toHaveBeenCalledOnce());
expect(page.result).toBeNull();
const query = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
if (!query) {
throw new Error("Expected the current managed query subscription");
}
managed.publish(query, {
result: { count: 1, sessions: [{ key: "current" }] } as SessionsListResult,
agentId: "main",
loading: false,
error: null,
});
refresh.resolve();
await vi.waitFor(() => expect(page.result?.sessions[0]?.key).toBe("current"));
});
it("preserves rows across a same-client reconnect and adopts the refreshed managed list", async () => {
const client = {} as GatewayBrowserClient;
const mutableGateway = createGateway(client);
const managed = createManagedSessions();
const context = createContext(mutableGateway.gateway, managed.sessions);
const staleResult = { count: 1, sessions: [{ key: "stale" }] } as SessionsListResult;
const freshResult = { count: 1, sessions: [{ key: "fresh" }] } as SessionsListResult;
const page = await createRenderedPage(context, staleResult);
const query = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
if (!query) {
throw new Error("Expected the Sessions page to subscribe to its managed query");
}
mutableGateway.emit({ phase: "reconnecting", client });
expect(page.result?.sessions.map((row) => row.key)).toEqual(["stale"]);
mutableGateway.emit({ phase: "connected", client });
managed.publish(query, { result: freshResult, agentId: "main", loading: false, error: null });
await vi.waitFor(() => expect(page.result?.sessions.map((row) => row.key)).toEqual(["fresh"]));
});
it("retires the old managed listener and checkpoint work after capability replacement", async () => {
const checkpoints = deferred<SessionCompactionCheckpoint[]>();
const previous = createManagedSessions({
listCheckpoints: vi.fn(() => checkpoints.promise),
});
const { gateway } = createGateway({} as GatewayBrowserClient);
const context = createContext(gateway, previous.sessions);
const page = await createRenderedPage(context, {
count: 1,
sessions: [{ key: "previous" }],
} as SessionsListResult);
const previousQuery = vi.mocked(previous.subscribeList).mock.calls[0]?.[0];
if (!previousQuery) {
throw new Error("Expected the previous capability subscription");
}
const checkpointRequest = page.loadCheckpoint("main");
await vi.waitFor(() => expect(previous.sessions.listCheckpoints).toHaveBeenCalledOnce());
const replacement = createManagedSessions();
page.context = { ...context, sessions: replacement.sessions };
page.requestUpdate();
await page.updateComplete;
previous.publish(previousQuery, {
result: { count: 1, sessions: [{ key: "stale" }] } as SessionsListResult,
agentId: "main",
loading: false,
error: null,
});
checkpoints.resolve([{ checkpointId: "stale" }] as SessionCompactionCheckpoint[]);
await checkpointRequest;
expect(page.result).toBeNull();
expect(page.loading).toBe(false);
expect(page.checkpointItemsByKey).toEqual({});
expect(page.checkpointLoadingKey).toBeNull();
});
it("switches exact managed queries for selected and all-agent scopes", async () => {
const managed = createManagedSessions();
const context = createContext(
createGateway({} as GatewayBrowserClient).gateway,
managed.sessions,
);
let notifyScopeChange: Parameters<ApplicationContext["agentSelection"]["subscribe"]>[0] = () =>
undefined;
context.agentSelection.subscribe = (listener) => {
notifyScopeChange = listener;
return () => undefined;
};
const page = await createPage(context);
await vi.waitFor(() => expect(managed.subscribeList).toHaveBeenCalledOnce());
const selectedQuery = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
expect(selectedQuery).toEqual({
limit: 50,
includeGlobal: true,
includeUnknown: false,
includeDerivedTitles: false,
includeLastMessage: false,
archivedFilter: "active",
agentId: "main",
});
context.agentSelection.state.scopeId = null;
notifyScopeChange(context.agentSelection.state);
await vi.waitFor(() => expect(managed.subscribeList).toHaveBeenCalledTimes(2));
const allAgentsQuery = vi.mocked(managed.subscribeList).mock.calls[1]?.[0];
expect(allAgentsQuery).toEqual(expect.not.objectContaining({ agentId: expect.anything() }));
if (!selectedQuery || !allAgentsQuery) {
throw new Error("Expected both managed query scopes");
}
managed.publish(selectedQuery, {
result: { count: 1, sessions: [{ key: "retired" }] } as SessionsListResult,
agentId: "main",
loading: false,
error: null,
});
managed.publish(allAgentsQuery, {
result: { count: 1, sessions: [{ key: "current" }] } as SessionsListResult,
agentId: null,
loading: false,
error: null,
});
await vi.waitFor(() => expect(page.result?.sessions[0]?.key).toBe("current"));
});
it("keeps last-good rows while a managed refresh loads and fails", async () => {
const managed = createManagedSessions();
const context = createContext(
createGateway({} as GatewayBrowserClient).gateway,
managed.sessions,
);
const result = { count: 1, sessions: [{ key: "last-good" }] } as SessionsListResult;
const page = await createRenderedPage(context, result);
const query = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
if (!query) {
throw new Error("Expected a managed query subscription");
}
managed.publish(query, { result, agentId: "main", loading: true, error: null });
expect(page.loading).toBe(true);
expect(page.result?.sessions.map((row) => row.key)).toEqual(["last-good"]);
managed.publish(query, {
result,
agentId: "main",
loading: false,
error: "managed refresh failed",
});
expect(page.loading).toBe(false);
expect(page.error).toBe("managed refresh failed");
expect(page.result?.sessions.map((row) => row.key)).toEqual(["last-good"]);
});
it("reconciles checkpoint caches only when the managed result pointer changes", async () => {
const key = "agent:main:checkpointed";
const checkpoint = (checkpointId: string): SessionCompactionCheckpoint => ({
checkpointId,
sessionKey: key,
sessionId: `session-${checkpointId}`,
createdAt: checkpointId === "old" ? 1 : 2,
reason: "manual",
preCompaction: { sessionId: `pre-${checkpointId}` },
postCompaction: { sessionId: `post-${checkpointId}` },
});
const oldCheckpoint = checkpoint("old");
const newCheckpoint = checkpoint("new");
const listCheckpoints = vi.fn(async () => [newCheckpoint]);
const managed = createManagedSessions({ listCheckpoints });
const context = createContext(
createGateway({} as GatewayBrowserClient).gateway,
managed.sessions,
);
const initialResult = {
count: 1,
sessions: [
{
key,
compactionCheckpointCount: 1,
latestCompactionCheckpoint: { checkpointId: "old" },
},
],
} as SessionsListResult;
const page = await createRenderedPage(context, initialResult, "active", key);
await vi.waitFor(() => expect(listCheckpoints).toHaveBeenCalled());
listCheckpoints.mockClear();
page.checkpointItemsByKey = { [key]: [oldCheckpoint] };
const query = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
if (!query) {
throw new Error("Expected a managed query subscription");
}
managed.publish(query, { result: initialResult, agentId: "main", loading: true, error: null });
expect(listCheckpoints).not.toHaveBeenCalled();
managed.publish(query, {
result: {
count: 1,
sessions: [
{
key,
compactionCheckpointCount: 2,
latestCompactionCheckpoint: { checkpointId: "new" },
},
],
} as SessionsListResult,
agentId: "main",
loading: false,
error: null,
});
await vi.waitFor(() => expect(listCheckpoints).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(page.checkpointItemsByKey[key]).toEqual([newCheckpoint]));
});
it("adopts a managed snapshot that arrives under the bulk-delete lock after its tail refresh", async () => {
const deleted = deferred<{
deleted: string[];
errors: string[];
preservedWorktrees: Array<{ id: string; branch: string; path: string }>;
}>();
const deleteMany = vi.fn(() => deleted.promise);
const managed = createManagedSessions({ deleteMany });
const context = createContext(
createGateway({} as GatewayBrowserClient).gateway,
managed.sessions,
);
const page = await createRenderedPage(context, {
count: 1,
sessions: [{ key: "before" }],
} as SessionsListResult);
const query = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
if (!query) {
throw new Error("Expected a managed query subscription");
}
managed.refreshList.mockClear();
page.selectedKeys = new Set(["before"]);
vi.mocked(showConfirmDialog).mockResolvedValue(true);
const deleting = page.deleteSelected();
await vi.waitFor(() => expect(deleteMany).toHaveBeenCalledOnce());
const duringResult = {
count: 1,
sessions: [{ key: "arrived-during-mutation" }],
} as SessionsListResult;
managed.publish(query, {
result: duringResult,
agentId: "main",
loading: false,
error: null,
});
expect(page.result?.sessions.map((row) => row.key)).toEqual(["before"]);
deleted.resolve({ deleted: [], errors: [], preservedWorktrees: [] });
await deleting;
expect(managed.refreshList).toHaveBeenCalledWith({ ...query, force: true });
expect(deleteMany.mock.invocationCallOrder[0]).toBeLessThan(
managed.refreshList.mock.invocationCallOrder[0]!,
);
expect(page.result?.sessions.map((row) => row.key)).toEqual(["arrived-during-mutation"]);
});
});
@@ -6,8 +6,13 @@ import type {
SessionsListResult,
} from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import type { SessionsRouteData } from "./sessions-page.ts";
import type {
SessionCapability,
SessionListOptions,
SessionListSnapshot,
} from "../../lib/sessions/index.ts";
import type { SessionRefreshOptions } from "../../lib/sessions/session-capability.ts";
import type { SessionsRouteData } from "./route.ts";
import type { TranscriptSearchState } from "./view.ts";
import "./sessions-page.ts";
@@ -31,7 +36,6 @@ export type TestSessionsPage = HTMLElement & {
sessionMutationPending: boolean;
transcriptSearchQuery: string;
transcriptSearch: TranscriptSearchState;
loadSessions: () => Promise<void>;
updateTranscriptSearchQuery: (query: string) => void;
runTranscriptSearch: () => Promise<void>;
loadCheckpoint: (sessionKey: string) => Promise<void>;
@@ -105,8 +109,55 @@ export function createGateway(client: GatewayBrowserClient): MutableGateway {
}
export function createSessions(overrides: Partial<SessionCapability> = {}): SessionCapability {
return createManagedSessions(overrides).sessions;
}
function sessionListKey(options: SessionListOptions | SessionRefreshOptions): string {
const {
force: _force,
backgroundHydrate: _backgroundHydrate,
offset: _offset,
append: _append,
...scope
} = options as SessionRefreshOptions;
return JSON.stringify(scope);
}
export function createManagedSessions(overrides: Partial<SessionCapability> = {}) {
const subscribe = () => () => undefined;
return {
const snapshots = new Map<string, SessionListSnapshot>();
const listeners = new Map<string, Set<(snapshot: SessionListSnapshot) => void>>();
const emptySnapshot = (): SessionListSnapshot => ({
result: null,
agentId: null,
loading: false,
error: null,
});
const publish = (options: SessionListOptions, snapshot: SessionListSnapshot) => {
const key = sessionListKey(options);
snapshots.set(key, snapshot);
listeners.get(key)?.forEach((listener) => listener(snapshot));
};
const listSnapshot = vi.fn((options: SessionListOptions) => {
return snapshots.get(sessionListKey(options)) ?? emptySnapshot();
});
const subscribeList = vi.fn(
(options: SessionListOptions, listener: (snapshot: SessionListSnapshot) => void) => {
const key = sessionListKey(options);
const scoped = listeners.get(key) ?? new Set();
scoped.add(listener);
listeners.set(key, scoped);
return () => {
scoped.delete(listener);
};
},
);
const refreshList = vi.fn(async (options: SessionRefreshOptions = {}) => {
const snapshot = listSnapshot(options);
publish(options, { ...snapshot, loading: true, error: null });
publish(options, { ...snapshot, loading: false, error: null });
});
const sessions = {
state: {
result: null,
agentId: null,
@@ -114,8 +165,14 @@ export function createSessions(overrides: Partial<SessionCapability> = {}): Sess
loading: false,
error: null,
deletedSessions: [],
groups: [],
groupSettings: [],
sectionOrder: [],
},
list: vi.fn(async () => null),
listSnapshot,
subscribeList,
refreshList,
listCheckpoints: vi.fn(async () => []),
deleteMany: vi.fn(async () => ({ deleted: [], errors: [], preservedWorktrees: [] })),
patch: vi.fn(async () => null),
@@ -125,6 +182,7 @@ export function createSessions(overrides: Partial<SessionCapability> = {}): Sess
subscribe,
...overrides,
} as unknown as SessionCapability;
return { sessions, publish, listSnapshot, subscribeList, refreshList };
}
export function createContext(
@@ -167,7 +225,9 @@ export async function createRenderedPage(
page.routeData = {
gateway: context.gateway,
gatewaySnapshot: context.gateway.snapshot,
sessions: context.sessions,
result,
loading: false,
error: null,
expandedSessionKey,
statusFilter,
+33 -85
View File
@@ -16,6 +16,7 @@ import { getWorkboardState } from "../../lib/workboard/index.ts";
import {
createContext,
createGateway,
createManagedSessions,
createRenderedPage,
createSessions,
type TestSessionsPage,
@@ -514,81 +515,6 @@ describe("sessions page lifecycle", () => {
expect(menu.querySelector('[value="workboard"]')?.hasAttribute("disabled")).toBe(true);
});
it("rejects preloaded data after a same-client reconnect and loads the current epoch", async () => {
const client = {} as GatewayBrowserClient;
const mutableGateway = createGateway(client);
const preloadedSnapshot = mutableGateway.gateway.snapshot;
mutableGateway.emit({ phase: "reconnecting", client });
mutableGateway.emit({ phase: "connected", client });
const freshResult = { count: 1, sessions: [{ key: "fresh" }] } as SessionsListResult;
const sessions = createSessions({ list: vi.fn(async () => freshResult) });
const context = createContext(mutableGateway.gateway, sessions);
const page = document.createElement("openclaw-sessions-page") as TestSessionsPage;
page.context = context;
page.render = () => nothing;
page.routeData = {
gateway: mutableGateway.gateway,
gatewaySnapshot: preloadedSnapshot,
result: { count: 1, sessions: [{ key: "stale" }] } as SessionsListResult,
error: null,
expandedSessionKey: null,
statusFilter: "active",
};
document.body.append(page);
await page.updateComplete;
await vi.waitFor(() => expect(page.result?.sessions[0]?.key).toBe("fresh"));
expect(sessions.list).toHaveBeenCalledOnce();
expect(page.result?.sessions.map((session) => session.key)).toEqual(["fresh"]);
});
it("rejects session and checkpoint results after the sessions capability changes", async () => {
const list = deferred<SessionsListResult | null>();
const checkpoints = deferred<SessionCompactionCheckpoint[]>();
const sessions = createSessions({
list: vi.fn(() => list.promise),
listCheckpoints: vi.fn(() => checkpoints.promise),
});
const { gateway } = createGateway({} as GatewayBrowserClient);
const context = createContext(gateway, sessions);
const page = await createPage(context);
const listRequest = page.loadSessions();
const checkpointRequest = page.loadCheckpoint("main");
await vi.waitFor(() => {
expect(sessions.list).toHaveBeenCalledOnce();
expect(sessions.listCheckpoints).toHaveBeenCalledOnce();
});
page.context = { ...context, sessions: createSessions() };
page.requestUpdate();
await page.updateComplete;
list.resolve({ count: 1, sessions: [{ key: "stale" }] } as SessionsListResult);
checkpoints.resolve([{ checkpointId: "stale" }] as SessionCompactionCheckpoint[]);
await Promise.all([listRequest, checkpointRequest]);
expect(page.result).toBeNull();
expect(page.loading).toBe(false);
expect(page.checkpointItemsByKey).toEqual({});
expect(page.checkpointLoadingKey).toBeNull();
});
it("scopes list requests while preserving an all-agents escape", async () => {
const sessions = createSessions();
const context = createContext(createGateway({} as GatewayBrowserClient).gateway, sessions);
const page = await createPage(context);
await page.loadSessions();
expect(sessions.list).toHaveBeenLastCalledWith(expect.objectContaining({ agentId: "main" }));
context.agentSelection.state.scopeId = null;
await page.loadSessions();
expect(sessions.list).toHaveBeenLastCalledWith(
expect.not.objectContaining({ agentId: expect.anything() }),
);
});
it("invalidates checkpoint work and mutation locks on same-client disconnect", async () => {
const checkpoints = deferred<SessionCompactionCheckpoint[]>();
const sessions = createSessions({
@@ -757,11 +683,10 @@ describe("sessions page lifecycle", () => {
});
it("stops an active cloud worker and refreshes the session roster", async () => {
const request = vi.fn(() => Promise.resolve({ ok: true }));
const list = vi.fn(async () => ({ count: 0, sessions: [] }) as unknown as SessionsListResult);
const sessions = createSessions({ list });
const stopped = deferred<{ ok: true }>();
const request = vi.fn(() => stopped.promise);
const managed = createManagedSessions();
const { gateway } = createGateway({ request } as unknown as GatewayBrowserClient);
const page = await createPage(createContext(gateway, sessions));
const row = {
key: "agent:main:cloud",
label: "Cloud task",
@@ -778,9 +703,31 @@ describe("sessions page lifecycle", () => {
remoteWorkspaceDir: "/workspace",
},
} as GatewaySessionRow;
const page = await createRenderedPage(createContext(gateway, managed.sessions), {
count: 1,
sessions: [row],
} as SessionsListResult);
const query = vi.mocked(managed.subscribeList).mock.calls[0]?.[0];
if (!query) {
throw new Error("Expected a managed query subscription");
}
managed.refreshList.mockClear();
vi.mocked(showConfirmDialog).mockResolvedValue(true);
await page.stopCloudWorker(row);
const stopping = page.stopCloudWorker(row);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
managed.publish(query, {
result: {
count: 1,
sessions: [{ ...row, label: "Updated while stopping" }],
} as SessionsListResult,
agentId: "main",
loading: false,
error: null,
});
expect(page.result?.sessions[0]?.label).toBe("Cloud task");
stopped.resolve({ ok: true });
await stopping;
expect(showConfirmDialog).toHaveBeenCalledWith({
message: 'Stop the cloud worker for "Cloud task"?',
@@ -792,7 +739,8 @@ describe("sessions page lifecycle", () => {
{ key: "agent:main:cloud", agentId: "main" },
{ timeoutMs: 10 * 60_000 },
);
expect(list).toHaveBeenCalledOnce();
expect(managed.refreshList).toHaveBeenCalledWith({ ...query, force: true });
expect(page.result?.sessions[0]?.label).toBe("Updated while stopping");
expect(page.sessionMutationPending).toBe(false);
});
@@ -800,10 +748,10 @@ describe("sessions page lifecycle", () => {
const request = vi.fn(() =>
Promise.resolve({ status: "unavailable", worker: { state: "destroyed" } }),
);
const list = vi.fn(async () => ({ count: 0, sessions: [] }) as unknown as SessionsListResult);
const sessions = createSessions({ list });
const managed = createManagedSessions();
const { gateway } = createGateway({ request } as unknown as GatewayBrowserClient);
const page = await createPage(createContext(gateway, sessions));
const page = await createPage(createContext(gateway, managed.sessions));
managed.refreshList.mockClear();
const toast = document.createElement("openclaw-toast-host");
document.body.append(toast);
await toast.updateComplete;
@@ -832,7 +780,7 @@ describe("sessions page lifecycle", () => {
expect(request).toHaveBeenCalledWith("environments.destroy", {
environmentId: "environment-1",
});
expect(list).toHaveBeenCalledOnce();
expect(managed.refreshList).toHaveBeenCalledOnce();
expect(toast.querySelector(".app-toast__message")?.textContent).toBe(
'Cloud worker for "Cloud task" is destroyed.',
);
+148 -202
View File
@@ -46,6 +46,7 @@ import {
filterSessionRows,
scopedAgentParamsForSession,
type SessionArchivedFilter,
type SessionListSnapshot,
} from "../../lib/sessions/index.ts";
import { fetchPagedSessionRows } from "../../lib/sessions/paged-session-rows.ts";
import {
@@ -64,6 +65,7 @@ import {
import { showToast } from "../../lib/toast.ts";
import { isActiveWorkboardCard } from "../../lib/workboard/card-state.ts";
import { captureSessionToWorkboard } from "../../lib/workboard/index.ts";
import { GatewayPageController } from "../../lit/gateway-page-controller.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
@@ -73,20 +75,11 @@ import {
} from "./agent-scope.ts";
import { rememberSessionCustomGroup, sessionCategoryNames } from "./custom-groups.ts";
import { loadStoredGroupBy, saveStoredGroupBy } from "./page-state.ts";
import { sessionsPageListQuery, type SessionsRouteData } from "./route.ts";
import { renderSessions, type SessionsProps, type TranscriptSearchState } from "./view.ts";
const SESSIONS_DOCS_URL = "https://docs.openclaw.ai/concepts/session";
export type SessionsRouteData = {
// Client identity alone cannot distinguish provider replacement or reconnect epochs.
gateway: ApplicationContext["gateway"];
gatewaySnapshot: ApplicationContext["gateway"]["snapshot"];
result: SessionsListResult | null;
error: string | null;
expandedSessionKey: string | null;
statusFilter: SessionArchivedFilter;
};
type SessionsPageRequestScope = {
epoch: number;
context: ApplicationContext;
@@ -103,6 +96,12 @@ type InputDialogOpener = (typeof import("../../components/input-dialog.ts"))["sh
type SessionDeleteRow = Pick<GatewaySessionRow, "key" | "archived" | "sessionId">;
type SessionsPageListBinding = {
sessions: ApplicationContext["sessions"];
query: ReturnType<typeof sessionsPageListQuery>;
key: string;
};
class SessionsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context?: ApplicationContext;
@@ -138,75 +137,30 @@ class SessionsPage extends OpenClawLightDomElement {
@state() private checkpointBusyKey: string | null = null;
@state() private checkpointErrorByKey: Record<string, string> = {};
private sessionRequestId = 0;
// Async completions belong to one context/capability/connection epoch. Bump
// before releasing locks so stale finally blocks cannot clear newer work.
private pageEpoch = 0;
private routeDataInitialized = false;
private routeDataEnabled = true;
private appliedRouteData?: SessionsRouteData;
private ignorePendingSharedRefresh = false;
private sessionMutationPending = false;
private sessionReloadQueued = false;
private sharedSessionsResult: SessionsListResult | null = null;
private sharedSessionsLoading = false;
private gatewayClient: GatewayBrowserClient | null = null;
private gatewayConnected = false;
private sessionMenuTrigger: HTMLElement | null = null;
// Guards the async work fetch: a menu reopened for another session must not
// adopt a stale response.
private sessionMenuWorkVersion = 0;
private hasBoundGatewaySource = false;
private sessionsSource?: ApplicationContext["sessions"];
private hasBoundSessionsSource = false;
private listBinding?: SessionsPageListBinding;
private unsubscribeList?: () => void;
private appliedListResult: SessionsListResult | null | undefined;
private readonly observeAgentScope = watchAgentScope(() => {
this.resetTranscriptSearchState(this.transcriptSearchQuery);
if (this.routeDataInitialized && !this.deepLinkSessionKey) {
if (!this.deepLinkSessionKey) {
this.page = 0;
this.selectedKeys = new Set();
void this.loadSessions();
this.routeDataEnabled = false;
this.bindSessionList();
}
this.requestUpdate();
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.sessions,
(sessions) => {
const sourceChanged =
this.hasBoundSessionsSource && !Object.is(this.sessionsSource, sessions);
this.hasBoundSessionsSource = true;
this.sessionsSource = sessions;
if (sourceChanged) {
this.invalidatePageWork();
this.resetProviderState();
}
this.sharedSessionsResult = sessions.state.result;
this.sharedSessionsLoading = sessions.state.loading;
const cleanup = sessions.subscribe((snapshot) => {
if (!Object.is(this.context?.sessions, sessions)) {
return;
}
const resultChanged = snapshot.result !== this.sharedSessionsResult;
const refreshCompleted = this.sharedSessionsLoading && !snapshot.loading;
this.sharedSessionsResult = snapshot.result;
this.sharedSessionsLoading = snapshot.loading;
if (snapshot.loading || !this.routeDataInitialized || this.sessionMutationPending) {
return;
}
if (this.ignorePendingSharedRefresh && refreshCompleted) {
this.ignorePendingSharedRefresh = false;
return;
}
if (resultChanged) {
this.scheduleSessionReload();
}
});
if (sourceChanged && this.routeDataInitialized) {
this.scheduleSessionReload();
}
return cleanup;
},
)
.watch(
() => this.context?.agentIdentity,
(agentIdentity, notify) => agentIdentity.subscribe(notify),
@@ -215,20 +169,6 @@ class SessionsPage extends OpenClawLightDomElement {
() => this.context?.agentSelection,
(agentSelection) => this.observeAgentScope(agentSelection),
)
.effect(
() => this.context?.gateway,
(gateway) => {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
const cleanup = gateway.subscribe((snapshot) => {
if (Object.is(this.context?.gateway, gateway)) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, resetForSourceBind);
return cleanup;
},
)
.watch(
() => this.context?.runtimeConfig,
(runtimeConfig, notify) => runtimeConfig.subscribe(notify),
@@ -237,6 +177,15 @@ class SessionsPage extends OpenClawLightDomElement {
() => this.context?.workboard,
(workboard, notify) => workboard.subscribe(notify),
);
private readonly gatewayLifecycle = new GatewayPageController(this, {
getGateway: () => this.context?.gateway,
onIdentityChange: () => {
const retiredResult = this.listBinding?.sessions.listSnapshot(this.listBinding.query).result;
this.resetProviderState();
this.appliedListResult = retiredResult;
},
invalidateRequests: () => this.invalidatePageWork(),
});
private transcriptSearchArgs() {
const context = this.context;
@@ -261,7 +210,7 @@ class SessionsPage extends OpenClawLightDomElement {
query,
result: this.result,
listSessions: context.sessions.list,
listOptions: this.sessionListOptions(),
listOptions: this.sessionListOptions(context),
resolveAgentId: (sessionKey) =>
parseAgentSessionKey(sessionKey)?.agentId ?? this.sessionAgentId(sessionKey, context),
});
@@ -306,57 +255,38 @@ class SessionsPage extends OpenClawLightDomElement {
});
override willUpdate(changed: PropertyValues) {
const sessions = this.context?.sessions;
if (sessions && this.listBinding && this.listBinding.sessions !== sessions) {
this.unsubscribeList?.();
this.unsubscribeList = undefined;
this.listBinding = undefined;
this.invalidatePageWork();
this.resetProviderState();
}
if (changed.has("routeData") || changed.has("context")) {
this.applyRouteData();
}
this.bindSessionList();
}
override disconnectedCallback() {
this.unsubscribeList?.();
this.unsubscribeList = undefined;
this.listBinding = undefined;
this.subscriptions.clear();
this.invalidatePageWork();
// Dialogs mount on document.body, so navigating away would otherwise leave
// one over the destination, still submitting against this detached page.
this.dialogLifecycle?.abort();
this.gatewayClient = null;
this.gatewayConnected = false;
super.disconnectedCallback();
}
private applyGatewaySnapshot(
snapshot: ApplicationContext["gateway"]["snapshot"],
resetForSourceBind = false,
) {
const clientChanged = resetForSourceBind || snapshot.client !== this.gatewayClient;
const connectionChanged = (snapshot.phase === "connected") !== this.gatewayConnected;
const becameConnected = snapshot.phase === "connected" && !this.gatewayConnected;
this.gatewayClient = snapshot.client;
this.gatewayConnected = snapshot.phase === "connected";
if (clientChanged || connectionChanged) {
this.invalidatePageWork();
this.ignorePendingSharedRefresh = false;
}
if (clientChanged) {
this.resetProviderState();
}
if (snapshot.phase !== "connected" || !snapshot.client) {
this.requestUpdate();
return;
}
if (this.routeDataInitialized && (clientChanged || becameConnected)) {
this.ignorePendingSharedRefresh = true;
void this.loadSessions();
}
this.requestUpdate();
}
private invalidatePageWork() {
this.pageEpoch += 1;
this.sessionRequestId += 1;
this.submittedTranscriptSearchQuery = "";
this.transcriptSearch = { status: "idle" };
void this.transcriptSearchTask.run(this.transcriptSearchArgs());
this.resetCheckpointTask();
this.sessionReloadQueued = false;
this.loading = false;
this.checkpointBusyKey = null;
this.sessionMutationPending = false;
@@ -375,6 +305,7 @@ class SessionsPage extends OpenClawLightDomElement {
this.checkpointTaskKey = null;
this.checkpointBusyKey = null;
this.checkpointErrorByKey = {};
this.appliedListResult = undefined;
}
private captureRequestScope(): SessionsPageRequestScope | null {
@@ -383,8 +314,8 @@ class SessionsPage extends OpenClawLightDomElement {
return null;
}
const gateway = context.gateway;
const client = gateway.snapshot.client;
if (gateway.snapshot.phase !== "connected" || !client) {
const client = this.gatewayLifecycle.gateway === gateway ? this.gatewayLifecycle.client : null;
if (!this.gatewayLifecycle.connected || !client) {
return null;
}
return {
@@ -465,7 +396,6 @@ class SessionsPage extends OpenClawLightDomElement {
this.appliedRouteData = data;
this.routeDataEnabled = true;
}
this.routeDataInitialized = true;
if (!this.routeDataEnabled) {
return;
}
@@ -488,13 +418,9 @@ class SessionsPage extends OpenClawLightDomElement {
// Only route-driven expansion narrows the list query; interactive drawer
// opens must keep loading the full roster (see sessionListOptions).
this.deepLinkSessionKey = data.expandedSessionKey;
const gateway = context.gateway;
const snapshot = gateway.snapshot;
this.gatewayClient = snapshot.client;
this.gatewayConnected = snapshot.phase === "connected";
if (data.gateway !== gateway || data.gatewaySnapshot !== snapshot) {
if (!this.gatewayLifecycle.isRouteDataCurrent(data) || data.sessions !== context.sessions) {
this.routeDataEnabled = false;
void this.loadSessions();
void this.refreshSessionList();
if (data.expandedSessionKey) {
void this.loadCheckpoint(data.expandedSessionKey);
}
@@ -503,41 +429,15 @@ class SessionsPage extends OpenClawLightDomElement {
this.result = data.result
? filterSessionRows(data.result, { archivedFilter: data.statusFilter })
: null;
this.appliedListResult = data.result;
this.error = data.error;
this.loading = false;
const sharedSessions = context.sessions.state;
this.ignorePendingSharedRefresh = sharedSessions.loading;
this.loading = data.loading;
this.ensureAgentIdentities(this.result);
if (data.expandedSessionKey) {
void this.loadCheckpoint(data.expandedSessionKey);
}
}
private scheduleSessionReload() {
if (this.sessionReloadQueued) {
return;
}
this.sessionReloadQueued = true;
const epoch = this.pageEpoch;
queueMicrotask(() => {
if (epoch !== this.pageEpoch) {
return;
}
this.sessionReloadQueued = false;
const context = this.context;
const gateway = context?.gateway.snapshot;
if (
this.isConnected &&
context &&
gateway?.phase === "connected" &&
gateway.client &&
!context.sessions.state.loading
) {
void this.loadSessions();
}
});
}
private sessionAgentId(
key: string,
context: ApplicationContext | undefined = this.context,
@@ -559,62 +459,89 @@ class SessionsPage extends OpenClawLightDomElement {
return this.sessionAgentId(key, context) ?? resolveSessionNavigationAgentId(context);
}
private sessionListOptions() {
// Narrow the query only for a route deep link (?session=...); an open
// drawer is pure UI state and must not filter subsequent reloads.
const deepLinkKey = this.deepLinkSessionKey;
const scopeAgentId = this.context?.agentSelection.state.scopeId ?? undefined;
return {
activeMinutes:
deepLinkKey || this.statusFilter !== "active"
? undefined
: parseStrictPositiveInteger(this.activeMinutes),
limit: deepLinkKey
? DEFAULT_SESSION_LIST_QUERY.limit
: parseStrictPositiveInteger(this.limit),
search: deepLinkKey ?? undefined,
includeGlobal: deepLinkKey ? true : this.includeGlobal,
includeUnknown: deepLinkKey ? true : this.includeUnknown,
archivedFilter: this.statusFilter,
...(deepLinkKey
? { agentId: this.sessionAgentId(deepLinkKey) }
: scopeAgentId
? { agentId: scopeAgentId }
: {}),
};
private sessionListOptions(context: ApplicationContext) {
return sessionsPageListQuery(context, {
activeMinutes: parseStrictPositiveInteger(this.activeMinutes),
limit: parseStrictPositiveInteger(this.limit),
includeGlobal: this.includeGlobal,
includeUnknown: this.includeUnknown,
statusFilter: this.statusFilter,
deepLinkSessionKey: this.deepLinkSessionKey,
});
}
private async loadSessions() {
const scope = this.captureRequestScope();
private bindSessionList(refreshMissing = true): SessionsPageListBinding | undefined {
const context = this.context;
if (!context) {
return undefined;
}
const sessions = context.sessions;
const query = this.sessionListOptions(context);
const key = JSON.stringify(query);
const current = this.listBinding;
if (current?.sessions === sessions && current.key === key) {
return current;
}
this.unsubscribeList?.();
const binding = { sessions, query, key };
this.listBinding = binding;
this.appliedListResult = undefined;
const apply = (snapshot: SessionListSnapshot) => {
this.applyListSnapshot(binding, snapshot);
};
this.unsubscribeList = sessions.subscribeList(query, apply);
const snapshot = sessions.listSnapshot(query);
apply(snapshot);
if (
refreshMissing &&
!snapshot.result &&
!snapshot.loading &&
context.gateway.snapshot.phase === "connected"
) {
void sessions.refreshList({ ...query, force: true });
}
return binding;
}
private applyListSnapshot(binding: SessionsPageListBinding, snapshot: SessionListSnapshot) {
if (this.listBinding !== binding || this.context?.sessions !== binding.sessions) {
return;
}
this.loading = snapshot.loading;
this.error = snapshot.error;
const result = snapshot.result;
if (this.sessionMutationPending || !result || result === this.appliedListResult) {
return;
}
const previous = this.result;
this.appliedListResult = result;
this.result = filterSessionRows(result, { archivedFilter: this.statusFilter });
this.ensureAgentIdentities(this.result);
const checkpointKey = this.reconcileCheckpointCache(previous, this.result);
if (checkpointKey) {
void this.loadCheckpoint(checkpointKey);
}
}
private async refreshSessionList(scope = this.captureRequestScope()) {
if (!scope) {
return;
}
const requestId = ++this.sessionRequestId;
const previous = this.result;
this.routeDataEnabled = false;
this.loading = true;
this.error = null;
try {
const result = await scope.sessions.list(this.sessionListOptions());
if (requestId !== this.sessionRequestId || !this.isRequestScopeCurrent(scope)) {
return;
}
this.result = result
? filterSessionRows(result, { archivedFilter: this.statusFilter })
: null;
this.ensureAgentIdentities(this.result);
const checkpointKey = this.reconcileCheckpointCache(previous, this.result);
if (checkpointKey) {
void this.loadCheckpoint(checkpointKey);
}
} catch (error) {
if (requestId === this.sessionRequestId && this.isRequestScopeCurrent(scope)) {
this.error = formatUiError(error);
}
} finally {
if (requestId === this.sessionRequestId && this.isRequestScopeCurrent(scope)) {
this.loading = false;
}
const binding = this.bindSessionList(false);
if (!binding || binding.sessions !== scope.sessions || !this.isRequestScopeCurrent(scope)) {
return;
}
await binding.sessions.refreshList({ ...binding.query, force: true });
if (this.isRequestScopeCurrent(scope) && this.listBinding === binding) {
this.applyListSnapshot(binding, binding.sessions.listSnapshot(binding.query));
}
}
private adoptCurrentListSnapshot() {
const binding = this.listBinding;
if (binding) {
this.applyListSnapshot(binding, binding.sessions.listSnapshot(binding.query));
}
}
@@ -713,7 +640,7 @@ class SessionsPage extends OpenClawLightDomElement {
this.selectedKeys = new Set();
// Explicit filter edits leave deep-link mode; load the full roster.
this.deepLinkSessionKey = null;
void this.loadSessions();
void this.refreshSessionList();
}
private updateStatusFilter(statusFilter: SessionArchivedFilter) {
@@ -790,6 +717,7 @@ class SessionsPage extends OpenClawLightDomElement {
}
}
this.sessionMutationPending = true;
let mutationError: string | null = null;
try {
const result = await scope.sessions.deleteMany(requests);
if (!this.isRequestScopeCurrent(scope)) {
@@ -848,16 +776,21 @@ class SessionsPage extends OpenClawLightDomElement {
});
}
}
await this.refreshSessionList(scope);
if (result.errors.length > 0) {
this.error = formatUiExternalText(result.errors.join("; "));
mutationError = formatUiExternalText(result.errors.join("; "));
}
} catch (error) {
if (this.isRequestScopeCurrent(scope)) {
this.error = formatUiError(error);
mutationError = formatUiError(error);
}
} finally {
if (this.isRequestScopeCurrent(scope)) {
this.sessionMutationPending = false;
this.adoptCurrentListSnapshot();
if (mutationError) {
this.error = mutationError;
}
}
}
}
@@ -879,7 +812,7 @@ class SessionsPage extends OpenClawLightDomElement {
search: _deepLinkSearch,
agentId: _linkedAgentId,
...filters
} = this.sessionListOptions();
} = this.sessionListOptions(scope.context);
const agentId = scope.context.agentSelection.state.scopeId?.trim();
const listOptions = { ...filters, ...(agentId ? { agentId } : {}) };
const listed = await fetchPagedSessionRows({
@@ -956,6 +889,7 @@ class SessionsPage extends OpenClawLightDomElement {
return;
}
this.sessionMutationPending = true;
let mutationError: string | null = null;
try {
const agentId = parseAgentSessionKey(row.key)?.agentId;
const result = await requestCloudWorkerStop(scope.client, stopAction, {
@@ -971,15 +905,19 @@ class SessionsPage extends OpenClawLightDomElement {
});
}
if (this.isRequestScopeCurrent(scope)) {
await this.loadSessions();
await this.refreshSessionList(scope);
}
} catch (error) {
if (this.isRequestScopeCurrent(scope)) {
this.error = formatUiError(error);
mutationError = formatUiError(error);
}
} finally {
if (this.isRequestScopeCurrent(scope)) {
this.sessionMutationPending = false;
this.adoptCurrentListSnapshot();
if (mutationError) {
this.error = mutationError;
}
}
}
}
@@ -1176,6 +1114,10 @@ class SessionsPage extends OpenClawLightDomElement {
this.error = scope.sessions.state.error;
return "failed";
}
await this.refreshSessionList(scope);
if (!this.isRequestScopeCurrent(scope)) {
return "stale";
}
const selectedKeys = new Set(this.selectedKeys);
selectedKeys.delete(key);
this.selectedKeys = selectedKeys;
@@ -1261,7 +1203,11 @@ class SessionsPage extends OpenClawLightDomElement {
if (!context) {
return;
}
const leavingDeepLink = this.deepLinkSessionKey !== null;
this.deepLinkSessionKey = null;
if (leavingDeepLink) {
void this.refreshSessionList();
}
if (this.expandedSessionKey === sessionKey) {
this.resetCheckpointTask();
this.expandedSessionKey = null;
@@ -1661,7 +1607,7 @@ class SessionsPage extends OpenClawLightDomElement {
this.page = 0;
this.selectedKeys = new Set();
this.deepLinkSessionKey = null;
void this.loadSessions();
void this.refreshSessionList();
},
onSearchChange: (query) => {
this.searchQuery = query;
@@ -1686,7 +1632,7 @@ class SessionsPage extends OpenClawLightDomElement {
this.pageSize = pageSize;
this.page = 0;
},
onRefresh: () => void this.loadSessions(),
onRefresh: () => void this.refreshSessionList(),
onStatusFilterChange: (statusFilter) => this.updateStatusFilter(statusFilter),
onDeleteAllArchived: () => void this.deleteAllArchived(),
onPatch: (key, patch) => void this.patchSession(key, patch),