| ... | @@ -5,7 +5,8 @@ import { | ... | @@ -5,7 +5,8 @@ import { |
| 5 | derivePendingUserInputProgress, | 5 | derivePendingUserInputProgress, |
| 6 | type PendingUserInputDraftAnswer, | 6 | type PendingUserInputDraftAnswer, |
| 7 | } from "../../pendingUserInput"; | 7 | } from "../../pendingUserInput"; |
| 8 | import { CheckIcon } from "lucide-react"; | 8 | import { CheckIcon, ChevronDownIcon } from "lucide-react"; |
| | 9 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 9 | import { cn } from "~/lib/utils"; | 10 | import { cn } from "~/lib/utils"; |
| 10 | | 11 | |
| 11 | interface PendingUserInputPanelProps { | 12 | interface PendingUserInputPanelProps { |
| ... | @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 65 | questionId: string; | 66 | questionId: string; |
| 66 | optionLabel: string; | 67 | optionLabel: string; |
| 67 | } | null>(null); | 68 | } | null>(null); |
| | 69 | // Collapsing hides everything but the header so a tall prompt stops covering |
| | 70 | // the thread the user is trying to read. Scoped to a single question: the card |
| | 71 | // is keyed by request id so the next prompt starts expanded, and storing the |
| | 72 | // collapsed question's id (rather than a bare flag) reopens the card when the |
| | 73 | // prompt advances to its next question, which can happen without a click — |
| | 74 | // sending from the composer advances the active question. |
| | 75 | const [collapsedQuestionId, setCollapsedQuestionId] = useState<string | null>(null); |
| | 76 | const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; |
| 68 | | 77 | |
| 69 | useEffect(() => { | 78 | useEffect(() => { |
| 70 | onAdvanceRef.current = onAdvance; | 79 | onAdvanceRef.current = onAdvance; |
| ... | @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 118 | | 127 | |
| 119 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is | 128 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is |
| 120 | // outside editable fields. Multi-select prompts toggle options in place; single- | 129 | // outside editable fields. Multi-select prompts toggle options in place; single- |
| 121 | // select prompts keep the existing auto-advance behavior. | 130 | // select prompts keep the existing auto-advance behavior. Collapsed prompts opt |
| | 131 | // out, since the numbers they refer to are not on screen. |
| 122 | useEffect(() => { | 132 | useEffect(() => { |
| 123 | if (!activeQuestion || isResponding) return; | 133 | if (!activeQuestion || isResponding || isCollapsed) return; |
| 124 | const handler = (event: globalThis.KeyboardEvent) => { | 134 | const handler = (event: globalThis.KeyboardEvent) => { |
| 125 | if (event.metaKey || event.ctrlKey || event.altKey) return; | 135 | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 126 | const target = event.target; | 136 | const target = event.target; |
| ... | @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 144 | }; | 154 | }; |
| 145 | document.addEventListener("keydown", handler); | 155 | document.addEventListener("keydown", handler); |
| 146 | return () => document.removeEventListener("keydown", handler); | 156 | return () => document.removeEventListener("keydown", handler); |
| 147 | }, [activeQuestion, isResponding]); | 157 | }, [activeQuestion, isCollapsed, isResponding]); |
| 148 | | 158 | |
| 149 | if (!activeQuestion) { | 159 | if (!activeQuestion) { |
| 150 | return null; | 160 | return null; |
| ... | @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 153 | const customAnswerActive = progress.customAnswer.trim().length > 0; | 163 | const customAnswerActive = progress.customAnswer.trim().length > 0; |
| 154 | | 164 | |
| 155 | return ( | 165 | return ( |
| 156 | <div className="px-4 py-3 sm:px-5"> | 166 | <Collapsible |
| 157 | <div className="mb-2 flex items-center gap-3"> | 167 | className="py-3" |
| 158 | <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase"> | 168 | open={!isCollapsed} |
| 159 | {activeQuestion.header} | 169 | onOpenChange={(open) => { |
| 160 | </span> | 170 | setCollapsedQuestionId(open ? null : activeQuestion.id); |
| 161 | {prompt.questions.length > 1 ? ( | 171 | }} |
| 162 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums"> | 172 | > |
| 163 | {questionIndex + 1}/{prompt.questions.length} | 173 | {/* The trigger's wrapper is inset less than the card's text column, and |
| | 174 | the trigger pays the difference back as padding: the hover background |
| | 175 | and focus ring bleed 10px past that column on both sides, while the |
| | 176 | header label and the chevron still line up with the left and right |
| | 177 | edges of the question text below. The negative block margin keeps the |
| | 178 | taller hit area from pushing the panel down. */} |
| | 179 | <div className="px-1.5 sm:px-2.5"> |
| | 180 | <CollapsibleTrigger |
| | 181 | title={ |
| | 182 | isCollapsed ? "Show the question and its options" : "Hide the question and its options" |
| | 183 | } |
| | 184 | data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} |
| | 185 | className="group -my-1 flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/40 focus-visible:ring-1 focus-visible:ring-primary/25" |
| | 186 | > |
| | 187 | <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase group-hover:text-foreground"> |
| | 188 | {activeQuestion.header} |
| 164 | </span> | 189 | </span> |
| 165 | ) : null} | 190 | {prompt.questions.length > 1 ? ( |
| | 191 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums"> |
| | 192 | {questionIndex + 1}/{prompt.questions.length} |
| | 193 | </span> |
| | 194 | ) : null} |
| | 195 | {/* Collapsed, the header is otherwise just a section label and a |
| | 196 | counter, so the question itself is echoed here as a one-line |
| | 197 | reminder of what is being asked. */} |
| | 198 | {isCollapsed ? ( |
| | 199 | <span className="min-w-0 flex-1 truncate text-secondary-label text-xs"> |
| | 200 | {activeQuestion.question} |
| | 201 | </span> |
| | 202 | ) : null} |
| | 203 | {/* The chevron points at the body: down while it is open below the |
| | 204 | header, up while it is collapsed into it. */} |
| | 205 | <ChevronDownIcon |
| | 206 | aria-hidden="true" |
| | 207 | className={cn( |
| | 208 | "ml-auto size-3.5 shrink-0 text-secondary-label transition-transform duration-150 group-hover:text-foreground", |
| | 209 | isCollapsed && "rotate-180", |
| | 210 | )} |
| | 211 | /> |
| | 212 | </CollapsibleTrigger> |
| 166 | </div> | 213 | </div> |
| 167 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> | 214 | {/* The panel carries the horizontal padding itself: it clips its content |
| 168 | {activeQuestion.multiSelect ? ( | 215 | while the height animates, so the option buttons have to sit inside |
| 169 | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> | 216 | that padding or their focus rings get shaved off at the edges. */} |
| 170 | ) : null} | 217 | <CollapsiblePanel className="px-4 sm:px-5"> |
| 171 | <div className="mt-3 space-y-1.5"> | 218 | <div className="pt-2 pb-0.5"> |
| 172 | {activeQuestion.options.map((option, index) => { | 219 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> |
| 173 | const isOptimisticallySelected = | 220 | {activeQuestion.multiSelect ? ( |
| 174 | optimisticSingleSelect?.questionId === activeQuestion.id && | 221 | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> |
| 175 | optimisticSingleSelect.optionLabel === option.label; | 222 | ) : null} |
| 176 | const isSelected = | 223 | <div className="mt-3 space-y-1.5"> |
| 177 | isOptimisticallySelected || | 224 | {activeQuestion.options.map((option, index) => { |
| 178 | (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); | 225 | const isOptimisticallySelected = |
| 179 | const shortcutKey = index < 9 ? index + 1 : null; | 226 | optimisticSingleSelect?.questionId === activeQuestion.id && |
| 180 | const className = cn( | 227 | optimisticSingleSelect.optionLabel === option.label; |
| 181 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", | 228 | const isSelected = |
| 182 | isSelected | 229 | isOptimisticallySelected || |
| 183 | ? "border-primary/30 bg-primary/8 text-foreground" | 230 | (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); |
| 184 | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", | 231 | const shortcutKey = index < 9 ? index + 1 : null; |
| 185 | isResponding && "opacity-50 cursor-not-allowed", | 232 | const className = cn( |
| 186 | !isResponding && "cursor-pointer", | 233 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", |
| 187 | ); | 234 | isSelected |
| 188 | const content = ( | 235 | ? "border-primary/30 bg-primary/8 text-foreground" |
| 189 | <> | 236 | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", |
| 190 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> | 237 | isResponding && "opacity-50 cursor-not-allowed", |
| 191 | <span className="text-sm font-medium">{option.label}</span> | 238 | !isResponding && "cursor-pointer", |
| 192 | {option.description && option.description !== option.label ? ( | 239 | ); |
| 193 | <span className="text-secondary-label text-xs">{option.description}</span> | 240 | const content = ( |
| 194 | ) : null} | 241 | <> |
| 195 | </div> | 242 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> |
| 196 | {isSelected ? ( | 243 | <span className="text-sm font-medium">{option.label}</span> |
| 197 | <CheckIcon className="size-3.5 shrink-0 text-primary" /> | 244 | {option.description && option.description !== option.label ? ( |
| 198 | ) : shortcutKey !== null ? ( | 245 | <span className="text-secondary-label text-xs">{option.description}</span> |
| 199 | <kbd | 246 | ) : null} |
| 200 | className={cn( | 247 | </div> |
| 201 | "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150", | 248 | {isSelected ? ( |
| 202 | "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground", | 249 | <CheckIcon className="size-3.5 shrink-0 text-primary" /> |
| 203 | )} | 250 | ) : shortcutKey !== null ? ( |
| | 251 | <kbd |
| | 252 | className={cn( |
| | 253 | "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150", |
| | 254 | "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground", |
| | 255 | )} |
| | 256 | > |
| | 257 | {shortcutKey} |
| | 258 | </kbd> |
| | 259 | ) : null} |
| | 260 | </> |
| | 261 | ); |
| | 262 | return ( |
| | 263 | <button |
| | 264 | key={`${activeQuestion.id}:${option.label}`} |
| | 265 | type="button" |
| | 266 | disabled={isResponding} |
| | 267 | onClick={() => { |
| | 268 | handleOptionSelection(activeQuestion.id, option.label); |
| | 269 | }} |
| | 270 | className={className} |
| 204 | > | 271 | > |
| 205 | {shortcutKey} | 272 | {content} |
| 206 | </kbd> | 273 | </button> |
| 207 | ) : null} | 274 | ); |
| 208 | </> | 275 | })} |
| 209 | ); | 276 | </div> |
| 210 | return ( | 277 | </div> |
| 211 | <button | 278 | </CollapsiblePanel> |
| 212 | key={`${activeQuestion.id}:${option.label}`} | 279 | </Collapsible> |
| 213 | type="button" | | |
| 214 | disabled={isResponding} | | |
| 215 | onClick={() => { | | |
| 216 | handleOptionSelection(activeQuestion.id, option.label); | | |
| 217 | }} | | |
| 218 | className={className} | | |
| 219 | > | | |
| 220 | {content} | | |
| 221 | </button> | | |
| 222 | ); | | |
| 223 | })} | | |
| 224 | </div> | | |
| 225 | </div> | | |
| 226 | ); | 280 | ); |
| 227 | }); | 281 | }); |