mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
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:
@@ -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));
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ResolveSessionApprovalInput {
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
alwaysApprove?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveSessionUserInputInput {
|
||||
|
||||
Reference in New Issue
Block a user