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