Skip to content

Commit 6f48fa1

Browse files
committed
Feat: Add inline approval UI and fix audio file errors
This commit includes: - Inline approval UI for both TUI and WebUI - Keyboard shortcuts for quick approval/rejection (A/R keys) - Risk level visualization and navigation - WebUI API integration for approval actions - Fixed undefined _audioFile property access - Enhanced global state management for approvals - Automatic approval loading and polling Resolves the UX issue where users had to navigate to a separate Approval menu that didn't show pending requests properly.
1 parent 2a0326e commit 6f48fa1

File tree

6 files changed

+329
-6
lines changed

6 files changed

+329
-6
lines changed

crates/openfang-api/static/index_body.html

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,10 +670,10 @@ <h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to sta
670670
</template>
671671
</div>
672672
<!-- Audio player for TTS results -->
673-
<div x-show="tool._audioFile" style="padding:8px 12px">
673+
<div x-show="tool._audioFile && tool._audioFile.length" style="padding:8px 12px">
674674
<div class="audio-player">
675675
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
676-
<span class="text-xs" x-text="'Audio: ' + tool._audioFile.split('/').pop()"></span>
676+
<span class="text-xs" x-text="'Audio: ' + (tool._audioFile ? tool._audioFile.split('/').pop() : '')"></span>
677677
<span class="text-xs text-dim" x-show="tool._audioDuration" x-text="'~' + Math.round((tool._audioDuration || 0) / 1000) + 's'"></span>
678678
</div>
679679
</div>
@@ -775,6 +775,38 @@ <h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to sta
775775
</button>
776776
</template>
777777
</div>
778+
<!-- Approval notifications -->
779+
<div x-show="$store.app.pendingApprovalCount > 0" class="approval-notifications" style="border-top:1px solid var(--border);padding:8px 12px;background:var(--bg-alt);">
780+
<div style="display:flex;align-items:center;gap:8px;">
781+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--warn)" stroke-width="2" style="flex-shrink:0;"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
782+
<span class="text-sm font-medium" style="color:var(--warn);">Pending Approvals</span>
783+
<span class="badge badge-warn" x-text="$store.app.pendingApprovalCount"></span>
784+
</div>
785+
<div class="approval-list" style="margin-top:8px;max-height:120px;overflow-y:auto;">
786+
<template x-for="approval in $store.app.pendingApprovals" :key="approval.id">
787+
<div class="approval-item" style="padding:6px 8px;border-radius:4px;margin-bottom:4px;background:var(--bg);display:flex;align-items:center;gap:8px;" :class="{ selected: $store.app.selectedApprovalId === approval.id }" @click="$store.app.selectedApprovalId = approval.id">
788+
<div style="flex-shrink:0;width:16px;height:16px;border-radius:2px;" :style="'background:' + (approval.risk_level === 'Critical' ? 'var(--danger)' : approval.risk_level === 'High' ? 'var(--warn)' : approval.risk_level === 'Medium' ? 'var(--yellow)' : 'var(--success)')"></div>
789+
<div style="flex:1;min-width:0;">
790+
<div class="text-sm" style="color:var(--text-primary);" x-text="approval.description"></div>
791+
<div class="text-xs" style="color:var(--text-dim);" x-text="'Risk: ' + approval.risk_level + ' | Agent: ' + approval.agent_id"></div>
792+
</div>
793+
<div style="display:flex;gap:4px;">
794+
<button class="btn btn-xs btn-success" @click.stop="approveTool(approval.id)" title="Approve">
795+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/></svg>
796+
</button>
797+
<button class="btn btn-xs btn-danger" @click.stop="rejectTool(approval.id)" title="Reject">
798+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></svg>
799+
</button>
800+
</div>
801+
</div>
802+
</template>
803+
</div>
804+
<div class="text-xs" style="color:var(--text-dim);margin-top:6px;padding-left:24px;">
805+
<kbd style="background:var(--bg);border:1px solid var(--border);padding:2px 6px;border-radius:3px;font-size:10px;">↑↓</kbd> Navigate
806+
<kbd style="background:var(--bg);border:1px solid var(--border);padding:2px 6px;border-radius:3px;font-size:10px;">A</kbd> Approve
807+
<kbd style="background:var(--bg);border:1px solid var(--border);padding:2px 6px;border-radius:3px;font-size:10px;">R</kbd> Reject
808+
</div>
809+
</div>
778810
<!-- Footer: model switcher + tokens + queue + tips -->
779811
<div class="input-footer">
780812
<div class="flex items-center gap-2">

