feat(ui): dismissible sidebar attention chips (#106223)

* feat(ui): dismissible sidebar attention chips

* fix(ui): use toSorted for attention signatures

* refactor(ui): move attention dismissal store to its own module

* fix(ui): make attention dismissals safe across tabs

* style(ui): drop redundant String() in storage mock

* fix(ui): prune dismissals only after fresh data and key overdue snoozes by incident
This commit is contained in:
Peter Steinberger
2026-07-13 05:11:59 -07:00
committed by GitHub
parent 200103a72b
commit 3e21c2f256
4 changed files with 344 additions and 25 deletions
@@ -0,0 +1,104 @@
// Per-gateway, per-browser snooze state for the sidebar attention chips.
// Deliberately client-side chrome (like nav width / dock layout), not gateway
// state: dismissing a nag on one device should not acknowledge it everywhere.
import { normalizeGatewayTokenScope } from "../app/gateway-scope.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
const SIDEBAR_ATTENTION_KINDS = [
"cronFailed",
"cronOverdue",
"modelAuthExpired",
"modelAuthExpiring",
] as const;
export type SidebarAttentionKind = (typeof SIDEBAR_ATTENTION_KINDS)[number];
export type SidebarAttentionDismissals = Partial<Record<SidebarAttentionKind, string>>;
// Minimal chip shape the snooze logic needs; keeps this module free of the
// component's item type so the two files cannot form an import cycle.
type DismissableChip = { kind: SidebarAttentionKind; signature: string };
const DISMISSED_STORE_PREFIX = "openclaw.control.sidebarAttention.v1:";
export function dismissalStoreKey(gatewayUrl: string): string {
return `${DISMISSED_STORE_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
}
export function loadDismissals(gatewayUrl: string): SidebarAttentionDismissals {
const storage = getSafeLocalStorage();
if (!storage) {
return {};
}
try {
const parsed: unknown = JSON.parse(storage.getItem(dismissalStoreKey(gatewayUrl)) ?? "null");
if (!parsed || typeof parsed !== "object") {
return {};
}
const result: SidebarAttentionDismissals = {};
for (const kind of SIDEBAR_ATTENTION_KINDS) {
const value = (parsed as Record<string, unknown>)[kind];
if (typeof value === "string") {
result[kind] = value;
}
}
return result;
} catch {
return {};
}
}
export function saveDismissals(gatewayUrl: string, dismissals: SidebarAttentionDismissals) {
const storage = getSafeLocalStorage();
if (!storage) {
return;
}
try {
if (Object.keys(dismissals).length === 0) {
storage.removeItem(dismissalStoreKey(gatewayUrl));
} else {
storage.setItem(dismissalStoreKey(gatewayUrl), JSON.stringify(dismissals));
}
} catch {
// Quota/privacy-mode failures just lose the snooze; chips reappear.
}
}
/**
* Record one dismissal via read-merge-write against the persisted map, not a
* caller-held snapshot: another tab may have dismissed a different chip since
* this tab last loaded, and a blind write would drop that entry.
*/
export function addDismissal(
gatewayUrl: string,
kind: SidebarAttentionKind,
signature: string,
): SidebarAttentionDismissals {
const next = { ...loadDismissals(gatewayUrl), [kind]: signature };
saveDismissals(gatewayUrl, next);
return next;
}
/**
* Drop dismissals whose chip is gone or whose entity set changed, so a state
* that clears and later recurs surfaces again instead of staying hidden by a
* stale snooze. Returns the input object when nothing changed.
*/
export function pruneDismissals(
dismissals: SidebarAttentionDismissals,
items: readonly DismissableChip[],
): SidebarAttentionDismissals {
const next: SidebarAttentionDismissals = {};
let changed = false;
for (const kind of SIDEBAR_ATTENTION_KINDS) {
const stored = dismissals[kind];
if (stored === undefined) {
continue;
}
if (items.some((item) => item.kind === kind && item.signature === stored)) {
next[kind] = stored;
} else {
changed = true;
}
}
return changed ? next : dismissals;
}
+85 -7
View File
@@ -1,7 +1,13 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CronJob, ModelAuthStatusResult } from "../api/types.ts";
import {
addDismissal,
dismissalStoreKey,
pruneDismissals,
type SidebarAttentionKind,
} from "./sidebar-attention-dismissals.ts";
import { buildSidebarAttentionItems } from "./sidebar-attention.ts";
const NOW = 1_750_000_000_000;
@@ -34,18 +40,25 @@ describe("buildSidebarAttentionItems", () => {
it("flags enabled failing cron jobs but not disabled ones", () => {
const items = buildSidebarAttentionItems({
cronJobs: [
cronJob({ state: { lastRunStatus: "error" } as CronJob["state"] }),
cronJob({ enabled: false, state: { lastRunStatus: "error" } as CronJob["state"] }),
cronJob({ id: "beta", state: { lastRunStatus: "error" } as CronJob["state"] }),
cronJob({ id: "alpha", state: { lastRunStatus: "error" } as CronJob["state"] }),
cronJob({
id: "off",
enabled: false,
state: { lastRunStatus: "error" } as CronJob["state"],
}),
],
modelAuthStatus: null,
now: NOW,
});
expect(items).toEqual([
{
kind: "cronFailed",
severity: "error",
icon: "clock",
label: "1 cron job(s) failed",
label: "2 cron job(s) failed",
routeId: "cron",
signature: "alpha\nbeta",
},
]);
});
@@ -53,19 +66,25 @@ describe("buildSidebarAttentionItems", () => {
it("flags overdue jobs only past the grace window", () => {
const items = buildSidebarAttentionItems({
cronJobs: [
cronJob({ state: { nextRunAtMs: NOW - 400_000 } as CronJob["state"] }),
cronJob({ state: { nextRunAtMs: NOW - 100_000 } as CronJob["state"] }),
cronJob({ enabled: false, state: { nextRunAtMs: NOW - 400_000 } as CronJob["state"] }),
cronJob({ id: "late", state: { nextRunAtMs: NOW - 400_000 } as CronJob["state"] }),
cronJob({ id: "soon", state: { nextRunAtMs: NOW - 100_000 } as CronJob["state"] }),
cronJob({
id: "off",
enabled: false,
state: { nextRunAtMs: NOW - 400_000 } as CronJob["state"],
}),
],
modelAuthStatus: null,
now: NOW,
});
expect(items).toEqual([
{
kind: "cronOverdue",
severity: "warning",
icon: "clock",
label: "1 cron job(s) overdue",
routeId: "cron",
signature: `late@${NOW - 400_000}`,
},
]);
});
@@ -99,17 +118,76 @@ describe("buildSidebarAttentionItems", () => {
});
expect(items).toEqual([
{
kind: "modelAuthExpired",
severity: "error",
icon: "plug",
label: "Model auth expired: Codex",
routeId: "model-providers",
signature: "openai",
},
{
kind: "modelAuthExpiring",
severity: "warning",
icon: "plug",
label: "Model auth expiring: Claude (6d)",
routeId: "model-providers",
signature: "anthropic",
},
]);
});
});
describe("pruneDismissals", () => {
const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature });
it("keeps a dismissal while the same entity set is still affected", () => {
const dismissals = { cronFailed: "alpha\nbeta" };
expect(pruneDismissals(dismissals, [chip("cronFailed", "alpha\nbeta")])).toBe(dismissals);
});
it("drops a dismissal when the affected set changes so the chip resurfaces", () => {
expect(
pruneDismissals({ cronFailed: "alpha", modelAuthExpired: "openai" }, [
chip("cronFailed", "alpha\nbeta"),
chip("modelAuthExpired", "openai"),
]),
).toEqual({ modelAuthExpired: "openai" });
});
it("drops a dismissal once the underlying state clears", () => {
expect(pruneDismissals({ cronFailed: "alpha" }, [])).toEqual({});
});
});
describe("addDismissal", () => {
function createStorageMock(): Storage {
const map = new Map<string, string>();
return {
get length() {
return map.size;
},
clear: () => map.clear(),
getItem: (key: string) => map.get(key) ?? null,
key: (index: number) => [...map.keys()][index] ?? null,
removeItem: (key: string) => void map.delete(key),
setItem: (key: string, value: string) => void map.set(key, value),
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("merges with the persisted map so another tab's dismissal survives", () => {
vi.stubGlobal("localStorage", createStorageMock());
const key = dismissalStoreKey("ws://gateway.test");
// Another tab dismissed a cron chip after this tab last loaded.
localStorage.setItem(key, JSON.stringify({ cronFailed: "alpha" }));
const next = addDismissal("ws://gateway.test", "modelAuthExpired", "openai");
const expected = { cronFailed: "alpha", modelAuthExpired: "openai" };
expect(next).toEqual(expected);
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual(expected);
});
});
+112 -16
View File
@@ -16,6 +16,15 @@ import { isMonitoredAuthProvider, loadModelAuthStatus } from "../lib/model-auth.
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { icons, type IconName } from "./icons.ts";
import {
addDismissal,
dismissalStoreKey,
loadDismissals,
pruneDismissals,
saveDismissals,
type SidebarAttentionDismissals,
type SidebarAttentionKind,
} from "./sidebar-attention-dismissals.ts";
// A cron job counts as overdue when its next planned run is this far in the
// past; mirrors the threshold the Overview attention list used.
@@ -28,10 +37,21 @@ const VISIBILITY_REFRESH_MIN_AGE_MS = 60_000;
const IDLE_REFRESH_INTERVAL_MS = 10 * 60_000;
export type SidebarAttentionItem = {
kind: SidebarAttentionKind;
severity: "error" | "warning";
icon: IconName;
label: string;
routeId: NavigationRouteId;
// Sorted identities of the entities behind the chip. A dismissal stores
// this signature so the chip stays hidden only while the same incident set
// is affected; any change (new job/provider, new overdue run) resurfaces
// it. Failed-cron and auth chips key on entity ids alone on purpose: a
// persistently failing job gets a new lastRunAtMs every schedule tick, and
// short-lived OAuth tokens (e.g. Copilot) roll expiry continuously — either
// in the signature would resurface a dismissed chip within minutes. The
// cost is that a recover-then-recur cycle nobody observed stays snoozed;
// pruneAfterRefresh re-arms as soon as any tab sees the cleared state.
signature: string;
};
export function buildSidebarAttentionItems(params: {
@@ -40,14 +60,17 @@ export function buildSidebarAttentionItems(params: {
now: number;
}): SidebarAttentionItem[] {
const items: SidebarAttentionItem[] = [];
const signatureOf = (ids: readonly string[]) => ids.toSorted().join("\n");
const failedCron = params.cronJobs.filter(isCronJobActiveFailure).length;
if (failedCron > 0) {
const failedCron = params.cronJobs.filter(isCronJobActiveFailure);
if (failedCron.length > 0) {
items.push({
kind: "cronFailed",
severity: "error",
icon: "clock",
label: t("attention.cronFailed", { count: String(failedCron) }),
label: t("attention.cronFailed", { count: String(failedCron.length) }),
routeId: "cron",
signature: signatureOf(failedCron.map((job) => job.id)),
});
}
const overdueCron = params.cronJobs.filter(
@@ -55,13 +78,18 @@ export function buildSidebarAttentionItems(params: {
job.enabled &&
job.state?.nextRunAtMs != null &&
params.now - job.state.nextRunAtMs > CRON_OVERDUE_GRACE_MS,
).length;
if (overdueCron > 0) {
);
if (overdueCron.length > 0) {
items.push({
kind: "cronOverdue",
severity: "warning",
icon: "clock",
label: t("attention.cronOverdue", { count: String(overdueCron) }),
label: t("attention.cronOverdue", { count: String(overdueCron.length) }),
routeId: "cron",
// nextRunAtMs is the incident identity: stable while a job stays stuck,
// new once it runs again and later goes overdue anew — so a fresh
// overdue episode resurfaces even if no tab observed the recovery.
signature: signatureOf(overdueCron.map((job) => `${job.id}@${job.state?.nextRunAtMs}`)),
});
}
@@ -71,17 +99,20 @@ export function buildSidebarAttentionItems(params: {
);
if (expired.length > 0) {
items.push({
kind: "modelAuthExpired",
severity: "error",
icon: "plug",
label: t("attention.modelAuthExpired", {
providers: expired.map((provider) => provider.displayName).join(", "),
}),
routeId: "model-providers",
signature: signatureOf(expired.map((provider) => provider.provider)),
});
}
const expiring = monitored.filter((provider) => provider.status === "expiring");
if (expiring.length > 0) {
items.push({
kind: "modelAuthExpiring",
severity: "warning",
icon: "plug",
label: t("attention.modelAuthExpiring", {
@@ -90,6 +121,7 @@ export function buildSidebarAttentionItems(params: {
.join(", "),
}),
routeId: "model-providers",
signature: signatureOf(expiring.map((provider) => provider.provider)),
});
}
return items;
@@ -101,11 +133,13 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
@state() private cronJobs: CronJob[] = [];
@state() private modelAuthStatus: ModelAuthStatusResult | null = null;
@state() private dismissed: SidebarAttentionDismissals = {};
@property({ attribute: false }) onNavigate?: (routeId: NavigationRouteId) => void;
private loadedClient: GatewayBrowserClient | null = null;
private loadedAtMs = 0;
private dismissedScope: string | null = null;
private idleRefreshTimer: ReturnType<typeof globalThis.setInterval> | null = null;
private readonly subscriptions = new SubscriptionsController(this).effect(
@@ -116,6 +150,17 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
},
);
// Cross-tab sync: another tab's dismiss/prune fires "storage" here, so this
// tab re-reads instead of rendering (or later writing) a stale snapshot.
private readonly syncDismissalsFromStorage = (event: StorageEvent) => {
if (!this.dismissedScope) {
return;
}
if (event.key === null || event.key === dismissalStoreKey(this.dismissedScope)) {
this.dismissed = loadDismissals(this.dismissedScope);
}
};
private readonly refreshIfStale = () => {
if (document.visibilityState !== "visible") {
return;
@@ -130,11 +175,13 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
override connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this.refreshIfStale);
globalThis.addEventListener("storage", this.syncDismissalsFromStorage);
this.idleRefreshTimer = globalThis.setInterval(this.refreshIfStale, IDLE_REFRESH_INTERVAL_MS);
}
override disconnectedCallback() {
document.removeEventListener("visibilitychange", this.refreshIfStale);
globalThis.removeEventListener("storage", this.syncDismissalsFromStorage);
if (this.idleRefreshTimer !== null) {
globalThis.clearInterval(this.idleRefreshTimer);
this.idleRefreshTimer = null;
@@ -146,6 +193,11 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
private synchronize(gateway: ApplicationContext["gateway"]) {
const snapshot = gateway.snapshot;
const gatewayUrl = gateway.connection.gatewayUrl;
if (gatewayUrl && gatewayUrl !== this.dismissedScope) {
this.dismissedScope = gatewayUrl;
this.dismissed = loadDismissals(gatewayUrl);
}
if (!snapshot.connected || !snapshot.client) {
this.loadedClient = null;
this.cronJobs = [];
@@ -182,9 +234,42 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
]);
if (isCurrent()) {
this.loadedAtMs = Date.now();
this.pruneAfterRefresh();
}
}
// Re-arm stale snoozes only right after this tab's own data refresh: fresh
// data is the only safe basis for deciding a chip is gone. Pruning from
// render/update hooks would let a hidden tab with stale data clobber a
// dismissal another tab just wrote (its storage event triggers an update
// here). Against the persisted map, not the in-memory snapshot, for the
// same lost-update reason as addDismissal. A failed fetch (empty cron list,
// null auth status) prunes those kinds, which fails safe — re-nag, never
// stay hidden.
private pruneAfterRefresh() {
if (!this.dismissedScope) {
return;
}
const items = buildSidebarAttentionItems({
cronJobs: this.cronJobs,
modelAuthStatus: this.modelAuthStatus,
now: Date.now(),
});
const stored = loadDismissals(this.dismissedScope);
const pruned = pruneDismissals(stored, items);
if (pruned !== stored) {
saveDismissals(this.dismissedScope, pruned);
}
this.dismissed = pruned;
}
private dismiss(item: SidebarAttentionItem) {
if (!this.dismissedScope) {
return;
}
this.dismissed = addDismissal(this.dismissedScope, item.kind, item.signature);
}
override render() {
if (!this.context?.gateway.snapshot.connected) {
return nothing;
@@ -193,7 +278,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
cronJobs: this.cronJobs,
modelAuthStatus: this.modelAuthStatus,
now: Date.now(),
});
}).filter((item) => this.dismissed[item.kind] !== item.signature);
if (items.length === 0) {
return nothing;
}
@@ -201,15 +286,26 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
<div class="sidebar-attention" role="status">
${items.map(
(item) => html`
<button
type="button"
class="sidebar-attention__item sidebar-attention__item--${item.severity}"
title=${item.label}
@click=${() => this.onNavigate?.(item.routeId)}
>
<span class="sidebar-attention__icon" aria-hidden="true">${icons[item.icon]}</span>
<span class="sidebar-attention__label">${item.label}</span>
</button>
<div class="sidebar-attention__item sidebar-attention__item--${item.severity}">
<button
type="button"
class="sidebar-attention__open"
title=${item.label}
@click=${() => this.onNavigate?.(item.routeId)}
>
<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>
</div>
`,
)}
</div>
+43 -2
View File
@@ -958,18 +958,59 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
.sidebar-attention__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-hover);
color: var(--text);
font-size: 12px;
}
/* Navigate + dismiss are sibling buttons inside the pill (buttons can't nest);
the open button carries the click padding so the whole pill stays a target. */
.sidebar-attention__open {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: center;
gap: 8px;
padding: 6px 0 6px 8px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.sidebar-attention__dismiss {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
padding: 6px 8px 6px 6px;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
opacity: 0.6;
}
.sidebar-attention__dismiss:hover {
color: var(--text);
opacity: 1;
}
.sidebar-attention__dismiss svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-attention__item--warning {
border-color: var(--warning-subtle, rgba(234, 179, 8, 0.25));
background: rgba(234, 179, 8, 0.06);