mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
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:
+11
-11
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user