fix(ui): expose mobile Inbox dismiss actions (#129379)

Keep per-item dismissal visible on touch layouts, add selected-tab Dismiss shown for dismissible entries, and preserve non-dismissible approvals.

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-25 18:09:17 -07:00
committed by GitHub
parent 3d0df74b0f
commit 238c884986
12 changed files with 665 additions and 142 deletions
@@ -121,6 +121,7 @@ export function isSidebarAttentionDismissed(
function pruneDismissals(
dismissals: SidebarAttentionDismissals,
active: readonly SidebarAttentionDismissal[],
scope?: { cronInventoryComplete: boolean; modelAuthAgentId: string | null },
): SidebarAttentionDismissals {
const next: SidebarAttentionDismissals = {};
let changed = false;
@@ -129,9 +130,25 @@ function pruneDismissals(
if (!stored) {
continue;
}
const current = stored.filter((signature) =>
active.some((dismissal) => dismissal.kind === kind && dismissal.signature === signature),
);
const current = stored.filter((signature) => {
// Selected-agent responses are partial: they may re-arm their own auth
// warning, but only an all-agent cron inventory may re-arm cron entries.
const authoritative =
!scope ||
(kind === "modelAuthExpired"
? Boolean(
scope.modelAuthAgentId &&
(!signature.startsWith("agent:") ||
signature.startsWith(`agent:${scope.modelAuthAgentId}\n`)),
)
: kind === "cronFailed" || kind === "cronOverdue"
? scope.cronInventoryComplete
: true);
return (
!authoritative ||
active.some((dismissal) => dismissal.kind === kind && dismissal.signature === signature)
);
});
if (current.length > 0) {
next[kind] = current;
}
@@ -145,9 +162,10 @@ function pruneDismissals(
export function reconcileSidebarAttentionDismissals(params: {
active: readonly SidebarAttentionDismissal[];
gatewayUrl: string;
scope?: { cronInventoryComplete: boolean; modelAuthAgentId: string | null };
}): SidebarAttentionDismissals {
const stored = loadDismissals(params.gatewayUrl);
const pruned = pruneDismissals(stored, params.active);
const pruned = pruneDismissals(stored, params.active, params.scope);
if (pruned !== stored) {
saveDismissals(params.gatewayUrl, pruned);
}
+7 -2
View File
@@ -27,12 +27,17 @@ type SidebarAttentionContent = Omit<SidebarAttentionItem, "category" | "dismissa
export function buildSidebarAttentionEntries(params: {
cronJobs: readonly CronJob[];
cronOwnerByJobId?: ReadonlyMap<string, string>;
modelAuthStatus: ModelAuthStatusResult | null;
modelAuthAgentId?: string | null;
now: number;
}): SidebarAttentionItem[] {
const entries: SidebarAttentionItem[] = [];
const cronJobName = (job: CronJob) => job.name?.trim() || job.id;
const cronMeta = (job: CronJob, status: string, time: string) => {
const context = params.cronOwnerByJobId?.get(job.id);
return { ...(context ? { context } : {}), status, time };
};
const boundedQuestion = (question: string) => clampText(question, ALERT_QUESTION_MAX_LENGTH);
const attentionEntry = (
item: SidebarAttentionContent,
@@ -74,7 +79,7 @@ export function buildSidebarAttentionEntries(params: {
icon: "clock",
label: jobName,
detail: t("attention.automationFailed", { time }),
meta: { status: t("attention.failed"), time },
meta: cronMeta(job, t("attention.failed"), time),
action: { kind: "navigate", routeId: "cron" },
signature: job.id,
},
@@ -110,7 +115,7 @@ export function buildSidebarAttentionEntries(params: {
icon: "clock",
label: jobName,
detail: t("attention.automationOverdue", { time }),
meta: { status: t("attention.overdue"), time },
meta: cronMeta(job, t("attention.overdue"), time),
action: { kind: "navigate", routeId: "cron" },
signature,
},
@@ -129,4 +129,30 @@ describe.runIf("__vitest_browser__" in globalThis)("Inbox panel layout", () => {
expect(getComputedStyle(summary!).paddingBlock).toBe("8px");
expect(item!.getBoundingClientRect().right).toBeCloseTo(list!.getBoundingClientRect().right, 1);
});
it("keeps mobile dismiss actions visible and touch-sized", () => {
const shell = document.createElement("div");
shell.className = "shell shell--mobile-nav";
shell.innerHTML = `
<section class="sidebar-issues-panel">
<header class="sidebar-issues-panel__header">
<button class="sidebar-issues-panel__dismiss-shown" type="button">Dismiss shown</button>
</header>
<div class="sidebar-issues-panel__summary">
<button class="sidebar-issues-panel__dismiss" type="button">Dismiss</button>
</div>
</section>
`;
document.body.append(shell);
const dismiss = shell.querySelector<HTMLElement>(".sidebar-issues-panel__dismiss")!;
const dismissShown = shell.querySelector<HTMLElement>(".sidebar-issues-panel__dismiss-shown")!;
const style = getComputedStyle(dismiss);
expect(style.opacity).toBe("1");
expect(style.pointerEvents).not.toBe("none");
expect(dismiss.getBoundingClientRect().width).toBeGreaterThanOrEqual(40);
expect(dismiss.getBoundingClientRect().height).toBeGreaterThanOrEqual(40);
expect(dismissShown.getBoundingClientRect().height).toBeGreaterThanOrEqual(40);
});
});
@@ -60,6 +60,9 @@ export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams)
const visibleEntries = params.entries.filter((entry) =>
sidebarInboxEntryMatchesTab(entry, params.selectedTab),
);
const visibleDismissals = visibleEntries.flatMap((entry) =>
entry.dismissal ? [entry.dismissal] : [],
);
const tabCounts = sidebarInboxTabCounts(params.entries);
const custodianItems = params.entries.filter(
(entry) => entry.type === "attention" && entry.action.kind === "askCustodian",
@@ -130,19 +133,34 @@ export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams)
>
${t("attention.issues")}
</h2>
${renderSidebarAskOpenClawButton({
count: custodianItems.length,
severity: custodianSeverity,
snapshot: params.context.gateway.snapshot,
})}
<button
type="button"
class="sidebar-brand__icon sidebar-issues-panel__mobile-close"
aria-label=${t("common.close")}
@click=${() => params.onClose(true)}
>
${icons.x}
</button>
<div class="sidebar-issues-panel__header-actions">
${visibleDismissals.length > 0
? html`<button
type="button"
class="btn btn--xs btn--ghost sidebar-issues-panel__dismiss-shown"
@click=${() => {
for (const dismissal of visibleDismissals) {
params.onDismiss(dismissal);
}
}}
>
${t("attention.dismissShown")}
</button>`
: nothing}
${renderSidebarAskOpenClawButton({
count: custodianItems.length,
severity: custodianSeverity,
snapshot: params.context.gateway.snapshot,
})}
<button
type="button"
class="sidebar-brand__icon sidebar-issues-panel__mobile-close"
aria-label=${t("common.close")}
@click=${() => params.onClose(true)}
>
${icons.x}
</button>
</div>
</header>
${renderHubTabs<IssueTab>({
id: "sidebar-issues",
@@ -0,0 +1,87 @@
import type { ApplicationContext } from "../app/context.ts";
import { hasNativeUpdateBridge } from "../app/native-link-routing.ts";
import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts";
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
import {
isUpdateAttentionForced,
resolveUpdateAttentionDismissal,
} from "./sidebar-attention-dismissals.ts";
import type { SidebarAttentionDismissal } from "./sidebar-attention-entries.ts";
type SidebarUpdateContext = Pick<ApplicationContext, "gateway" | "overlays">;
export type SidebarUpdateAttentionState = {
actionable: boolean;
busy: boolean;
canUpdate: boolean;
dismissal: SidebarAttentionDismissal | null;
forced: boolean;
present: boolean;
};
export function resolveSidebarUpdateAttention(
context: SidebarUpdateContext,
): SidebarUpdateAttentionState {
const snapshot = context.overlays.snapshot;
const campaign = snapshot.updateSchedule?.campaign;
const busy =
snapshot.updateRunning ||
snapshot.updateReconciliationPending ||
campaign?.state === "applying";
const canUpdate = canCallGatewayMethod(context.gateway.snapshot, "update.run", "operator.admin");
const canHydrateCampaign = canCallGatewayMethod(
context.gateway.snapshot,
"update.status",
"operator.admin",
);
const campaignPendingHydration =
campaign && !snapshot.updateCampaignStatusHydrated && canHydrateCampaign;
const present = snapshot.updateReconciliationPending
? true
: campaignPendingHydration
? Boolean(snapshot.updateRunning || snapshot.updateStatusBanner)
: Boolean(
snapshot.updateRunning ||
snapshot.updateStatusBanner ||
snapshot.updateAvailable ||
campaign,
);
const dismissal = resolveUpdateAttentionDismissal({
gatewayBootId: context.gateway.snapshot.hello?.server?.bootId,
updateAvailable: snapshot.updateAvailable,
updateSchedule: snapshot.updateSchedule,
});
const forced =
snapshot.updateRunning ||
snapshot.updateReconciliationPending ||
campaign?.state === "applying" ||
isUpdateAttentionForced(snapshot.updateStatusBanner?.tone);
return {
actionable: isUpdateActionable(snapshot.updateAvailable, snapshot.updateSchedule, busy),
busy,
canUpdate,
dismissal,
forced,
present,
};
}
export function startSidebarUpdateAttention(params: {
context: SidebarUpdateContext;
nativeUpdateDeclined: boolean;
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
}) {
const state = resolveSidebarUpdateAttention(params.context);
if (!state.actionable || state.busy || !state.canUpdate) {
return;
}
const snapshot = params.context.overlays.snapshot;
void confirmAndStartUpdate({
startGatewayUpdate: () => void params.context.overlays.runUpdate(),
...(params.watchUpdateProgress ? { watchUpdateProgress: params.watchUpdateProgress } : {}),
updateAvailable: snapshot.updateAvailable,
updateSchedule: snapshot.updateSchedule,
viaNativeApp: !params.nativeUpdateDeclined && hasNativeUpdateBridge(),
});
}
+52 -8
View File
@@ -27,6 +27,7 @@ import {
type SidebarAttentionKind,
} from "./sidebar-attention-entries.ts";
import { buildSidebarAttentionEntries } from "./sidebar-attention-items.ts";
import { resolveSidebarUpdateAttention } from "./sidebar-attention-update.ts";
import "./sidebar-attention.ts";
function deferred<T>() {
@@ -68,7 +69,6 @@ type SidebarAttentionElement = HTMLElement & {
context: ApplicationContext;
updateComplete: Promise<boolean>;
cronJobs: CronJob[];
hasUpdateSurface(): boolean;
startUpdate(): void;
modelAuthStatus: ModelAuthStatusResult | null;
loadedAtMs: number;
@@ -142,6 +142,17 @@ describe("automation attention", () => {
expect(overdue?.label).toBe("stalled-id");
});
it("shows automation owners only when the caller supplies an all-agent owner map", () => {
const item = buildSidebarAttentionEntries({
cronJobs: [cronJob("writer-job")],
cronOwnerByJobId: new Map([["writer-job", "Writer"]]),
modelAuthStatus: null,
now: 0,
})[0];
expect(item?.meta?.context).toBe("Writer");
});
it("orders failed before overdue and newest first within each group", () => {
const failedJob = cronJob("failed");
failedJob.state = { lastRunStatus: "error", lastRunAtMs: 200 };
@@ -267,7 +278,10 @@ describe("sidebar attention refresh ownership", () => {
snapshot: { approvalQueue: [] },
subscribe: () => () => undefined,
} as unknown as ApplicationContext["overlays"];
const selectionState = { selectedId: "main" as string | null };
const selectionState = {
selectedId: "main" as string | null,
scopeId: "main" as string | null,
};
const selectionListeners = new Set<() => void>();
const agentSelection = {
state: selectionState,
@@ -299,8 +313,12 @@ describe("sidebar attention refresh ownership", () => {
expect(request.mock.calls.find(([method]) => method === "models.authStatus")?.[1]).toEqual({
agentId: "main",
});
expect(request.mock.calls.find(([method]) => method === "cron.list")?.[1]).toMatchObject({
agentId: "main",
});
selectionState.selectedId = "writer";
selectionState.scopeId = "writer";
for (const listener of selectionListeners) {
listener();
}
@@ -308,6 +326,9 @@ describe("sidebar attention refresh ownership", () => {
expect(request.mock.calls.filter(([method]) => method === "models.authStatus")[1]?.[1]).toEqual(
{ agentId: "writer" },
);
expect(request.mock.calls.filter(([method]) => method === "cron.list")[1]?.[1]).toMatchObject({
agentId: "writer",
});
const currentAuth = { ts: 2, providers: [] } as ModelAuthStatusResult;
now = 200_000;
@@ -333,6 +354,7 @@ describe("sidebar attention refresh ownership", () => {
expect(localStorage.getItem(dismissalStoreKey(gateway.connection.gatewayUrl))).not.toBeNull();
selectionState.selectedId = null;
selectionState.scopeId = null;
for (const listener of selectionListeners) {
listener();
}
@@ -388,7 +410,10 @@ describe("sidebar attention refresh ownership", () => {
return () => undefined;
},
} as unknown as ApplicationGateway;
const selectionState = { selectedId: "main" as string | null };
const selectionState = {
selectedId: "main" as string | null,
scopeId: "main" as string | null,
};
const selectionListeners = new Set<() => void>();
const provider = createApplicationContextProvider({
gateway,
@@ -412,6 +437,7 @@ describe("sidebar attention refresh ownership", () => {
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
selectionState.selectedId = "writer";
selectionState.scopeId = "writer";
for (const listener of selectionListeners) {
listener();
}
@@ -466,7 +492,7 @@ describe("sidebar attention refresh ownership", () => {
subscribe: () => () => undefined,
} as unknown as ApplicationContext["overlays"];
const agentSelection = {
state: { selectedId: "main" },
state: { selectedId: "main", scopeId: "main" },
subscribe: () => () => undefined,
} as unknown as ApplicationContext["agentSelection"];
vi.stubGlobal("localStorage", createTestStorageMock());
@@ -549,14 +575,14 @@ describe("update attention", () => {
overlays: { snapshot: overlaySnapshot },
} as unknown as ApplicationContext;
expect(element.hasUpdateSurface()).toBe(false);
expect(resolveSidebarUpdateAttention(element.context).present).toBe(false);
gatewaySnapshot.hello.auth.scopes = ["operator.read"];
expect(element.hasUpdateSurface()).toBe(true);
expect(resolveSidebarUpdateAttention(element.context).present).toBe(true);
gatewaySnapshot.hello.auth.scopes = ["operator.admin"];
overlaySnapshot.updateCampaignStatusHydrated = true;
expect(element.hasUpdateSurface()).toBe(true);
expect(resolveSidebarUpdateAttention(element.context).present).toBe(true);
});
it("keeps restart reconciliation visible after update metadata clears", () => {
@@ -574,7 +600,7 @@ describe("update attention", () => {
},
} as unknown as ApplicationContext;
expect(element.hasUpdateSurface()).toBe(true);
expect(resolveSidebarUpdateAttention(element.context).present).toBe(true);
});
it.each([
@@ -646,12 +672,14 @@ describe("reconcileSidebarAttentionDismissals", () => {
const reconcile = (
dismissals: Record<string, string[]>,
active: Array<{ kind: SidebarAttentionKind; signature: string }>,
scope?: { cronInventoryComplete: boolean; modelAuthAgentId: string | null },
) => {
vi.stubGlobal("localStorage", createTestStorageMock());
localStorage.setItem(dismissalStoreKey(gatewayUrl), JSON.stringify(dismissals));
return reconcileSidebarAttentionDismissals({
active,
gatewayUrl,
...(scope ? { scope } : {}),
});
};
@@ -670,6 +698,22 @@ describe("reconcileSidebarAttentionDismissals", () => {
]),
).toEqual({ modelAuthExpired: ["openai"] });
});
it("preserves dismissals outside a selected agent's partial inventory", () => {
expect(
reconcile(
{
cronFailed: ["main-job", "writer-job"],
modelAuthExpired: ["agent:main\nopenai", "agent:writer\nopenai"],
},
[chip("cronFailed", "main-job"), chip("modelAuthExpired", "agent:main\nopenai")],
{ cronInventoryComplete: false, modelAuthAgentId: "main" },
),
).toEqual({
cronFailed: ["main-job", "writer-job"],
modelAuthExpired: ["agent:main\nopenai", "agent:writer\nopenai"],
});
});
});
describe("scope upgrade dismissal fact", () => {
+66 -100
View File
@@ -8,16 +8,16 @@ import type { NavigationRouteId } from "../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../app/context.ts";
import type { ExecApprovalDecision } from "../app/exec-approval.ts";
import {
hasNativeUpdateBridge,
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
NATIVE_UPDATE_DECLINED_EVENT,
} from "../app/native-link-routing.ts";
import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts";
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
import type { UpdateProgress } from "../app/update-confirmation.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import { createInitialCronState, loadCronJobsPage } from "../lib/cron/index.ts";
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
import { loadModelAuthStatus } from "../lib/model-auth.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import "../styles/sidebar-footer-update.css";
@@ -28,10 +28,8 @@ import {
dismissSidebarAttention,
dismissalStoreKey,
isSidebarAttentionDismissed,
isUpdateAttentionForced,
loadDismissals,
reconcileSidebarAttentionDismissals,
resolveUpdateAttentionDismissal,
type SidebarAttentionDismissals,
} from "./sidebar-attention-dismissals.ts";
import {
@@ -47,6 +45,10 @@ import {
compareSidebarAttentionEntries,
} from "./sidebar-attention-items.ts";
import type { SidebarAttentionPanelPosition } from "./sidebar-attention-panel.runtime.ts";
import {
resolveSidebarUpdateAttention,
startSidebarUpdateAttention,
} from "./sidebar-attention-update.ts";
import type { IssueTab } from "./sidebar-issues-tabs.ts";
import "./tooltip.ts";
@@ -54,6 +56,7 @@ type SidebarAttentionPanelRenderer =
typeof import("./sidebar-attention-panel.runtime.ts").renderSidebarAttentionPanel;
type SidebarAttentionPanelRuntime = typeof import("./sidebar-attention-panel.runtime.ts");
type UpdateProgressWatcher = (listener: (progress: UpdateProgress) => void) => () => void;
type SidebarAttentionAgentScope = { selectedId: string | null; scopeId: string | null };
// A visibility change only refetches a connection-scoped stale snapshot.
const VISIBILITY_REFRESH_MIN_AGE_MS = 60_000;
@@ -85,7 +88,7 @@ class SidebarAttention extends OpenClawLightDomElement {
private loadedClient: GatewayBrowserClient | null = null;
private loadedGateway: ApplicationContext["gateway"] | null = null;
private loadedAgentId: string | null = null;
private loadedAgentScope: SidebarAttentionAgentScope | null = null;
// Cron events may restart the combined task; retain the committed auth owner so an
// interrupted agent switch reissues auth instead of displaying the prior agent's alert.
private modelAuthAgentId: string | null = null;
@@ -104,14 +107,15 @@ class SidebarAttention extends OpenClawLightDomElement {
[
null as ApplicationContext["gateway"] | null,
null as GatewayBrowserClient | null,
null as string | null,
null as SidebarAttentionAgentScope | null,
true as boolean,
] as const,
task: async ([gateway, client, agentId, refreshModelAuth], { signal }) => {
if (!gateway || !client) {
task: async ([gateway, client, agentScope, refreshModelAuth], { signal }) => {
if (!gateway || !client || !agentScope) {
return initialState;
}
const cron = createInitialCronState({ client, connected: true });
cron.cronAgentId = agentScope.scopeId;
const loads: Promise<unknown>[] = [
loadCronJobsPage(cron).then(() => {
if (!signal.aborted) {
@@ -119,21 +123,21 @@ class SidebarAttention extends OpenClawLightDomElement {
}
}),
];
if (refreshModelAuth && agentId) {
if (refreshModelAuth && agentScope.selectedId) {
loads.push(
loadModelAuthStatus(client, {
agentId,
agentId: agentScope.selectedId,
signal,
})
.catch(() => null)
.then((modelAuthStatus) => {
if (!signal.aborted) {
this.modelAuthStatus = modelAuthStatus;
this.modelAuthAgentId = agentId;
this.modelAuthAgentId = agentScope.selectedId;
}
}),
);
} else if (!agentId) {
} else if (!agentScope.selectedId) {
this.modelAuthStatus = null;
this.modelAuthAgentId = null;
}
@@ -160,7 +164,7 @@ class SidebarAttention extends OpenClawLightDomElement {
() => {
const gateway = this.context?.gateway;
if (gateway) {
this.synchronize(gateway);
this.synchronize(gateway, { refreshModelAuth: false });
}
},
)
@@ -254,7 +258,7 @@ class SidebarAttention extends OpenClawLightDomElement {
void this.loadTask.run([null, null, null, false]);
this.loadedClient = null;
this.loadedGateway = null;
this.loadedAgentId = null;
this.loadedAgentScope = null;
this.modelAuthAgentId = null;
super.disconnectedCallback();
}
@@ -316,28 +320,37 @@ class SidebarAttention extends OpenClawLightDomElement {
void this.loadTask.run([null, null, null, false]);
this.loadedClient = null;
this.loadedGateway = null;
this.loadedAgentId = null;
this.loadedAgentScope = null;
this.modelAuthAgentId = null;
this.cronJobs = [];
this.modelAuthStatus = null;
return;
}
const agentId = this.context?.agentSelection.state.selectedId ?? null;
const agentScope: SidebarAttentionAgentScope = {
selectedId: this.context?.agentSelection.state.selectedId ?? null,
scopeId: this.context?.agentSelection.state.scopeId ?? null,
};
const loadedAgentScope = this.loadedAgentScope;
if (
gateway === this.loadedGateway &&
snapshot.client === this.loadedClient &&
agentId === this.loadedAgentId
loadedAgentScope &&
agentScope.selectedId === loadedAgentScope.selectedId &&
agentScope.scopeId === loadedAgentScope.scopeId
) {
return;
}
if (loadedAgentScope && agentScope.scopeId !== loadedAgentScope.scopeId) {
this.cronJobs = [];
}
this.loadedGateway = gateway;
this.loadedClient = snapshot.client;
this.loadedAgentId = agentId;
this.loadedAgentScope = agentScope;
void this.loadTask.run([
gateway,
snapshot.client,
agentId,
options.refreshModelAuth !== false || agentId !== this.modelAuthAgentId,
agentScope,
options.refreshModelAuth !== false || agentScope.selectedId !== this.modelAuthAgentId,
]);
}
@@ -352,6 +365,10 @@ class SidebarAttention extends OpenClawLightDomElement {
entry.dismissal ? [entry.dismissal] : [],
),
gatewayUrl: this.dismissedScope,
scope: {
cronInventoryComplete: this.loadedAgentScope?.scopeId === null,
modelAuthAgentId: this.modelAuthAgentId,
},
});
}
@@ -378,50 +395,28 @@ class SidebarAttention extends OpenClawLightDomElement {
private buildAttentionEntries() {
return buildSidebarAttentionEntries({
cronJobs: this.cronJobs,
cronOwnerByJobId: this.cronOwnerByJobId(),
modelAuthStatus: this.modelAuthStatus,
modelAuthAgentId: this.modelAuthAgentId,
now: Date.now(),
});
}
private hasUpdateSurface(): boolean {
const snapshot = this.context?.overlays.snapshot;
if (!snapshot) {
return false;
private cronOwnerByJobId(): ReadonlyMap<string, string> | undefined {
const selection = this.context?.agentSelection.state;
const roster = this.context?.agents?.state.agentsList;
if (!selection || selection.scopeId !== null || !roster) {
return undefined;
}
const campaign = snapshot.updateSchedule?.campaign;
if (snapshot.updateReconciliationPending) {
return true;
}
const canHydrateCampaign = canCallGatewayMethod(
this.context?.gateway.snapshot,
"update.status",
"operator.admin",
const namesByAgentId = new Map(
roster.agents.map((agent) => [normalizeAgentId(agent.id), normalizeAgentLabel(agent)]),
);
if (campaign && !snapshot.updateCampaignStatusHydrated && canHydrateCampaign) {
return Boolean(snapshot.updateRunning || snapshot.updateStatusBanner);
}
return Boolean(
snapshot.updateRunning || snapshot.updateStatusBanner || snapshot.updateAvailable || campaign,
);
}
private updateAttentionDismissal() {
const snapshot = this.context?.overlays.snapshot;
return resolveUpdateAttentionDismissal({
gatewayBootId: this.context?.gateway.snapshot.hello?.server?.bootId,
updateAvailable: snapshot?.updateAvailable,
updateSchedule: snapshot?.updateSchedule,
});
}
private updateSurfaceForced(): boolean {
const snapshot = this.context?.overlays.snapshot;
return (
snapshot?.updateRunning ||
snapshot?.updateReconciliationPending ||
snapshot?.updateSchedule?.campaign?.state === "applying" ||
isUpdateAttentionForced(snapshot?.updateStatusBanner?.tone)
const defaultId = normalizeAgentId(roster.defaultId);
return new Map(
this.cronJobs.map((job) => {
const ownerId = normalizeAgentId(job.agentId ?? defaultId);
return [job.id, namesByAgentId.get(ownerId) ?? ownerId];
}),
);
}
@@ -431,12 +426,13 @@ class SidebarAttention extends OpenClawLightDomElement {
return [];
}
const overlaySnapshot = context.overlays.snapshot;
const updateState = resolveSidebarUpdateAttention(context);
const update = buildUpdateInboxEntry({
canDismiss: canCallGatewayMethod(context.gateway.snapshot, "update.run", "operator.admin"),
dismissal: this.updateAttentionDismissal(),
forced: this.updateSurfaceForced(),
canDismiss: updateState.canUpdate,
dismissal: updateState.dismissal,
forced: updateState.forced,
severity: overlaySnapshot.updateStatusBanner?.tone === "danger" ? "error" : "warning",
visible: this.hasUpdateSurface(),
visible: updateState.present,
});
const scopeUpgrade = buildScopeUpgradeInboxEntry({
scopes: context.gateway.snapshot.hello?.auth?.scopes,
@@ -457,29 +453,13 @@ class SidebarAttention extends OpenClawLightDomElement {
}
private readonly startUpdate = () => {
const context = this.context;
const snapshot = context?.overlays.snapshot;
const campaign = snapshot?.updateSchedule?.campaign;
const busy =
snapshot?.updateRunning ||
snapshot?.updateReconciliationPending ||
campaign?.state === "applying";
if (
!context ||
!snapshot ||
busy ||
!isUpdateActionable(snapshot.updateAvailable, snapshot.updateSchedule, busy) ||
!canCallGatewayMethod(context.gateway.snapshot, "update.run", "operator.admin")
) {
return;
if (this.context) {
startSidebarUpdateAttention({
context: this.context,
nativeUpdateDeclined: this.nativeUpdateDeclined,
watchUpdateProgress: this.watchUpdateProgress,
});
}
void confirmAndStartUpdate({
startGatewayUpdate: () => void context.overlays.runUpdate(),
...(this.watchUpdateProgress ? { watchUpdateProgress: this.watchUpdateProgress } : {}),
updateAvailable: snapshot.updateAvailable,
updateSchedule: snapshot.updateSchedule,
viaNativeApp: !this.nativeUpdateDeclined && hasNativeUpdateBridge(),
});
};
private readonly closeOnOutsidePointer = (event: PointerEvent) => {
@@ -640,21 +620,7 @@ class SidebarAttention extends OpenClawLightDomElement {
const entries = this.currentInboxEntries();
const updateEntry = entries.find((entry) => entry.type === "update");
const updateDismissal = updateEntry?.dismissal ?? null;
const overlaySnapshot = this.context.overlays.snapshot;
const updateBusy =
overlaySnapshot.updateRunning ||
overlaySnapshot.updateReconciliationPending ||
overlaySnapshot.updateSchedule?.campaign?.state === "applying";
const updateActionable = isUpdateActionable(
overlaySnapshot.updateAvailable,
overlaySnapshot.updateSchedule,
updateBusy,
);
const canUpdate = canCallGatewayMethod(
this.context.gateway.snapshot,
"update.run",
"operator.admin",
);
const updateState = resolveSidebarUpdateAttention(this.context);
const count = entries.length;
const label = t(count === 1 ? "attention.issueCount" : "attention.issueCountPlural", {
count: String(count),
@@ -693,11 +659,11 @@ class SidebarAttention extends OpenClawLightDomElement {
type="button"
class="sidebar-footer-update"
aria-label=${t("updates.sidebar.availableTitle")}
?disabled=${updateBusy || !updateActionable || !canUpdate}
?disabled=${updateState.busy || !updateState.actionable || !updateState.canUpdate}
@click=${this.startUpdate}
>
<span class="sidebar-footer-update__icon" aria-hidden="true"
>${updateBusy ? icons.refresh : icons.download}</span
>${updateState.busy ? icons.refresh : icons.download}</span
>
<span class="sidebar-footer-update__label">${t("updates.sidebar.action")}</span>
</button>
+3 -2
View File
@@ -144,7 +144,7 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
const desktopItem = await openLimitedAccessItem(desktopPanel);
await desktopItem.getByRole("button", { name: "Request admin" }).waitFor();
await captureProof(desktop, "desktop-inbox-limited-access.png");
await desktopItem.getByRole("button", { name: "Dismiss Limited access" }).click();
await desktopPanel.getByRole("button", { name: "Dismiss shown" }).click();
await expect.poll(() => desktopInbox.getAttribute("aria-label")).toBe("0 inbox items");
await expect.poll(() => desktopItem.count()).toBe(0);
await desktopPanel.getByRole("tab", { name: "All", exact: true }).waitFor();
@@ -176,7 +176,8 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
const mobilePanel = mobile.locator("#sidebar-issues-panel");
await mobilePanel.waitFor();
await waitForAnimations(mobilePanel);
await openLimitedAccessItem(mobilePanel);
const mobileItem = await openLimitedAccessItem(mobilePanel);
await mobileItem.getByRole("button", { name: "Dismiss Limited access" }).waitFor();
await captureProof(mobile, "mobile-inbox-limited-access.png");
});
@@ -0,0 +1,328 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { Browser, Page } from "playwright";
import { expect } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
type SidebarAttentionScopeFlowOptions = {
artifactDir: string;
baseUrl: string;
browser: Browser;
captureProof: boolean;
};
function visibleDrawerButton(page: Page) {
return page.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible").first();
}
async function captureProof(
params: SidebarAttentionScopeFlowOptions,
page: Page,
fileName: string,
) {
if (!params.captureProof) {
return;
}
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(params.artifactDir, fileName),
});
}
async function holdProof(page: Page, enabled: boolean) {
if (enabled) {
await page.waitForTimeout(600);
}
}
async function setDarkTheme(page: Page) {
await page.emulateMedia({ colorScheme: "dark" });
await page.evaluate(() => {
const root = document.documentElement;
root.dataset.themeMode = "dark";
root.dataset.themeResolved = "dark";
root.classList.remove("wa-light");
root.classList.add("wa-dark");
root.style.colorScheme = "dark";
});
await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe("dark");
}
export async function runSidebarAttentionScopeFlow(params: SidebarAttentionScopeFlowOptions) {
if (params.captureProof) {
await mkdir(params.artifactDir, { recursive: true });
}
const context = await params.browser.newContext({
locale: "en-US",
recordVideo: params.captureProof
? { dir: params.artifactDir, size: { height: 900, width: 1440 } }
: undefined,
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
const proofVideo = page.video();
const failedJob = (id: string, name: string, agentId: string) => ({
id,
agentId,
name,
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "test" },
state: { lastRunStatus: "error", lastError: "Provider request failed" },
});
const mainJob = failedJob("main-release-digest", "Main release digest", "main");
const writerJob = failedJob("writer-release-digest", "Writer release digest", "writer");
const cronResponse = (jobs: Array<ReturnType<typeof failedJob>>) => ({
jobs,
snapshotRevision: `sidebar-agent-scope-${jobs.map((job) => job.id).join("-")}`,
total: jobs.length,
offset: 0,
limit: 50,
hasMore: false,
nextOffset: null,
});
const gateway = await installMockGateway(page, {
methodResponses: {
"agents.list": {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [
{ id: "main", identity: { name: "Main" }, name: "Main" },
{ id: "writer", identity: { name: "Writer" }, name: "Writer" },
],
},
"cron.list": {
cases: [
{ match: { agentId: "main" }, response: cronResponse([mainJob]) },
{ match: { agentId: "writer" }, response: cronResponse([writerJob]) },
{ response: cronResponse([mainJob, writerJob]) },
],
},
"models.authStatus": { providers: [], ts: 1 },
},
});
const waitForCronScope = async (agentId: string | null) => {
await expect
.poll(async () =>
(await gateway.getRequests("cron.list")).some((request) => {
const requestParams = request.params as Record<string, unknown> | undefined;
return agentId === null
? Boolean(requestParams && !Object.hasOwn(requestParams, "agentId"))
: requestParams?.agentId === agentId;
}),
)
.toBe(true);
};
const cronScopeRequestCount = async (agentId: string | null) =>
(await gateway.getRequests("cron.list")).filter((request) => {
const requestParams = request.params as Record<string, unknown> | undefined;
return agentId === null
? Boolean(requestParams && !Object.hasOwn(requestParams, "agentId"))
: requestParams?.agentId === agentId;
}).length;
try {
await page.goto(`${params.baseUrl}new`);
await setDarkTheme(page);
await waitForCronScope("main");
await gateway.emitGatewayEvent("exec.approval.requested", {
id: "approval-global",
createdAtMs: 1_000,
expiresAtMs: Date.now() + 60_000,
request: {
command: "pnpm test:changed",
agentId: "main",
sessionKey: "agent:main:main",
},
});
const sidebar = page.locator("openclaw-app-sidebar");
const automationRows = sidebar.locator('[data-attention-kind="cronFailed"]');
const approvalRow = sidebar.locator('[data-approval-id="approval-global"]');
const openAutomations = async () => {
await sidebar.locator(".sidebar-issues-button").click();
await expect.poll(() => approvalRow.count()).toBe(1);
await sidebar.getByRole("tab", { name: /Automations/ }).click();
};
await openAutomations();
await expect.poll(() => automationRows.getByText("Main release digest").count()).toBe(1);
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(0);
const mainDismiss = automationRows.getByRole("button", {
name: "Dismiss Main release digest",
});
await expect
.poll(() => mainDismiss.evaluate((element) => getComputedStyle(element).opacity))
.toBe("1");
await expect
.poll(() => mainDismiss.evaluate((element) => getComputedStyle(element).pointerEvents))
.toBe("auto");
await holdProof(page, params.captureProof);
await captureProof(params, page, "08-desktop-inbox-main-agent.png");
await sidebar.locator(".sidebar-issues-button").click();
await sidebar.getByRole("button", { name: /Switch agent/ }).click();
await sidebar
.locator('wa-dropdown.sidebar-agent-menu wa-dropdown-item[value="agent:writer"]')
.click();
await waitForCronScope("writer");
await openAutomations();
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(1);
await expect.poll(() => automationRows.getByText("Main release digest").count()).toBe(0);
await holdProof(page, params.captureProof);
await captureProof(params, page, "09-desktop-inbox-writer-agent.png");
await sidebar.locator(".sidebar-issues-button").click();
await sidebar.getByRole("link", { name: "Automations", exact: true }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/automations");
const pageScope = page.locator(".agent-scope-control openclaw-agent-select");
await pageScope.locator(".agent-select__trigger").click();
await pageScope
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: "All agents" })
.click();
await waitForCronScope(null);
await openAutomations();
await expect.poll(() => automationRows.getByText("Main release digest").count()).toBe(1);
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(1);
await expect
.poll(() =>
automationRows
.filter({ hasText: "Main release digest" })
.locator(".sidebar-issues-panel__meta-context")
.textContent(),
)
.toBe("Main");
await expect
.poll(() =>
automationRows
.filter({ hasText: "Writer release digest" })
.locator(".sidebar-issues-panel__meta-context")
.textContent(),
)
.toBe("Writer");
await holdProof(page, params.captureProof);
await captureProof(params, page, "10-desktop-inbox-all-agents.png");
await automationRows
.filter({ hasText: "Writer release digest" })
.getByRole("button", { name: "Dismiss Writer release digest" })
.click();
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(0);
await sidebar.locator(".sidebar-issues-button").click();
const selectPageScope = async (label: string, agentId: string | null) => {
const previousCount = await cronScopeRequestCount(agentId);
await pageScope.locator(".agent-select__trigger").click();
await pageScope
.locator("wa-dropdown-item[data-agent-option]")
.filter({ hasText: label })
.click();
await expect.poll(() => cronScopeRequestCount(agentId)).toBeGreaterThan(previousCount);
};
await selectPageScope("Main", "main");
await selectPageScope("All agents", null);
await openAutomations();
await expect.poll(() => automationRows.getByText("Main release digest").count()).toBe(1);
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(0);
await sidebar.locator(".sidebar-issues-button").click();
const writerJobNext = failedJob(
"writer-release-digest-next",
"Writer release digest",
"writer",
);
await gateway.setMethodResponse("cron.list", {
cases: [
{ match: { agentId: "main" }, response: cronResponse([mainJob]) },
{ match: { agentId: "writer" }, response: cronResponse([writerJobNext]) },
{ response: cronResponse([mainJob, writerJobNext]) },
],
});
const previousAllCount = await cronScopeRequestCount(null);
await gateway.emitGatewayEvent("cron", {});
await expect.poll(() => cronScopeRequestCount(null)).toBeGreaterThan(previousAllCount);
await selectPageScope("Writer", "writer");
await gateway.emitGatewayEvent("update.available", {
schedule: {
autoEnabled: false,
channel: "dev",
install: { kind: "git", git: { status: "behind", commitsBehind: 246 } },
target: {
kind: "git",
commitsBehind: 246,
upstreamRef: "origin/main",
upstreamSha: "9f3c21a0000000000000000000000000000000aa",
},
},
updateAvailable: {
channel: "dev",
commitsBehind: 246,
currentSha: "1111111111111111111111111111111111111111",
currentVersion: "2026.8.1",
latestVersion: "2026.8.1",
upstreamRef: "origin/main",
upstreamSha: "9f3c21a0000000000000000000000000000000aa",
},
});
const sidebarUpdate = sidebar.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(1);
await sidebar.locator(".sidebar-issues-button").click();
await expect.poll(() => sidebarUpdate.count()).toBe(1);
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(1);
await expect.poll(() => automationRows.getByText("Main release digest").count()).toBe(0);
await sidebar.locator(".sidebar-issues-button").click();
await page.setViewportSize({ height: 852, width: 393 });
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--mobile-nav");
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
const floatingKinds = await page
.locator(".sidebar-attention--floating [data-attention-kind]")
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-attention-kind")),
);
await visibleDrawerButton(page).click();
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--nav-drawer-open");
await sidebar.locator(".sidebar-issues-button").click();
await expect.poll(() => sidebarUpdate.isVisible()).toBe(true);
await expect.poll(() => approvalRow.count()).toBe(1);
await sidebar.getByRole("tab", { name: /Automations/ }).click();
await expect.poll(() => automationRows.getByText("Writer release digest").count()).toBe(1);
await expect.poll(() => automationRows.getByText("Main release digest").count()).toBe(0);
await holdProof(page, params.captureProof);
await captureProof(params, page, "11-mobile-inbox-writer-agent.png");
await sidebar.getByRole("button", { name: "Dismiss shown" }).click();
await expect.poll(() => automationRows.count()).toBe(0);
await sidebar.getByRole("tab", { name: /All/ }).click();
await expect.poll(() => approvalRow.count()).toBe(1);
await expect.poll(() => sidebarUpdate.isVisible()).toBe(true);
await holdProof(page, params.captureProof);
await captureProof(params, page, "12-mobile-inbox-dismissed-alerts.png");
expect(floatingKinds).toEqual([]);
} finally {
await context.close();
if (proofVideo) {
await proofVideo.saveAs(path.join(params.artifactDir, "inbox-agent-scope.webm"));
}
}
}
@@ -0,0 +1,21 @@
// Control UI browser proof covers selected-agent and all-agent Inbox automation scope.
import path from "node:path";
import { it } from "vitest";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
import { runSidebarAttentionScopeFlow } from "./sidebar-attention-scope.e2e.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI Inbox automation scope",
startServerBeforeBrowser: true,
});
suite.define(() => {
it("scopes automation attention and bulk dismissal across agents", async () => {
await runSidebarAttentionScopeFlow({
artifactDir: path.join(process.cwd(), ".artifacts", "control-ui-e2e", "inbox-agent-scope"),
baseUrl: suite.server.baseUrl,
browser: suite.browser,
captureProof: false,
});
});
});
+1
View File
@@ -4323,6 +4323,7 @@ export const en: TranslationMap = {
failed: "Failed",
overdue: "Overdue",
dismissItem: "Dismiss {item}",
dismissShown: "Dismiss shown",
emptyTitle: "Nothing waiting",
emptyBody: "New requests and alerts land here.",
issues: "Inbox",
+21 -13
View File
@@ -106,6 +106,17 @@
height: 16px;
}
.sidebar-issues-panel__header-actions {
display: flex;
align-items: center;
gap: 4px;
margin-inline-start: auto;
}
.sidebar-issues-panel__dismiss-shown {
min-height: 28px;
}
.sidebar-issues-panel__ask {
flex: 0 0 32px;
}
@@ -147,6 +158,16 @@
scroll-padding-block-end: 58px;
}
.shell--mobile-nav .sidebar-issues-panel__dismiss {
width: 40px;
height: 40px;
flex-basis: 40px;
}
.shell--mobile-nav .sidebar-issues-panel__dismiss-shown {
min-height: 40px;
}
/* Hub-tab strip (shared hub-tabs.css owns the tab look); this block only
places it in the popover: the track hairline doubles as the header/list
separator, so it must span the full panel width. */
@@ -475,10 +496,7 @@
background: transparent;
color: var(--muted);
cursor: var(--cursor-action);
opacity: 0;
pointer-events: none;
transition:
opacity var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
@@ -488,17 +506,7 @@
height: 14px;
}
.sidebar-issues-panel__summary:focus-within .sidebar-issues-panel__dismiss {
opacity: 1;
pointer-events: auto;
}
@media (hover: hover) {
.sidebar-issues-panel__summary:hover .sidebar-issues-panel__dismiss {
opacity: 1;
pointer-events: auto;
}
.sidebar-issues-panel__dismiss:hover {
background: var(--bg-hover);
color: var(--text-strong);