From aaacee8166cda8eb9972441444d36ca7dfe8679f Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Sun, 19 Jul 2026 14:37:20 +0800
Subject: [PATCH] fix(ui): prevent stale sidebar attention refreshes (#111061)
---
ui/src/components/sidebar-attention.test.ts | 129 ++++++++++++++++++++
ui/src/components/sidebar-attention.ts | 15 ++-
2 files changed, 142 insertions(+), 2 deletions(-)
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;