From f4e465d29580ad416f1ff512ee7d27ecec7d727e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 15:21:33 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20sidebar=20cleanups=20=E2=80=94?= =?UTF-8?q?=20shared=20tooltips,=20one=20idle-import=20helper,=20cross-tab?= =?UTF-8?q?=20outbox=20bridge=20(#112780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ui): sidebar cleanups — shared tooltips, one idle-import helper, cross-tab outbox bridge Three bounded cleanups following the offline-state work: - Tooltip unification: every sidebar-family tooltip (session-row badges, offline status, agent card, attention actions, brand icons) now uses the shared component instead of raw title attrs, completing the direction #112639 started. Aria labels unchanged. - One idle-import helper (ui/src/lib/idle-import.ts): the duplicated idle-load/retry logic from app-sidebar chrome and app-host's outbox loader collapses into createIdleImport (cached promise clears on failure, one idle retry while online, online re-arm, dispose). The helper migration is net negative at its call sites. - Cross-tab outbox bridge: subscribeStoredChatOutboxChanges now also notifies on storage events for the composer outbox keys, installed on first subscribe and removed with the last subscriber, so a message queued in another tab refreshes badges here. * fix(ui): keep idle-import scheduling statement-form for narrow-safe types and consistent-return * fix(ui): give idle-import a strictly void schedule and promise-only load --- ui/src/app/app-host.ts | 69 ++++------- ui/src/components/app-sidebar.ts | 105 ++++++---------- ui/src/components/session-row-badges.test.ts | 26 ++-- ui/src/components/session-row-badges.ts | 116 ++++++++++-------- ui/src/components/settings-sidebar.test.ts | 6 +- ui/src/components/sidebar-agent-card.ts | 13 +- ui/src/components/sidebar-attention.ts | 41 ++++--- ui/src/lib/chat/outbox-store.test.ts | 37 +++++- ui/src/lib/chat/outbox-store.ts | 26 +++- ui/src/lib/idle-import.test.ts | 103 ++++++++++++++++ ui/src/lib/idle-import.ts | 63 ++++++++++ .../app-sidebar-cases/attention.ts | 35 ++++++ .../test-helpers/app-sidebar-cases/basics.ts | 32 +++++ .../app-sidebar-cases/catalog-live.ts | 14 ++- .../app-sidebar-cases/child-sessions.ts | 6 +- 15 files changed, 486 insertions(+), 206 deletions(-) create mode 100644 ui/src/lib/idle-import.test.ts create mode 100644 ui/src/lib/idle-import.ts diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index f5b3af2eecf4..13826444c2f8 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -51,6 +51,7 @@ import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts"; import { i18n, isSupportedLocale, t } from "../i18n/index.ts"; import { copyToClipboard } from "../lib/clipboard.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import { createIdleImport } from "../lib/idle-import.ts"; import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts"; import { searchForSession } from "../lib/sessions/index.ts"; import "../lib/toast.ts"; @@ -145,8 +146,6 @@ type OutboxStoreRuntime = { subscribeStoredChatOutboxChanges: (listener: () => void) => () => void; }; -let outboxStoreModuleLoad: Promise | null = null; - function diffAgentRoster( previous: readonly GatewayAgentRow[], next: readonly GatewayAgentRow[], @@ -532,7 +531,10 @@ class OpenClawShell extends OpenClawLightDomElement { private agentRosterRefreshTimer: ReturnType | null = null; private outboxStoreRuntime: OutboxStoreRuntime | null = null; private outboxStoreUnsubscribe: (() => void) | null = null; - private outboxStoreRetryAttempted = false; + private readonly outboxStoreImport = createIdleImport( + () => import("../lib/chat/outbox-store.ts").then((module): OutboxStoreRuntime => module), + (runtime) => this.installOutboxStoreRuntime(runtime), + ); private lastNativeNavState: NativeNavState | undefined; private didConsiderNativeRouteRestore = false; private pendingNativeNewSession = false; @@ -657,7 +659,10 @@ class OpenClawShell extends OpenClawLightDomElement { override connectedCallback() { super.connectedCallback(); - this.scheduleOutboxStoreLoad(); + if (this.outboxStoreRuntime) { + this.installOutboxStoreRuntime(this.outboxStoreRuntime); + } + this.outboxStoreImport.schedule(); this.nativeHistoryState = readNativeHistoryState(); this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, this.openPalette); @@ -705,57 +710,26 @@ class OpenClawShell extends OpenClawLightDomElement { window.removeEventListener("openclaw:native-new-session", this.handleNativeNewSession); window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.handleDeferredTerminalToggle); window.removeEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.handleDeferredBrowserToggle); - window.removeEventListener("online", this.loadOutboxStore); + this.outboxStoreImport.dispose(); this.outboxStoreUnsubscribe?.(); this.outboxStoreUnsubscribe = null; - this.outboxStoreRuntime = null; - this.outboxStoreRetryAttempted = false; setSettingsChangeListener(null); this.resetShellEpochState(); super.disconnectedCallback(); } - private scheduleOutboxStoreLoad() { - if ("requestIdleCallback" in window) { - requestIdleCallback(this.loadOutboxStore, { timeout: 3000 }); - } else { - setTimeout(this.loadOutboxStore, 1500); + private installOutboxStoreRuntime(runtime: OutboxStoreRuntime) { + this.outboxStoreRuntime = runtime; + if (!this.isConnected) { + return; } + this.outboxStoreUnsubscribe?.(); + this.outboxStoreUnsubscribe = runtime.subscribeStoredChatOutboxChanges(() => + this.requestUpdate(), + ); + this.requestUpdate(); } - private readonly loadOutboxStore = () => { - outboxStoreModuleLoad ??= import("../lib/chat/outbox-store.ts") - .then((module): OutboxStoreRuntime => module) - .catch((error: unknown) => { - outboxStoreModuleLoad = null; - throw error; - }); - void outboxStoreModuleLoad - .then((runtime) => { - if (!this.isConnected) { - return; - } - window.removeEventListener("online", this.loadOutboxStore); - this.outboxStoreRetryAttempted = false; - this.outboxStoreRuntime = runtime; - this.outboxStoreUnsubscribe?.(); - this.outboxStoreUnsubscribe = runtime.subscribeStoredChatOutboxChanges(() => - this.requestUpdate(), - ); - this.requestUpdate(); - }) - .catch(() => { - if (!this.isConnected) { - return; - } - window.addEventListener("online", this.loadOutboxStore, { once: true }); - if (navigator.onLine && !this.outboxStoreRetryAttempted) { - this.outboxStoreRetryAttempted = true; - this.scheduleOutboxStoreLoad(); - } - }); - }; - private resetShellEpochState() { this.navDrawerOpen = false; this.navDrawerTrigger = null; @@ -1332,9 +1306,8 @@ class OpenClawShell extends OpenClawLightDomElement { this.syncSidebarWorkboard(); // Chunks are usually served by the gateway, so a failed idle load of the // outbox module recovers on reconnect, not only on a browser online event. - if (snapshot.connected && !this.outboxStoreRuntime && outboxStoreModuleLoad === null) { - this.outboxStoreRetryAttempted = false; - this.loadOutboxStore(); + if (snapshot.connected) { + void this.outboxStoreImport.load().catch(() => undefined); } } diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 714ba520d870..0990fd5ad35a 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -16,6 +16,7 @@ import { t } from "../i18n/index.ts"; import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts"; import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; import { BoardAvailabilityController } from "../lib/board/availability-controller.ts"; +import { sessionHasBoard } from "../lib/board/provider.ts"; import "./menu-surface.ts"; import "./session-menu.ts"; import "./sidebar-agent-card.ts"; @@ -24,8 +25,8 @@ import "./sidebar-build-chip.ts"; import "./sidebar-update-card.ts"; import "./theme-mode-toggle.ts"; import "./tooltip.ts"; -import { sessionHasBoard } from "../lib/board/provider.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import { createIdleImport } from "../lib/idle-import.ts"; import { searchForSession } from "../lib/sessions/index.ts"; import { areUiSessionKeysEquivalent, normalizeAgentId } from "../lib/sessions/session-key.ts"; import { pluginTabKey } from "../pages/plugin/route.ts"; @@ -51,40 +52,14 @@ import { renderOfflineSidebarStatus, renderSessionRowBadges } from "./session-ro const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "") ? "⌘K" : "Ctrl K"; -let lobsterPetModuleLoad: Promise | null = null; -let viewerFacepileModuleLoad: Promise | null = null; - -function scheduleSidebarChromeLoad() { - if ( - (lobsterPetModuleLoad || customElements.get("openclaw-lobster-pet")) && - (viewerFacepileModuleLoad || customElements.get("openclaw-viewer-facepile")) - ) { - return; - } - const start = () => { - // A failed chunk fetch must not pin a rejected promise forever: clear the - // cache and retry when connectivity returns. The sidebar mounts once per - // page, so without this a transient failure would disable the pet for the - // whole session; a deploy-pruned chunk stays off until reload, by design. - if (!customElements.get("openclaw-lobster-pet")) { - lobsterPetModuleLoad ??= import("./lobster-pet.ts").catch(() => { - lobsterPetModuleLoad = null; - window.addEventListener("online", () => start(), { once: true }); - }); - } - if (!customElements.get("openclaw-viewer-facepile")) { - viewerFacepileModuleLoad ??= import("./viewer-facepile.ts").catch(() => { - viewerFacepileModuleLoad = null; - window.addEventListener("online", () => start(), { once: true }); - }); - } - }; - if ("requestIdleCallback" in window) { - requestIdleCallback(() => start(), { timeout: 3000 }); - } else { - setTimeout(start, 1500); - } -} +// The shared loader retries transient chunk failures online; a deploy-pruned +// chunk still stays off until reload when that retry fails, by design. +const sidebarChromeImport = createIdleImport(() => + Promise.all([ + customElements.get("openclaw-lobster-pet") ? undefined : import("./lobster-pet.ts"), + customElements.get("openclaw-viewer-facepile") ? undefined : import("./viewer-facepile.ts"), + ]), +); class AppSidebar extends AppSidebarSessionListElement { @state() private logoVisit: LobsterLogoVisitDetail | null = null; @@ -130,7 +105,7 @@ class AppSidebar extends AppSidebarSessionListElement { super.connectedCallback(); // The decorative pet's large module stays out of startup and upgrades in place. // Its first visit is at least 15 seconds after load, so idle loading cannot miss one. - scheduleSidebarChromeLoad(); + sidebarChromeImport.schedule(); } protected override firstUpdated() { @@ -215,12 +190,13 @@ class AppSidebar extends AppSidebarSessionListElement { this.activeRouteId === "chat" && areUiSessionKeysEquivalent(this.getRouteSessionKey(), mainKey); const stateBadge = mainRow?.hasActiveRun - ? html`` + ? html` + + ` : mainRow?.unread === true && !active ? html` ${t("nav.home")} ${sessionHasBoard(mainKey) - ? html`${icons.layoutDashboard}` + ? html` + ${icons.layoutDashboard} + ` : nothing} ${stateBadge !== nothing || approvalNeeded || outboxCount > 0 ? html` ${stateBadge} ${approvalNeeded - ? html`${icons.alertTriangle}` + ? html` + ${icons.alertTriangle} + ` : nothing} ${renderSessionRowBadges({ hasAutomation: false, outboxCount })} ` @@ -343,15 +321,12 @@ class AppSidebar extends AppSidebarSessionListElement { .onNavigate=${(routeId: "about") => this.onNavigate?.(routeId)} > ${this.offline - ? html` - ${renderOfflineSidebarStatus({ - queuedOutboxCount: this.queuedOutboxCount, - reconnecting, - onRetry: () => this.onRetryConnect?.(), - })} - ` + ? renderOfflineSidebarStatus({ + queuedOutboxCount: this.queuedOutboxCount, + reconnecting, + title: this.lastError ? redactLoginFailureError(this.lastError) : reconnecting, + onRetry: () => this.onRetryConnect?.(), + }) : nothing} `; + return html` + + `; } diff --git a/ui/src/components/settings-sidebar.test.ts b/ui/src/components/settings-sidebar.test.ts index 812992740584..7c85cd7383de 100644 --- a/ui/src/components/settings-sidebar.test.ts +++ b/ui/src/components/settings-sidebar.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import { pt_BR } from "../i18n/locales/pt-BR.ts"; import { renderSettingsSidebar } from "./settings-sidebar.ts"; +import "./tooltip.ts"; let container: HTMLDivElement; @@ -363,7 +364,10 @@ describe("settings sidebar search", () => { renderSidebar(true, "connection refused?token=settings-secret", 3); const button = container.querySelector(".sidebar-footer-bar__status"); - expect(button?.title).toBe("connection refused?[redacted-credential]"); + expect(button?.hasAttribute("title")).toBe(false); + expect( + (button?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)?.content, + ).toBe("connection refused?[redacted-credential]"); expect(button?.textContent).toContain("3 queued"); expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued"); button?.click(); diff --git a/ui/src/components/sidebar-agent-card.ts b/ui/src/components/sidebar-agent-card.ts index d126c07d80fa..322a7da25fb2 100644 --- a/ui/src/components/sidebar-agent-card.ts +++ b/ui/src/components/sidebar-agent-card.ts @@ -66,12 +66,13 @@ class SidebarAgentCard extends OpenClawLightDomContentsElement { : nothing} ${this.approvalCount > 0 - ? html`${this.approvalCount}` + ? html` + ${this.approvalCount} + ` : nothing} ${this.menuUnread && !this.menuOpen ? html` html` `, )} diff --git a/ui/src/lib/chat/outbox-store.test.ts b/ui/src/lib/chat/outbox-store.test.ts index 277866f001a2..b23ce9fa4e06 100644 --- a/ui/src/lib/chat/outbox-store.test.ts +++ b/ui/src/lib/chat/outbox-store.test.ts @@ -1,9 +1,10 @@ -// @vitest-environment node +/* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStorageMock } from "../../test-helpers/storage.ts"; import { resolveStoredChatOutboxScope, storedChatOutboxScopeKey, + subscribeStoredChatOutboxChanges, summarizeStoredChatOutboxes, } from "./outbox-store.ts"; @@ -12,10 +13,44 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("stored outbox summaries", () => { + it("bridges matching cross-tab storage changes until the last subscriber leaves", () => { + const addEventListener = vi.spyOn(window, "addEventListener"); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + const unsubscribeFirst = subscribeStoredChatOutboxChanges(firstListener); + const unsubscribeSecond = subscribeStoredChatOutboxChanges(secondListener); + + expect(addEventListener).toHaveBeenCalledWith("storage", expect.any(Function)); + + window.dispatchEvent( + new StorageEvent("storage", { key: "openclaw.control.chatComposer.v2:gateway" }), + ); + expect(firstListener).toHaveBeenCalledTimes(1); + expect(secondListener).toHaveBeenCalledTimes(1); + + window.dispatchEvent(new StorageEvent("storage", { key: "openclaw.control.settings.v1" })); + expect(firstListener).toHaveBeenCalledTimes(1); + expect(secondListener).toHaveBeenCalledTimes(1); + + window.dispatchEvent( + new StorageEvent("storage", { key: "openclaw.control.chatComposer.v1:gateway" }), + ); + expect(firstListener).toHaveBeenCalledTimes(2); + expect(secondListener).toHaveBeenCalledTimes(2); + + unsubscribeFirst(); + expect(removeEventListener).not.toHaveBeenCalledWith("storage", expect.any(Function)); + + unsubscribeSecond(); + expect(removeEventListener).toHaveBeenCalledWith("storage", expect.any(Function)); + }); + it("routes shipped bare-main rows to the known default agent", () => { const gatewayUrl = "ws://gateway.test/control"; const legacyKey = `openclaw.control.chatComposer.v1:${encodeURIComponent(gatewayUrl)}`; diff --git a/ui/src/lib/chat/outbox-store.ts b/ui/src/lib/chat/outbox-store.ts index dabf120c4cd2..c4769298e260 100644 --- a/ui/src/lib/chat/outbox-store.ts +++ b/ui/src/lib/chat/outbox-store.ts @@ -13,6 +13,7 @@ const LEGACY_STORAGE_KEY_PREFIX = "openclaw.control.chatComposer.v1:"; const STORAGE_KEY_PREFIX = "openclaw.control.chatComposer.v2:"; export const UNRESOLVED_GLOBAL_AGENT_SCOPE = "@unresolved"; const storedChatOutboxChangeListeners = new Set<() => void>(); +let storageChangeListenerInstalled = false; export type ChatComposerScope = { settings?: { gatewayUrl?: string | null }; @@ -57,7 +58,21 @@ const storedMainAliasByStorage = new WeakMap< export function subscribeStoredChatOutboxChanges(listener: () => void): () => void { storedChatOutboxChangeListeners.add(listener); - return () => storedChatOutboxChangeListeners.delete(listener); + if (!storageChangeListenerInstalled && typeof window !== "undefined") { + storageChangeListenerInstalled = true; + window.addEventListener("storage", handleStoredChatOutboxStorageChange); + } + return () => { + storedChatOutboxChangeListeners.delete(listener); + if ( + storageChangeListenerInstalled && + storedChatOutboxChangeListeners.size === 0 && + typeof window !== "undefined" + ) { + storageChangeListenerInstalled = false; + window.removeEventListener("storage", handleStoredChatOutboxStorageChange); + } + }; } export function notifyStoredChatOutboxChanges(): void { @@ -70,6 +85,15 @@ export function notifyStoredChatOutboxChanges(): void { } } +function handleStoredChatOutboxStorageChange(event: StorageEvent): void { + if ( + event.key?.startsWith(STORAGE_KEY_PREFIX) || + event.key?.startsWith(LEGACY_STORAGE_KEY_PREFIX) + ) { + notifyStoredChatOutboxChanges(); + } +} + export function storageTargetForGateway( gatewayUrl: string | null | undefined, ): ComposerStorageTarget { diff --git a/ui/src/lib/idle-import.test.ts b/ui/src/lib/idle-import.test.ts new file mode 100644 index 000000000000..ad46ac111ea2 --- /dev/null +++ b/ui/src/lib/idle-import.test.ts @@ -0,0 +1,103 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createIdleImport } from "./idle-import.ts"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + "requestIdleCallback", + vi.fn((callback: IdleRequestCallback) => + window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0), + ), + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("createIdleImport", () => { + it("waits for the idle schedule before importing", async () => { + const importModule = vi.fn(async () => "loaded"); + const onLoaded = vi.fn(); + const idleImport = createIdleImport(importModule, onLoaded); + + idleImport.schedule(); + + expect(importModule).not.toHaveBeenCalled(); + expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 3000 }); + + await vi.runAllTimersAsync(); + + expect(importModule).toHaveBeenCalledTimes(1); + expect(onLoaded).toHaveBeenCalledWith("loaded"); + }); + + it("clears a failed import and re-arms it when the browser comes online", async () => { + vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); + const importModule = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue("loaded"); + const onLoaded = vi.fn(); + const idleImport = createIdleImport(importModule, onLoaded); + + idleImport.schedule(); + await vi.runAllTimersAsync(); + + expect(importModule).toHaveBeenCalledTimes(1); + + window.dispatchEvent(new Event("online")); + await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledWith("loaded")); + + expect(importModule).toHaveBeenCalledTimes(2); + }); + + it("retries one failed import while the browser remains online", async () => { + const importModule = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValue("loaded"); + const onLoaded = vi.fn(); + const idleImport = createIdleImport(importModule, onLoaded); + + idleImport.schedule(); + await vi.runAllTimersAsync(); + + expect(importModule).toHaveBeenCalledTimes(2); + expect(onLoaded).toHaveBeenCalledWith("loaded"); + }); + + it("removes its online retry listener when disposed", async () => { + const importModule = vi.fn<() => Promise>().mockRejectedValue(new Error("offline")); + const removeEventListener = vi.spyOn(window, "removeEventListener"); + const idleImport = createIdleImport(importModule); + + await expect(idleImport.load()).rejects.toThrow("offline"); + idleImport.dispose(); + window.dispatchEvent(new Event("online")); + await Promise.resolve(); + + expect(removeEventListener).toHaveBeenCalledWith("online", expect.any(Function)); + expect(importModule).toHaveBeenCalledTimes(1); + }); + + it("shares and retains a successful module promise", async () => { + const module = { ready: true }; + const importModule = vi.fn(async () => module); + const onLoaded = vi.fn(); + const idleImport = createIdleImport(importModule, onLoaded); + + const first = idleImport.load(); + const second = idleImport.load(); + + await expect(first).resolves.toBe(module); + await expect(second).resolves.toBe(module); + await expect(idleImport.load()).resolves.toBe(module); + expect(importModule).toHaveBeenCalledTimes(1); + expect(onLoaded).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/lib/idle-import.ts b/ui/src/lib/idle-import.ts new file mode 100644 index 000000000000..a79bd42e7297 --- /dev/null +++ b/ui/src/lib/idle-import.ts @@ -0,0 +1,63 @@ +export function createIdleImport(importModule: () => Promise, onLoaded?: (value: T) => void) { + let moduleLoad: Promise | null = null; + let active = false; + let onlineRetryAttempted = false; + + const run = (): Promise => { + moduleLoad ??= importModule() + .then((value) => { + window.removeEventListener("online", start); + onLoaded?.(value); + return value; + }) + .catch((error: unknown) => { + // A failed chunk fetch must not pin a rejected promise forever. + moduleLoad = null; + if (!active) { + throw error; + } + window.addEventListener("online", start, { once: true }); + if (navigator.onLine && !onlineRetryAttempted) { + onlineRetryAttempted = true; + scheduleIdle(); + } + throw error; + }); + return moduleLoad; + }; + + const start = () => active && void run().catch(() => undefined); + + const scheduleIdle = () => { + if (moduleLoad) { + return; + } + if ("requestIdleCallback" in window) { + requestIdleCallback(start, { timeout: 3000 }); + } else { + setTimeout(start, 1500); + } + }; + + const activate = () => { + active = true; + onlineRetryAttempted = false; + }; + + const dispose = () => { + active = false; + window.removeEventListener("online", start); + }; + + return { + schedule: (): void => { + activate(); + scheduleIdle(); + }, + load: (): Promise => { + activate(); + return run(); + }, + dispose, + }; +} diff --git a/ui/src/test-helpers/app-sidebar-cases/attention.ts b/ui/src/test-helpers/app-sidebar-cases/attention.ts index c0bb15151acd..182b782ed84b 100644 --- a/ui/src/test-helpers/app-sidebar-cases/attention.ts +++ b/ui/src/test-helpers/app-sidebar-cases/attention.ts @@ -180,6 +180,41 @@ describe("AppSidebar session attention", () => { expect(sidebar.textContent).not.toContain("Run failed:"); }); + it("uses shared tooltips for Home and agent approval badges", async () => { + const mainKey = "agent:main:main"; + const approval = { + id: "approval-main", + kind: "exec", + request: { command: "git status", sessionKey: mainKey }, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + } satisfies ExecApprovalRequest; + const { sidebar } = await mountSidebar( + createGateway({} as GatewayBrowserClient), + createSessionsHarness("main", [mainKey]).sessions, + "panel", + null, + [approval], + ); + + const homeBadge = sidebar.querySelector(".nav-item--home .session-approval-badge"); + expect(homeBadge?.getAttribute("aria-label")).toBe("Approval needed"); + expect(homeBadge?.hasAttribute("title")).toBe(false); + expect( + (homeBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, + ).toBe("Approval needed"); + + const agentBadge = sidebar.querySelector(".sidebar-agent-card__approval-count"); + const agentLabel = agentBadge?.getAttribute("aria-label"); + expect(agentLabel).toBeTruthy(); + expect(agentBadge?.hasAttribute("title")).toBe(false); + expect( + (agentBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, + ).toBe(agentLabel); + }); + it("shows an error icon and reason for an unread failure", async () => { const sessionsHarness = createSessionsHarness("main", [sessionKey]); setRows(sessionsHarness, [failedRow()]); diff --git a/ui/src/test-helpers/app-sidebar-cases/basics.ts b/ui/src/test-helpers/app-sidebar-cases/basics.ts index aebadd90d24b..37d2e36ce6c7 100644 --- a/ui/src/test-helpers/app-sidebar-cases/basics.ts +++ b/ui/src/test-helpers/app-sidebar-cases/basics.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentsListResult } from "../../api/types.ts"; +import { + clearSessionBoardAvailability, + recordSessionBoardAvailability, +} from "../../lib/board/provider.ts"; import { createGateway, createGatewayHarness, @@ -413,6 +417,34 @@ describe("AppSidebar agent chip", () => { expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent).toContain( "Working", ); + const spinner = sidebar.querySelector(".nav-item--home .session-run-spinner"); + expect(spinner?.hasAttribute("title")).toBe(false); + expect( + (spinner?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, + ).toBe("Active run"); + }); + + it("uses the shared tooltip for the Home dashboard glyph", async () => { + const mainKey = "agent:main:main"; + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar(gateway, createSessions("main", [mainKey])); + + try { + recordSessionBoardAvailability(mainKey, true); + sidebar.requestUpdate(); + await sidebar.updateComplete; + + const glyph = sidebar.querySelector(".nav-item--home .sidebar-board-glyph"); + expect(glyph?.getAttribute("aria-label")).toBe("Dashboard available"); + expect(glyph?.hasAttribute("title")).toBe(false); + expect( + (glyph?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, + ).toBe("Dashboard available"); + } finally { + clearSessionBoardAvailability(); + } }); it("keeps the sessions list flat for the selected agent and flags other-agent unread", async () => { diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts index 0c5c1b8b6475..b9de5501a217 100644 --- a/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-live.ts @@ -225,9 +225,12 @@ describe("AppSidebar session catalog pagination", () => { expect(local?.querySelector(".sidebar-session-catalog-host__head")).toBeNull(); expect(local?.textContent).not.toContain("Gateway Mac"); expect(local?.textContent).toContain("Local plan"); - expect(local?.querySelector(".session-row-badge--pull-request")?.getAttribute("title")).toBe( - "#111751, #111772 · Merged", - ); + const pullRequestBadge = local?.querySelector(".session-row-badge--pull-request"); + expect(pullRequestBadge?.hasAttribute("title")).toBe(false); + expect( + (pullRequestBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, + ).toBe("#111751, #111772 · Merged"); expect(local?.textContent).not.toContain("Remote review"); expect(remote?.textContent).toContain("Build Node"); expect(remote?.textContent).toContain("Remote review"); @@ -302,8 +305,11 @@ describe("AppSidebar session catalog pagination", () => { `[data-session-key="${backingSessionKey}"]`, ); expect(linkedRow?.getAttribute("draggable")).toBe("true"); + const pullRequestBadge = linkedRow?.querySelector(".session-row-badge--pull-request"); + expect(pullRequestBadge?.hasAttribute("title")).toBe(false); expect( - linkedRow?.querySelector(".session-row-badge--pull-request")?.getAttribute("title"), + (pullRequestBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, ).toBe("#107302 · Draft"); expect(linkedRow?.querySelector('[data-sidebar-session-pin="true"]')).not.toBeNull(); expect(linkedRow?.querySelector('[data-session-menu="true"]')).not.toBeNull(); diff --git a/ui/src/test-helpers/app-sidebar-cases/child-sessions.ts b/ui/src/test-helpers/app-sidebar-cases/child-sessions.ts index 3e53b11eb36c..359c8e5343bf 100644 --- a/ui/src/test-helpers/app-sidebar-cases/child-sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/child-sessions.ts @@ -220,7 +220,11 @@ describe("AppSidebar agent chip", () => { ); expect(parentBadge?.dataset.workspaceConflicts).toBe("2"); expect(parentBadge?.dataset.placementState).toBeUndefined(); - expect(parentBadge?.getAttribute("title")).toBe("Cloud worker children: 2 workspace conflicts"); + expect(parentBadge?.hasAttribute("title")).toBe(false); + expect( + (parentBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null) + ?.content, + ).toBe("Cloud worker children: 2 workspace conflicts"); }); it("loads every child-session page before marking a parent complete", async () => {