| ... | ... | @@ -5,7 +5,8 @@ import { |
| 5 | 5 | derivePendingUserInputProgress, |
| 6 | 6 | type PendingUserInputDraftAnswer, |
| 7 | 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 | 10 | import { cn } from "~/lib/utils"; |
| 10 | 11 | |
| 11 | 12 | interface PendingUserInputPanelProps { |
| ... | ... | @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 65 | 66 | questionId: string; |
| 66 | 67 | optionLabel: string; |
| 67 | 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 | 78 | useEffect(() => { |
| 70 | 79 | onAdvanceRef.current = onAdvance; |
| ... | ... | @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 118 | 127 | |
| 119 | 128 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is |
| 120 | 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 | 132 | useEffect(() => { |
| 123 | | if (!activeQuestion || isResponding) return; |
| 133 | if (!activeQuestion || isResponding || isCollapsed) return; |
| 124 | 134 | const handler = (event: globalThis.KeyboardEvent) => { |
| 125 | 135 | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 126 | 136 | const target = event.target; |
| ... | ... | @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 144 | 154 | }; |
| 145 | 155 | document.addEventListener("keydown", handler); |
| 146 | 156 | return () => document.removeEventListener("keydown", handler); |
| 147 | | }, [activeQuestion, isResponding]); |
| 157 | }, [activeQuestion, isCollapsed, isResponding]); |
| 148 | 158 | |
| 149 | 159 | if (!activeQuestion) { |
| 150 | 160 | return null; |
| ... | ... | @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 153 | 163 | const customAnswerActive = progress.customAnswer.trim().length > 0; |
| 154 | 164 | |
| 155 | 165 | return ( |
| 156 | | <div className="px-4 py-3 sm:px-5"> |
| 157 | | <div className="mb-2 flex items-center gap-3"> |
| 158 | | <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase"> |
| 159 | | {activeQuestion.header} |
| 160 | | </span> |
| 161 | | {prompt.questions.length > 1 ? ( |
| 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"> |
| 163 | | {questionIndex + 1}/{prompt.questions.length} |
| 166 | <Collapsible |
| 167 | className="py-3" |
| 168 | open={!isCollapsed} |
| 169 | onOpenChange={(open) => { |
| 170 | setCollapsedQuestionId(open ? null : activeQuestion.id); |
| 171 | }} |
| 172 | > |
| 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 | 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 | 213 | </div> |
| 167 | | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> |
| 168 | | {activeQuestion.multiSelect ? ( |
| 169 | | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> |
| 170 | | ) : null} |
| 171 | | <div className="mt-3 space-y-1.5"> |
| 172 | | {activeQuestion.options.map((option, index) => { |
| 173 | | const isOptimisticallySelected = |
| 174 | | optimisticSingleSelect?.questionId === activeQuestion.id && |
| 175 | | optimisticSingleSelect.optionLabel === option.label; |
| 176 | | const isSelected = |
| 177 | | isOptimisticallySelected || |
| 178 | | (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); |
| 179 | | const shortcutKey = index < 9 ? index + 1 : null; |
| 180 | | const className = cn( |
| 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", |
| 182 | | isSelected |
| 183 | | ? "border-primary/30 bg-primary/8 text-foreground" |
| 184 | | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", |
| 185 | | isResponding && "opacity-50 cursor-not-allowed", |
| 186 | | !isResponding && "cursor-pointer", |
| 187 | | ); |
| 188 | | const content = ( |
| 189 | | <> |
| 190 | | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> |
| 191 | | <span className="text-sm font-medium">{option.label}</span> |
| 192 | | {option.description && option.description !== option.label ? ( |
| 193 | | <span className="text-secondary-label text-xs">{option.description}</span> |
| 194 | | ) : null} |
| 195 | | </div> |
| 196 | | {isSelected ? ( |
| 197 | | <CheckIcon className="size-3.5 shrink-0 text-primary" /> |
| 198 | | ) : shortcutKey !== null ? ( |
| 199 | | <kbd |
| 200 | | className={cn( |
| 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", |
| 202 | | "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground", |
| 203 | | )} |
| 214 | {/* The panel carries the horizontal padding itself: it clips its content |
| 215 | while the height animates, so the option buttons have to sit inside |
| 216 | that padding or their focus rings get shaved off at the edges. */} |
| 217 | <CollapsiblePanel className="px-4 sm:px-5"> |
| 218 | <div className="pt-2 pb-0.5"> |
| 219 | <p className="text-sm text-foreground/90">{activeQuestion.question}</p> |
| 220 | {activeQuestion.multiSelect ? ( |
| 221 | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> |
| 222 | ) : null} |
| 223 | <div className="mt-3 space-y-1.5"> |
| 224 | {activeQuestion.options.map((option, index) => { |
| 225 | const isOptimisticallySelected = |
| 226 | optimisticSingleSelect?.questionId === activeQuestion.id && |
| 227 | optimisticSingleSelect.optionLabel === option.label; |
| 228 | const isSelected = |
| 229 | isOptimisticallySelected || |
| 230 | (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); |
| 231 | const shortcutKey = index < 9 ? index + 1 : null; |
| 232 | const className = cn( |
| 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", |
| 234 | isSelected |
| 235 | ? "border-primary/30 bg-primary/8 text-foreground" |
| 236 | : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", |
| 237 | isResponding && "opacity-50 cursor-not-allowed", |
| 238 | !isResponding && "cursor-pointer", |
| 239 | ); |
| 240 | const content = ( |
| 241 | <> |
| 242 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> |
| 243 | <span className="text-sm font-medium">{option.label}</span> |
| 244 | {option.description && option.description !== option.label ? ( |
| 245 | <span className="text-secondary-label text-xs">{option.description}</span> |
| 246 | ) : null} |
| 247 | </div> |
| 248 | {isSelected ? ( |
| 249 | <CheckIcon className="size-3.5 shrink-0 text-primary" /> |
| 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} |
| 206 | | </kbd> |
| 207 | | ) : null} |
| 208 | | </> |
| 209 | | ); |
| 210 | | return ( |
| 211 | | <button |
| 212 | | key={`${activeQuestion.id}:${option.label}`} |
| 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> |
| 272 | {content} |
| 273 | </button> |
| 274 | ); |
| 275 | })} |
| 276 | </div> |
| 277 | </div> |
| 278 | </CollapsiblePanel> |
| 279 | </Collapsible> |
| 226 | 280 | ); |
| 227 | 281 | }); |