Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 239 additions & 80 deletions cmd/odek/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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 <kbd>d</kbd>';
denyBtn.title = 'Deny [d]';
denyBtn.addEventListener('click', () => sendApproval('deny'));
const trustBtn = document.createElement('button');
trustBtn.className = 'trust';
trustBtn.innerHTML = 'trust session <kbd>t</kbd>';
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 <kbd>a</kbd>';
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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading