diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts index d981c0b0b6b9..3c5842873054 100644 --- a/ui/src/components/sidebar-attention.test.ts +++ b/ui/src/components/sidebar-attention.test.ts @@ -1,7 +1,13 @@ /* @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { CronJob, ModelAuthStatusResult } from "../api/types.ts"; +import type { ApplicationContext, ApplicationGateway } from "../app/context.ts"; import type { ExecApprovalRequest } from "../app/exec-approval.ts"; +import { createApplicationContextProvider } from "../test-helpers/application-context.ts"; +import { createStorageMock as createTestStorageMock } from "../test-helpers/storage.ts"; +import { waitForFast } from "../test-helpers/wait-for.ts"; import { addDismissal, dismissalStoreKey, @@ -9,6 +15,37 @@ import { type SidebarAttentionKind, } from "./sidebar-attention-dismissals.ts"; import { buildSidebarAttentionItems } from "./sidebar-attention-items.ts"; +import "./sidebar-attention.ts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function cronJob(id: string): CronJob { + return { + id, + name: id, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "test" }, + state: { lastRunStatus: "error" }, + }; +} + +type SidebarAttentionElement = HTMLElement & { + updateComplete: Promise; + cronJobs: CronJob[]; + modelAuthStatus: ModelAuthStatusResult | null; + loadedAtMs: number; +}; function approval(id: string): ExecApprovalRequest { return { @@ -57,6 +94,98 @@ describe("pending approval attention", () => { }); }); +describe("sidebar attention refresh ownership", () => { + afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("keeps the latest refresh when an older load on the same client finishes last", async () => { + const firstCron = deferred(); + const firstAuth = deferred(); + const secondCron = deferred(); + const secondAuth = deferred(); + const responses = { + "cron.list": [firstCron, secondCron], + "models.authStatus": [firstAuth, secondAuth], + }; + const request = vi.fn((method: keyof typeof responses) => { + const response = responses[method].shift(); + if (!response) { + throw new Error(`Unexpected request: ${method}`); + } + return response.promise; + }); + const client = { request } as unknown as GatewayBrowserClient; + const snapshot = { + client, + connected: true, + reconnecting: false, + hello: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + lastError: null, + lastErrorCode: null, + }; + const gateway = { + snapshot, + connection: { + gatewayUrl: "ws://gateway.test", + token: "", + bootstrapToken: "", + password: "", + }, + subscribe: () => () => undefined, + } as unknown as ApplicationGateway; + const overlays = { + snapshot: { approvalQueue: [] }, + subscribe: () => () => undefined, + } as unknown as ApplicationContext["overlays"]; + const storage = createTestStorageMock(); + vi.stubGlobal("localStorage", storage); + localStorage.setItem( + dismissalStoreKey(gateway.connection.gatewayUrl), + JSON.stringify({ cronFailed: "current" }), + ); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + let now = 120_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + + const provider = createApplicationContextProvider({ gateway, overlays } as ApplicationContext); + const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement; + provider.append(element); + document.body.append(provider); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + + document.dispatchEvent(new Event("visibilitychange")); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(4)); + + const currentAuth = { ts: 2, providers: [] } as ModelAuthStatusResult; + now = 200_000; + secondCron.resolve({ jobs: [cronJob("current")] }); + secondAuth.resolve(currentAuth); + await waitForFast(() => expect(element.loadedAtMs).toBe(200_000)); + expect(element.cronJobs.map((job) => job.id)).toEqual(["current"]); + expect(element.modelAuthStatus).toBe(currentAuth); + expect(localStorage.getItem(dismissalStoreKey(gateway.connection.gatewayUrl))).not.toBeNull(); + + now = 300_000; + firstCron.resolve({ jobs: [cronJob("stale")] }); + firstAuth.resolve({ ts: 1, providers: [] }); + await Promise.all([firstCron.promise, firstAuth.promise]); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 0); + }); + await element.updateComplete; + + expect(element.cronJobs.map((job) => job.id)).toEqual(["current"]); + expect(element.modelAuthStatus).toBe(currentAuth); + expect(element.loadedAtMs).toBe(200_000); + expect(localStorage.getItem(dismissalStoreKey(gateway.connection.gatewayUrl))).not.toBeNull(); + }); +}); + describe("pruneDismissals", () => { const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature }); diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts index 079509b80a48..79aa4e797060 100644 --- a/ui/src/components/sidebar-attention.ts +++ b/ui/src/components/sidebar-attention.ts @@ -47,6 +47,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement { @property({ attribute: false }) onOpenApprovals?: () => void; private loadedClient: GatewayBrowserClient | null = null; + private loadGeneration = 0; private loadedAtMs = 0; private dismissedScope: string | null = null; private idleRefreshTimer: ReturnType | null = null; @@ -101,6 +102,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement { this.idleRefreshTimer = null; } this.subscriptions.clear(); + this.loadGeneration += 1; this.loadedClient = null; super.disconnectedCallback(); } @@ -113,6 +115,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement { this.dismissed = loadDismissals(gatewayUrl); } if (!snapshot.connected || !snapshot.client) { + this.loadGeneration += 1; this.loadedClient = null; this.cronJobs = []; this.modelAuthStatus = null; @@ -122,12 +125,20 @@ class SidebarAttention extends OpenClawLightDomContentsElement { return; } this.loadedClient = snapshot.client; - void this.load(gateway, snapshot.client); + // Stale refreshes reuse the same client, so identity alone cannot retire + // an older completion once the replacement load starts. + const generation = ++this.loadGeneration; + void this.load(gateway, snapshot.client, generation); } - private async load(gateway: ApplicationContext["gateway"], client: GatewayBrowserClient) { + private async load( + gateway: ApplicationContext["gateway"], + client: GatewayBrowserClient, + generation: number, + ) { const isCurrent = () => this.isConnected && + this.loadGeneration === generation && this.loadedClient === client && gateway.snapshot.client === client && gateway.snapshot.connected;