feat: add 'Always approve' button for tool-call approvals

When a tool-call approval banner appears, users can now click 'Always
approve' to both approve the current call AND add the tool to the
session's autoApprovedToolNames. Future calls to that tool are then
auto-approved without showing the banner.

- Add alwaysApprove?: boolean to ResolveSessionApprovalInput
- In resolveSessionApproval, atomically append tool to session
  approvalSettings when alwaysApprove is true (avoids running guard)
- Add 'Always approve' button in ApprovalBanner (tool-call kind only)
- Wire through ChatPane and App.tsx with updated callback signatures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 19:34:50 +01:00
co-authored by Copilot
parent 4f1ae86021
commit 1868a79d9a
6 changed files with 32 additions and 9 deletions
+8
View File
@@ -699,6 +699,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
sessionId: string, sessionId: string,
approvalId: string, approvalId: string,
decision: ApprovalDecision, decision: ApprovalDecision,
alwaysApprove?: boolean,
): Promise<WorkspaceState> { ): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace(); const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId); const session = this.requireSession(workspace, sessionId);
@@ -726,6 +727,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId)); this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
session.updatedAt = resolvedAt; session.updatedAt = resolvedAt;
if (decision === 'approved' && alwaysApprove && approval.toolName) {
const existing = session.approvalSettings?.autoApprovedToolNames ?? [];
if (!existing.includes(approval.toolName)) {
session.approvalSettings = { autoApprovedToolNames: [...existing, approval.toolName] };
}
}
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) => const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
upsertRunApprovalEvent(run, resolvedApproval)); upsertRunApprovalEvent(run, resolvedApproval));
+1 -1
View File
@@ -113,7 +113,7 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
service.cancelSessionTurn(input.sessionId), service.cancelSessionTurn(input.sessionId),
); );
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) => ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision), service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision, input.alwaysApprove),
); );
ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) => ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) =>
service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform), service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform),
+2 -2
View File
@@ -247,8 +247,8 @@ export default function App() {
<ChatPane <ChatPane
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })} onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }} onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
onResolveApproval={(approvalId, decision) => onResolveApproval={(approvalId, decision, alwaysApprove) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision }) api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove })
} }
onResolveUserInput={(userInputId, answer, wasFreeform) => onResolveUserInput={(userInputId, answer, wasFreeform) =>
api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform }) api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform })
+4 -4
View File
@@ -39,7 +39,7 @@ interface ChatPaneProps {
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>; runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onSend: (content: string) => Promise<void>; onSend: (content: string) => Promise<void>;
onCancelTurn?: () => void; onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>; onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
onSetInteractionMode?: (mode: InteractionMode) => void; onSetInteractionMode?: (mode: InteractionMode) => void;
onDismissPlanReview?: () => void; onDismissPlanReview?: () => void;
@@ -178,14 +178,14 @@ export function ChatPane({
} }
} }
async function handleResolveApproval(decision: ApprovalDecision) { async function handleResolveApproval(decision: ApprovalDecision, alwaysApprove?: boolean) {
if (!pendingApproval || !onResolveApproval || isResolvingApproval) return; if (!pendingApproval || !onResolveApproval || isResolvingApproval) return;
setApprovalError(undefined); setApprovalError(undefined);
setIsResolvingApproval(true); setIsResolvingApproval(true);
try { try {
await onResolveApproval(pendingApproval.id, decision); await onResolveApproval(pendingApproval.id, decision, alwaysApprove);
} catch (error) { } catch (error) {
setApprovalError(error instanceof Error ? error.message : String(error)); setApprovalError(error instanceof Error ? error.message : String(error));
} finally { } finally {
@@ -384,7 +384,7 @@ export function ChatPane({
<ApprovalBanner <ApprovalBanner
approval={pendingApproval} approval={pendingApproval}
isResolving={isResolvingApproval} isResolving={isResolvingApproval}
onResolve={(decision) => void handleResolveApproval(decision)} onResolve={(decision, alwaysApprove) => void handleResolveApproval(decision, alwaysApprove)}
position={totalPendingCount > 1 ? 1 : undefined} position={totalPendingCount > 1 ? 1 : undefined}
total={totalPendingCount > 1 ? totalPendingCount : undefined} total={totalPendingCount > 1 ? totalPendingCount : undefined}
/> />
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react'; import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldBan, ShieldCheck, X } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView'; import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView';
@@ -15,7 +15,7 @@ export function ApprovalBanner({
total, total,
}: { }: {
approval: PendingApprovalRecord; approval: PendingApprovalRecord;
onResolve: (decision: ApprovalDecision) => void; onResolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void;
isResolving: boolean; isResolving: boolean;
position?: number; position?: number;
total?: number; total?: number;
@@ -23,6 +23,7 @@ export function ApprovalBanner({
const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval'; const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval';
const hasMessages = approval.messages && approval.messages.length > 0; const hasMessages = approval.messages && approval.messages.length > 0;
const showPosition = position !== undefined && total !== undefined && total > 1; const showPosition = position !== undefined && total !== undefined && total > 1;
const canAlwaysApprove = approval.kind === 'tool-call' && !!approval.toolName;
return ( return (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3" role="alert"> <div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3" role="alert">
@@ -87,6 +88,19 @@ export function ApprovalBanner({
{isResolving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />} {isResolving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
Approve Approve
</button> </button>
{canAlwaysApprove && (
<button
aria-label={`Always approve ${approval.toolName}`}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600/20 px-3.5 py-1.5 text-[12px] font-medium text-emerald-300 transition hover:bg-emerald-600/30 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isResolving}
onClick={() => onResolve('approved', true)}
title={`Auto-approve "${approval.toolName}" for the rest of this session`}
type="button"
>
<ShieldBan className="size-3" />
Always approve
</button>
)}
<button <button
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-zinc-300 transition hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-zinc-300 transition hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={isResolving} disabled={isResolving}
+1
View File
@@ -34,6 +34,7 @@ export interface ResolveSessionApprovalInput {
sessionId: string; sessionId: string;
approvalId: string; approvalId: string;
decision: ApprovalDecision; decision: ApprovalDecision;
alwaysApprove?: boolean;
} }
export interface ResolveSessionUserInputInput { export interface ResolveSessionUserInputInput {