@@ -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
9581081window . 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 ──
9701122window . 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
18752034document . 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