mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
200103a72b
commit
3e21c2f256
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user