Skip to content

Commit 238c884

Browse files
fix(ui): expose mobile Inbox dismiss actions (openclaw#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>
1 parent 3d0df74 commit 238c884

12 files changed

Lines changed: 665 additions & 142 deletions

ui/src/components/sidebar-attention-dismissals.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export function isSidebarAttentionDismissed(
121121
function pruneDismissals(
122122
dismissals: SidebarAttentionDismissals,
123123
active: readonly SidebarAttentionDismissal[],
124+
scope?: { cronInventoryComplete: boolean; modelAuthAgentId: string | null },
124125
): SidebarAttentionDismissals {
125126
const next: SidebarAttentionDismissals = {};
126127
let changed = false;
@@ -129,9 +130,25 @@ function pruneDismissals(
129130
if (!stored) {
130131
continue;
131132
}
132-
const current = stored.filter((signature) =>
133-
active.some((dismissal) => dismissal.kind === kind && dismissal.signature === signature),
134-
);
133+
const current = stored.filter((signature) => {
134+
// Selected-agent responses are partial: they may re-arm their own auth
135+
// warning, but only an all-agent cron inventory may re-arm cron entries.
136+
const authoritative =
137+
!scope ||
138+
(kind === "modelAuthExpired"
139+
? Boolean(
140+
scope.modelAuthAgentId &&
141+
(!signature.startsWith("agent:") ||
142+
signature.startsWith(`agent:${scope.modelAuthAgentId}\n`)),
143+
)
144+
: kind === "cronFailed" || kind === "cronOverdue"
145+
? scope.cronInventoryComplete
146+
: true);
147+
return (
148+
!authoritative ||
149+
active.some((dismissal) => dismissal.kind === kind && dismissal.signature === signature)
150+
);
151+
});
135152
if (current.length > 0) {
136153
next[kind] = current;
137154
}
@@ -145,9 +162,10 @@ function pruneDismissals(
145162
export function reconcileSidebarAttentionDismissals(params: {
146163
active: readonly SidebarAttentionDismissal[];
147164
gatewayUrl: string;
165+
scope?: { cronInventoryComplete: boolean; modelAuthAgentId: string | null };
148166
}): SidebarAttentionDismissals {
149167
const stored = loadDismissals(params.gatewayUrl);
150-
const pruned = pruneDismissals(stored, params.active);
168+
const pruned = pruneDismissals(stored, params.active, params.scope);
151169
if (pruned !== stored) {
152170
saveDismissals(params.gatewayUrl, pruned);
153171
}

ui/src/components/sidebar-attention-items.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,17 @@ type SidebarAttentionContent = Omit<SidebarAttentionItem, "category" | "dismissa
2727

2828
export function buildSidebarAttentionEntries(params: {
2929
cronJobs: readonly CronJob[];
30+
cronOwnerByJobId?: ReadonlyMap<string, string>;
3031
modelAuthStatus: ModelAuthStatusResult | null;
3132
modelAuthAgentId?: string | null;
3233
now: number;
3334
}): SidebarAttentionItem[] {
3435
const entries: SidebarAttentionItem[] = [];
3536
const cronJobName = (job: CronJob) => job.name?.trim() || job.id;
37+
const cronMeta = (job: CronJob, status: string, time: string) => {
38+
const context = params.cronOwnerByJobId?.get(job.id);
39+
return { ...(context ? { context } : {}), status, time };
40+
};
3641
const boundedQuestion = (question: string) => clampText(question, ALERT_QUESTION_MAX_LENGTH);
3742
const attentionEntry = (
3843
item: SidebarAttentionContent,
@@ -74,7 +79,7 @@ export function buildSidebarAttentionEntries(params: {
7479
icon: "clock",
7580
label: jobName,
7681
detail: t("attention.automationFailed", { time }),
77-
meta: { status: t("attention.failed"), time },
82+
meta: cronMeta(job, t("attention.failed"), time),
7883
action: { kind: "navigate", routeId: "cron" },
7984
signature: job.id,
8085
},
@@ -110,7 +115,7 @@ export function buildSidebarAttentionEntries(params: {
110115
icon: "clock",
111116
label: jobName,
112117
detail: t("attention.automationOverdue", { time }),
113-
meta: { status: t("attention.overdue"), time },
118+
meta: cronMeta(job, t("attention.overdue"), time),
114119
action: { kind: "navigate", routeId: "cron" },
115120
signature,
116121
},

ui/src/components/sidebar-attention-layout.browser.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,30 @@ describe.runIf("__vitest_browser__" in globalThis)("Inbox panel layout", () => {
129129
expect(getComputedStyle(summary!).paddingBlock).toBe("8px");
130130
expect(item!.getBoundingClientRect().right).toBeCloseTo(list!.getBoundingClientRect().right, 1);
131131
});
132+
133+
it("keeps mobile dismiss actions visible and touch-sized", () => {
134+
const shell = document.createElement("div");
135+
shell.className = "shell shell--mobile-nav";
136+
shell.innerHTML = `
137+
<section class="sidebar-issues-panel">
138+
<header class="sidebar-issues-panel__header">
139+
<button class="sidebar-issues-panel__dismiss-shown" type="button">Dismiss shown</button>
140+
</header>
141+
<div class="sidebar-issues-panel__summary">
142+
<button class="sidebar-issues-panel__dismiss" type="button">Dismiss</button>
143+
</div>
144+
</section>
145+
`;
146+
document.body.append(shell);
147+
148+
const dismiss = shell.querySelector<HTMLElement>(".sidebar-issues-panel__dismiss")!;
149+
const dismissShown = shell.querySelector<HTMLElement>(".sidebar-issues-panel__dismiss-shown")!;
150+
const style = getComputedStyle(dismiss);
151+
152+
expect(style.opacity).toBe("1");
153+
expect(style.pointerEvents).not.toBe("none");
154+
expect(dismiss.getBoundingClientRect().width).toBeGreaterThanOrEqual(40);
155+
expect(dismiss.getBoundingClientRect().height).toBeGreaterThanOrEqual(40);
156+
expect(dismissShown.getBoundingClientRect().height).toBeGreaterThanOrEqual(40);
157+
});
132158
});

ui/src/components/sidebar-attention-panel.runtime.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams)
6060
const visibleEntries = params.entries.filter((entry) =>
6161
sidebarInboxEntryMatchesTab(entry, params.selectedTab),
6262
);
63+
const visibleDismissals = visibleEntries.flatMap((entry) =>
64+
entry.dismissal ? [entry.dismissal] : [],
65+
);
6366
const tabCounts = sidebarInboxTabCounts(params.entries);
6467
const custodianItems = params.entries.filter(
6568
(entry) => entry.type === "attention" && entry.action.kind === "askCustodian",
@@ -130,19 +133,34 @@ export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams)
130133
>
131134
${t("attention.issues")}
132135
</h2>
133-
${renderSidebarAskOpenClawButton({
134-
count: custodianItems.length,
135-
severity: custodianSeverity,
136-
snapshot: params.context.gateway.snapshot,
137-
})}
138-
<button
139-
type="button"
140-
class="sidebar-brand__icon sidebar-issues-panel__mobile-close"
141-
aria-label=${t("common.close")}
142-
@click=${() => params.onClose(true)}
143-
>
144-
${icons.x}
145-
</button>
136+
<div class="sidebar-issues-panel__header-actions">
137+
${visibleDismissals.length > 0
138+
? html`<button
139+
type="button"
140+
class="btn btn--xs btn--ghost sidebar-issues-panel__dismiss-shown"
141+
@click=${() => {
142+
for (const dismissal of visibleDismissals) {
143+
params.onDismiss(dismissal);
144+
}
145+
}}
146+
>
147+
${t("attention.dismissShown")}
148+
</button>`
149+
: nothing}
150+
${renderSidebarAskOpenClawButton({
151+
count: custodianItems.length,
152+
severity: custodianSeverity,
153+
snapshot: params.context.gateway.snapshot,
154+
})}
155+
<button
156+
type="button"
157+
class="sidebar-brand__icon sidebar-issues-panel__mobile-close"
158+
aria-label=${t("common.close")}
159+
@click=${() => params.onClose(true)}
160+
>
161+
${icons.x}
162+
</button>
163+
</div>
146164
</header>
147165
${renderHubTabs<IssueTab>({
148166
id: "sidebar-issues",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { ApplicationContext } from "../app/context.ts";
2+
import { hasNativeUpdateBridge } from "../app/native-link-routing.ts";
3+
import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts";
4+
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
5+
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
6+
import {
7+
isUpdateAttentionForced,
8+
resolveUpdateAttentionDismissal,
9+
} from "./sidebar-attention-dismissals.ts";
10+
import type { SidebarAttentionDismissal } from "./sidebar-attention-entries.ts";
11+
12+
type SidebarUpdateContext = Pick<ApplicationContext, "gateway" | "overlays">;
13+
14+
export type SidebarUpdateAttentionState = {
15+
actionable: boolean;
16+
busy: boolean;
17+
canUpdate: boolean;
18+
dismissal: SidebarAttentionDismissal | null;
19+
forced: boolean;
20+
present: boolean;
21+
};
22+
23+
export function resolveSidebarUpdateAttention(
24+
context: SidebarUpdateContext,
25+
): SidebarUpdateAttentionState {
26+
const snapshot = context.overlays.snapshot;
27+
const campaign = snapshot.updateSchedule?.campaign;
28+
const busy =
29+
snapshot.updateRunning ||
30+
snapshot.updateReconciliationPending ||
31+
campaign?.state === "applying";
32+
const canUpdate = canCallGatewayMethod(context.gateway.snapshot, "update.run", "operator.admin");
33+
const canHydrateCampaign = canCallGatewayMethod(
34+
context.gateway.snapshot,
35+
"update.status",
36+
"operator.admin",
37+
);
38+
const campaignPendingHydration =
39+
campaign && !snapshot.updateCampaignStatusHydrated && canHydrateCampaign;
40+
const present = snapshot.updateReconciliationPending
41+
? true
42+
: campaignPendingHydration
43+
? Boolean(snapshot.updateRunning || snapshot.updateStatusBanner)
44+
: Boolean(
45+
snapshot.updateRunning ||
46+
snapshot.updateStatusBanner ||
47+
snapshot.updateAvailable ||
48+
campaign,
49+
);
50+
const dismissal = resolveUpdateAttentionDismissal({
51+
gatewayBootId: context.gateway.snapshot.hello?.server?.bootId,
52+
updateAvailable: snapshot.updateAvailable,
53+
updateSchedule: snapshot.updateSchedule,
54+
});
55+
const forced =
56+
snapshot.updateRunning ||
57+
snapshot.updateReconciliationPending ||
58+
campaign?.state === "applying" ||
59+
isUpdateAttentionForced(snapshot.updateStatusBanner?.tone);
60+
return {
61+
actionable: isUpdateActionable(snapshot.updateAvailable, snapshot.updateSchedule, busy),
62+
busy,
63+
canUpdate,
64+
dismissal,
65+
forced,
66+
present,
67+
};
68+
}
69+
70+
export function startSidebarUpdateAttention(params: {
71+
context: SidebarUpdateContext;
72+
nativeUpdateDeclined: boolean;
73+
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
74+
}) {
75+
const state = resolveSidebarUpdateAttention(params.context);
76+
if (!state.actionable || state.busy || !state.canUpdate) {
77+
return;
78+
}
79+
const snapshot = params.context.overlays.snapshot;
80+
void confirmAndStartUpdate({
81+
startGatewayUpdate: () => void params.context.overlays.runUpdate(),
82+
...(params.watchUpdateProgress ? { watchUpdateProgress: params.watchUpdateProgress } : {}),
83+
updateAvailable: snapshot.updateAvailable,
84+
updateSchedule: snapshot.updateSchedule,
85+
viaNativeApp: !params.nativeUpdateDeclined && hasNativeUpdateBridge(),
86+
});
87+
}

0 commit comments

Comments
 (0)