diff --git a/cmd/odek/ui/app.js b/cmd/odek/ui/app.js index ac749bc..0414937 100644 --- a/cmd/odek/ui/app.js +++ b/cmd/odek/ui/app.js @@ -373,7 +373,13 @@ function connect() { break; case 'approval_request': - showApprovalDialog(event); + queueApproval(event); + break; + + case 'approval_ack': + // The request was answered (by this or another connected client); + // drop it from the queue if it is still shown. + dismissApproval(event.id); break; case 'skill_event': @@ -877,95 +883,241 @@ function truncateStr(s, n) { return s.length > n ? s.substring(0, n) + '…' : s; } -// ── Approval ── -let approvalId = null; - -window.showApprovalDialog = function(event) { - approvalId = event.id; - const riskEl = document.getElementById('approval-risk'); - riskEl.textContent = event.risk; - riskEl.className = 'approval-risk ' + event.risk; - // Set icon based on risk - const iconEl = document.getElementById('approval-icon'); - const iconMap = { '🟡': '⚠️', '🔴': '🚫', '🟢': '✅' }; - iconEl.textContent = iconMap[event.risk] || '🛡️'; - iconEl.className = 'approval-icon ' + (event.risk || ''); - document.getElementById('approval-command').textContent = event.command; - const descEl = document.getElementById('approval-desc'); +// ── Approvals ── +// Approval requests are queued and rendered one at a time as an inline +// decision card pinned at the bottom of the message stream — the user can +// scroll back through the transcript while deciding. Cards are fully +// keyboard-operable (a = approve, d = deny, t = trust session) and announce +// themselves to assistive technology via role="alertdialog" + focus move. +// An approval_ack from the server (answer given on another client) dismisses +// the matching request. + +let approvalQueue = []; // approvalRequest events, FIFO +let activeApprovalId = null; // id of the request currently rendered +let activeApprovalCard = null; + +// Risk-class presentation metadata. The server sends the class string +// (system_write, destructive, …); level drives the badge color and the +// "why" line explains the class in plain language. +const APPROVAL_RISK_META = { + local_write: { icon: '🟡', level: 'warn', why: 'Writes or deletes files in the working directory.' }, + system_write: { icon: '⚠️', level: 'warn', why: 'Modifies system files or settings outside the workspace.' }, + destructive: { icon: '🚫', level: 'danger', why: 'Irreversibly destroys data. This cannot be undone.' }, + network_egress: { icon: '🌐', level: 'warn', why: 'Sends data out to the network.' }, + code_execution: { icon: '⚠️', level: 'warn', why: 'Executes arbitrary code.' }, + install: { icon: '📦', level: 'warn', why: 'Installs packages or dependencies.' }, + unknown: { icon: '🚫', level: 'danger', why: 'Unrecognized command — the gate fails closed on these.' }, + blocked: { icon: '🚫', level: 'danger', why: 'Hard-blocked, unrecoverable operation.' }, + safe: { icon: '✅', level: 'ok', why: 'Read-only operation.' }, +}; + +function approvalRiskMeta(risk) { + return APPROVAL_RISK_META[risk] || { icon: '🛡️', level: 'warn', why: 'Requires your explicit approval.' }; +} + +window.queueApproval = function(event) { + approvalQueue.push(event); + if (!activeApprovalId) showNextApproval(); +}; + +// dismissApproval removes a request from the queue (and its card if shown) +// without sending a response — used when the server acks an answer that +// came from another client. +function dismissApproval(id) { + const idx = approvalQueue.findIndex(e => e.id === id); + if (idx >= 0) approvalQueue.splice(idx, 1); + if (activeApprovalId === id) { + removeActiveApprovalCard(); + showNextApproval(); + } +} + +function showNextApproval() { + const event = approvalQueue[0]; + if (!event) { + activeApprovalId = null; + activeApprovalCard = null; + return; + } + activeApprovalId = event.id; + renderApprovalCard(event); +} + +function removeActiveApprovalCard() { + if (activeApprovalCard) { + activeApprovalCard.remove(); + activeApprovalCard = null; + } + activeApprovalId = null; +} + +function renderApprovalCard(event) { + removeActiveApprovalCard(); + const meta = approvalRiskMeta(event.risk); + + const card = document.createElement('div'); + card.className = 'approval-card'; + card.dataset.level = meta.level; + card.setAttribute('role', 'alertdialog'); + card.setAttribute('aria-labelledby', 'ac-title'); + card.setAttribute('aria-describedby', 'ac-why'); + card.tabIndex = -1; + + // Header: icon, titles, risk badge + const head = document.createElement('div'); + head.className = 'ac-head'; + const icon = document.createElement('span'); + icon.className = 'ac-icon'; + icon.textContent = meta.icon; + const titles = document.createElement('div'); + titles.className = 'ac-titles'; + const title = document.createElement('div'); + title.className = 'ac-title'; + title.id = 'ac-title'; + title.textContent = 'Approval required'; + const sub = document.createElement('div'); + sub.className = 'ac-sub'; + sub.textContent = 'the agent wants to run this operation'; + titles.append(title, sub); + const risk = document.createElement('span'); + risk.className = 'ac-risk'; + risk.dataset.level = meta.level; + risk.textContent = event.risk || 'unknown'; + head.append(icon, titles, risk); + + // Plain-language explanation of the risk class + const why = document.createElement('div'); + why.className = 'ac-why'; + why.id = 'ac-why'; + why.textContent = meta.why; + + // The command / operation, verbatim + const command = document.createElement('pre'); + command.className = 'ac-command'; + command.textContent = event.command; + + card.append(head, why, command); + if (event.description) { - descEl.textContent = event.description; - descEl.style.display = 'block'; - } else { - descEl.style.display = 'none'; - } - // Hide the Trust button when the server says it is not allowed for - // this class (destructive / blocked). Each call gets its own approval. - const trustBtn = document.querySelector('#approval-actions .trust'); - if (trustBtn) { - trustBtn.style.display = (event.allow_trust === false) ? 'none' : ''; - } - - // Approval-fatigue interrupt. When the server flags friction=true the - // user has already approved the threshold number of this class - // recently; require them to type the literal word 'approve' instead - // of clicking, after a 1.5s gate. - const overlay = document.getElementById('approval-overlay'); - let frictionInput = document.getElementById('approval-friction-input'); - let frictionMsg = document.getElementById('approval-friction-msg'); + const desc = document.createElement('div'); + desc.className = 'ac-desc'; + desc.textContent = event.description; + card.appendChild(desc); + } + + // Friction mode: after repeated recent approvals of this class, require + // typing the literal word 'approve' (after a 1.5s gate). + let frictionInput = null; if (event.friction) { - if (!frictionMsg) { - frictionMsg = document.createElement('div'); - frictionMsg.id = 'approval-friction-msg'; - frictionMsg.style.cssText = 'color: var(--accent); font-size: 13px; margin-top: 8px;'; - document.getElementById('approval-actions').parentNode.insertBefore(frictionMsg, document.getElementById('approval-actions')); - } - frictionMsg.textContent = - `⚠️ You have approved ${event.friction_approvals || 0} ${event.risk} operations in the last minute. ` + - `Type the word 'approve' to proceed.`; - frictionMsg.style.display = ''; - - if (!frictionInput) { - frictionInput = document.createElement('input'); - frictionInput.id = 'approval-friction-input'; - frictionInput.type = 'text'; - frictionInput.placeholder = 'type: approve'; - frictionInput.style.cssText = 'width: 100%; margin-top: 6px; padding: 6px;'; - frictionMsg.parentNode.insertBefore(frictionInput, document.getElementById('approval-actions')); - } - frictionInput.value = ''; - frictionInput.style.display = ''; - - // Disable Approve button until correct word typed + 1.5s elapsed. - const approveBtn = document.querySelector('#approval-actions .approve'); - if (approveBtn) { - approveBtn.disabled = true; - setTimeout(() => { - frictionInput.oninput = () => { - approveBtn.disabled = (frictionInput.value.trim().toLowerCase() !== 'approve'); - }; - }, 1500); - } - } else { - if (frictionMsg) frictionMsg.style.display = 'none'; - if (frictionInput) frictionInput.style.display = 'none'; - const approveBtn = document.querySelector('#approval-actions .approve'); - if (approveBtn) approveBtn.disabled = false; + const fr = document.createElement('div'); + fr.className = 'ac-friction'; + const msg = document.createElement('div'); + msg.className = 'ac-friction-msg'; + msg.textContent = '⚠️ You approved ' + (event.friction_approvals || 0) + ' ' + (event.risk || '') + + ' operations in the last minute. Type the word “approve” to proceed.'; + frictionInput = document.createElement('input'); + frictionInput.className = 'ac-friction-input'; + frictionInput.type = 'text'; + frictionInput.placeholder = 'type: approve'; + frictionInput.setAttribute('aria-label', 'Type approve to confirm'); + fr.append(msg, frictionInput); + card.appendChild(fr); } - overlay.classList.add('active'); -}; + // Actions + const actions = document.createElement('div'); + actions.className = 'ac-actions'; + const denyBtn = document.createElement('button'); + denyBtn.className = 'deny'; + denyBtn.innerHTML = 'deny d'; + denyBtn.title = 'Deny [d]'; + denyBtn.addEventListener('click', () => sendApproval('deny')); + const trustBtn = document.createElement('button'); + trustBtn.className = 'trust'; + trustBtn.innerHTML = 'trust session t'; + trustBtn.title = 'Trust this risk class for the rest of the session [t]'; + trustBtn.addEventListener('click', () => sendApproval('trust')); + const approveBtn = document.createElement('button'); + approveBtn.className = 'approve'; + approveBtn.innerHTML = 'approve a'; + approveBtn.title = 'Approve [a]'; + approveBtn.addEventListener('click', () => sendApproval('approve')); + actions.append(denyBtn, trustBtn, approveBtn); + card.appendChild(actions); + + // Trust shortcut is suppressed for destructive / blocked / unknown. + if (event.allow_trust === false) trustBtn.style.display = 'none'; + + // Queue position indicator + if (approvalQueue.length > 1) { + const pos = document.createElement('div'); + pos.className = 'ac-queue-pos'; + pos.textContent = 'request 1 of ' + approvalQueue.length + ' — more waiting'; + card.appendChild(pos); + } + + // Friction gating: approve stays disabled until the word is typed and + // 1.5s have elapsed. + if (event.friction && frictionInput) { + approveBtn.disabled = true; + setTimeout(() => { + frictionInput.addEventListener('input', () => { + approveBtn.disabled = frictionInput.value.trim().toLowerCase() !== 'approve'; + }); + }, 1500); + frictionInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !approveBtn.disabled) sendApproval('approve'); + e.stopPropagation(); + }); + } + + activeApprovalCard = card; + hideEmptyState(); + messagesEl.appendChild(card); + forceScrollBottom(); + card.focus({ preventScroll: true }); +} window.sendApproval = function(action) { - if (!approvalId) return; + if (!activeApprovalId) return; + const event = approvalQueue[0]; + // Honor the friction gate for keyboard-triggered approvals too. + if (event && event.friction && action === 'approve') { + const input = activeApprovalCard && activeApprovalCard.querySelector('.ac-friction-input'); + if (input && input.value.trim().toLowerCase() !== 'approve') return; + } ws.send(JSON.stringify({ type: 'approval_response', - id: approvalId, + id: activeApprovalId, action: action })); - document.getElementById('approval-overlay').classList.remove('active'); - approvalId = null; + approvalQueue.shift(); + removeActiveApprovalCard(); + showNextApproval(); }; +// Keyboard operation while an approval card is active. Ignored when the +// user is typing in an input/textarea (the friction input handles its own +// Enter) or when modifier keys are held. +document.addEventListener('keydown', (e) => { + if (!activeApprovalId) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + const tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + const trustVisible = activeApprovalCard && + !activeApprovalCard.querySelector('.trust').style.display; + if (e.key === 'a' || e.key === 'A') { + e.preventDefault(); + sendApproval('approve'); + } else if (e.key === 'd' || e.key === 'D') { + e.preventDefault(); + sendApproval('deny'); + } else if ((e.key === 't' || e.key === 'T') && trustVisible) { + e.preventDefault(); + sendApproval('trust'); + } +}); + // ── Confirm Dialog ── window.hideConfirmDialog = function() { document.getElementById('confirm-overlay').classList.remove('active'); @@ -1216,6 +1368,9 @@ window.newSession = function() { // Reset all streaming + tool state. resetTurnState(); + // Any pending approval belongs to the previous session's run — drop it. + approvalQueue = []; + removeActiveApprovalCard(); busy = false; hideLoading(); hideCancel(); sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN; @@ -1749,6 +1904,10 @@ async function loadAndRenderSession(sid) { // Clear current messages and reset all streaming state. resetTurnState(); + // Pending approvals belong to the previous view — drop them (the + // server-side request times out on its own). + approvalQueue = []; + removeActiveApprovalCard(); busy = false; hideLoading(); hideCancel(); sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN; promptEl.disabled = false; @@ -1873,10 +2032,10 @@ promptEl.focus(); // Handle keyboard shortcuts globally document.addEventListener('keydown', (e) => { - // Escape closes modals + // Escape closes overlays. Approval cards are deliberately NOT dismissed — + // a decision must be made explicitly. if (e.key === 'Escape') { document.getElementById('shortcuts-overlay').classList.remove('active'); - document.getElementById('approval-overlay').classList.remove('active'); document.getElementById('confirm-overlay').classList.remove('active'); pendingDeleteId = null; } diff --git a/cmd/odek/ui/index.html b/cmd/odek/ui/index.html index a1d88c5..d3a14ef 100644 --- a/cmd/odek/ui/index.html +++ b/cmd/odek/ui/index.html @@ -94,27 +94,6 @@