feat(approvals): make the inbox opt-in (default OFF) and drop em-dashes

Owner decision: every Approvals Inbox UI surface (header bell, drawer,
phone overview answer strips, reload seeding) now requires enabling
approvalsInboxEnabled in App Settings -> Panels; only an explicit true
turns it on. The store, endpoints, and push Approve/Deny actions keep
running regardless (the push buttons are already opt-in per subscription).

Also replaces em-dashes with plain punctuation across the newly authored
comments, docs, and strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-09 15:55:51 +02:00
parent ff10a50bc0
commit 6c744f8677
13 changed files with 54 additions and 51 deletions
+14 -12
View File
@@ -1,10 +1,12 @@
/**
* @fileoverview Approvals Inbox UI — cross-session queue of prompts waiting on a human.
* @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human.
*
* Renders the header bell (count badge, shown only while items are pending) and
* the right-side drawer of approval cards, seeds pending items from
* `GET /api/approvals` on init/reconnect (so tab alerts survive a reload), and
* answers items in place via `POST /api/approvals/:id/answer`. Cards render
* Everything here is gated on the OPT-IN `approvalsInboxEnabled` setting
* (synced, default OFF): with it off, no bell, no drawer, no overview strips,
* no seeding. When on, the header bell renders only while items are pending
* (count badge), opening a right-side drawer of approval cards; pending items
* are seeded from `GET /api/approvals` on init/reconnect (so tab alerts
* survive a reload) and answered in place via `POST /api/approvals/:id/answer`. Cards render
* buttons from the server-parsed dialog options; without parsed options they
* fall back to Approve/Deny (permission/question) or a text prompt (idle).
* Backend: src/web/approval-inbox.ts, design: docs/approvals-inbox-plan.md.
@@ -13,7 +15,7 @@
* @dependency app.js (CodemanApp class, this.approvals, setPendingHook/clearPendingHooks, selectSession)
* @dependency constants.js (escapeHtml)
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
* @loadorder 11.6 of 17 — after ultracode-panel.js, before admin-ui.js
* @loadorder 11.6 of 17, after ultracode-panel.js, before admin-ui.js
*/
/** Map an approval kind to the pendingHooks entry that drives tab alerts. */
@@ -22,14 +24,14 @@ function approvalKindToHook(kind) {
}
Object.assign(CodemanApp.prototype, {
/** Synced setting, default ON (only an explicit false disables). */
/** Synced setting, default OFF, opt-in via App Settings → Panels. */
approvalsInboxEnabled() {
return this.loadAppSettingsFromStorage().approvalsInboxEnabled !== false;
return this.loadAppSettingsFromStorage().approvalsInboxEnabled === true;
},
/**
* Seed pending approvals from the server. Called from handleInit, i.e. on
* every page load AND SSE reconnect — this is what makes pending alerts
* every page load AND SSE reconnect; this is what makes pending alerts
* survive a reload (pre-inbox they lived only in SSE-transient memory).
*/
async seedApprovals() {
@@ -51,7 +53,7 @@ Object.assign(CodemanApp.prototype, {
_onApprovalPending(item) {
if (!item || !item.id) return;
if (!this.approvals) this.approvals = new Map();
// One active item per session (server invariant) — drop any stale sibling.
// One active item per session (server invariant): drop any stale sibling.
for (const [id, existing] of this.approvals) {
if (existing.sessionId === item.sessionId) this.approvals.delete(id);
}
@@ -88,7 +90,7 @@ Object.assign(CodemanApp.prototype, {
this.showToast(action === 'deny' ? 'Denied' : 'Answer sent', 'success');
} else {
// 404/409 = resolved elsewhere or the dialog left the screen; refresh truth.
this.showToast('Could not answer — prompt may already be resolved', 'warning');
this.showToast('Could not answer, the prompt may already be resolved', 'warning');
this.seedApprovals();
}
},
@@ -104,7 +106,7 @@ Object.assign(CodemanApp.prototype, {
});
if (data) this.showToast('Prompt sent', 'success');
else {
this.showToast('Could not send — session may be busy', 'warning');
this.showToast('Could not send, the session may be busy', 'warning');
this.seedApprovals();
}
},
+1 -1
View File
@@ -2509,7 +2509,7 @@ html.mobile-init .file-browser-panel {
/* Approvals Inbox answer strip: sits under a NEEDS YOU row (sibling of the
row <button>, see _buildMobileOverviewApprovalStrip). Buttons inherit no
toolbar styling on purpose — they are one-tap dialog answers, not runs. */
toolbar styling on purpose; they are one-tap dialog answers, not runs. */
.mobile-overview-row-wrap {
width: 100%;
}
+4 -4
View File
@@ -39,7 +39,7 @@ Object.assign(CodemanApp.prototype, {
},
_onHookElicitationComplete(data) {
// Question answered in the terminal — clear the action alert without
// Question answered in the terminal: clear the action alert without
// waiting for `stop` (the turn may keep running for a long time).
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
@@ -172,7 +172,7 @@ Object.assign(CodemanApp.prototype, {
if (event.data?.type === 'notification-click') {
const { sessionId, action, approvalId } = event.data;
if (action) {
// Approve/Deny action buttons on a push — answer via the
// Approve/Deny action buttons on a push: answer via the
// Approvals Inbox instead of just focusing the session.
this.handleNotificationAction?.(action, approvalId, sessionId);
} else if (sessionId && this.sessions.has(sessionId)) {
@@ -342,8 +342,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
// Approvals Inbox: synced, default ON (only an explicit false disables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled !== false;
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
+2 -2
View File
@@ -10643,7 +10643,7 @@ kbd {
display: none !important;
}
/* "Approvals" header bell — appears ONLY while prompts are pending (JS toggles
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
the marker class on count changes), so it ships hidden and stays out of the
default header. Same marker pattern as the attachments button. */
.btn-approvals {
@@ -10672,7 +10672,7 @@ kbd {
pointer-events: none;
}
/* Approvals Inbox drawer — same shell as the attachment history drawer. */
/* Approvals Inbox drawer: same shell as the attachment history drawer. */
.approvals-drawer {
position: fixed;
top: var(--header-height);
+1 -1
View File
@@ -159,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
body: JSON.stringify({ action }),
}).then((res) => {
if (res && res.ok) return undefined;
// 401/404/409: let the human see the state — fall back to a tab.
// 401/404/409: let the human see the state by falling back to a tab.
return openOrFocus(sessionId, action, approvalId, targetUrl);
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
);