diff --git a/ui/src/e2e/session-list-filter.e2e.test.ts b/ui/src/e2e/session-list-filter.e2e.test.ts index cb32c4bb9aee..f8c0d231ef24 100644 --- a/ui/src/e2e/session-list-filter.e2e.test.ts +++ b/ui/src/e2e/session-list-filter.e2e.test.ts @@ -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; + 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"; diff --git a/ui/src/lib/sessions/index-list.test.ts b/ui/src/lib/sessions/index-list.test.ts index bb1e422d61b8..75dd8cf9eebd 100644 --- a/ui/src/lib/sessions/index-list.test.ts +++ b/ui/src/lib/sessions/index-list.test.ts @@ -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((resolve) => { diff --git a/ui/src/lib/sessions/session-roster-refresh.ts b/ui/src/lib/sessions/session-roster-refresh.ts index 3794127393d5..cdebeb5126e6 100644 --- a/ui/src/lib/sessions/session-roster-refresh.ts +++ b/ui/src/lib/sessions/session-roster-refresh.ts @@ -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 && diff --git a/ui/src/pages/gateway-source-replacement.test.ts b/ui/src/pages/gateway-source-replacement.test.ts index 5536607a0cea..75b1edc21b2e 100644 --- a/ui/src/pages/gateway-source-replacement.test.ts +++ b/ui/src/pages/gateway-source-replacement.test.ts @@ -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", diff --git a/ui/src/pages/route-provenance.test.ts b/ui/src/pages/route-provenance.test.ts index a047d0102cae..2ea824847a14 100644 --- a/ui/src/pages/route-provenance.test.ts +++ b/ui/src/pages/route-provenance.test.ts @@ -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(); + const refresh = deferred(); + const managedSnapshot = { result: null, agentId: null, loading: false, error: null }; const request = loadRoute(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(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 () => ({})), diff --git a/ui/src/pages/sessions/route.test.ts b/ui/src/pages/sessions/route.test.ts new file mode 100644 index 000000000000..f4bac75d0a0b --- /dev/null +++ b/ui/src/pages/sessions/route.test.ts @@ -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); + }); +}); diff --git a/ui/src/pages/sessions/route.ts b/ui/src/pages/sessions/route.ts index 3e32c139d91f..fed8f6961e2c 100644 --- a/ui/src/pages/sessions/route.ts +++ b/ui/src/pages/sessions/route.ts @@ -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 { 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, }; } diff --git a/ui/src/pages/sessions/sessions-page.roster.test.ts b/ui/src/pages/sessions/sessions-page.roster.test.ts new file mode 100644 index 000000000000..43b1cd4e1717 --- /dev/null +++ b/ui/src/pages/sessions/sessions-page.roster.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +async function createPage(context: ApplicationContext): Promise { + 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(); + 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(); + 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[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"]); + }); +}); diff --git a/ui/src/pages/sessions/sessions-page.test-support.ts b/ui/src/pages/sessions/sessions-page.test-support.ts index 2d58d4b3e534..fa085c1209a9 100644 --- a/ui/src/pages/sessions/sessions-page.test-support.ts +++ b/ui/src/pages/sessions/sessions-page.test-support.ts @@ -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; updateTranscriptSearchQuery: (query: string) => void; runTranscriptSearch: () => Promise; loadCheckpoint: (sessionKey: string) => Promise; @@ -105,8 +109,55 @@ export function createGateway(client: GatewayBrowserClient): MutableGateway { } export function createSessions(overrides: Partial = {}): 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 = {}) { const subscribe = () => () => undefined; - return { + const snapshots = new Map(); + const listeners = new Map 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 = {}): 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 = {}): 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, diff --git a/ui/src/pages/sessions/sessions-page.test.ts b/ui/src/pages/sessions/sessions-page.test.ts index 1d5e3aa3a3b7..53eafecab8a3 100644 --- a/ui/src/pages/sessions/sessions-page.test.ts +++ b/ui/src/pages/sessions/sessions-page.test.ts @@ -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(); - const checkpoints = deferred(); - 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(); 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.', ); diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index fe75735949bb..62e70ff762b6 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -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; +type SessionsPageListBinding = { + sessions: ApplicationContext["sessions"]; + query: ReturnType; + 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 = {}; - 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),