mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
committed by
GitHub
parent
dd1ee7b375
commit
77ececee54
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => ({})),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user