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 @@

Sessions

- -
-
-
-
🛡
-
-
Approval required
-
agent wants to execute a command
-
-
-
-
- -
- - - -
-
-
-
@@ -127,6 +106,9 @@

Keyboard shortcuts

Toggle shortcuts?
Refresh sessions⌘R
Toggle thinkingAlt+T
+
Approve (when prompted)A
+
Deny (when prompted)D
+
Trust session (when prompted)T
diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index 6e6ebda..494bc35 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -1486,7 +1486,7 @@ body.light { } /* ── Dialogs ───────────────────────────────────────────────────────── */ -#approval-overlay, #shortcuts-overlay, #confirm-overlay { +#shortcuts-overlay, #confirm-overlay { display: none; position: fixed; inset: 0; @@ -1497,13 +1497,13 @@ body.light { align-items: center; justify-content: center; } -#approval-overlay.active, #shortcuts-overlay.active, #confirm-overlay.active { +#shortcuts-overlay.active, #confirm-overlay.active { display: flex; animation: fadeIn .15s ease-out; } /* Shared dialog card */ -#approval-dialog, #shortcuts-dialog, #confirm-dialog { +#shortcuts-dialog, #confirm-dialog { background: var(--bg-2); border: 1px solid var(--border-2); border-radius: 6px; @@ -1515,7 +1515,6 @@ body.light { overflow: hidden; } /* Hairline amber accent at top of every dialog */ -#approval-dialog::before, #shortcuts-dialog::before, #confirm-dialog::before { content: ''; @@ -1530,50 +1529,102 @@ body.light { to { transform: translateY(0) scale(1); opacity: 1; filter: blur(0); } } -/* Approval */ -#approval-dialog { max-width: 480px; padding: 24px 26px; } -.approval-header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } -.approval-icon { font-size: 22px; flex-shrink: 0; } -.approval-title { font-family: var(--ui); font-size: 13px; font-weight: 500; color: var(--text); letter-spacing: .02em; } -.approval-subtitle { font-family: var(--ui); font-size: 11px; color: var(--text-3); margin-top: 1px; letter-spacing: .04em; } -.approval-risk { - display: inline-flex; - align-items: center; +/* ── Approval card (inline, pinned in message stream) ─────────────── */ +.approval-card { + position: sticky; + bottom: var(--space-2); + z-index: 15; + width: min(560px, 100%); + margin: var(--space-3) auto; + padding: var(--space-4) var(--space-5); + background: var(--bg-2); + border: 1px solid var(--border-2); + border-radius: var(--r-md); + box-shadow: 0 18px 48px rgba(0,0,0,.55), + 0 0 0 1px rgba(200,134,10,.08); + overflow: hidden; + animation: dialogUp .28s var(--ease); +} +/* Hairline amber accent at top */ +.approval-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, var(--amber) 50%, transparent 100%); + opacity: .6; +} +.approval-card[data-level="danger"] { + border-color: rgba(224,82,82,.35); + box-shadow: 0 18px 48px rgba(0,0,0,.55), + 0 0 0 1px rgba(224,82,82,.12); +} +.approval-card[data-level="danger"]::before { + background: linear-gradient(90deg, transparent 0%, var(--red) 50%, transparent 100%); +} +.approval-card:focus { outline: none; box-shadow: 0 18px 48px rgba(0,0,0,.55), var(--ring); } + +.ac-head { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-2); } +.ac-icon { font-size: 20px; flex-shrink: 0; } +.ac-titles { flex: 1; min-width: 0; } +.ac-title { font-size: var(--fs-base); font-weight: 500; color: var(--text); letter-spacing: .02em; } +.ac-sub { font-size: var(--fs-xs); color: var(--text-3); margin-top: 1px; letter-spacing: .04em; } +.ac-risk { padding: 2px 8px; - border-radius: 2px; - font-family: var(--ui); - font-size: 11px; + border-radius: var(--r-sm); + font-size: var(--fs-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .1em; - margin-bottom: 14px; + flex-shrink: 0; } -.approval-risk.🟡 { background: rgba(246,166,35,.12); color: #f6a623; } -.approval-risk.🔴 { background: rgba(224,82,82,.12); color: var(--red); } -.approval-risk.🟢 { background: rgba(46,184,122,.12); color: var(--green); } -#approval-command { +.ac-risk[data-level="warn"] { background: rgba(246,166,35,.12); color: #f6a623; } +.ac-risk[data-level="danger"] { background: rgba(224,82,82,.12); color: var(--red); } +.ac-risk[data-level="ok"] { background: rgba(46,184,122,.12); color: var(--green); } + +.ac-why { font-size: var(--fs-xs); color: var(--text-2); letter-spacing: .03em; margin-bottom: var(--space-3); line-height: 1.5; } + +.ac-command { font-family: var(--code); - font-size: 12px; + font-size: var(--fs-sm); background: #04040a; border: 1px solid var(--border); - border-radius: 3px; - padding: 10px 14px; + border-radius: var(--r-sm); + padding: var(--space-3) var(--space-4); color: var(--text); white-space: pre-wrap; word-break: break-all; - margin-bottom: 10px; - max-height: 160px; + margin-bottom: var(--space-3); + max-height: 180px; overflow-y: auto; line-height: 1.55; } -#approval-desc { font-family: var(--ui); font-size: 11px; color: var(--text-2); margin-bottom: 18px; line-height: 1.55; } -#approval-actions { display: flex; gap: 8px; justify-content: flex-end; } -#approval-actions button { +body.light .ac-command { background: var(--bg-3); } + +.ac-desc { font-size: var(--fs-xs); color: var(--text-2); margin-bottom: var(--space-3); line-height: 1.55; } + +.ac-friction { margin-bottom: var(--space-3); } +.ac-friction-msg { color: var(--amber-2); font-size: var(--fs-sm); margin-bottom: var(--space-2); line-height: 1.5; } +.ac-friction-input { + width: 100%; font-family: var(--ui); - padding: 8px 18px; - border-radius: 4px; + font-size: var(--fs-sm); + padding: 6px var(--space-2); + background: var(--bg-3); + border: 1px solid var(--border-2); + border-radius: var(--r-sm); + color: var(--text); + outline: none; +} +.ac-friction-input:focus { border-color: var(--amber); box-shadow: var(--ring); } + +.ac-actions { display: flex; gap: var(--space-2); justify-content: flex-end; } +.ac-actions button { + font-family: var(--ui); + padding: 8px 16px; + border-radius: var(--r-sm); border: none; - font-size: 11px; + font-size: var(--fs-xs); font-weight: 500; letter-spacing: .06em; text-transform: lowercase; @@ -1582,24 +1633,36 @@ body.light { border-color .18s var(--ease), transform .15s var(--ease), box-shadow .25s var(--ease); } -#approval-actions button:active { transform: scale(.96); transition-duration: .08s; } -#approval-actions .deny { background: transparent; color: var(--red); border: 1px solid rgba(224,82,82,.3); } -#approval-actions .deny:hover { background: rgba(224,82,82,.12); border-color: var(--red); box-shadow: 0 4px 14px -4px rgba(224,82,82,.4); } -#approval-actions .trust { background: transparent; color: var(--text-2); border: 1px solid var(--border-2); } -#approval-actions .trust:hover { background: var(--bg-3); border-color: var(--border-3); color: var(--text); } -#approval-actions .approve { +.ac-actions button:disabled { opacity: .45; cursor: not-allowed; } +.ac-actions button:active { transform: scale(.96); transition-duration: .08s; } +.ac-actions kbd { + font-family: var(--ui); + font-size: inherit; + border: 1px solid currentColor; + border-radius: 3px; + padding: 0 3px; + opacity: .55; + margin-left: 2px; +} +.ac-actions .deny { background: transparent; color: var(--red); border: 1px solid rgba(224,82,82,.3); } +.ac-actions .deny:hover { background: rgba(224,82,82,.12); border-color: var(--red); box-shadow: 0 4px 14px -4px rgba(224,82,82,.4); } +.ac-actions .trust { background: transparent; color: var(--text-2); border: 1px solid var(--border-2); } +.ac-actions .trust:hover { background: var(--bg-3); border-color: var(--border-3); color: var(--text); } +.ac-actions .approve { background: linear-gradient(135deg, var(--amber-2) 0%, var(--amber) 100%); color: #0e0b04; border: 1px solid transparent; box-shadow: 0 1px 0 rgba(255,210,140,.3) inset, 0 4px 14px -4px rgba(200,134,10,.4); } -#approval-actions .approve:hover { +.ac-actions .approve:hover:not(:disabled) { box-shadow: 0 1px 0 rgba(255,210,140,.4) inset, 0 8px 22px -6px rgba(200,134,10,.6); transform: translateY(-1px); } +.ac-queue-pos { margin-top: var(--space-2); font-size: var(--fs-xs); color: var(--text-3); letter-spacing: .05em; text-align: right; } + /* Shortcuts */ #shortcuts-dialog { max-width: 360px; padding: 22px 26px; } #shortcuts-dialog h3 { @@ -1682,7 +1745,6 @@ body.light #messages { background: var(--bg); } body.light #input-area { background: var(--bg); } body.light .code-block { background: var(--bg-3); } body.light .code-block pre { background: var(--bg-3); color: #3a3530; } -body.light #approval-command { background: var(--bg-3); } body.light .tb-body { background: var(--bg-3); } body.light .msg.assistant .bubble .content { color: var(--text); }