From 8c3ffdc43a958e6a1e856a851514d73faa9c8d44 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Fri, 2 Jan 2026 17:57:15 -0800 Subject: [PATCH] fix: polish parallel tool call approval UI (#454) Co-authored-by: Letta --- src/cli/App.tsx | 119 +++++++++++++++++++++ src/cli/components/PendingApprovalStub.tsx | 57 ++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/cli/components/PendingApprovalStub.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8c622bb..f488a63 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -81,6 +81,7 @@ import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; import { NewAgentDialog } from "./components/NewAgentDialog"; import { OAuthCodeDialog } from "./components/OAuthCodeDialog"; +import { PendingApprovalStub } from "./components/PendingApprovalStub"; import { PinDialog, validateAgentName } from "./components/PinDialog"; // QuestionDialog removed - now using InlineQuestionApproval import { ReasoningMessage } from "./components/ReasoningMessageRich"; @@ -520,6 +521,99 @@ export default function App({ // This is the approval currently being shown to the user const currentApproval = pendingApprovals[approvalResults.length]; const currentApprovalContext = approvalContexts[approvalResults.length]; + const activeApprovalId = currentApproval?.toolCallId ?? null; + + // Build Sets/Maps for three approval states (excluding the active one): + // - pendingIds: undecided approvals (index > approvalResults.length) + // - queuedIds: decided but not yet executed (index < approvalResults.length) + // Used to render appropriate stubs while one approval is active + const { + pendingIds, + queuedIds, + approvalMap, + stubDescriptions, + queuedDecisions, + } = useMemo(() => { + const pending = new Set(); + const queued = new Set(); + const map = new Map(); + const descriptions = new Map(); + const decisions = new Map< + string, + { type: "approve" | "deny"; reason?: string } + >(); + + // Helper to compute stub description - called once per approval during memo + const computeStubDescription = ( + approval: ApprovalRequest, + ): string | undefined => { + try { + const args = JSON.parse(approval.toolArgs || "{}"); + + if ( + isFileEditTool(approval.toolName) || + isFileWriteTool(approval.toolName) + ) { + return args.file_path || undefined; + } + if (isShellTool(approval.toolName)) { + const cmd = + typeof args.command === "string" + ? args.command + : Array.isArray(args.command) + ? args.command.join(" ") + : ""; + return cmd.length > 50 ? `${cmd.slice(0, 50)}...` : cmd || undefined; + } + if (isPatchTool(approval.toolName)) { + return "patch operation"; + } + return undefined; + } catch { + return undefined; + } + }; + + const activeIndex = approvalResults.length; + + for (let i = 0; i < pendingApprovals.length; i++) { + const approval = pendingApprovals[i]; + if (!approval?.toolCallId || approval.toolCallId === activeApprovalId) { + continue; + } + + const id = approval.toolCallId; + map.set(id, approval); + + const desc = computeStubDescription(approval); + if (desc) { + descriptions.set(id, desc); + } + + if (i < activeIndex) { + // Decided but not yet executed + queued.add(id); + const result = approvalResults[i]; + if (result) { + decisions.set(id, { + type: result.type, + reason: result.type === "deny" ? result.reason : undefined, + }); + } + } else { + // Undecided (waiting in queue) + pending.add(id); + } + } + + return { + pendingIds: pending, + queuedIds: queued, + approvalMap: map, + stubDescriptions: descriptions, + queuedDecisions: decisions, + }; + }, [pendingApprovals, approvalResults, activeApprovalId]); // Overlay/selector state - only one can be open at a time type ActiveOverlay = @@ -5843,6 +5937,31 @@ Plan file path: ${planFilePath}`; ) : ln.kind === "assistant" ? ( + ) : ln.kind === "tool_call" && + ln.toolCallId && + queuedIds.has(ln.toolCallId) ? ( + // Render stub for queued (decided but not executed) approval + + ) : ln.kind === "tool_call" && + ln.toolCallId && + pendingIds.has(ln.toolCallId) ? ( + // Render stub for pending (undecided) approval + ) : ln.kind === "tool_call" ? ( " + * - Queued (decision made): "✓ Decision queued: approve" or "✕ Decision queued: deny" + */ +export const PendingApprovalStub = memo( + ({ toolName, description, decision }: Props) => { + if (decision) { + // Queued state - decision made but not yet executed + const isApprove = decision.type === "approve"; + return ( + + + + {isApprove ? "✓" : "✕"} + + {" Decision queued: "} + {isApprove ? "approve" : "deny"}{" "} + ({toolName}) + + + ); + } + + // Pending state - awaiting user decision + return ( + + + + {" Awaiting approval: "} + {toolName} + {description && ({description})} + + + ); + }, +); + +PendingApprovalStub.displayName = "PendingApprovalStub";