| 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 |