Skip to content

Commit a122d29

Browse files
committed
feat(ui): WebUI modernization PR2 — approval flow as inline decision cards
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).
1 parent 60c2ee1 commit a122d29

3 files changed

Lines changed: 344 additions & 141 deletions

File tree

cmd/odek/ui/app.js

Lines changed: 239 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,13 @@ function connect() {
373373
break;
374374

375375
case 'approval_request':
376-
showApprovalDialog(event);
376+
queueApproval(event);
377+
break;
378+
379+
case 'approval_ack':
380+
// The request was answered (by this or another connected client);
381+
// drop it from the queue if it is still shown.
382+
dismissApproval(event.id);
377383
break;
378384

379385
case 'skill_event':
@@ -877,95 +883,241 @@ function truncateStr(s, n) {
877883
return s.length > n ? s.substring(0, n) + '…' : s;
878884
}
879885

880-
// ── Approval ──
881-
let approvalId = null;
882-
883-
window.showApprovalDialog = function(event) {
884-
approvalId = event.id;
885-
const riskEl = document.getElementById('approval-risk');
886-
riskEl.textContent = event.risk;
887-
riskEl.className = 'approval-risk ' + event.risk;
888-
// Set icon based on risk
889-
const iconEl = document.getElementById('approval-icon');
890-
const iconMap = { '🟡': '⚠️', '🔴': '🚫', '🟢': '✅' };
891-
iconEl.textContent = iconMap[event.risk] || '🛡️';
892-
iconEl.className = 'approval-icon ' + (event.risk || '');
893-
document.getElementById('approval-command').textContent = event.command;
894-
const descEl = document.getElementById('approval-desc');
886+
// ── Approvals ──
887+
// Approval requests are queued and rendered one at a time as an inline
888+
// decision card pinned at the bottom of the message stream — the user can
889+
// scroll back through the transcript while deciding. Cards are fully
890+
// keyboard-operable (a = approve, d = deny, t = trust session) and announce
891+
// themselves to assistive technology via role="alertdialog" + focus move.
892+
// An approval_ack from the server (answer given on another client) dismisses
893+
// the matching request.
894+
895+
let approvalQueue = []; // approvalRequest events, FIFO
896+
let activeApprovalId = null; // id of the request currently rendered
897+
let activeApprovalCard = null;
898+
899+
// Risk-class presentation metadata. The server sends the class string
900+
// (system_write, destructive, …); level drives the badge color and the
901+
// "why" line explains the class in plain language.
902+
const APPROVAL_RISK_META = {
903+
local_write: { icon: '🟡', level: 'warn', why: 'Writes or deletes files in the working directory.' },
904+
system_write: { icon: '⚠️', level: 'warn', why: 'Modifies system files or settings outside the workspace.' },
905+
destructive: { icon: '🚫', level: 'danger', why: 'Irreversibly destroys data. This cannot be undone.' },
906+
network_egress: { icon: '🌐', level: 'warn', why: 'Sends data out to the network.' },
907+
code_execution: { icon: '⚠️', level: 'warn', why: 'Executes arbitrary code.' },
908+
install: { icon: '📦', level: 'warn', why: 'Installs packages or dependencies.' },
909+
unknown: { icon: '🚫', level: 'danger', why: 'Unrecognized command — the gate fails closed on these.' },
910+
blocked: { icon: '🚫', level: 'danger', why: 'Hard-blocked, unrecoverable operation.' },
911+
safe: { icon: '✅', level: 'ok', why: 'Read-only operation.' },
912+
};
913+
914+
function approvalRiskMeta(risk) {
915+
return APPROVAL_RISK_META[risk] || { icon: '🛡️', level: 'warn', why: 'Requires your explicit approval.' };
916+
}
917+
918+
window.queueApproval = function(event) {
919+
approvalQueue.push(event);
920+
if (!activeApprovalId) showNextApproval();
921+
};
922+
923+
// dismissApproval removes a request from the queue (and its card if shown)
924+
// without sending a response — used when the server acks an answer that
925+
// came from another client.
926+
function dismissApproval(id) {
927+
const idx = approvalQueue.findIndex(e => e.id === id);
928+
if (idx >= 0) approvalQueue.splice(idx, 1);
929+
if (activeApprovalId === id) {
930+
removeActiveApprovalCard();
931+
showNextApproval();
932+
}
933+
}
934+
935+
function showNextApproval() {
936+
const event = approvalQueue[0];
937+
if (!event) {
938+
activeApprovalId = null;
939+
activeApprovalCard = null;
940+
return;
941+
}
942+
activeApprovalId = event.id;
943+
renderApprovalCard(event);
944+
}
945+
946+
function removeActiveApprovalCard() {
947+
if (activeApprovalCard) {
948+
activeApprovalCard.remove();
949+
activeApprovalCard = null;
950+
}
951+
activeApprovalId = null;
952+
}
953+
954+
function renderApprovalCard(event) {
955+
removeActiveApprovalCard();
956+
const meta = approvalRiskMeta(event.risk);
957+
958+
const card = document.createElement('div');
959+
card.className = 'approval-card';
960+
card.dataset.level = meta.level;
961+
card.setAttribute('role', 'alertdialog');
962+
card.setAttribute('aria-labelledby', 'ac-title');
963+
card.setAttribute('aria-describedby', 'ac-why');
964+
card.tabIndex = -1;
965+
966+
// Header: icon, titles, risk badge
967+
const head = document.createElement('div');
968+
head.className = 'ac-head';
969+
const icon = document.createElement('span');
970+
icon.className = 'ac-icon';
971+
icon.textContent = meta.icon;
972+
const titles = document.createElement('div');
973+
titles.className = 'ac-titles';
974+
const title = document.createElement('div');
975+
title.className = 'ac-title';
976+
title.id = 'ac-title';
977+
title.textContent = 'Approval required';
978+
const sub = document.createElement('div');
979+
sub.className = 'ac-sub';
980+
sub.textContent = 'the agent wants to run this operation';
981+
titles.append(title, sub);
982+
const risk = document.createElement('span');
983+
risk.className = 'ac-risk';
984+
risk.dataset.level = meta.level;
985+
risk.textContent = event.risk || 'unknown';
986+
head.append(icon, titles, risk);
987+
988+
// Plain-language explanation of the risk class
989+
const why = document.createElement('div');
990+
why.className = 'ac-why';
991+
why.id = 'ac-why';
992+
why.textContent = meta.why;
993+
994+
// The command / operation, verbatim
995+
const command = document.createElement('pre');
996+
command.className = 'ac-command';
997+
command.textContent = event.command;
998+
999+
card.append(head, why, command);
1000+
8951001
if (event.description) {
896-
descEl.textContent = event.description;
897-
descEl.style.display = 'block';
898-
} else {
899-
descEl.style.display = 'none';
900-
}
901-
// Hide the Trust button when the server says it is not allowed for
902-
// this class (destructive / blocked). Each call gets its own approval.
903-
const trustBtn = document.querySelector('#approval-actions .trust');
904-
if (trustBtn) {
905-
trustBtn.style.display = (event.allow_trust === false) ? 'none' : '';
906-
}
907-
908-
// Approval-fatigue interrupt. When the server flags friction=true the
909-
// user has already approved the threshold number of this class
910-
// recently; require them to type the literal word 'approve' instead
911-
// of clicking, after a 1.5s gate.
912-
const overlay = document.getElementById('approval-overlay');
913-
let frictionInput = document.getElementById('approval-friction-input');
914-
let frictionMsg = document.getElementById('approval-friction-msg');
1002+
const desc = document.createElement('div');
1003+
desc.className = 'ac-desc';
1004+
desc.textContent = event.description;
1005+
card.appendChild(desc);
1006+
}
1007+
1008+
// Friction mode: after repeated recent approvals of this class, require
1009+
// typing the literal word 'approve' (after a 1.5s gate).
1010+
let frictionInput = null;
9151011
if (event.friction) {
916-
if (!frictionMsg) {
917-
frictionMsg = document.createElement('div');
918-
frictionMsg.id = 'approval-friction-msg';
919-
frictionMsg.style.cssText = 'color: var(--accent); font-size: 13px; margin-top: 8px;';
920-
document.getElementById('approval-actions').parentNode.insertBefore(frictionMsg, document.getElementById('approval-actions'));
921-
}
922-
frictionMsg.textContent =
923-
`⚠️ You have approved ${event.friction_approvals || 0} ${event.risk} operations in the last minute. ` +
924-
`Type the word 'approve' to proceed.`;
925-
frictionMsg.style.display = '';
926-
927-
if (!frictionInput) {
928-
frictionInput = document.createElement('input');
929-
frictionInput.id = 'approval-friction-input';
930-
frictionInput.type = 'text';
931-
frictionInput.placeholder = 'type: approve';
932-
frictionInput.style.cssText = 'width: 100%; margin-top: 6px; padding: 6px;';
933-
frictionMsg.parentNode.insertBefore(frictionInput, document.getElementById('approval-actions'));
934-
}
935-
frictionInput.value = '';
936-
frictionInput.style.display = '';
937-
938-
// Disable Approve button until correct word typed + 1.5s elapsed.
939-
const approveBtn = document.querySelector('#approval-actions .approve');
940-
if (approveBtn) {
941-
approveBtn.disabled = true;
942-
setTimeout(() => {
943-
frictionInput.oninput = () => {
944-
approveBtn.disabled = (frictionInput.value.trim().toLowerCase() !== 'approve');
945-
};
946-
}, 1500);
947-
}
948-
} else {
949-
if (frictionMsg) frictionMsg.style.display = 'none';
950-
if (frictionInput) frictionInput.style.display = 'none';
951-
const approveBtn = document.querySelector('#approval-actions .approve');
952-
if (approveBtn) approveBtn.disabled = false;
1012+
const fr = document.createElement('div');
1013+
fr.className = 'ac-friction';
1014+
const msg = document.createElement('div');
1015+
msg.className = 'ac-friction-msg';
1016+
msg.textContent = '⚠️ You approved ' + (event.friction_approvals || 0) + ' ' + (event.risk || '') +
1017+
' operations in the last minute. Type the word “approve” to proceed.';
1018+
frictionInput = document.createElement('input');
1019+
frictionInput.className = 'ac-friction-input';
1020+
frictionInput.type = 'text';
1021+
frictionInput.placeholder = 'type: approve';
1022+
frictionInput.setAttribute('aria-label', 'Type approve to confirm');
1023+
fr.append(msg, frictionInput);
1024+
card.appendChild(fr);
9531025
}
9541026

955-
overlay.classList.add('active');
956-
};
1027+
// Actions
1028+
const actions = document.createElement('div');
1029+
actions.className = 'ac-actions';
1030+
const denyBtn = document.createElement('button');
1031+
denyBtn.className = 'deny';
1032+
denyBtn.innerHTML = 'deny <kbd>d</kbd>';
1033+
denyBtn.title = 'Deny [d]';
1034+
denyBtn.addEventListener('click', () => sendApproval('deny'));
1035+
const trustBtn = document.createElement('button');
1036+
trustBtn.className = 'trust';
1037+
trustBtn.innerHTML = 'trust session <kbd>t</kbd>';
1038+
trustBtn.title = 'Trust this risk class for the rest of the session [t]';
1039+
trustBtn.addEventListener('click', () => sendApproval('trust'));
1040+
const approveBtn = document.createElement('button');
1041+
approveBtn.className = 'approve';
1042+
approveBtn.innerHTML = 'approve <kbd>a</kbd>';
1043+
approveBtn.title = 'Approve [a]';
1044+
approveBtn.addEventListener('click', () => sendApproval('approve'));
1045+
actions.append(denyBtn, trustBtn, approveBtn);
1046+
card.appendChild(actions);
1047+
1048+
// Trust shortcut is suppressed for destructive / blocked / unknown.
1049+
if (event.allow_trust === false) trustBtn.style.display = 'none';
1050+
1051+
// Queue position indicator
1052+
if (approvalQueue.length > 1) {
1053+
const pos = document.createElement('div');
1054+
pos.className = 'ac-queue-pos';
1055+
pos.textContent = 'request 1 of ' + approvalQueue.length + ' — more waiting';
1056+
card.appendChild(pos);
1057+
}
1058+
1059+
// Friction gating: approve stays disabled until the word is typed and
1060+
// 1.5s have elapsed.
1061+
if (event.friction && frictionInput) {
1062+
approveBtn.disabled = true;
1063+
setTimeout(() => {
1064+
frictionInput.addEventListener('input', () => {
1065+
approveBtn.disabled = frictionInput.value.trim().toLowerCase() !== 'approve';
1066+
});
1067+
}, 1500);
1068+
frictionInput.addEventListener('keydown', (e) => {
1069+
if (e.key === 'Enter' && !approveBtn.disabled) sendApproval('approve');
1070+
e.stopPropagation();
1071+
});
1072+
}
1073+
1074+
activeApprovalCard = card;
1075+
hideEmptyState();
1076+
messagesEl.appendChild(card);
1077+
forceScrollBottom();
1078+
card.focus({ preventScroll: true });
1079+
}
9571080

