mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user