From 238c884986b54dfab6ec8069a7cb5ea4e7385ba4 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Tue, 25 Aug 2026 18:09:17 -0700 Subject: [PATCH] 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> --- .../sidebar-attention-dismissals.ts | 26 +- ui/src/components/sidebar-attention-items.ts | 9 +- .../sidebar-attention-layout.browser.test.ts | 26 ++ .../sidebar-attention-panel.runtime.ts | 44 ++- ui/src/components/sidebar-attention-update.ts | 87 +++++ ui/src/components/sidebar-attention.test.ts | 60 +++- ui/src/components/sidebar-attention.ts | 166 ++++----- ui/src/e2e/device-scope-upgrade.e2e.test.ts | 5 +- ...idebar-attention-scope.e2e.test-support.ts | 328 ++++++++++++++++++ .../e2e/sidebar-attention-scope.e2e.test.ts | 21 ++ ui/src/i18n/locales/en.ts | 1 + ui/src/styles/sidebar-issues.css | 34 +- 12 files changed, 665 insertions(+), 142 deletions(-) create mode 100644 ui/src/components/sidebar-attention-update.ts create mode 100644 ui/src/e2e/sidebar-attention-scope.e2e.test-support.ts create mode 100644 ui/src/e2e/sidebar-attention-scope.e2e.test.ts diff --git a/ui/src/components/sidebar-attention-dismissals.ts b/ui/src/components/sidebar-attention-dismissals.ts index 463b4549df5a..1b28235f0cee 100644 --- a/ui/src/components/sidebar-attention-dismissals.ts +++ b/ui/src/components/sidebar-attention-dismissals.ts @@ -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); } diff --git a/ui/src/components/sidebar-attention-items.ts b/ui/src/components/sidebar-attention-items.ts index ebc34679c210..7116a862ee63 100644 --- a/ui/src/components/sidebar-attention-items.ts +++ b/ui/src/components/sidebar-attention-items.ts @@ -27,12 +27,17 @@ type SidebarAttentionContent = Omit; 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, }, diff --git a/ui/src/components/sidebar-attention-layout.browser.test.ts b/ui/src/components/sidebar-attention-layout.browser.test.ts index e742cc5bd033..83d9c0df60fb 100644 --- a/ui/src/components/sidebar-attention-layout.browser.test.ts +++ b/ui/src/components/sidebar-attention-layout.browser.test.ts @@ -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 = ` + + `; + document.body.append(shell); + + const dismiss = shell.querySelector(".sidebar-issues-panel__dismiss")!; + const dismissShown = shell.querySelector(".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); + }); }); diff --git a/ui/src/components/sidebar-attention-panel.runtime.ts b/ui/src/components/sidebar-attention-panel.runtime.ts index 70687773a4b6..305f68ab371c 100644 --- a/ui/src/components/sidebar-attention-panel.runtime.ts +++ b/ui/src/components/sidebar-attention-panel.runtime.ts @@ -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")} - ${renderSidebarAskOpenClawButton({ - count: custodianItems.length, - severity: custodianSeverity, - snapshot: params.context.gateway.snapshot, - })} - + ${renderHubTabs({ id: "sidebar-issues", diff --git a/ui/src/components/sidebar-attention-update.ts b/ui/src/components/sidebar-attention-update.ts new file mode 100644 index 000000000000..f00595b43f03 --- /dev/null +++ b/ui/src/components/sidebar-attention-update.ts @@ -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; + +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(), + }); +} diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts index 7ddb4d7dc276..020812961aac 100644 --- a/ui/src/components/sidebar-attention.test.ts +++ b/ui/src/components/sidebar-attention.test.ts @@ -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() { @@ -68,7 +69,6 @@ type SidebarAttentionElement = HTMLElement & { context: ApplicationContext; updateComplete: Promise; 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, 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", () => { diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts index 08687c71e4ab..5f38bb59d728 100644 --- a/ui/src/components/sidebar-attention.ts +++ b/ui/src/components/sidebar-attention.ts @@ -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[] = [ 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 | 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} > ${updateState.busy ? icons.refresh : icons.download} ${t("updates.sidebar.action")} diff --git a/ui/src/e2e/device-scope-upgrade.e2e.test.ts b/ui/src/e2e/device-scope-upgrade.e2e.test.ts index 800c8bc8b197..42b268a4127c 100644 --- a/ui/src/e2e/device-scope-upgrade.e2e.test.ts +++ b/ui/src/e2e/device-scope-upgrade.e2e.test.ts @@ -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"); }); diff --git a/ui/src/e2e/sidebar-attention-scope.e2e.test-support.ts b/ui/src/e2e/sidebar-attention-scope.e2e.test-support.ts new file mode 100644 index 000000000000..5c49717e9f58 --- /dev/null +++ b/ui/src/e2e/sidebar-attention-scope.e2e.test-support.ts @@ -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>) => ({ + 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 | 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 | 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((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")); + } + } +} diff --git a/ui/src/e2e/sidebar-attention-scope.e2e.test.ts b/ui/src/e2e/sidebar-attention-scope.e2e.test.ts new file mode 100644 index 000000000000..563715e4947b --- /dev/null +++ b/ui/src/e2e/sidebar-attention-scope.e2e.test.ts @@ -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, + }); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2215c562b771..05cc9cf3d978 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/styles/sidebar-issues.css b/ui/src/styles/sidebar-issues.css index c70949c4e8cd..071a9c391591 100644 --- a/ui/src/styles/sidebar-issues.css +++ b/ui/src/styles/sidebar-issues.css @@ -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);