返回 DeepSeek-Reasonix
ApprovalModal.tsx
根目录 / desktop / frontend / src / components / ApprovalModal.tsx
1 import { useCallback, useEffect, useId, useRef, useState } from "react";
2 import type { KeyboardEvent as ReactKeyboardEvent } from "react";
3 import gsap from "gsap";
4 import { useT, type Translator } from "../lib/i18n";
5 import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types";
6 import {
7 DecisionConfirmBar,
8 PromptAction,
9 PromptBadge,
10 PromptDescriptionDisclosure,
11 PromptHeaderAction,
12 PromptShelf,
13 } from "./PromptShelf";
14 import { DUR_FAST } from "../lib/gsapAnimations";
15 import {
16 FileReferenceMenu,
17 insertTextAtSelection,
18 pickInlineFileReference,
19 useFileReferenceMenu,
20 } from "./FileReferenceMenu";
21
22 function animateShelfExit(
23 el: HTMLDivElement,
24 options: { opacity: number; y: number; duration: number; ease: string; onComplete: () => void },
25 ) {
26 const animator = typeof gsap.to === "function"
27 ? gsap
28 : (gsap as unknown as { default?: typeof gsap }).default;
29 if (animator && typeof animator.to === "function") {
30 animator.to(el, options);
31 return;
32 }
33 options.onComplete();
34 }
35
36 function requiresFreshHumanApproval(tool: string): boolean {
37 return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape" || tool === "config_write";
38 }
39
40 const APPROVAL_MODE_RANK: Record<ToolApprovalMode, number> = { ask: 0, auto: 1, yolo: 2 };
41
42 export function approvalToolLabel(tool: string, t: Translator): string {
43 switch (tool) {
44 case "bash":
45 return t("approval.toolLabelBash");
46 case "edit_file":
47 return t("approval.toolLabelEditFile");
48 case "write_file":
49 return t("approval.toolLabelWriteFile");
50 case "multi_edit":
51 return t("approval.toolLabelMultiEdit");
52 case "move_file":
53 return t("approval.toolLabelMoveFile");
54 case "web_fetch":
55 return t("approval.toolLabelWebFetch");
56 case "run_skill":
57 return t("approval.toolLabelRunSkill");
58 case "remember":
59 return t("approval.toolLabelRemember");
60 case "forget":
61 return t("approval.toolLabelForget");
62 case "sandbox_escape":
63 return t("approval.toolLabelSandboxEscape");
64 case "config_write":
65 return t("approval.toolLabelConfigWrite");
66 case "plan_mode_read_only_command":
67 return t("approval.toolLabelPlanModeReadOnly");
68 case "exit_plan_mode":
69 return t("approval.toolLabelExitPlan");
70 default:
71 return tool;
72 }
73 }
74
75 const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
76 const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
77 const configWriteEnglishSubjectPrefix = "write Reasonix config: ";
78 const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
79
80 function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
81 const trimmed = subject.trim();
82 if (tool === "sandbox_escape") {
83 if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
84 const localizedPrefix = t("approval.sandboxEscapeSubjectPrefix");
85 if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
86 return trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length).trim() || t("approval.sandboxEscapeSubjectFallback");
87 }
88 if (localizedPrefix !== sandboxEscapeEnglishSubjectPrefix && trimmed.startsWith(localizedPrefix)) {
89 return trimmed.slice(localizedPrefix.length).trim() || t("approval.sandboxEscapeSubjectFallback");
90 }
91 return trimmed;
92 }
93 if (tool === "config_write") {
94 if (trimmed.startsWith(configWriteEnglishSubjectPrefix)) {
95 return `${t("approval.configWriteSubjectPrefix")}${trimmed.slice(configWriteEnglishSubjectPrefix.length)}`;
96 }
97 return trimmed;
98 }
99 if (tool === "remember") {
100 return trimmed
101 .replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
102 .replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
103 }
104 if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
105 return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
106 }
107 const bashTrust = trimmed.match(planModeBashEnglishSubject);
108 if (bashTrust) {
109 return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
110 }
111 return trimmed;
112 }
113
114 function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
115 let trimmed = reason?.trim() ?? "";
116 let matchedRule = "";
117 const matchedRulePrefix = "Matched permission rule: ";
118 if (trimmed.startsWith(matchedRulePrefix)) {
119 const [ruleLine, ...remainingLines] = trimmed.split(/\r?\n/);
120 matchedRule = t("approval.matchedPermissionRule", { rule: ruleLine.slice(matchedRulePrefix.length).trim() });
121 trimmed = remainingLines.join("\n").trim();
122 }
123 let localized = trimmed;
124 if (tool === "bash" && trimmed.includes("nested or indirect shell execution")) {
125 localized = t("approval.dynamicBashReason");
126 }
127 if (tool === "config_write") {
128 localized = !trimmed || trimmed.includes("Reasonix-managed configuration file") ? t("approval.configWriteReason") : trimmed;
129 }
130 if (tool === "sandbox_escape") {
131 if (trimmed.includes("could not wrap this command") || trimmed.includes("does not provide an OS-level Bash sandbox")) {
132 localized = t("approval.sandboxEscapeWrapReason");
133 } else if (
134 trimmed.includes("failed while starting this command") ||
135 trimmed.includes("could not start this command") ||
136 trimmed.includes("Run this command unconfined once?")
137 ) {
138 localized = t("approval.sandboxEscapeRuntimeReason");
139 } else {
140 localized ||= t("approval.sandboxEscapeRuntimeReason");
141 }
142 }
143 return [matchedRule, localized].filter(Boolean).join(" ");
144 }
145
146 function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
147 if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
148 return t("approval.planModeBashTrustReason");
149 }
150 return reason;
151 }
152
153 type DecisionAction = {
154 key: string;
155 label: string;
156 desc: string;
157 tone?: "default" | "danger";
158 primary?: boolean;
159 // Plan revision and plan guidance open inline editors instead of submitting.
160 // Other recovery actions use direct-click submit (no select-then-confirm).
161 kind: "submit" | "toggle-revision" | "toggle-guidance" | "direct";
162 run?: () => void;
163 };
164
165 const RECOVERY_FEEDBACK_MAX = 1000;
166
167 function recoveryReasonText(
168 changeKind: string | undefined,
169 fallback: string | undefined,
170 t: Translator,
171 ): string {
172 switch ((changeKind ?? "").toLowerCase()) {
173 case "risk":
174 return t("approval.recoveryReasonRisk");
175 case "scope":
176 return t("approval.recoveryReasonScope");
177 case "strategy":
178 return t("approval.recoveryReasonStrategy");
179 case "uncertain":
180 case "same_strategy":
181 return t("approval.recoveryReasonUncertain");
182 default:
183 return fallback?.trim() || t("approval.recoveryReasonUncertain");
184 }
185 }
186
187 type PlanLine = { key: string; text: string };
188 type PlanDelta = { removed: string[]; added: string[] };
189
190 function planLines(raw: string | undefined): PlanLine[] {
191 return (raw ?? "")
192 .split(/\r?\n/)
193 .map((line) => line.replace(/\s+\[[^\]\r\n]+\]\s*$/, "").trimEnd())
194 .filter((line) => line.trim() !== "")
195 .map((line) => {
196 const match = line.match(/^(\s*)(?:\d+\.\s*)?(.*)$/);
197 const nested = (match?.[1].length ?? 0) > 0;
198 const body = (match?.[2] ?? line).replace(/\s+/g, " ").trim();
199 return { key: `${nested ? 1 : 0}:${body}`, text: `${nested ? " " : ""}${body}` };
200 });
201 }
202
203 // LCS keeps unchanged steps out of the card and turns additions, removals, and
204 // reordering into a compact plan-level delta. Status suffixes are ignored.
205 function planDelta(beforeRaw: string | undefined, afterRaw: string | undefined): PlanDelta | null {
206 const before = planLines(beforeRaw);
207 const after = planLines(afterRaw);
208 if (before.length === 0 || after.length === 0) return null;
209 const dp = Array.from({ length: before.length + 1 }, () => Array<number>(after.length + 1).fill(0));
210 for (let i = before.length - 1; i >= 0; i -= 1) {
211 for (let j = after.length - 1; j >= 0; j -= 1) {
212 dp[i][j] = before[i].key === after[j].key
213 ? dp[i + 1][j + 1] + 1
214 : Math.max(dp[i + 1][j], dp[i][j + 1]);
215 }
216 }
217 const removed: string[] = [];
218 const added: string[] = [];
219 let i = 0;
220 let j = 0;
221 while (i < before.length && j < after.length) {
222 if (before[i].key === after[j].key) {
223 i += 1;
224 j += 1;
225 } else if (dp[i + 1][j] >= dp[i][j + 1]) {
226 removed.push(before[i].text);
227 i += 1;
228 } else {
229 added.push(after[j].text);
230 j += 1;
231 }
232 }
233 while (i < before.length) removed.push(before[i++].text);
234 while (j < after.length) added.push(after[j++].text);
235 return removed.length > 0 || added.length > 0 ? { removed, added } : null;
236 }
237
238 export function ApprovalModal({
239 approval,
240 onAnswer,
241 onResolveRecovery,
242 onRevisePlan,
243 onExitPlan,
244 onStop,
245 cwd,
246 tabId,
247 workspaceScopeKey,
248 insertRequest,
249 onRevisionActiveChange,
250 toolApprovalMode,
251 }: {
252 approval: WireApproval;
253 onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
254 onResolveRecovery?: (action: "continue" | "continue_task" | "revise", feedback?: string) => void;
255 onRevisePlan?: (text: string) => void;
256 onExitPlan?: () => void;
257 onStop: () => void;
258 cwd?: string;
259 tabId?: string;
260 workspaceScopeKey?: string;
261 insertRequest?: ComposerInsertRequest | null;
262 onRevisionActiveChange?: (active: boolean) => void;
263 toolApprovalMode?: ToolApprovalMode;
264 }) {
265 const t = useT();
266 const isPlanApproval = approval.tool === "exit_plan_mode";
267 const isRecoveryApproval = approval.kind === "recovery" || Boolean(approval.recovery);
268 const recovery = approval.recovery;
269 const recoveryChangeKind = (recovery?.change_kind ?? "").toLowerCase();
270 const isRecoveryPlanChange =
271 isRecoveryApproval && (recoveryChangeKind === "strategy" || recoveryChangeKind === "scope");
272 const taskGrantScope = recovery?.task_grant_scope?.trim() ?? "";
273 const toolLabel = approvalToolLabel(approval.tool, t);
274 const isFreshHumanApproval = approval.fresh === true || requiresFreshHumanApproval(approval.tool) || isRecoveryApproval;
275 const hasFreshSessionGrant = approval.tool === "sandbox_escape" || approval.tool === "config_write";
276 // Switching the approval segmented control to a more permissive mode does not
277 // resolve an already-pending request; say so on the card instead of leaving
278 // the user to wonder why the switch "did nothing".
279 const initialToolApprovalModeRef = useRef(toolApprovalMode);
280 const approvalModeRelaxed =
281 !isPlanApproval &&
282 toolApprovalMode !== undefined &&
283 initialToolApprovalModeRef.current !== undefined &&
284 APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current];
285 const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
286 const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
287 const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
288 // Plan approvals already show the plan above; keep a short hint. Tool
289 // approvals render their command/subject in the details block, so header
290 // metadata is only a fallback when there is no subject to show there.
291 const toolMeta = isPlanApproval ? t("approval.planReadyHint") : (!subject ? (reason || approval.tool) : undefined);
292 const hasToolDetails = Boolean(reason || subject);
293 // Subject (command) is visible by default; long reason can collapse.
294 const [reasonOpen, setReasonOpen] = useState(() => {
295 if (isRecoveryApproval) return false; // recovery details stay collapsed
296 return Boolean(reason) && reason.length <= 160;
297 });
298 // Immediate Plan/Auto decisions have no hidden selection. Ordinary tool
299 // approvals retain select-then-confirm and default to Allow once.
300 const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval || isRecoveryApproval ? -1 : 0));
301 const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null);
302 const [descriptionTruncated, setDescriptionTruncated] = useState(false);
303 const [revisionOpen, setRevisionOpen] = useState(false);
304 const [revisionText, setRevisionText] = useState("");
305 const [recoveryGuidanceOpen, setRecoveryGuidanceOpen] = useState(false);
306 const [recoveryGuidanceText, setRecoveryGuidanceText] = useState("");
307 const [grantSimilarForTask, setGrantSimilarForTask] = useState(false);
308 const [submitting, setSubmitting] = useState(false);
309 const instanceId = useId();
310 const cardRef = useRef<HTMLDivElement | null>(null);
311 const shelfRef = useRef<HTMLDivElement | null>(null);
312 const inputRef = useRef<HTMLTextAreaElement | null>(null);
313 const recoveryGuidanceRef = useRef<HTMLTextAreaElement | null>(null);
314 const recoveryGuidanceTriggerRef = useRef<HTMLButtonElement | null>(null);
315 const consumedInsertIdRef = useRef(0);
316 const onRevisionActiveChangeRef = useRef(onRevisionActiveChange);
317 const revisionActiveRef = useRef(false);
318 onRevisionActiveChangeRef.current = onRevisionActiveChange;
319 // When consecutive approvals arrive, animate the old card out before
320 // the new one slides in. GSAP fromTo on the shelf wrapper avoids the
321 // jarring pop when the API cycles through 4+ pending approvals.
322 const closingRef = useRef(false);
323 const fileMenu = useFileReferenceMenu(revisionText, cwd, tabId, workspaceScopeKey);
324
325 const answerWithExit = (fn: () => void) => {
326 if (closingRef.current || submitting) return;
327 closingRef.current = true;
328 setSubmitting(true);
329 const el = shelfRef.current;
330 if (el) {
331 animateShelfExit(el, {
332 opacity: 0,
333 y: 8,
334 duration: DUR_FAST,
335 ease: "power2.in",
336 onComplete: fn,
337 });
338 } else {
339 fn();
340 }
341 };
342
343 const resolveRecovery = useCallback(
344 (action: "continue" | "continue_task" | "revise", feedback?: string) => {
345 const resolve = onResolveRecovery ?? ((a: "continue" | "continue_task" | "revise") => onAnswer(a !== "revise", false, false));
346 if (action === "revise") {
347 const text = feedback?.trim().slice(0, RECOVERY_FEEDBACK_MAX) ?? "";
348 resolve("revise", text || undefined);
349 return;
350 }
351 resolve(action);
352 },
353 [onResolveRecovery, onAnswer],
354 );
355
356 const toolActions: DecisionAction[] = isRecoveryPlanChange
357 ? [
358 {
359 key: "1",
360 label: t("approval.recoveryAdoptPlan"),
361 desc: t("approval.recoveryAdoptPlanDesc"),
362 kind: "direct",
363 run: () => resolveRecovery("continue"),
364 },
365 {
366 key: "2",
367 label: t("approval.recoveryAdjustPlan"),
368 desc: t("approval.recoveryAdjustPlanDesc"),
369 kind: "toggle-guidance",
370 },
371 ]
372 : isRecoveryApproval
373 ? [
374 {
375 key: "1",
376 label: t("approval.recoveryRevise"),
377 desc: t("approval.recoveryReviseDesc"),
378 primary: true,
379 kind: "direct",
380 run: () => resolveRecovery("revise"),
381 },
382 {
383 key: "2",
384 label: grantSimilarForTask
385 ? t("approval.recoveryContinueTask")
386 : t("approval.recoveryContinue"),
387 desc: grantSimilarForTask
388 ? t("approval.recoveryContinueTaskDesc")
389 : t("approval.recoveryContinueDesc"),
390 kind: "direct",
391 run: () => resolveRecovery(grantSimilarForTask && recovery?.can_grant_task ? "continue_task" : "continue"),
392 },
393 ]
394 : isPlanApproval
395 ? [
396 {
397 key: "1",
398 label: t("approval.startExecution"),
399 desc: t("approval.startExecutionDesc"),
400 primary: true,
401 kind: "direct",
402 run: () => onAnswer(true, false, false),
403 },
404 {
405 key: "2",
406 label: t("approval.revisePlan"),
407 desc: t("approval.revisePlanDesc"),
408 kind: "toggle-revision",
409 },
410 ...(onExitPlan
411 ? [{
412 key: "3",
413 label: t("approval.exitPlanWithoutExecution"),
414 desc: t("approval.exitPlanWithoutExecutionDesc"),
415 kind: "direct" as const,
416 run: () => onExitPlan(),
417 }]
418 : []),
419 ]
420 : [
421 {
422 key: "1",
423 label: t("approval.allowOnce"),
424 desc: t("approval.allowOnceDesc"),
425 kind: "submit",
426 run: () => onAnswer(true, false, false),
427 },
428 ...(isFreshHumanApproval
429 ? hasFreshSessionGrant
430 ? [
431 {
432 key: "2",
433 label: t(approval.tool === "config_write" ? "approval.allowConfigWriteSession" : "approval.allowSandboxEscapeSession"),
434 desc: t(approval.tool === "config_write" ? "approval.allowConfigWriteSessionDesc" : "approval.allowSandboxEscapeSessionDesc"),
435 kind: "submit" as const,
436 run: () => onAnswer(true, true, false),
437 },
438 {
439 key: "3",
440 label: t("approval.deny"),
441 desc: t("approval.denyDesc"),
442 tone: "danger" as const,
443 kind: "submit" as const,
444 run: () => onAnswer(false, false, false),
445 },
446 ]
447 : [
448 {
449 key: "2",
450 label: t("approval.deny"),
451 desc: t("approval.denyDesc"),
452 tone: "danger" as const,
453 kind: "submit" as const,
454 run: () => onAnswer(false, false, false),
455 },
456 ]
457 : [
458 {
459 key: "2",
460 label: t("approval.allowRuleSession"),
461 desc: t("approval.allowRuleSessionDesc"),
462 kind: "submit" as const,
463 run: () => onAnswer(true, true, false),
464 },
465 {
466 key: "3",
467 label: t("approval.allowRulePersistent"),
468 desc: t("approval.allowRulePersistentDesc"),
469 kind: "submit" as const,
470 run: () => onAnswer(true, true, true),
471 },
472 {
473 key: "4",
474 label: t("approval.deny"),
475 desc: t("approval.denyDesc"),
476 tone: "danger" as const,
477 kind: "submit" as const,
478 run: () => onAnswer(false, false, false),
479 },
480 ]),
481 ];
482
483 const actionCount = toolActions.length;
484 const selectedIndexRef = useRef(selectedIndex);
485 selectedIndexRef.current = selectedIndex;
486 const selectedAction = toolActions[Math.min(selectedIndex, actionCount - 1)] ?? toolActions[0];
487 const selectedDescriptionId = !isPlanApproval && !isRecoveryApproval && selectedIndex >= 0
488 ? `${instanceId}-description-${selectedIndex}`
489 : undefined;
490 const descriptionExpanded = selectedDescriptionId !== undefined && expandedDescriptionId === selectedDescriptionId;
491
492 useEffect(() => {
493 cardRef.current?.focus();
494 setRevisionOpen(false);
495 setRevisionText("");
496 setRecoveryGuidanceOpen(false);
497 setRecoveryGuidanceText("");
498 setGrantSimilarForTask(false);
499 setReasonOpen(isRecoveryApproval ? false : Boolean(reason) && reason.length <= 160);
500 setSelectedIndex(isPlanApproval || isRecoveryApproval ? -1 : 0);
501 setSubmitting(false);
502 closingRef.current = false;
503 }, [approval.id, isPlanApproval, isRecoveryApproval, reason]);
504
505 useEffect(() => {
506 setExpandedDescriptionId(null);
507 }, [approval.id]);
508
509 const confirmSelected = useCallback(() => {
510 if (submitting || closingRef.current) return;
511 if (isPlanApproval || isRecoveryApproval) return;
512 const action = toolActions[selectedIndexRef.current];
513 if (!action) return;
514 if (action.kind === "toggle-revision") {
515 setRevisionOpen((open) => !open);
516 return;
517 }
518 if (action.kind === "toggle-guidance") {
519 setGrantSimilarForTask(false);
520 setRecoveryGuidanceOpen(true);
521 return;
522 }
523 if (action.run) answerWithExit(action.run);
524 }, [submitting, toolActions, isPlanApproval, isRecoveryApproval]);
525
526 const activateAction = useCallback((action: DecisionAction, index: number) => {
527 if (submitting) return;
528 if (action.kind === "direct" && action.run) {
529 answerWithExit(action.run);
530 return;
531 }
532 if (action.kind === "toggle-revision") {
533 setRevisionOpen((open) => !open);
534 return;
535 }
536 if (action.kind === "toggle-guidance") {
537 setGrantSimilarForTask(false);
538 setRecoveryGuidanceOpen(true);
539 return;
540 }
541 setSelectedIndex(index);
542 }, [submitting]);
543
544 useEffect(() => {
545 const onKeyDown = (event: globalThis.KeyboardEvent) => {
546 if (submitting) return;
547 if (isRecoveryApproval && recoveryGuidanceOpen && event.key === "Escape") {
548 event.preventDefault();
549 setRecoveryGuidanceOpen(false);
550 setRecoveryGuidanceText("");
551 requestAnimationFrame(() => {
552 if (isRecoveryPlanChange) cardRef.current?.focus();
553 else recoveryGuidanceTriggerRef.current?.focus();
554 });
555 return;
556 }
557 const target = event.target instanceof Element ? event.target : null;
558 const tag = target?.tagName.toLowerCase();
559 // Editing revision / file menu owns arrows and digits while focused.
560 // Custom recovery guidance owns all decision shortcuts while expanded.
561 const editing =
562 tag === "input" ||
563 tag === "textarea" ||
564 tag === "select" ||
565 (target instanceof HTMLElement && target.isContentEditable) ||
566 (isRecoveryApproval && recoveryGuidanceOpen);
567 if (editing && (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4")) {
568 return;
569 }
570 if (tag === "input" || tag === "textarea" || tag === "select" || (target instanceof HTMLElement && target.isContentEditable)) return;
571 const immediateDecision = isPlanApproval || isRecoveryApproval;
572 if (immediateDecision && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
573 return;
574 }
575 if (event.key === "ArrowUp") {
576 event.preventDefault();
577 setSelectedIndex((i) => {
578 const base = i < 0 ? 0 : i;
579 return (base - 1 + actionCount) % actionCount;
580 });
581 } else if (event.key === "ArrowDown") {
582 event.preventDefault();
583 setSelectedIndex((i) => {
584 const base = i < 0 ? -1 : i;
585 return (base + 1) % actionCount;
586 });
587 } else if (event.key === "Enter") {
588 if (isRecoveryApproval && selectedIndexRef.current < 0) return;
589 event.preventDefault();
590 confirmSelected();
591 } else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4") {
592 if (isRecoveryApproval && recoveryGuidanceOpen) return;
593 const index = Number(event.key) - 1;
594 if (index < 0 || index >= actionCount) return;
595 event.preventDefault();
596 if (immediateDecision) {
597 const action = toolActions[index];
598 if (action) activateAction(action, index);
599 return;
600 }
601 setSelectedIndex(index);
602 } else if (event.key === "Escape") {
603 event.preventDefault();
604 answerWithExit(onStop);
605 }
606 };
607 document.addEventListener("keydown", onKeyDown);
608 return () => document.removeEventListener("keydown", onKeyDown);
609 }, [actionCount, activateAction, confirmSelected, onStop, submitting, isPlanApproval, isRecoveryApproval, isRecoveryPlanChange, recoveryGuidanceOpen, toolActions]);
610
611 useEffect(() => {
612 revisionActiveRef.current = revisionOpen;
613 onRevisionActiveChangeRef.current?.(revisionOpen);
614 if (revisionOpen) inputRef.current?.focus();
615 }, [revisionOpen]);
616
617 useEffect(() => () => {
618 if (revisionActiveRef.current) onRevisionActiveChangeRef.current?.(false);
619 }, []);
620
621 const focusRevisionInput = (caret = revisionText.length) => {
622 requestAnimationFrame(() => {
623 const input = inputRef.current;
624 if (!input) return;
625 input.focus();
626 input.setSelectionRange(caret, caret);
627 });
628 };
629
630 const insertRevisionText = useCallback((text: string) => {
631 const input = inputRef.current;
632 const start = input?.selectionStart ?? revisionText.length;
633 const end = input?.selectionEnd ?? start;
634 const next = insertTextAtSelection(revisionText, text, start, end);
635 setRevisionText(next.value);
636 focusRevisionInput(next.caret);
637 }, [revisionText]);
638
639 useEffect(() => {
640 if (!insertRequest || insertRequest.id === consumedInsertIdRef.current) return;
641 consumedInsertIdRef.current = insertRequest.id;
642 insertRevisionText(insertRequest.text);
643 }, [insertRequest, insertRevisionText]);
644
645 const pickRevisionFile = (entry: DirEntry) => {
646 const next = pickInlineFileReference(revisionText, fileMenu.atRaw, fileMenu.atDir, entry);
647 setRevisionText(next);
648 focusRevisionInput(next.length);
649 };
650
651 const onRevisionKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
652 if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
653 submitRevision();
654 event.stopPropagation();
655 return;
656 }
657 if (fileMenu.open) {
658 if (event.key === "ArrowDown" && fileMenu.count > 0) {
659 event.preventDefault();
660 fileMenu.setActive((index) => (index + 1) % fileMenu.count);
661 return;
662 }
663 if (event.key === "ArrowUp" && fileMenu.count > 0) {
664 event.preventDefault();
665 fileMenu.setActive((index) => (index - 1 + fileMenu.count) % fileMenu.count);
666 return;
667 }
668 if ((event.key === "Enter" || event.key === "Tab") && fileMenu.count > 0) {
669 event.preventDefault();
670 const entry = fileMenu.items[fileMenu.active];
671 if (entry) pickRevisionFile(entry);
672 return;
673 }
674 if (event.key === "Escape") {
675 event.preventDefault();
676 fileMenu.dismiss();
677 return;
678 }
679 }
680 event.stopPropagation();
681 };
682
683 const submitRevision = () => {
684 const text = revisionText.trim();
685 if (!text) {
686 inputRef.current?.focus();
687 return;
688 }
689 answerWithExit(() => onRevisePlan?.(text));
690 };
691
692 const closeRecoveryGuidance = () => {
693 setRecoveryGuidanceOpen(false);
694 setRecoveryGuidanceText("");
695 requestAnimationFrame(() => {
696 if (isRecoveryPlanChange) cardRef.current?.focus();
697 else recoveryGuidanceTriggerRef.current?.focus();
698 });
699 };
700
701 const submitRecoveryGuidance = () => {
702 const text = recoveryGuidanceText.trim();
703 if (!text) {
704 recoveryGuidanceRef.current?.focus();
705 return;
706 }
707 answerWithExit(() => resolveRecovery("revise", text));
708 };
709
710 const onRecoveryGuidanceKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
711 if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
712 event.preventDefault();
713 event.stopPropagation();
714 submitRecoveryGuidance();
715 return;
716 }
717 if (event.key === "Escape") {
718 event.preventDefault();
719 event.stopPropagation();
720 closeRecoveryGuidance();
721 return;
722 }
723 event.stopPropagation();
724 };
725
726 const recoveryReason = isRecoveryApproval
727 ? recoveryReasonText(
728 recovery?.change_kind,
729 recovery?.change_rationale || recovery?.review_rationale || reason,
730 t,
731 )
732 : "";
733 const recoveryActionSummary =
734 recovery?.next_action ||
735 recovery?.next_tool ||
736 subjectSummary ||
737 approval.tool;
738 const recoveryPlanDelta = isRecoveryPlanChange
739 ? planDelta(recovery?.plan_before, recovery?.plan_after)
740 : null;
741 const hasRecoveryDetails = Boolean(
742 recovery?.failed_summary ||
743 recovery?.diagnosis ||
744 recovery?.change_rationale ||
745 recovery?.review_rationale ||
746 recovery?.source_agent,
747 );
748
749 const confirmIsDanger = selectedAction?.tone === "danger";
750 const confirmLabel =
751 selectedAction?.kind === "toggle-revision"
752 ? revisionOpen
753 ? t("common.cancel")
754 : t("approval.revisePlan")
755 : t("decision.confirm");
756
757 return (
758 <div ref={shelfRef}>
759 <PromptShelf
760 decision
761 actionsRole={isPlanApproval || isRecoveryApproval ? "group" : "listbox"}
762 className={isPlanApproval ? "prompt-shelf--plan-approval" : isRecoveryApproval ? "prompt-shelf--recovery-approval" : "prompt-shelf--tool-approval"}
763 barRef={cardRef}
764 titleId={isPlanApproval ? "plan-approval-title" : isRecoveryApproval ? "recovery-approval-title" : "tool-approval-title"}
765 title={
766 isPlanApproval
767 ? t("approval.planReady")
768 : isRecoveryPlanChange
769 ? t("approval.recoveryPlanChangePending")
770 : isRecoveryApproval
771 ? t("approval.recoveryPending")
772 : t("approval.toolPending")
773 }
774 badges={
775 <>
776 {!isPlanApproval && !isRecoveryApproval && <PromptBadge tone="amber">{toolLabel}</PromptBadge>}
777 {isPlanApproval && revisionOpen && <PromptBadge>{t("approval.revisePlan")}</PromptBadge>}
778 {isRecoveryPlanChange && (
779 <PromptBadge>
780 {t(recoveryChangeKind === "strategy" ? "approval.recoveryDecisionStrategy" : "approval.recoveryDecisionScope")}
781 </PromptBadge>
782 )}
783 </>
784 }
785 meta={isRecoveryApproval ? undefined : toolMeta}
786 headerActions={
787 <>
788 {isRecoveryApproval && hasRecoveryDetails && (
789 <PromptHeaderAction onClick={() => setReasonOpen((open) => !open)} disabled={submitting}>
790 {t(reasonOpen ? "approval.recoveryHideTechnicalDetails" : "approval.recoveryTechnicalDetails")}
791 </PromptHeaderAction>
792 )}
793 {!isPlanApproval && !isRecoveryApproval && hasToolDetails && reason && (
794 <PromptHeaderAction onClick={() => setReasonOpen((open) => !open)} disabled={submitting}>
795 {t(reasonOpen ? "approval.hideDetails" : "approval.details")}
796 </PromptHeaderAction>
797 )}
798 {!isPlanApproval && !isRecoveryApproval && (
799 <PromptHeaderAction
800 onClick={() => answerWithExit(onStop)}
801 ariaLabel={t("decision.stopTask")}
802 disabled={submitting}
803 >
804 {t("decision.stopTask")}
805 </PromptHeaderAction>
806 )}
807 </>
808 }
809 actions={
810 <>
811 {toolActions.map((action, index) => {
812 const actionNode = (
813 <PromptAction
814 key={action.key}
815 keyLabel={action.key}
816 label={action.label}
817 description={action.desc}
818 descriptionId={`${instanceId}-description-${index}`}
819 descriptionDisclosure
820 onDescriptionOverflowChange={!isPlanApproval && !isRecoveryApproval && selectedIndex === index
821 ? setDescriptionTruncated
822 : undefined}
823 onClick={() => {
824 activateAction(action, index);
825 }}
826 primary={action.primary}
827 selected={selectedIndex === index}
828 tone={action.tone}
829 role={isPlanApproval || isRecoveryApproval ? "button" : "option"}
830 disabled={submitting}
831 />
832 );
833 if (isRecoveryApproval && !isRecoveryPlanChange && index === 1 && recovery?.can_grant_task) {
834 return (
835 <div
836 key={action.key}
837 className={[
838 "recovery-continue-option",
839 grantSimilarForTask ? "recovery-continue-option--granted" : "",
840 ].filter(Boolean).join(" ")}
841 >
842 {actionNode}
843 {!recoveryGuidanceOpen && (
844 <label className="recovery-task-grant">
845 <input
846 type="checkbox"
847 checked={grantSimilarForTask}
848 onChange={(event) => setGrantSimilarForTask(event.target.checked)}
849 disabled={submitting}
850 />
851 <span>
852 <strong>{t("approval.recoveryTaskGrant")}</strong>
853 <small>
854 {taskGrantScope ? (
855 <>
856 {t("approval.recoveryTaskGrantScope")} <code>{taskGrantScope}</code>
857 </>
858 ) : t("approval.recoveryTaskGrantDesc")}
859 </small>
860 </span>
861 </label>
862 )}
863 </div>
864 );
865 }
866 return actionNode;
867 })}
868 </>
869 }
870 note={
871 !isPlanApproval && !isRecoveryApproval && selectedDescriptionId && descriptionTruncated ? (
872 <PromptDescriptionDisclosure
873 descriptionId={`${selectedDescriptionId}-detail`}
874 label={selectedAction?.label}
875 description={selectedAction?.desc ?? ""}
876 expanded={descriptionExpanded}
877 onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)}
878 disabled={submitting}
879 />
880 ) : isRecoveryApproval ? (
881 recoveryGuidanceOpen ? (
882 <div className="recovery-guidance">
883 <textarea
884 ref={recoveryGuidanceRef}
885 className="plan-revision__input recovery-guidance__input"
886 value={recoveryGuidanceText}
887 rows={3}
888 maxLength={RECOVERY_FEEDBACK_MAX}
889 aria-label={t("approval.recoveryGuidanceLabel")}
890 placeholder={t("approval.recoveryGuidancePlaceholder")}
891 onChange={(event) => setRecoveryGuidanceText(event.target.value.slice(0, RECOVERY_FEEDBACK_MAX))}
892 onKeyDown={onRecoveryGuidanceKeyDown}
893 disabled={submitting}
894 autoFocus
895 />
896 <div className="recovery-guidance__actions">
897 <button className="btn" type="button" onClick={closeRecoveryGuidance} disabled={submitting}>
898 {t("common.cancel")}
899 </button>
900 <button
901 className="btn btn--primary"
902 type="button"
903 onClick={submitRecoveryGuidance}
904 disabled={submitting || !recoveryGuidanceText.trim()}
905 >
906 {t(isRecoveryPlanChange ? "approval.recoveryPlanGuidanceSubmit" : "approval.recoveryGuidanceSubmit")}
907 </button>
908 </div>
909 </div>
910 ) : isRecoveryPlanChange ? undefined : (
911 <button
912 ref={recoveryGuidanceTriggerRef}
913 type="button"
914 className="recovery-guidance-trigger"
915 aria-expanded="false"
916 onClick={() => {
917 // Guidance rejects the pending action; a task-scoped grant
918 // belongs only to Continue and would be misleading here.
919 setGrantSimilarForTask(false);
920 setRecoveryGuidanceOpen(true);
921 }}
922 disabled={submitting}
923 >
924 {t("approval.recoveryGuidanceTrigger")}
925 </button>
926 )
927 ) : undefined
928 }
929 footer={
930 isRecoveryApproval || isPlanApproval ? undefined : (
931 <DecisionConfirmBar
932 hint={t("decision.selectHint")}
933 confirmLabel={confirmLabel}
934 onConfirm={confirmSelected}
935 disabled={submitting}
936 danger={confirmIsDanger}
937 />
938 )
939 }
940 >
941 {(approvalModeRelaxed ||
942 isRecoveryApproval ||
943 (!isPlanApproval && !isRecoveryApproval && (subject || (reasonOpen && reason))) ||
944 (isPlanApproval && revisionOpen)) && (
945 <>
946 {approvalModeRelaxed && !isRecoveryApproval && (
947 <div className="approval-mode-hint">{t("approval.modeSwitchPendingHint")}</div>
948 )}
949 {isRecoveryApproval && (
950 <section className="recovery-summary" aria-label={t("approval.recoverySummaryLabel")}>
951 <p className="recovery-summary__reason">{recoveryReason}</p>
952 {!isRecoveryPlanChange && recoveryActionSummary && (
953 <p className="recovery-summary__action">
954 <span>{t("approval.recoveryNextLabel")}</span>
955 <code>{recoveryActionSummary}</code>
956 </p>
957 )}
958 </section>
959 )}
960 {isRecoveryPlanChange && recoveryPlanDelta && (
961 <section className="plan-change-delta" aria-label={t("approval.recoveryPlanDeltaLabel")}>
962 <div className="plan-change-delta__title">{t("approval.recoveryPlanDeltaLabel")}</div>
963 {recoveryPlanDelta.removed.length > 0 && (
964 <div className="plan-change-delta__group plan-change-delta__group--removed">
965 <div className="plan-change-delta__label">{t("approval.recoveryPlanRemoved")}</div>
966 {recoveryPlanDelta.removed.map((line, index) => (
967 <div className="plan-change-delta__line" key={`removed-${index}-${line}`}><span>−</span>{line}</div>
968 ))}
969 </div>
970 )}
971 {recoveryPlanDelta.added.length > 0 && (
972 <div className="plan-change-delta__group plan-change-delta__group--added">
973 <div className="plan-change-delta__label">{t("approval.recoveryPlanAdded")}</div>
974 {recoveryPlanDelta.added.map((line, index) => (
975 <div className="plan-change-delta__line" key={`added-${index}-${line}`}><span>+</span>{line}</div>
976 ))}
977 </div>
978 )}
979 </section>
980 )}
981 {isRecoveryApproval && reasonOpen && (
982 <dl className="approval-details recovery-details">
983 {recovery?.failed_summary && (
984 <div className="recovery-detail-row">
985 <dt>{t("approval.recoveryFailedLabel")}</dt>
986 <dd>
987 {recovery.failed_tool && <code>{recovery.failed_tool}</code>}
988 {recovery.failed_tool && " · "}
989 {recovery.failed_summary}
990 </dd>
991 </div>
992 )}
993 {recovery?.diagnosis && (
994 <div className="recovery-detail-row">
995 <dt>{t("approval.recoveryDiagnosisLabel")}</dt>
996 <dd>{recovery.diagnosis}</dd>
997 </div>
998 )}
999 {(recovery?.change_rationale || recovery?.review_rationale) && (
1000 <div className="recovery-detail-row">
1001 <dt>{t("approval.recoveryWhyLabel")}</dt>
1002 <dd>{recovery.change_rationale || recovery.review_rationale}</dd>
1003 </div>
1004 )}
1005 {recovery?.source_agent && (
1006 <div className="recovery-detail-row">
1007 <dt>{t("approval.recoverySourceLabel")}</dt>
1008 <dd><code>{recovery.source_agent}</code></dd>
1009 </div>
1010 )}
1011 </dl>
1012 )}
1013 {!isPlanApproval && !isRecoveryApproval && subject && (
1014 <div className="approval-details">
1015 <pre className="approval-subject">{subject}</pre>
1016 {reasonOpen && reason && <div className="approval-reason">{reason}</div>}
1017 </div>
1018 )}
1019 {isPlanApproval && revisionOpen && (
1020 <div className="plan-revision">
1021 <textarea
1022 ref={inputRef}
1023 className="plan-revision__input"
1024 value={revisionText}
1025 rows={3}
1026 placeholder={t("approval.revisePlanPlaceholder")}
1027 onChange={(event) => setRevisionText(event.target.value)}
1028 onFocus={() => onRevisionActiveChange?.(true)}
1029 onKeyDown={onRevisionKeyDown}
1030 disabled={submitting}
1031 />
1032 {fileMenu.open && (
1033 <FileReferenceMenu
1034 items={fileMenu.items}
1035 activeIndex={fileMenu.active}
1036 onPick={pickRevisionFile}
1037 onHover={fileMenu.setActive}
1038 />
1039 )}
1040 <div className="plan-revision__actions">
1041 <button className="btn" type="button" onClick={() => setRevisionOpen(false)} disabled={submitting}>
1042 {t("common.cancel")}
1043 </button>
1044 <button className="btn btn--primary" type="button" onClick={submitRevision} disabled={submitting}>
1045 {t("approval.sendRevision")}
1046 </button>
1047 </div>
1048 </div>
1049 )}
1050 </>
1051 )}
1052 </PromptShelf>
1053 </div>
1054 );
1055 }
1056
1056 lines Plain Text