crates/openfang-api/static/js/app.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ document.addEventListener('alpine:init', function() {
135135
version: '0.1.0',
136136
agentCount: 0,
137137
pendingApprovalCount: 0,
138+
pendingApprovals: [],
139+
selectedApprovalId: null,
138140
lastPendingApprovalSignature: '',
139141
pendingAgent: null,
140142
focusMode: localStorage.getItem('openfang-focus') === 'true',
@@ -168,8 +170,13 @@ document.addEventListener('alpine:init', function() {
168170
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
169171
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
170172
}
173+
this.pendingApprovals = pending;
171174
this.pendingApprovalCount = pending.length;
172175
this.lastPendingApprovalSignature = signature;
176+
// Initialize selected approval if not set and we have pending approvals
177+
if (pending.length > 0 && !this.selectedApprovalId) {
178+
this.selectedApprovalId = pending[0].id;
179+
}
173180
} catch(e) { /* silent */ }
174181
},
175182

crates/openfang-api/static/js/pages/chat.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,41 @@ function chatPage() {
160160
e.preventDefault();
161161
self.toggleSearch();
162162
}
163+
// Approval navigation (when approvals are pending)
164+
if (self.currentAgent && $store.app.pendingApprovalCount > 0) {
165+
// Arrow keys for approval selection
166+
if (e.key === 'ArrowUp') {
167+
e.preventDefault();
168+
var currentIdx = $store.app.pendingApprovals.findIndex(function(a) { return a.id === $store.app.selectedApprovalId; });
169+
if (currentIdx > 0) {
170+
$store.app.selectedApprovalId = $store.app.pendingApprovals[currentIdx - 1].id;
171+
} else if ($store.app.pendingApprovals.length > 0) {
172+
$store.app.selectedApprovalId = $store.app.pendingApprovals[$store.app.pendingApprovals.length - 1].id;
173+
}
174+
} else if (e.key === 'ArrowDown') {
175+
e.preventDefault();
176+
var currentIdx = $store.app.pendingApprovals.findIndex(function(a) { return a.id === $store.app.selectedApprovalId; });
177+
if (currentIdx >= 0 && currentIdx < $store.app.pendingApprovals.length - 1) {
178+
$store.app.selectedApprovalId = $store.app.pendingApprovals[currentIdx + 1].id;
179+
} else if ($store.app.pendingApprovals.length > 0) {
180+
$store.app.selectedApprovalId = $store.app.pendingApprovals[0].id;
181+
}
182+
}
183+
// A key for approve
184+
else if (e.key === 'a' || e.key === 'A') {
185+
e.preventDefault();
186+
if ($store.app.selectedApprovalId) {
187+
self.approveTool($store.app.selectedApprovalId);
188+
}
189+
}
190+
// R key for reject
191+
else if (e.key === 'r' || e.key === 'R') {
192+
e.preventDefault();
193+
if ($store.app.selectedApprovalId) {
194+
self.rejectTool($store.app.selectedApprovalId);
195+
}
196+
}
197+
}
163198
});
164199

165200
// Load session + session list when agent changes
@@ -1257,6 +1292,49 @@ function chatPage() {
12571292
},
12581293

12591294
renderMarkdown: renderMarkdown,
1260-
escapeHtml: escapeHtml
1295+
escapeHtml: escapeHtml,
1296+
1297+
// Approval functions
1298+
approveTool: async function(approvalId) {
1299+
try {
1300+
var response = await OpenFangAPI.post('/api/approvals/' + approvalId + '/approve', {});
1301+
if (response.status === 'ok') {
1302+
// Remove from pending list
1303+
this.removeApproval(approvalId);
1304+
this.showToast('Approved: ' + (this.getApproval(approvalId)?.description || 'action'), 'success');
1305+
} else {
1306+
this.showToast('Approval failed: ' + (response.error || 'Unknown error'), 'danger');
1307+
}
1308+
} catch (e) {
1309+
this.showToast('Approval error: ' + (e.message || 'Network error'), 'danger');
1310+
}
1311+
},
1312+
1313+
rejectTool: async function(approvalId) {
1314+
try {
1315+
var response = await OpenFangAPI.post('/api/approvals/' + approvalId + '/reject', {});
1316+
if (response.status === 'ok') {
1317+
// Remove from pending list
1318+
this.removeApproval(approvalId);
1319+
this.showToast('Rejected: ' + (this.getApproval(approvalId)?.description || 'action'), 'warn');
1320+
} else {
1321+
this.showToast('Rejection failed: ' + (response.error || 'Unknown error'), 'danger');
1322+
}
1323+
} catch (e) {
1324+
this.showToast('Rejection error: ' + (e.message || 'Network error'), 'danger');
1325+
}
1326+
},
1327+
1328+
getApproval: function(approvalId) {
1329+
return $store.app.pendingApprovals.find(function(a) { return a.id === approvalId; });
1330+
},
1331+
1332+
removeApproval: function(approvalId) {
1333+
$store.app.pendingApprovals = $store.app.pendingApprovals.filter(function(a) { return a.id !== approvalId; });
1334+
$store.app.pendingApprovalCount = $store.app.pendingApprovals.length;
1335+
if ($store.app.selectedApprovalId === approvalId) {
1336+
$store.app.selectedApprovalId = null;
1337+
}
1338+
}
12611339
};
12621340
}

