perf(ui): reduce idle native session catalog polling (#174404)

* perf(ui): reduce idle native session catalog polling

Refresh visible native catalogs every two minutes while retaining visibility catch-up, explicit refresh, retries, and progressive results. Remove the catalog-change event mode because the Gateway neither advertises nor emits that event. Fixture periodic calls fall from 2 to 0.5 per minute per tab; external discovery can take up to two minutes plus native indexing time.

* docs(ui): align native catalog freshness guidance
This commit is contained in:
Peter Steinberger authored and GitHub committed 2026-10-16 10:05:38 -07:00
1 parent 5e1a01c966
commit fb6a79afee
21 files changed
+113 -147

No files matched your search

+3 -2
View File
@@ -190,8 +190,9 @@ Open the **Codex** group in the normal sessions sidebar. It lists the same sessi
grouped by host. **Load more sessions** appends the next page from each host that
has older rows, and those appended rows survive the sidebar's periodic refresh.
Each host appears as soon as its resident catalog is ready. The visible page
reconciles after node-connectivity changes, when it regains focus, and at most
every 30 seconds. A changed result gets a faster follow-up pass. Sessions created
reconciles when the tab becomes visible again or reconnects, and every two minutes
while visible. Hidden tabs pause discovery; explicit refreshes and catalog actions
do not wait for the periodic refresh. Sessions created
in Codex Desktop, the CLI, or another native client appear after the host's
background directory reconciliation. Native events update threads driven by that
Gateway without waiting for the periodic scan. Searches and pagination use the
+3 -3
View File
@@ -566,9 +566,9 @@ Approve the node pairing upgrade when those commands first appear.
The sidebar groups rows by their Gateway or paired-node host and shows each
host's newest bounded page as soon as that computer answers. It reconciles again
after host-connectivity changes, when the page regains focus, and at most every
30 seconds while visible, so Claude sessions created outside OpenClaw appear
without a reload. A changed catalog gets a faster follow-up pass. Use **Load more
after host-connectivity changes, when the page becomes visible, and every two
minutes while visible, so Claude sessions created outside OpenClaw appear
without a reload. Discovery pauses while hidden. Use **Load more
sessions** below a catalog group to append the next page for every host that has
more history; appended rows stay visible and are re-fetched to the same depth
across refreshes. Catalog clients use `sessions.catalog.list`; opening a row uses
+3 -3
View File
@@ -204,9 +204,9 @@ Catalogs containing visible sessions remain present when another host fails.
The Control UI requests progressive catalog updates. Each local or paired host
appears when its own App Server listing settles; the aggregate response remains
the compatibility and recovery snapshot. The visible page reconciles after
connectivity changes, on focus, and at most every 30 seconds, with a faster pass
after changes. Native Codex sessions created in another client are therefore
eventually discovered without importing them into OpenClaw storage.
connectivity changes, when the page becomes visible, and every two minutes while
visible. Discovery pauses while hidden. Native Codex sessions created in another
client are therefore eventually discovered without importing them into OpenClaw storage.
Catalog discovery is passive. Listing or reading metadata must not call
`thread/resume`, subscribe the OpenClaw client to live thread requests, or
+1 -1
View File
@@ -41,7 +41,7 @@ Workboard sidebar entries offer pin/unpin and, with write access, confirmed boar
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`). Channel cards refresh automatically when the Gateway accepts a configuration change, including after guided setup; a slow check does not delay the updated runtime status.
- Channel check refreshes keep the previous snapshot visible while slow provider checks finish, and label partial snapshots when a check or audit exceeds its UI budget.
- Threads (a workspace page at `/sessions`, with a **Worktrees** tab alongside it): list configured-agent sessions by default, pin frequent root sessions, rename them, archive or restore sessions, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`). The sidebar offers **Active / Snoozed / Archived / All** status filters; All includes snoozed sessions and dims archived rows with an explicit label. **Snooze** in an eligible session's sidebar menu hides it from the Active sidebar without stopping runs or blocking messages. Choose a preset wake time (in one or three hours, this evening, tomorrow, or next Monday); time, a new user message, or a completed agent run wakes it. Find it under Snoozed and choose **Wake session**, or use **Undo** in the snooze confirmation. Snoozed rows show their wake time. The Sessions page retains its Active / Archived / All lifecycle views. Archived sessions keep their transcripts and remain shelved until explicitly unarchived or deleted. Sessions archived automatically at the active-session cap can also be deleted automatically when the session store exceeds its disk budget; manually archived and legacy sessions stay protected. Rows show an unread dot for active sessions with activity since they were last read, with mark-unread/mark-read actions (`sessions.patch { unread }`), and a Fork action that branches the transcript into a new session (`sessions.create { parentSessionKey, fork: true }`). Overview tiles above the table summarize the loaded roster (session count, live runs, unread sessions, total tokens, and archived count when available), each row carries a kind glyph with a live-run dot, status renders as a plain dot plus label, and the Tokens column shows a context-window usage meter when the session reports token and context sizes. Row management actions live in a per-row menu (kebab button or right-click) mirroring the sidebar's session menu, and the row drawer carries the agent runtime and run duration alongside the other session details.
- Native Claude and Codex sidebar catalogs stream one host at a time, then reconcile after node connectivity changes, on page focus, and at most every 30 seconds while visible. Catalog changes trigger a faster follow-up pass, so sessions created in the native tools appear without reloading the Control UI. Claude Desktop rows also retain their local custom-group label when present; OpenClaw reads that mapping from Desktop's local store and never writes it.
- Native Claude and Codex sidebar catalogs stream one host at a time, then reconcile every two minutes while visible and when a hidden tab becomes visible again or reconnects. Explicit refreshes and catalog actions refresh immediately; sessions created in native tools appear on the next discovery refresh without reloading the Control UI. Claude Desktop rows also retain their local custom-group label when present; OpenClaw reads that mapping from Desktop's local store and never writes it.
- Session grouping: a Group by control organizes the sessions table into sections by custom groups, channel, kind, agent, or date. Custom groups persist per session via `sessions.patch` (`category`), so sessions started from message channels (Discord, Telegram, WhatsApp, ...) can be categorized too; assign groups by dragging rows onto a section, or with the per-row group selector, and create groups with the New group action.
- Memory (a tab on the Agents page, scoped to the selected agent): dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`). When the `memory-wiki` plugin is enabled, the Diary view adds **Imported Insights** and **Memory Wiki** sub-tabs that browse imported source chats and the compiled wiki — clustered synthesis, entity, and concept pages plus annotated sources and reports, with claims, open questions, contradictions, and inline page previews (`wiki.importInsights`, `wiki.overview`, `wiki.get`).
- Import Memory (`/memory-import`, reached from the Agents page's Memory tab): preview and copy local Claude Code auto-memory, Codex consolidated memory, or Hermes memory files into the selected agent workspace (`migrations.memory.plan`, `migrations.memory.apply`).
+1 -1
View File
@@ -469,7 +469,7 @@ This is a personal display preference, stored in this browser separately for eac
Changing or clearing an owner or status filter never changes the saved preference.
**Reset** in the popover restores **When filtering** when the empty-group control is visible. Populated groups stay visible even when collapsed, and hidden custom groups remain available in **Move to group**. Choose **Never** to recover their headers as drag targets. Catalog sections and empty agent groups in team mode retain their existing behavior. On phone-width layouts, the choices open as a page inside the filter sheet, with **Back**. Escape returns focus to the setting without closing the filter panel.
Native CLI catalogs appear only when they contain sessions matching the current owner filter. Empty catalogs stay hidden even when discovery fails or the CLI can start new sessions. Catalogs load on connection. When the Gateway advertises catalog change events, catalogs refresh on those events with paced bursts and a ten-minute safety refresh. Otherwise, visible tabs refresh catalogs every 30 seconds. Returning to a hidden tab also refreshes its catalog. If more pages remain, discovery follows their cursors until a matching session appears, the catalog is exhausted, or a host reports an error, without repeating the first-page request. Discovery preserves its progress and pauses while the browser tab is hidden. A later refresh checks the first page for new sessions and restarts completed empty scans so older sessions that become visible are still discovered. Expanded catalogs preserve their loaded pages across refreshes. Populated catalogs remain visible when another host fails, with discovery details in their status indicator. Hidden catalogs do not keep the **Other** heading visible when it is the only remaining section. Native CLI starts remain available from **New session**.
Native CLI catalogs appear only when they contain sessions matching the current owner filter. Empty catalogs stay hidden even when discovery fails or the CLI can start new sessions. Catalogs load on connection, then refresh every two minutes while the tab is visible. Returning to a hidden tab refreshes its catalog after a short coalescing delay; explicit refreshes and catalog actions do not wait for the periodic refresh. If more pages remain, discovery follows their cursors until a matching session appears, the catalog is exhausted, or a host reports an error, without repeating the first-page request. Discovery preserves its progress and pauses while the browser tab is hidden. A later refresh checks the first page for new sessions and restarts completed empty scans so older sessions that become visible are still discovered. Expanded catalogs preserve their loaded pages across refreshes. Populated catalogs remain visible when another host fails, with discovery details in their status indicator. Hidden catalogs do not keep the **Other** heading visible when it is the only remaining section. Native CLI starts remain available from **New session**.
**Mark as unread** creates a reminder that remains unread while the current chat stays open, including while a run streams or completes. Leave and reopen the session, or choose **Mark as read**, to clear it.
@@ -22,8 +22,8 @@ import {
} from "./app-sidebar-session-catalog-state.ts";
import { sessionCatalogHostKey } from "./app-sidebar-session-types.ts";
const SESSION_CATALOG_SAFETY_REFRESH_MS = 10 * 60_000;
const SESSION_CATALOG_STABLE_REFRESH_MS = 30_000;
// External native clients can change their catalogs without a Gateway event.
const SESSION_CATALOG_REFRESH_MS = 2 * 60_000;
const SESSION_CATALOG_MAX_RETRIES = 3;
export function sessionCatalogListClient(
@@ -354,7 +354,6 @@ export async function refreshSessionCatalogsLive(params: {
catalogs: () => SessionCatalog[];
pageDepths: ReadonlyMap<string, number>;
connected: () => boolean;
catalogChangedEvents: boolean;
applyFinal: (catalogs: SessionCatalog[]) => void;
/** Finish the finite cursor sweep without repeating the root catalog request. */
continueRefresh: () => Promise<void>;
@@ -418,12 +417,7 @@ export async function refreshSessionCatalogsLive(params: {
if (requestIsCurrent() && params.connected()) {
const pending = live.refreshPending;
live.refreshPending = false;
const interval =
retryDelayMs ??
(params.catalogChangedEvents
? SESSION_CATALOG_SAFETY_REFRESH_MS
: SESSION_CATALOG_STABLE_REFRESH_MS);
live.schedule(interval, params.connected(), () => {
live.schedule(retryDelayMs ?? SESSION_CATALOG_REFRESH_MS, params.connected(), () => {
void params.refresh();
});
if (pending && retryDelayMs === null) {
@@ -42,7 +42,7 @@ function discoveryGateway(request: GatewayRequestHandler) {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
return gateway;
@@ -121,7 +121,7 @@ describe("AppSidebar hidden catalog discovery", () => {
await settle(sidebar);
expect(sidebar.textContent).toContain("Current session");
expect(sidebar.textContent).not.toContain("Retired session");
await vi.advanceTimersByTimeAsync(599_999);
await vi.advanceTimersByTimeAsync(119_999);
expect(currentRequest).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await settle(sidebar);
@@ -151,7 +151,7 @@ describe("AppSidebar hidden catalog discovery", () => {
...Array.from({ length: 7 }, (_, index) => `page-${index + 2}`),
]);
expect(sidebar.sessionData.sessionCatalogPageDepths.values().next().value).toBe(7);
await vi.advanceTimersByTimeAsync(300_000);
await vi.advanceTimersByTimeAsync(119_999);
expect(request).toHaveBeenCalledTimes(8);
await sidebar.sessionData.refreshSessionCatalogs();
await settleLitElement(sidebar);
@@ -173,12 +173,12 @@ describe("AppSidebar hidden catalog discovery", () => {
: catalogPage([]),
);
});
const { sidebar, gatewayHarness } = await mountDiscovery(request);
const { sidebar } = await mountDiscovery(request);
added = true;
await vi.advanceTimersByTimeAsync(300_000);
await vi.advanceTimersByTimeAsync(60_000);
expect(sidebar.textContent).not.toContain("Older session now visible");
expect(request).toHaveBeenCalledTimes(3);
gatewayHarness.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
await settle(sidebar);
expect(sidebar.textContent).toContain("Older session now visible");
@@ -389,8 +389,8 @@ describe("AppSidebar expanded catalog refresh visibility", () => {
page(cursor === "page-2" ? 2 : cursor === "page-3" ? 3 : 1, "Refreshed"),
);
});
const { sidebar, gateway } = await mountExpanded(request);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
const { sidebar } = await mountExpanded(request);
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
const issuedBeforeHide = 5;
expect(request).toHaveBeenCalledTimes(issuedBeforeHide);
@@ -27,13 +27,12 @@ async function mountTab(
request = createGatewayRequestMock().mockResolvedValue(
catalogPage([{ threadId: "stable", name: "Stable catalog" }]),
),
events: string[] | undefined = ["sessions.catalog.changed"],
) {
const gateway = createGatewayHarness(createTestGatewayClient(request));
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -57,41 +56,40 @@ describe("AppSidebar catalog event refresh", () => {
vi.useRealTimers();
});
it("ignores session churn and unrelated agents while coalescing catalog event bursts", async () => {
it("ignores session churn while coalescing tab activation bursts", async () => {
const { gateway, request } = await mountTab();
gateway.publishEvent("sessions.changed", {
agentId: "research",
sessionKey: "agent:research:x",
});
gateway.publishEvent("sessions.catalog.changed", { agentId: "research" });
await vi.advanceTimersByTimeAsync(5_000);
expect(request).toHaveBeenCalledTimes(1);
gateway.publishEvent("sessions.changed", { agentId: "main", sessionKey: "agent:main:x" });
await vi.advanceTimersByTimeAsync(5_000);
expect(request).toHaveBeenCalledTimes(1);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
expect(request).toHaveBeenCalledTimes(2);
});
it("keeps a stable 30-second fallback when catalog changes are not advertised", async () => {
const events = ["sessions.changed"];
it("discovers native sessions every two minutes while visible", async () => {
const request = createGatewayRequestMock()
.mockResolvedValueOnce(catalogPage([]))
.mockResolvedValue(catalogPage([{ threadId: "discovered", name: "New catalog row" }]));
const { gateway, sidebar } = await mountTab(request, events);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
const { gateway, sidebar } = await mountTab(request);
gateway.publishEvent("sessions.changed", { agentId: "main", sessionKey: "agent:main:x" });
await vi.advanceTimersByTimeAsync(29_999);
await vi.advanceTimersByTimeAsync(119_999);
expect.soft(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(2);
expect(sidebar.textContent).toContain("New catalog row");
await vi.advanceTimersByTimeAsync(29_999);
await vi.advanceTimersByTimeAsync(119_999);
expect(request).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(6 * 60_000);
expect(request).toHaveBeenCalledTimes(6);
});
it.each(["recovered", "exhausted"] as const)("paces busy retries until %s", async (outcome) => {
@@ -112,14 +110,14 @@ describe("AppSidebar catalog event refresh", () => {
} else {
request.mockRejectedValue(error);
}
const { gateway, sidebar } = await mountTab(request);
const { sidebar } = await mountTab(request);
await sidebar.sessionData.refreshSessionCatalogs();
await settleLitElement(sidebar);
expect(sidebar.textContent).toContain("Stable catalog");
expect(sidebar.sessionData.sessionCatalogRefreshStatus.error).toBeNull();
if (outcome === "recovered") {
for (let second = 0; second < 4; second += 1) {
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(1_000);
expect(request).toHaveBeenCalledTimes(2);
}
@@ -148,7 +146,7 @@ describe("AppSidebar catalog event refresh", () => {
});
expect(warning).toHaveBeenCalledOnce();
}
await vi.advanceTimersByTimeAsync(300_000);
await vi.advanceTimersByTimeAsync(119_999);
expect(request).toHaveBeenCalledTimes(outcome === "recovered" ? 3 : 5);
});
@@ -243,10 +241,10 @@ describe("AppSidebar catalog event refresh", () => {
let visibility: DocumentVisibilityState = "visible";
const spy = vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
try {
const { gateway, request, sidebar } = await mountTab();
const { request, sidebar } = await mountTab();
const context = sidebar.sessionData.context;
context?.connectionBootstrap.setForegroundRoute(undefined);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
@@ -142,7 +142,6 @@ describe("AppSidebar catalog authority", () => {
);
const gateway = createGatewayHarness(createTestGatewayClient(request));
const hello = gatewayHelloForMethods(["sessions.catalog.list"]);
hello.features!.events = ["sessions.catalog.changed"];
gateway.publish({ hello });
const { sidebar, provider } = await mountSidebar(
gateway.gateway,
@@ -174,7 +173,7 @@ describe("AppSidebar catalog authority", () => {
agentId: "main",
catalog: catalogPage([{ threadId: "stale", name: "Late catalog session" }]).catalogs[0],
});
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
if (pendingKind === "error") {
pending.reject(new Error("Stale catalog failure"));
} else {
@@ -1,4 +1,3 @@
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import type {
SessionCatalog,
SessionsCatalogArchiveParams,
@@ -243,24 +242,6 @@ export function updateSessionCatalogData(owner: SessionCatalogDataOwner): void {
void refreshSessionCatalogsInBackground(owner);
}
function sessionCatalogChangesAdvertised(owner: SessionCatalogDataOwner): boolean {
return (
owner.context?.gateway.snapshot.hello?.features?.events?.includes(
"sessions.catalog.changed",
) === true
);
}
export function applySessionCatalogChanged(owner: SessionCatalogDataOwner, payload: unknown): void {
if (!sessionCatalogChangesAdvertised(owner)) {
return;
}
const agentId = asNullableRecord(payload)?.agentId;
if (typeof agentId !== "string" || normalizeAgentId(agentId) === owner.sessionCatalogAgentId) {
scheduleSessionCatalogRefresh(owner);
}
}
export function applySessionCatalogHostEvent(
owner: SessionCatalogDataOwner,
payload: unknown,
@@ -318,7 +299,6 @@ export async function refreshSessionCatalogs(owner: SessionCatalogDataOwner): Pr
catalogs: () => owner.sessionCatalogs,
pageDepths: owner.sessionCatalogPageDepths,
connected: () => owner.isSessionDataHostConnected,
catalogChangedEvents: sessionCatalogChangesAdvertised(owner),
applyFinal: (catalogs) => {
owner.sessionCatalogs = owner.sessionCatalogLive.resumeDiscovery(catalogs);
owner.sessionCatalogRefreshStatus = completePanelRefresh();
@@ -231,7 +231,6 @@ export function refreshSidebarSessionList(
type SessionGatewayEventOwner = {
publishPresence(payload: unknown): void;
handleSessionCatalogHostEvent(payload: unknown): void;
handleSessionCatalogChanged(payload: unknown): void;
};
export function subscribeSessionDataGatewayEvents(
@@ -245,12 +244,6 @@ export function subscribeSessionDataGatewayEvents(
}
return;
}
if (event.event === "sessions.catalog.changed") {
if (canCallGatewayMethod(gateway.snapshot, "sessions.catalog.list", "operator.read")) {
owner.handleSessionCatalogChanged(event.payload);
}
return;
}
if (event.event === "presence") {
owner.publishPresence(event.payload);
}
@@ -33,7 +33,6 @@ import {
archiveSessionCatalog as archiveSessionCatalogData,
importSessionCatalog as importSessionCatalogData,
applySessionCatalogHostEvent as applySessionCatalogHostEventToData,
applySessionCatalogChanged as applySessionCatalogChangedToData,
invalidateSessionCatalogs as invalidateSessionCatalogData,
loadMoreSessionCatalog as loadMoreSessionCatalogData,
refreshSessionCatalogs as refreshSessionCatalogData,
@@ -398,8 +397,6 @@ export class SessionDataController implements SolidController, SessionCatalogDat
handleSessionCatalogHostEvent = applySessionCatalogHostEventToData.bind(null, this);
handleSessionCatalogChanged = applySessionCatalogChangedToData.bind(null, this);
private readonly handleCatalogSessionContinued = (
event: CustomEvent<CatalogSessionContinuedDetail>,
) => {
@@ -38,7 +38,7 @@ suite.define(() => {
],
};
const gateway = await installMockGateway(page, {
featureEvents: ["sessions.catalog.changed", "sessions.catalog.host"],
featureEvents: ["sessions.catalog.host"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: { "sessions.catalog.list": { catalogs: [catalog] } },
});
@@ -52,7 +52,15 @@ suite.define(() => {
};
await gateway.setMethodResponse("sessions.catalog.list", { catalogs: [pendingCatalog] });
const beforeRefresh = await gateway.deferNext("sessions.catalog.list");
await gateway.emitGatewayEvent("sessions.catalog.changed", { agentId: "main" });
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
document.dispatchEvent(new Event("visibilitychange"));
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
});
const request = await gateway.waitForRequest("sessions.catalog.list", {
after: beforeRefresh,
});
@@ -15,7 +15,7 @@ describe("AppSidebar session catalog request errors", () => {
assistantAgentId: "roboclaw",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -45,7 +45,7 @@ describe("AppSidebar session catalog request errors", () => {
assistantAgentId: "roboclaw",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
// A selection hello knows nothing about must not fetch the default's
@@ -72,7 +72,7 @@ describe("AppSidebar session catalog request errors", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -112,7 +112,7 @@ describe("AppSidebar session catalog request errors", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar, context } = await mountSidebar(
@@ -151,7 +151,7 @@ describe("AppSidebar session catalog request errors", () => {
assistantAgentId: "roboclaw",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar, context } = await mountSidebar(
@@ -199,7 +199,7 @@ describe("AppSidebar session catalog request errors", () => {
assistantAgentId: null,
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -231,7 +231,7 @@ describe("AppSidebar session catalog request errors", () => {
suspensionPhase: "accepting",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -292,7 +292,7 @@ describe("AppSidebar session catalog request errors", () => {
suspensionPhase: "accepting",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -7,7 +7,7 @@ import { catalogPage, createGatewayHarness, createSessions, mountSidebar } from
import "../../components/app-sidebar.tsx";
describe("AppSidebar session catalog pagination", () => {
it("keeps the scheduled safety refresh when a visible page receives focus", async () => {
it("keeps the scheduled refresh when a visible page receives focus", async () => {
vi.useFakeTimers();
try {
const request = vi.fn().mockResolvedValue(catalogPage([]));
@@ -15,7 +15,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -31,7 +31,7 @@ describe("AppSidebar session catalog pagination", () => {
await vi.advanceTimersByTimeAsync(50);
expect(request).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(10 * 60_000 - 51);
await vi.advanceTimersByTimeAsync(120_000 - 51);
expect(request).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(2);
@@ -40,13 +40,9 @@ describe("AppSidebar session catalog pagination", () => {
}
});
it.each(
(["tab return", "catalog change"] as const).flatMap((activation) =>
[false, true].map((discovery) => ({ activation, discovery })),
),
)(
"queues a fresh scan after $activation during an active scan (discovery: $discovery)",
async ({ activation, discovery }) => {
it.each([false, true])(
"queues a fresh scan after tab return during an active scan (discovery: %s)",
async (discovery) => {
vi.useFakeTimers();
let visibility: DocumentVisibilityState = "visible";
const visibilitySpy = vi
@@ -71,7 +67,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -83,14 +79,10 @@ describe("AppSidebar session catalog pagination", () => {
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledOnce();
if (activation === "tab return") {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
} else {
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
}
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
expect(request).toHaveBeenCalledOnce();
@@ -139,7 +131,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -156,7 +148,7 @@ describe("AppSidebar session catalog pagination", () => {
pending.resolve(catalogPage([]));
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(120_000);
expect(request).toHaveBeenCalledTimes(1);
visibility = "visible";
@@ -190,7 +182,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -210,11 +202,11 @@ describe("AppSidebar session catalog pagination", () => {
phase: "connected",
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(120_000);
expect(request).toHaveBeenCalledTimes(1);
visibility = "visible";
@@ -244,8 +236,8 @@ describe("AppSidebar session catalog pagination", () => {
await vi.advanceTimersByTimeAsync(0);
globalThis.dispatchEvent(new Event("focus"));
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
await vi.advanceTimersByTimeAsync(10 * 60_000);
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(120_000);
expect(request).not.toHaveBeenCalled();
} finally {
@@ -32,7 +32,7 @@ describe("AppSidebar session catalog pagination", () => {
previousGateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const sessions = createSessions("main", ["agent:main:main"]);
@@ -49,7 +49,7 @@ describe("AppSidebar session catalog pagination", () => {
currentGateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
provider.setContext(createContext(currentGateway.gateway, sessions));
@@ -60,7 +60,7 @@ describe("AppSidebar session catalog pagination", () => {
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.textContent).not.toContain("Stale session");
currentGateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
expect(currentRequest).toHaveBeenCalledTimes(2);
@@ -366,7 +366,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -430,7 +430,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -442,7 +442,7 @@ describe("AppSidebar session catalog pagination", () => {
await vi.advanceTimersByTimeAsync(0);
const oldProgressId = (request.mock.calls[0]?.[1] as { progressId?: string })?.progressId;
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
if (!oldProgressId) {
throw new Error("first catalog request has no progress id");
@@ -482,7 +482,7 @@ describe("AppSidebar session catalog pagination", () => {
}
});
it("discovers an external session on a catalog change and paces later changes", async () => {
it("discovers an external session after a visibility change and paces later activations", async () => {
vi.useFakeTimers();
try {
const empty = catalogPage([]);
@@ -496,7 +496,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -510,7 +510,7 @@ describe("AppSidebar session catalog pagination", () => {
expect(request).toHaveBeenCalledTimes(1);
expect(sidebar.textContent).not.toContain("External session");
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(4_999);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
@@ -518,19 +518,21 @@ describe("AppSidebar session catalog pagination", () => {
expect(request).toHaveBeenCalledTimes(2);
expect(sidebar.textContent).toContain("External session");
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(4_999);
expect(request).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(5 * 60_000);
await vi.advanceTimersByTimeAsync(119_999);
expect(request).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(4);
} finally {
vi.useRealTimers();
}
});
it("ignores a presence and focus burst without a catalog change", async () => {
it("ignores a presence and focus burst until the scheduled catalog refresh", async () => {
vi.useFakeTimers();
try {
const request = vi.fn().mockResolvedValue(catalogPage([]));
@@ -538,7 +540,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(
@@ -553,8 +555,10 @@ describe("AppSidebar session catalog pagination", () => {
presence: [{ deviceId: "node-1", mode: "node", reason: "connect" }],
});
globalThis.dispatchEvent(new Event("focus"));
await vi.advanceTimersByTimeAsync(5 * 60_000);
await vi.advanceTimersByTimeAsync(119_999);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(request).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
@@ -4,7 +4,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { catalogPage, mountSessionCatalogSidebar } from "../app-sidebar.ts";
export function registerCatalogPageHostTests() {
it("pages only cursor hosts and preserves exhausted hosts through a catalog change", async () => {
it("pages only cursor hosts and preserves exhausted hosts through a catalog refresh", async () => {
vi.useFakeTimers();
try {
const exhaustedHost: SessionCatalogHost = {
@@ -35,7 +35,7 @@ export function registerCatalogPageHostTests() {
catalogPage([{ threadId: "thread-2", name: "Current title" }], "page-3"),
)
.mockResolvedValueOnce(catalogPage([{ threadId: "thread-3", name: "Oldest" }]));
const { gateway, sidebar } = await mountSessionCatalogSidebar({
const { sidebar } = await mountSessionCatalogSidebar({
request,
} as unknown as GatewayBrowserClient);
@@ -69,7 +69,7 @@ export function registerCatalogPageHostTests() {
expect(sidebar.textContent).toContain("Retained remote session");
expect(retainedHost()).toEqual(exhaustedHost);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
await sidebar.updateComplete;
expect(request).toHaveBeenNthCalledWith(3, "sessions.catalog.list", {
@@ -34,7 +34,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const mounted = await mountSidebar(
@@ -92,7 +92,7 @@ describe("AppSidebar session catalog pagination", () => {
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
const hello = {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"];
gateway.publish({ hello });
const mounted = await mountSidebar(
@@ -174,7 +174,7 @@ describe("AppSidebar session catalog pagination", () => {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar, context } = await mountSidebar(
@@ -281,7 +281,7 @@ describe("AppSidebar session catalog pagination", () => {
registerCatalogPageHostTests();
it("discards a load-more response after a catalog change replaces its cursor", async () => {
it("discards a load-more response after a catalog refresh replaces its cursor", async () => {
vi.useFakeTimers();
try {
let resolveStalePage!: (value: ReturnType<typeof catalogPage>) => void;
@@ -296,14 +296,14 @@ describe("AppSidebar session catalog pagination", () => {
catalogPage([{ threadId: "thread-1", name: "Updated" }], "replacement-page"),
)
.mockResolvedValueOnce(catalogPage([{ threadId: "thread-3", name: "Replacement" }]));
const { gateway, sidebar } = await mountSessionCatalogSidebar({
const { sidebar } = await mountSessionCatalogSidebar({
request,
} as unknown as GatewayBrowserClient);
const loadMore = () =>
sidebar.querySelector<HTMLButtonElement>('[data-session-catalog-load-more="codex"]');
loadMore()?.click();
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
await sidebar.updateComplete;
expect(sidebar.textContent).toContain("Updated");
@@ -345,7 +345,7 @@ describe("AppSidebar session catalog pagination", () => {
if (errorPage === "expanded page") {
request.mockResolvedValueOnce(catalogErrorPage("Page refresh failed"));
}
const { gateway, sidebar } = await mountSessionCatalogSidebar({
const { sidebar } = await mountSessionCatalogSidebar({
request,
} as unknown as GatewayBrowserClient);
@@ -356,7 +356,7 @@ describe("AppSidebar session catalog pagination", () => {
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionCatalogs[0]?.hosts[0]?.sessions).toHaveLength(2);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
await sidebar.updateComplete;
const host = sidebar.sessionData.sessionCatalogs[0]?.hosts[0];
@@ -396,19 +396,19 @@ describe("AppSidebar session catalog pagination", () => {
catalogPage([{ threadId: "thread-3", name: "Reappeared" }], "page-2"),
)
.mockResolvedValue(catalogErrorPage("Replay failed"));
const { gateway, sidebar } = await mountSessionCatalogSidebar({
const { sidebar } = await mountSessionCatalogSidebar({
request,
} as unknown as GatewayBrowserClient);
sidebar.querySelector<HTMLButtonElement>('[data-session-catalog-load-more="codex"]')?.click();
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
await sidebar.updateComplete;
expect(sidebar.sessionData.sessionCatalogs[0]?.hosts).toEqual([]);
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(4_999);
expect(sidebar.sessionData.sessionCatalogs[0]?.hosts).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
@@ -17,7 +17,7 @@ describe("AppSidebar catalog reconnect", () => {
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
const hello = {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"];
gateway.publish({ hello });
const { sidebar } = await mountSidebar(
@@ -39,8 +39,8 @@ describe("AppSidebar catalog reconnect", () => {
await vi.advanceTimersByTimeAsync(0);
await sidebar.updateComplete;
expect(sidebar.textContent).not.toContain("Stale session");
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
await vi.advanceTimersByTimeAsync(200);
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
expect(request).toHaveBeenLastCalledWith("sessions.catalog.list", {
agentId: "main",
@@ -51,7 +51,7 @@ async function mountWithCatalog(
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.admin"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar, context } = await mountSidebar(
@@ -275,7 +275,7 @@ describe("AppSidebar catalog deletion", () => {
},
);
it.each(["event", "page"] as const)(
it.each(["refresh", "page"] as const)(
"keeps a pending delete hidden through an earlier %s response and requests fresh rows",
async (source) => {
vi.useFakeTimers();
@@ -311,7 +311,7 @@ describe("AppSidebar catalog deletion", () => {
cursors: { "gateway:local": "page-2" },
});
} else {
gateway.publishEvent("sessions.catalog.changed", { agentId: "main" });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(5_000);
}
expect(request.mock.calls.map(([method]) => method)).toEqual(["sessions.catalog.list"]);
+1 -1
View File
@@ -644,7 +644,7 @@ export async function mountSessionCatalogSidebar(client: GatewayBrowserClient) {
gateway.publish({
hello: {
auth: { role: "operator", scopes: ["operator.read"] },
features: { methods: ["sessions.catalog.list"], events: ["sessions.catalog.changed"] },
features: { methods: ["sessions.catalog.list"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(