fix: polish parallel tool call approval UI (#454)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-02 17:57:15 -08:00
committed by GitHub
parent d2e0fc3bc5
commit 8c3ffdc43a
2 changed files with 176 additions and 0 deletions

View File

@@ -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<string>();
const queued = new Set<string>();
const map = new Map<string, ApprovalRequest>();
const descriptions = new Map<string, string>();
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}`;
<ReasoningMessage line={ln} />
) : ln.kind === "assistant" ? (
<AssistantMessage line={ln} />
) : ln.kind === "tool_call" &&
ln.toolCallId &&
queuedIds.has(ln.toolCallId) ? (
// Render stub for queued (decided but not executed) approval
<PendingApprovalStub
toolName={
approvalMap.get(ln.toolCallId)?.toolName ||
ln.name ||
"Unknown"
}
description={stubDescriptions.get(ln.toolCallId)}
decision={queuedDecisions.get(ln.toolCallId)}
/>
) : ln.kind === "tool_call" &&
ln.toolCallId &&
pendingIds.has(ln.toolCallId) ? (
// Render stub for pending (undecided) approval
<PendingApprovalStub
toolName={
approvalMap.get(ln.toolCallId)?.toolName ||
ln.name ||
"Unknown"
}
description={stubDescriptions.get(ln.toolCallId)}
/>
) : ln.kind === "tool_call" ? (
<ToolCallMessage
line={ln}

View File

@@ -0,0 +1,57 @@
import { Box, Text } from "ink";
import { memo } from "react";
type Props = {
toolName: string;
description?: string;
/** If provided, shows as "Decision queued" instead of "Awaiting approval" */
decision?: {
type: "approve" | "deny";
reason?: string;
};
};
/**
* PendingApprovalStub - Compact placeholder for approvals that aren't currently active.
*
* When multiple tools need approval, only one shows the full approval UI at a time.
* Others display as this minimal stub to avoid cluttering the transcript.
*
* Two modes:
* - Pending (no decision): "⧗ Awaiting approval: <tool>"
* - 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 (
<Box>
<Text dimColor>
<Text color={isApprove ? "green" : "red"}>
{isApprove ? "✓" : "✕"}
</Text>
{" Decision queued: "}
<Text>{isApprove ? "approve" : "deny"}</Text>{" "}
<Text dimColor>({toolName})</Text>
</Text>
</Box>
);
}
// Pending state - awaiting user decision
return (
<Box>
<Text dimColor>
<Text color="yellow"></Text>
{" Awaiting approval: "}
<Text>{toolName}</Text>
{description && <Text dimColor> ({description})</Text>}
</Text>
</Box>
);
},
);
PendingApprovalStub.displayName = "PendingApprovalStub";