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
+11 -11
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Approvals Inbox — server-side registry of prompts waiting on a human.
* @fileoverview Approvals Inbox: server-side registry of prompts waiting on a human.
*
* One cross-session queue of pending Claude prompts (permission dialogs,
* AskUserQuestion/elicitation questions, idle prompts), fed by `/api/hook-event`
@@ -48,7 +48,7 @@ export interface ApprovalOption {
}
export interface ApprovalItem {
/** `${sessionId}:${seq}` — stable across re-captures, unique per prompt. */
/** `${sessionId}:${seq}`, stable across re-captures, unique per prompt. */
id: string;
sessionId: string;
sessionName: string;
@@ -96,7 +96,7 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
* single delayed re-capture picks up the frame the immediate capture missed.
*/
const RECAPTURE_DELAY_MS = 600;
/** Context kept per item — enough for a dialog plus a few lines above it. */
/** Context kept per item: enough for a dialog plus a few lines above it. */
const MAX_CONTEXT_CHARS = 4000;
const MAX_CONTEXT_LINES = 30;
const MAX_OPTION_LABEL_CHARS = 120;
@@ -144,9 +144,9 @@ export function normalizeCapturedFrame(raw: string | null | undefined): string |
* Options must be consecutively numbered from 1 (2..6 of them); description /
* wrap / separator lines between options are tolerated up to a small gap
* (AskUserQuestion puts a description under every option and a ─ separator
* before its "Chat about this" entry — measured against the live dialog). The
* before its "Chat about this" entry, measured against the live dialog). The
* LAST complete block in the frame wins (dialogs render at the bottom).
* Returns undefined when nothing parses — callers then fall back to
* Returns undefined when nothing parses; callers then fall back to
* approve/deny only, so a mis-parse can never route a digit at a dialog that
* does not have it.
*/
@@ -171,7 +171,7 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
commit();
run = [{ n: 1, label: m[2].trim().slice(0, MAX_OPTION_LABEL_CHARS) }];
} else if (run.length > 0 && ++gap > 3) {
// Too far past the last option for this to still be its description —
// Too far past the last option for this to still be its description:
// the block is over.
commit();
}
@@ -183,7 +183,7 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
// ─── Registry ────────────────────────────────────────────────────────────────
export class ApprovalInbox {
/** Keyed by sessionId — the one-active-item-per-session invariant lives here. */
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
private items = new Map<string, ApprovalItem>();
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
@@ -235,7 +235,7 @@ export class ApprovalInbox {
* Answer-time guard: re-capture the pane and check the dialog is still on
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
* frame parsed options: if a fresh capture then parses none, the dialog is
* gone (answered in the terminal moments ago) — the item resolves and the
* gone (answered in the terminal moments ago), so the item resolves and the
* answer must be refused, because the digit would land in whatever now has
* focus. Unparseable-from-the-start items stay answerable (approve/deny
* only), same risk the terminal user already carries.
@@ -250,7 +250,7 @@ export class ApprovalInbox {
try {
raw = capture();
} catch {
return true; // capture hiccup — inconclusive, keep the item answerable
return true; // capture hiccup: inconclusive, keep the item answerable
}
const context = normalizeCapturedFrame(raw);
if (!context) return true;
@@ -316,7 +316,7 @@ export class ApprovalInbox {
/**
* Resolve a session's pending item, if any (stop hook, exit, ...). `kinds`
* restricts which item kinds the signal may clear — the heuristic `working`
* restricts which item kinds the signal may clear: the heuristic `working`
* transition passes `['idle']` so a mid-turn flap cannot false-clear a
* pending permission/question dialog.
*/
@@ -347,7 +347,7 @@ export class ApprovalInbox {
const context = normalizeCapturedFrame(raw);
if (!context) return;
item.context = context;
// Idle prompts are not dialogs — never offer digit answers for them.
// Idle prompts are not dialogs; never offer digit answers for them.
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
}
+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))
);
+6 -6
View File
@@ -3,10 +3,10 @@
*
* The cross-session queue of prompts waiting on a human (see
* web/approval-inbox.ts, docs/approvals-inbox-plan.md):
* - `GET /api/approvals` — pending items, ownership-scoped in multi-user mode
* - `POST /api/approvals/:id/answer` — answer in place by sending the
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode
* - `POST /api/approvals/:id/answer`: answer in place by sending the
* corresponding keystrokes to the session (digit / Esc / idle-prompt text)
* - `POST /api/approvals/:id/dismiss` — drop the item without keystrokes
* - `POST /api/approvals/:id/dismiss`: drop the item without keystrokes
*
* Normal authed API surface (NOT the localhost hook-secret bypass). Answering
* is take-then-write: the item is removed BEFORE keystrokes go out so a
@@ -24,8 +24,8 @@ import type { SessionPort } from '../ports/index.js';
/**
* Keystrokes for an answer, or an error string. Menu answers are a single digit
* or Esc — dialogs react to the keypress itself, so no Enter is ever sent for
* them. Free text is allowed only for idle prompts (there IS no dialog; the
* or Esc (dialogs react to the keypress itself, so no Enter is ever sent for
* them). Free text is allowed only for idle prompts (there IS no dialog; the
* text lands in the composer and `\r` submits it, per the CLAUDE.md input
* discipline). `option` digits must match a PARSED option so a blind digit can
* never be routed at a dialog we could not read.
@@ -82,7 +82,7 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Covers unknown, already-answered, superseded and expired ids alike.
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Approval not found or no longer pending');
}
// Throws 404 (not 403) for sessions the caller does not own — same
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode)) {
+5 -4
View File
@@ -680,7 +680,7 @@ export const HookEventSchema = z.object({
/**
* Body of POST /api/approvals/:id/answer (Approvals Inbox).
* `option` digits are additionally validated against the item's PARSED options
* in the route — the schema alone must not authorize blind digit-poking.
* in the route; the schema alone must not authorize blind digit-poking.
*/
export const ApprovalAnswerSchema = z
.object({
@@ -791,9 +791,10 @@ export const SettingsUpdateSchema = z
*/
agentSkillEnabled: z.boolean().optional(),
/**
* Approvals Inbox UI (header badge + drawer, phone overview answer buttons).
* SYNCED, default ON: the surfaces only appear while a prompt is pending.
* The server-side store runs regardless (push actions keep working).
* Approvals Inbox UI (header bell + drawer, phone overview answer buttons).
* SYNCED, default OFF (opt-in): even with items pending, no surface renders
* until this is enabled. The server-side store and answer endpoints run
* regardless, so push Approve/Deny actions keep working either way.
*/
approvalsInboxEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
+1 -1
View File
@@ -2152,7 +2152,7 @@ export class WebServer extends EventEmitter {
body,
tag: `codeman-${event}-${sessionId}`,
sessionId,
// Approvals Inbox item id — lets sw.js answer an Approve/Deny action
// Approvals Inbox item id: lets sw.js answer an Approve/Deny action
// click directly (POST /api/approvals/:id/answer) with no tab open.
approvalId: typeof data.approvalId === 'string' ? data.approvalId : undefined,
urgency: template.urgency,