From a122d294c1492266c4912ffcc198a5b7dc6a4255 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 14:31:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20WebUI=20modernization=20PR2=20?= =?UTF-8?q?=E2=80=94=20approval=20flow=20as=20inline=20decision=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second PR of the WebUI modernization roadmap (Phase 2b: approval UX). Stacked on #109 (feat/webui-modernization-1). Replaces the blocking approval modal with an inline decision card pinned at the bottom of the message stream (position: sticky), so the operator can scroll back through the transcript while deciding. During an approval the agent is mid-turn (busy), so nothing is lost vs the modal; the gate itself is untouched - every decision still goes to the server as an explicit approval_response, and Escape no longer closes the prompt. - Queue: multiple approval requests (parallel tool calls) render one at a time with a "request 1 of N" indicator instead of stacking modals. - approval_ack handling: requests answered from another connected client are dismissed locally (previously the ack event was ignored). - Keyboard operation: a = approve, d = deny, t = trust session (when the class allows it); modifier combos and text inputs are respected, and the friction gate applies to keyboard approvals too. Keys are shown as kbd hints on the buttons and documented in the shortcuts dialog. - Accessibility: role="alertdialog" with aria-labelledby/describedby, focus moves to the card on show; DOM built with textContent throughout (no innerHTML with command data). - Risk badge bug fixed: the server sends the class string (system_write, destructive, ...) but the old CSS keyed on emoji classes (.approval-risk.X), so the badge rendered unstyled. Badges now use data-level attributes (warn/danger/ok) driven by a client-side risk-class map that also provides a plain-language "why this needs approval" line per class. - Friction mode rebuilt with stylesheet classes instead of inline cssText; the dead .approval-dialog fallback branch is gone (it could never fire - the HTML used an id). - Session switches (new/load) clear the approval queue so a stale card can never linger in a wiped message list. - All card content uses the new token scales (--space-*, --r-*, --fs-*). Verified: node --check, go vet, full cmd/odek test suite. UI logic reviewed for queue/ack races (single-threaded ordering guarantees local shift happens before the ack is processed; dismiss is idempotent). --- cmd/odek/ui/app.js | 319 ++++++++++++++++++++++++++++++----------- cmd/odek/ui/index.html | 24 +--- cmd/odek/ui/style.css | 142 ++++++++++++------ 3 files changed, 344 insertions(+), 141 deletions(-) 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); }