| 1 | import { useEffect, useId, useRef, useState } from "react"; |
| 2 | import { useT } from "../lib/i18n"; |
| 3 | import { |
| 4 | DecisionConfirmBar, |
| 5 | PromptAction, |
| 6 | PromptBadge, |
| 7 | PromptDescriptionDisclosure, |
| 8 | PromptShelf, |
| 9 | } from "./PromptShelf"; |
| 10 | |
| 11 | export function ClearContextCard({ |
| 12 | onCancel, |
| 13 | onConfirm, |
| 14 | }: { |
| 15 | onCancel: () => void; |
| 16 | onConfirm: () => void; |
| 17 | }) { |
| 18 | const t = useT(); |
| 19 | const shelfRef = useRef<HTMLDivElement | null>(null); |
| 20 | // Default safe choice: cancel. |
| 21 | const [selectedIndex, setSelectedIndex] = useState(0); |
| 22 | const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null); |
| 23 | const [descriptionTruncated, setDescriptionTruncated] = useState(false); |
| 24 | const [submitting, setSubmitting] = useState(false); |
| 25 | const instanceId = useId(); |
| 26 | const selectedIndexRef = useRef(0); |
| 27 | selectedIndexRef.current = selectedIndex; |
| 28 | const submittingRef = useRef(false); |
| 29 | submittingRef.current = submitting; |
| 30 | const onCancelRef = useRef(onCancel); |
| 31 | onCancelRef.current = onCancel; |
| 32 | const onConfirmRef = useRef(onConfirm); |
| 33 | onConfirmRef.current = onConfirm; |
| 34 | |
| 35 | const actions = [ |
| 36 | { |
| 37 | key: "1", |
| 38 | label: t("common.cancel"), |
| 39 | desc: t("clearContext.cancelDesc"), |
| 40 | tone: "default" as const, |
| 41 | }, |
| 42 | { |
| 43 | key: "2", |
| 44 | label: t("clearContext.clear"), |
| 45 | desc: t("clearContext.clearDesc"), |
| 46 | tone: "danger" as const, |
| 47 | }, |
| 48 | ]; |
| 49 | const selectedDescriptionId = `${instanceId}-description-${selectedIndex}`; |
| 50 | const descriptionExpanded = expandedDescriptionId === selectedDescriptionId; |
| 51 | |
| 52 | useEffect(() => { |
| 53 | shelfRef.current?.focus(); |
| 54 | setSelectedIndex(0); |
| 55 | setSubmitting(false); |
| 56 | }, []); |
| 57 | |
| 58 | const runSelected = () => { |
| 59 | if (submittingRef.current) return; |
| 60 | submittingRef.current = true; |
| 61 | setSubmitting(true); |
| 62 | if (selectedIndexRef.current === 1) onConfirmRef.current(); |
| 63 | else onCancelRef.current(); |
| 64 | }; |
| 65 | |
| 66 | useEffect(() => { |
| 67 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 68 | if (submittingRef.current) return; |
| 69 | const target = event.target instanceof Element ? event.target : null; |
| 70 | const tag = target?.tagName.toLowerCase(); |
| 71 | if (tag === "input" || tag === "textarea" || (target instanceof HTMLElement && target.isContentEditable)) return; |
| 72 | |
| 73 | if (event.key === "Escape") { |
| 74 | event.preventDefault(); |
| 75 | submittingRef.current = true; |
| 76 | setSubmitting(true); |
| 77 | onCancelRef.current(); |
| 78 | return; |
| 79 | } |
| 80 | if (event.key === "ArrowUp" || event.key === "ArrowDown") { |
| 81 | event.preventDefault(); |
| 82 | setSelectedIndex((i) => (i === 0 ? 1 : 0)); |
| 83 | return; |
| 84 | } |
| 85 | if (event.key === "Enter") { |
| 86 | event.preventDefault(); |
| 87 | runSelected(); |
| 88 | return; |
| 89 | } |
| 90 | if (event.key === "1") { |
| 91 | event.preventDefault(); |
| 92 | setSelectedIndex(0); |
| 93 | return; |
| 94 | } |
| 95 | if (event.key === "2") { |
| 96 | event.preventDefault(); |
| 97 | setSelectedIndex(1); |
| 98 | } |
| 99 | }; |
| 100 | document.addEventListener("keydown", onKeyDown); |
| 101 | return () => document.removeEventListener("keydown", onKeyDown); |
| 102 | }, []); |
| 103 | |
| 104 | return ( |
| 105 | <PromptShelf |
| 106 | decision |
| 107 | className="prompt-shelf--clear-context" |
| 108 | barRef={shelfRef} |
| 109 | titleId="clear-context-shelf-title" |
| 110 | title={t("clearContext.title")} |
| 111 | badges={<PromptBadge tone="amber">{t("clearContext.badge")}</PromptBadge>} |
| 112 | meta={t("clearContext.prompt")} |
| 113 | actions={ |
| 114 | <> |
| 115 | {actions.map((action, index) => ( |
| 116 | <PromptAction |
| 117 | key={action.key} |
| 118 | keyLabel={action.key} |
| 119 | label={action.label} |
| 120 | description={action.desc} |
| 121 | descriptionId={`${instanceId}-description-${index}`} |
| 122 | descriptionDisclosure |
| 123 | onDescriptionOverflowChange={selectedIndex === index ? setDescriptionTruncated : undefined} |
| 124 | onClick={() => { |
| 125 | if (submitting) return; |
| 126 | setSelectedIndex(index); |
| 127 | }} |
| 128 | selected={selectedIndex === index} |
| 129 | tone={action.tone} |
| 130 | disabled={submitting} |
| 131 | /> |
| 132 | ))} |
| 133 | </> |
| 134 | } |
| 135 | note={ |
| 136 | descriptionTruncated ? ( |
| 137 | <PromptDescriptionDisclosure |
| 138 | descriptionId={`${selectedDescriptionId}-detail`} |
| 139 | label={actions[selectedIndex]?.label} |
| 140 | description={actions[selectedIndex]?.desc ?? ""} |
| 141 | expanded={descriptionExpanded} |
| 142 | onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)} |
| 143 | disabled={submitting} |
| 144 | alwaysVisible={actions[selectedIndex]?.tone === "danger"} |
| 145 | /> |
| 146 | ) : undefined |
| 147 | } |
| 148 | footer={ |
| 149 | <DecisionConfirmBar |
| 150 | hint={t("decision.selectHint")} |
| 151 | confirmLabel={t("decision.confirm")} |
| 152 | onConfirm={runSelected} |
| 153 | disabled={submitting} |
| 154 | danger={selectedIndex === 1} |
| 155 | /> |
| 156 | } |
| 157 | /> |
| 158 | ); |
| 159 | } |
| 160 |