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