9581081
window.sendApproval = function(action) {
959-
if (!approvalId) return;
1082+
if (!activeApprovalId) return;
1083+
const event = approvalQueue[0];
1084+
// Honor the friction gate for keyboard-triggered approvals too.
1085+
if (event && event.friction && action === 'approve') {
1086+
const input = activeApprovalCard && activeApprovalCard.querySelector('.ac-friction-input');
1087+
if (input && input.value.trim().toLowerCase() !== 'approve') return;
1088+
}
9601089
ws.send(JSON.stringify({
9611090
type: 'approval_response',
962-
id: approvalId,
1091+
id: activeApprovalId,
9631092
action: action
9641093
}));
965-
document.getElementById('approval-overlay').classList.remove('active');
966-
approvalId = null;
1094+
approvalQueue.shift();
1095+
removeActiveApprovalCard();
1096+
showNextApproval();
9671097
};
9681098

1099+
// Keyboard operation while an approval card is active. Ignored when the
1100+
// user is typing in an input/textarea (the friction input handles its own
1101+
// Enter) or when modifier keys are held.
1102+
document.addEventListener('keydown', (e) => {
1103+
if (!activeApprovalId) return;
1104+
if (e.metaKey || e.ctrlKey || e.altKey) return;
1105+
const tag = (e.target && e.target.tagName) || '';
1106+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1107+
const trustVisible = activeApprovalCard &&
1108+
!activeApprovalCard.querySelector('.trust').style.display;
1109+
if (e.key === 'a' || e.key === 'A') {
1110+
e.preventDefault();
1111+
sendApproval('approve');
1112+
} else if (e.key === 'd' || e.key === 'D') {
1113+
e.preventDefault();
1114+
sendApproval('deny');
1115+
} else if ((e.key === 't' || e.key === 'T') && trustVisible) {
1116+
e.preventDefault();
1117+
sendApproval('trust');
1118+
}
1119+
});
1120+
9691121
// ── Confirm Dialog ──
9701122
window.hideConfirmDialog = function() {
9711123
document.getElementById('confirm-overlay').classList.remove('active');
@@ -1216,6 +1368,9 @@ window.newSession = function() {
12161368

12171369
// Reset all streaming + tool state.
12181370
resetTurnState();
1371+
// Any pending approval belongs to the previous session's run — drop it.
1372+
approvalQueue = [];
1373+
removeActiveApprovalCard();
12191374
busy = false;
12201375
hideLoading(); hideCancel();
12211376
sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN;
@@ -1749,6 +1904,10 @@ async function loadAndRenderSession(sid) {
17491904

17501905
// Clear current messages and reset all streaming state.
17511906
resetTurnState();
1907+
// Pending approvals belong to the previous view — drop them (the
1908+
// server-side request times out on its own).
1909+
approvalQueue = [];
1910+
removeActiveApprovalCard();
17521911
busy = false; hideLoading(); hideCancel();
17531912
sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN;
17541913
promptEl.disabled = false;
@@ -1873,10 +2032,10 @@ promptEl.focus();
18732032

18742033
// Handle keyboard shortcuts globally
18752034
document.addEventListener('keydown', (e) => {
1876-
// Escape closes modals
2035+
// Escape closes overlays. Approval cards are deliberately NOT dismissed —
2036+
// a decision must be made explicitly.
18772037
if (e.key === 'Escape') {
18782038
document.getElementById('shortcuts-overlay').classList.remove('active');
1879-
document.getElementById('approval-overlay').classList.remove('active');
18802039
document.getElementById('confirm-overlay').classList.remove('active');
18812040
pendingDeleteId = null;
18822041
}

0 commit comments

Comments
 (0)