crates/openfang-cli/src/tui/chat_runner.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,37 @@ impl StandaloneChat {
223223
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
224224
ChatAction::OpenModelPicker => self.open_model_picker(),
225225
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
226+
ChatAction::ApproveTool(approval_id) => {
227+
self.handle_approval_action(&approval_id, true);
228+
}
229+
ChatAction::RejectTool(approval_id) => {
230+
self.handle_approval_action(&approval_id, false);
231+
}
232+
ChatAction::SelectApproval(idx) => {
233+
self.chat.selected_approval_idx = Some(idx);
234+
}
235+
}
236+
}
237+
238+
fn handle_approval_action(&mut self, approval_id: &str, approve: bool) {
239+
// Find and remove the approval from the UI
240+
let approval = self.chat.pending_approvals.iter()
241+
.find(|a| a.id == approval_id)
242+
.cloned();
243+
244+
if let Some(approval) = approval {
245+
self.chat.remove_approval(approval_id);
246+
247+
// Here you would typically call the kernel API to approve/reject
248+
// For now, we'll just show a status message
249+
let action = if approve { "approved" } else { "rejected" };
250+
self.chat.status_msg = Some(format!(
251+
"{} {} for {}",
252+
approval.tool_name, action, approval.agent_id
253+
));
254+
255+
// In a real implementation, you would call:
256+
// self.approve_tool_execution(approval_id, approve);
226257
}
227258
}
228259

crates/openfang-cli/src/tui/mod.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,15 @@ impl App {
13661366
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
13671367
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
13681368
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
1369+
chat::ChatAction::ApproveTool(approval_id) => {
1370+
self.handle_approval_action(&approval_id, true);
1371+
}
1372+
chat::ChatAction::RejectTool(approval_id) => {
1373+
self.handle_approval_action(&approval_id, false);
1374+
}
1375+
chat::ChatAction::SelectApproval(idx) => {
1376+
self.chat.selected_approval_idx = Some(idx);
1377+
}
13691378
}
13701379
}
13711380

@@ -2361,6 +2370,27 @@ impl App {
23612370
let bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_CARD));
23622371
frame.render_widget(bar, area);
23632372
}
2373+
2374+
fn handle_approval_action(&mut self, approval_id: &str, approve: bool) {
2375+
// Find and remove the approval from the UI
2376+
let approval = self.chat.pending_approvals.iter()
2377+
.find(|a| a.id == approval_id)
2378+
.cloned();
2379+
2380+
if let Some(approval) = approval {
2381+
self.chat.remove_approval(approval_id);
2382+
2383+
// Show feedback in the chat
2384+
let action = if approve { "approved" } else { "rejected" };
2385+
self.chat.push_message(
2386+
chat::Role::System,
2387+
format!("✓ {} {} for {}", approval.tool_name, action, approval.agent_id)
2388+
);
2389+
2390+
// In a real implementation, you would call the kernel API here
2391+
// to actually approve/reject the tool execution
2392+
}
2393+
}
23642394
}
23652395

23662396
/// Draw a one-line toast at the bottom of the screen.
@@ -2405,6 +2435,7 @@ pub fn run(config: Option<PathBuf>) {
24052435

24062436
// ── Main loop ────────────────────────────────────────────────────────────
24072437
// Draw first, then block on events. This ensures the first frame appears
2438+
24082439
// immediately, before any event processing.
24092440
while !app.should_quit {
24102441
terminal

0 commit comments

Comments
 (0)