/** * @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human. * * 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. * * @mixin Extends CodemanApp.prototype via Object.assign * @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 */ /** Map an approval kind to the pendingHooks entry that drives tab alerts. */ function approvalKindToHook(kind) { return kind === 'permission' ? 'permission_prompt' : kind === 'question' ? 'elicitation_dialog' : 'idle_prompt'; } Object.assign(CodemanApp.prototype, { /** Synced setting, default OFF, opt-in via App Settings → Panels. */ approvalsInboxEnabled() { 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 * survive a reload (pre-inbox they lived only in SSE-transient memory). */ async seedApprovals() { if (!this.approvals) this.approvals = new Map(); this.approvals.clear(); if (this.approvalsInboxEnabled()) { const data = await this._apiJson('/api/approvals'); for (const item of (data && data.approvals) || []) { this.approvals.set(item.id, item); // Re-arm the tab alert state machine (idempotent set-add). this.setPendingHook(item.sessionId, approvalKindToHook(item.kind)); } } this.renderApprovals(); }, // ─── SSE handlers ──────────────────────────────────────────── _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. for (const [id, existing] of this.approvals) { if (existing.sessionId === item.sessionId) this.approvals.delete(id); } this.approvals.set(item.id, item); this.renderApprovals(); }, _onApprovalUpdated(item) { if (!item || !item.id || !this.approvals?.has(item.id)) return; this.approvals.set(item.id, item); this.renderApprovals(); }, _onApprovalResolved(info) { if (!info || !info.id || !this.approvals) return; if (this.approvals.delete(info.id)) { // Clear the matching tab alert: the inbox resolves on more signals than // the hook handlers do (superseded, expired, answered from another // device), and clearPendingHooks is a no-op when nothing is set. this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind)); this.renderApprovals(); } }, // ─── Actions ───────────────────────────────────────────────── async answerApproval(id, action, option) { const body = option !== undefined ? { action, option } : { action }; const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, { method: 'POST', body, }); if (data) { 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, the prompt may already be resolved', 'warning'); this.seedApprovals(); } }, /** Idle prompts: send the typed line from the card's input as a prompt. */ async answerApprovalIdleText(id) { const input = document.getElementById(`approvalText-${id}`); const text = input ? input.value.trim() : ''; if (!text) return; const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, { method: 'POST', body: { action: 'text', text }, }); if (data) this.showToast('Prompt sent', 'success'); else { this.showToast('Could not send, the session may be busy', 'warning'); this.seedApprovals(); } }, async dismissApproval(id) { await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/dismiss`, { method: 'POST', body: {} }); // The SSE resolved event also lands; delete now for instant feedback. if (this.approvals?.delete(id)) this.renderApprovals(); }, openApprovalSession(id) { const item = this.approvals?.get(id); if (!item) return; this.closeApprovalsInbox(); if (this.sessions.has(item.sessionId)) this.selectSession(item.sessionId); }, /** * Push-notification action relay (sw.js → settings-ui notification-click → * here). Falls back to opening the session when the item is unknown, or * when the inbox is disabled (a stale notification from before the toggle * flipped can still carry an action). */ handleNotificationAction(action, approvalId, sessionId) { if ((action === 'approve' || action === 'deny') && approvalId && this.approvalsInboxEnabled()) { this.answerApproval(approvalId, action); return; } if (sessionId && this.sessions.has(sessionId)) this.selectSession(sessionId); }, // ─── Rendering ─────────────────────────────────────────────── toggleApprovalsInbox() { const drawer = document.getElementById('approvalsDrawer'); if (!drawer) return; if (drawer.classList.contains('open')) this.closeApprovalsInbox(); else { drawer.classList.add('open'); document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'true'); this.renderApprovals(); } }, closeApprovalsInbox() { document.getElementById('approvalsDrawer')?.classList.remove('open'); document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false'); }, renderApprovals() { const count = this.approvals ? this.approvals.size : 0; const btn = document.querySelector('.btn-approvals'); if (btn) { // Marker-class visibility (base header rules are display !important): // the bell exists only while something is pending, so the header stays // untouched for everyone else. btn.classList.toggle('btn-approvals--hidden', count === 0 || !this.approvalsInboxEnabled()); const badge = document.getElementById('approvalsBadge'); if (badge) badge.textContent = String(count); } this.renderApprovalsDrawer(); // Phone overview NEEDS YOU rows re-render on the tab-render tail; nudge it // so inline approve/deny buttons appear without a state change elsewhere. this.renderSessionTabs?.(); }, renderApprovalsDrawer() { const drawer = document.getElementById('approvalsDrawer'); if (!drawer || !drawer.classList.contains('open')) return; const list = drawer.querySelector('.approvals-list'); if (!list) return; const items = this.approvals ? [...this.approvals.values()].sort((a, b) => a.createdAt - b.createdAt) : []; if (items.length === 0) { list.innerHTML = '
No pending approvals
'; return; } list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join(''); }, _approvalCardHtml(item) { const id = escapeHtml(item.id); const kindLabel = item.kind === 'permission' ? 'Permission' : item.kind === 'question' ? 'Question' : 'Idle'; const summary = item.toolName ? `${item.toolName}${item.toolSummary ? ': ' + item.toolSummary : ''}` : item.message || ''; const age = this._approvalAge(item.createdAt); let actions = ''; if (item.kind === 'idle') { actions = `
` + `` + `` + `
`; } else if (item.options && item.options.length) { actions = item.options .map( (o) => `` ) .join(''); } else { actions = `` + ``; } return ( `
` + `
` + `${kindLabel}` + `${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}` + `${age}` + `
` + (summary ? `
${escapeHtml(summary)}
` : '') + (item.context ? `
${escapeHtml(item.context)}
` : '') + `
${actions}
` + `
` + `` + `` + `
` + `
` ); }, _approvalAge(createdAt) { const s = Math.max(0, Math.floor((Date.now() - createdAt) / 1000)); if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m`; return `${Math.floor(s / 3600)}h`; }, });