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,
approvalId: string,
decision: ApprovalDecision,
alwaysApprove?: boolean,
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
@@ -726,6 +727,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
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) =>
upsertRunApprovalEvent(run, resolvedApproval));
+1 -1
View File
@@ -113,7 +113,7 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
service.cancelSessionTurn(input.sessionId),
);
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) =>
service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform),
+2 -2
View File
@@ -247,8 +247,8 @@ export default function App() {
<ChatPane
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
onResolveApproval={(approvalId, decision) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
onResolveApproval={(approvalId, decision, alwaysApprove) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove })
}
onResolveUserInput={(userInputId, answer, wasFreeform) =>
api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform })
+4 -4
View File
@@ -39,7 +39,7 @@ interface ChatPaneProps {
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onSend: (content: string) => Promise<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>;
onSetInteractionMode?: (mode: InteractionMode) => 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;
setApprovalError(undefined);
setIsResolvingApproval(true);
try {
await onResolveApproval(pendingApproval.id, decision);
await onResolveApproval(pendingApproval.id, decision, alwaysApprove);
} catch (error) {
setApprovalError(error instanceof Error ? error.message : String(error));
} finally {
@@ -384,7 +384,7 @@ export function ChatPane({
<ApprovalBanner
approval={pendingApproval}
isResolving={isResolvingApproval}
onResolve={(decision) => void handleResolveApproval(decision)}
onResolve={(decision, alwaysApprove) => void handleResolveApproval(decision, alwaysApprove)}
position={totalPendingCount > 1 ? 1 : undefined}
total={totalPendingCount > 1 ? totalPendingCount : undefined}
/>
@@ -1,5 +1,5 @@
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 { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView';
@@ -15,7 +15,7 @@ export function ApprovalBanner({
total,
}: {
approval: PendingApprovalRecord;
onResolve: (decision: ApprovalDecision) => void;
onResolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void;
isResolving: boolean;
position?: number;
total?: number;
@@ -23,6 +23,7 @@ export function ApprovalBanner({
const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval';
const hasMessages = approval.messages && approval.messages.length > 0;
const showPosition = position !== undefined && total !== undefined && total > 1;
const canAlwaysApprove = approval.kind === 'tool-call' && !!approval.toolName;
return (
<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" />}
Approve
</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
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}
+1
View File
@@ -34,6 +34,7 @@ export interface ResolveSessionApprovalInput {
sessionId: string;
approvalId: string;
decision: ApprovalDecision;
alwaysApprove?: boolean;
}
export interface ResolveSessionUserInputInput {