refactor(ui): sidebar cleanups — shared tooltips, one idle-import helper, cross-tab outbox bridge (#112780)

* 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 <openclaw-tooltip> 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
This commit is contained in:
Peter Steinberger
2026-07-22 15:21:33 -07:00
committed by GitHub
parent 0f3855aa81
commit f4e465d295
15 changed files with 486 additions and 206 deletions
+21 -48
View File
@@ -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<OutboxStoreRuntime> | null = null;
function diffAgentRoster(
previous: readonly GatewayAgentRow[],
next: readonly GatewayAgentRow[],
@@ -532,7 +531,10 @@ class OpenClawShell extends OpenClawLightDomElement {
private agentRosterRefreshTimer: ReturnType<typeof globalThis.setTimeout> | 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);
}
}
+40 -65
View File
@@ -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<unknown> | null = null;
let viewerFacepileModuleLoad: Promise<unknown> | 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`<span
class="session-run-spinner"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
? html`<openclaw-tooltip .content=${t("sessionsView.activeRun")}>
<span
class="session-run-spinner"
role="img"
aria-label=${t("sessionsView.activeRun")}
></span>
</openclaw-tooltip>`
: mainRow?.unread === true && !active
? html`<span
class="session-unread-dot"
@@ -244,25 +220,27 @@ class AppSidebar extends AppSidebarSessionListElement {
<span class="nav-item__icon" aria-hidden="true">${icons.home}</span>
<span class="nav-item__text">${t("nav.home")}</span>
${sessionHasBoard(mainKey)
? html`<span
class="sidebar-board-glyph"
role="img"
aria-label=${t("sessionsView.dashboardAvailable")}
title=${t("sessionsView.dashboardAvailable")}
>${icons.layoutDashboard}</span
>`
? html`<openclaw-tooltip .content=${t("sessionsView.dashboardAvailable")}>
<span
class="sidebar-board-glyph"
role="img"
aria-label=${t("sessionsView.dashboardAvailable")}
>${icons.layoutDashboard}</span
>
</openclaw-tooltip>`
: nothing}
${stateBadge !== nothing || approvalNeeded || outboxCount > 0
? html`<span class="nav-item__state sidebar-home-session-states">
${stateBadge}
${approvalNeeded
? html`<span
class="session-approval-badge"
role="img"
aria-label=${t("sessionsView.approvalNeeded")}
title=${t("sessionsView.approvalNeeded")}
>${icons.alertTriangle}</span
>`
? html`<openclaw-tooltip .content=${t("sessionsView.approvalNeeded")}>
<span
class="session-approval-badge"
role="img"
aria-label=${t("sessionsView.approvalNeeded")}
>${icons.alertTriangle}</span
>
</openclaw-tooltip>`
: nothing}
${renderSessionRowBadges({ hasAutomation: false, outboxCount })}
</span>`
@@ -343,15 +321,12 @@ class AppSidebar extends AppSidebarSessionListElement {
.onNavigate=${(routeId: "about") => this.onNavigate?.(routeId)}
></openclaw-sidebar-build-chip>
${this.offline
? html`<openclaw-tooltip
.content=${this.lastError ? redactLoginFailureError(this.lastError) : reconnecting}
>
${renderOfflineSidebarStatus({
queuedOutboxCount: this.queuedOutboxCount,
reconnecting,
onRetry: () => this.onRetryConnect?.(),
})}
</openclaw-tooltip>`
? renderOfflineSidebarStatus({
queuedOutboxCount: this.queuedOutboxCount,
reconnecting,
title: this.lastError ? redactLoginFailureError(this.lastError) : reconnecting,
onRetry: () => this.onRetryConnect?.(),
})
: nothing}
<openclaw-tooltip .content=${t("nav.settings")}>
<button
+19 -7
View File
@@ -4,6 +4,7 @@ import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n } from "../i18n/index.ts";
import { renderSessionRowBadges, type SessionPlacementState } from "./session-row-badges.ts";
import "./tooltip.ts";
let container: HTMLDivElement;
@@ -28,6 +29,13 @@ function renderBadges(placementState?: SessionPlacementState, workspaceConflictC
);
}
function expectTooltipText(badge: Element | null | undefined, text: string) {
expect(badge?.hasAttribute("title")).toBe(false);
expect(
(badge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)?.content,
).toBe(text);
}
describe("session row placement badges", () => {
it("renders the durable outbox count and stays quiet when empty", () => {
render(
@@ -40,7 +48,7 @@ describe("session row placement badges", () => {
const badge = container.querySelector<HTMLElement>(".session-row-badge--queued");
expect(badge?.getAttribute("aria-label")).toBe("3 messages queued to send");
expect(badge?.getAttribute("title")).toBe("3 messages queued to send");
expectTooltipText(badge, "3 messages queued to send");
expect(badge?.textContent).toContain("3");
expect(badge?.querySelector("svg")).not.toBeNull();
@@ -72,6 +80,7 @@ describe("session row placement badges", () => {
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
expect(badge?.dataset.placementState).toBe(placementState);
expect(badge?.getAttribute("aria-label")).toBe(`Cloud worker: ${placementState}`);
expectTooltipText(badge, `Cloud worker: ${placementState}`);
expect(badge?.querySelector("circle")).not.toBeNull();
expect(badge?.querySelector("rect")).toBeNull();
});
@@ -86,6 +95,7 @@ describe("session row placement badges", () => {
);
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
expectTooltipText(container.querySelector(".session-row-badge"), "Automation attached");
expect(container.querySelector(".session-row-badge--cloud")).toBeNull();
});
@@ -100,7 +110,7 @@ describe("session row placement badges", () => {
const badge = container.querySelector(".session-row-badge--pull-request");
expect(badge?.getAttribute("aria-label")).toBe("#111532 · Open");
expect(badge?.getAttribute("title")).toBe("#111532 · Open");
expectTooltipText(badge, "#111532 · Open");
expect(badge?.getAttribute("data-pull-request-state")).toBe("open");
expect(badge?.querySelector("svg")).not.toBeNull();
});
@@ -122,7 +132,7 @@ describe("session row placement badges", () => {
const badge = container.querySelector(".session-row-badge--pull-request");
expect(badge?.getAttribute("aria-label")).toBe(label);
expect(badge?.getAttribute("title")).toBe(label);
expectTooltipText(badge, label);
expect(badge?.getAttribute("data-pull-request-state")).toBe(state);
});
@@ -137,6 +147,7 @@ describe("session row placement badges", () => {
const badge = container.querySelector(".session-row-badge--approval");
expect(badge?.getAttribute("aria-label")).toBe("Approval needed");
expectTooltipText(badge, "Approval needed");
expect(badge?.querySelector("svg")).not.toBeNull();
});
@@ -180,11 +191,12 @@ describe("session row placement badges", () => {
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
expect(badge?.dataset.workspaceConflicts).toBe("3");
expect(badge?.getAttribute("title")).toBe("Cloud worker: active · 3 workspace conflicts");
expectTooltipText(badge, "Cloud worker: active · 3 workspace conflicts");
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
renderBadges("active", 1);
expect(container.querySelector(".session-row-badge--cloud")?.getAttribute("title")).toBe(
expectTooltipText(
container.querySelector(".session-row-badge--cloud"),
"Cloud worker: active · 1 workspace conflict",
);
});
@@ -195,7 +207,7 @@ describe("session row placement badges", () => {
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
expect(badge?.dataset.placementState).toBe("reclaimed");
expect(badge?.dataset.workspaceConflicts).toBe("2");
expect(badge?.getAttribute("title")).toBe("Cloud worker: reclaimed · 2 workspace conflicts");
expectTooltipText(badge, "Cloud worker: reclaimed · 2 workspace conflicts");
});
it("renders descendant conflict attention without claiming a parent placement state", () => {
@@ -204,6 +216,6 @@ describe("session row placement badges", () => {
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
expect(badge?.dataset.placementState).toBeUndefined();
expect(badge?.dataset.workspaceConflicts).toBe("2");
expect(badge?.getAttribute("title")).toBe("Cloud worker children: 2 workspace conflicts");
expectTooltipText(badge, "Cloud worker children: 2 workspace conflicts");
});
});
+62 -54
View File
@@ -1,4 +1,4 @@
import { html, nothing } from "lit";
import { html, nothing, type TemplateResult } from "lit";
// Deep import on purpose: the protocol barrel carries typebox and every
// schema, which must stay out of the Control UI startup bundle.
import { isCloudWorkerPlacementState } from "../../../packages/gateway-protocol/src/schema/session-placement-state.js";
@@ -37,6 +37,28 @@ function formatSessionPullRequestSummary(summary: SessionCatalogPullRequestSumma
return `${numbers} · ${pullRequestStateLabel(summary.state)}`;
}
function renderSessionRowBadge(
label: string,
icon: TemplateResult,
modifier = "",
count = 0,
pullRequestState?: SessionCatalogPullRequestSummary["state"],
placementState?: SessionPlacementState,
workspaceConflictCount = 0,
) {
return html`<openclaw-tooltip .content=${label}>
<span
class=${`session-row-badge${modifier ? ` ${modifier}` : ""}`}
data-pull-request-state=${pullRequestState ?? nothing}
data-placement-state=${placementState ?? nothing}
data-workspace-conflicts=${workspaceConflictCount ? String(workspaceConflictCount) : nothing}
role="img"
aria-label=${label}
>${icon}${count ? html`<span aria-hidden="true">${count}</span>` : nothing}</span
>
</openclaw-tooltip>`;
}
export function renderSessionRowBadges(params: {
isChild?: boolean;
hasAutomation: boolean;
@@ -99,54 +121,37 @@ export function renderSessionRowBadges(params: {
: "";
return html`<span class="session-row-badges">
${hasAutomation
? html`<span
class="session-row-badge"
role="img"
aria-label=${t("sessionsView.automationAttached")}
title=${t("sessionsView.automationAttached")}
>${icons.clock}</span
>`
? renderSessionRowBadge(t("sessionsView.automationAttached"), icons.clock)
: nothing}
${pullRequestLabel
? html`<span
class="session-row-badge session-row-badge--pull-request"
data-pull-request-state=${pullRequestState ?? nothing}
role="img"
aria-label=${pullRequestLabel}
title=${pullRequestLabel}
>${icons.gitPullRequest}</span
>`
? renderSessionRowBadge(
pullRequestLabel,
icons.gitPullRequest,
"session-row-badge--pull-request",
0,
pullRequestState,
)
: nothing}
${params.hasApproval
? html`<span
class="session-row-badge session-row-badge--approval"
role="img"
aria-label=${t("sessionsView.approvalNeeded")}
title=${t("sessionsView.approvalNeeded")}
>${icons.alertTriangle}</span
>`
? renderSessionRowBadge(
t("sessionsView.approvalNeeded"),
icons.alertTriangle,
"session-row-badge--approval",
)
: nothing}
${outboxCount > 0
? html`<span
class="session-row-badge session-row-badge--queued"
role="img"
aria-label=${outboxLabel}
title=${outboxLabel}
>${icons.clock}<span aria-hidden="true">${outboxCount}</span></span
>`
? renderSessionRowBadge(outboxLabel, icons.clock, "session-row-badge--queued", outboxCount)
: nothing}
${displayedPlacementState || hasWorkspaceConflict
? html`<span
class="session-row-badge session-row-badge--cloud"
data-placement-state=${displayedPlacementState ?? nothing}
data-workspace-conflicts=${hasWorkspaceConflict
? String(workspaceConflictCount)
: nothing}
role="img"
aria-label=${cloudLabel}
title=${cloudLabel}
>${icons.globe}</span
>`
? renderSessionRowBadge(
cloudLabel,
icons.globe,
"session-row-badge--cloud",
0,
undefined,
displayedPlacementState,
hasWorkspaceConflict ? workspaceConflictCount : 0,
)
: nothing}
</span>`;
}
@@ -160,17 +165,20 @@ export function renderOfflineSidebarStatus(props: {
const offline = t("common.offline");
const count = props.queuedOutboxCount;
const queued = count ? t("connection.queuedCount", { count: String(count) }) : null;
return html`<button
type="button"
class="sidebar-footer-bar__status"
aria-live="polite"
aria-label=${`${offline}${t("connection.retryNow")}${queued ? `${queued}` : ""}`}
title=${props.title ?? nothing}
@click=${props.onRetry}
>
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${offline}<span
class="sidebar-footer-bar__status-detail"
${props.reconnecting}</span
>${queued ? html`<span class="sidebar-footer-bar__status-detail">· ${queued}</span>` : nothing}
</button>`;
return html`<openclaw-tooltip .content=${props.title ?? ""}>
<button
type="button"
class="sidebar-footer-bar__status"
aria-live="polite"
aria-label=${`${offline}${t("connection.retryNow")}${queued ? `${queued}` : ""}`}
@click=${props.onRetry}
>
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${offline}<span
class="sidebar-footer-bar__status-detail"
${props.reconnecting}</span
>${queued
? html`<span class="sidebar-footer-bar__status-detail">· ${queued}</span>`
: nothing}
</button>
</openclaw-tooltip>`;
}
+5 -1
View File
@@ -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<HTMLButtonElement>(".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();
+7 -6
View File
@@ -66,12 +66,13 @@ class SidebarAgentCard extends OpenClawLightDomContentsElement {
: nothing}
</span>
${this.approvalCount > 0
? html`<span
class="sidebar-agent-approval-count sidebar-agent-card__approval-count"
aria-label=${approvalLabel}
title=${approvalLabel}
>${this.approvalCount}</span
>`
? html`<openclaw-tooltip .content=${approvalLabel}>
<span
class="sidebar-agent-approval-count sidebar-agent-card__approval-count"
aria-label=${approvalLabel}
>${this.approvalCount}</span
>
</openclaw-tooltip>`
: nothing}
${this.menuUnread && !this.menuOpen
? html`<span
+23 -18
View File
@@ -27,6 +27,7 @@ import {
buildSidebarAttentionItems,
type SidebarAttentionItem,
} from "./sidebar-attention-items.ts";
import "./tooltip.ts";
// Reloads are connection-scoped; a visibility change only refetches after the
// snapshot is older than this, so tab switches stay free of request bursts.
@@ -222,24 +223,28 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
${items.map(
(item) => html`
<div class="sidebar-attention__item sidebar-attention__item--${item.severity}">
<button
type="button"
class="sidebar-attention__open"
title=${item.label}
@click=${() => this.open(item)}
>
<span class="sidebar-attention__icon" aria-hidden="true">${icons[item.icon]}</span>
<span class="sidebar-attention__label">${item.label}</span>
</button>
<button
type="button"
class="sidebar-attention__dismiss"
title=${t("common.dismiss")}
aria-label=${t("common.dismiss")}
@click=${() => this.dismiss(item)}
>
${icons.x}
</button>
<openclaw-tooltip .content=${item.label}>
<button
type="button"
class="sidebar-attention__open"
@click=${() => this.open(item)}
>
<span class="sidebar-attention__icon" aria-hidden="true"
>${icons[item.icon]}</span
>
<span class="sidebar-attention__label">${item.label}</span>
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("common.dismiss")}>
<button
type="button"
class="sidebar-attention__dismiss"
aria-label=${t("common.dismiss")}
@click=${() => this.dismiss(item)}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
`,
)}
+36 -1
View File
@@ -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)}`;
+25 -1
View File
@@ -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 {
+103
View File
@@ -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<string>>()
.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<string>>()
.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<string>>().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);
});
});
+63
View File
@@ -0,0 +1,63 @@
export function createIdleImport<T>(importModule: () => Promise<T>, onLoaded?: (value: T) => void) {
let moduleLoad: Promise<T> | null = null;
let active = false;
let onlineRetryAttempted = false;
const run = (): Promise<T> => {
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<T> => {
activate();
return run();
},
dispose,
};
}
@@ -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()]);
@@ -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 () => {
@@ -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();
@@ -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 () => {