| 1 | import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { type PendingUserInput } from "../../session-logic"; |
| 4 | import { |
| 5 | derivePendingUserInputProgress, |
| 6 | type PendingUserInputDraftAnswer, |
| 7 | } from "../../pendingUserInput"; |
| 8 | import { CheckIcon } from "lucide-react"; |
| 9 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 10 | import { cn } from "~/lib/utils"; |
| 11 | import { ComposerBanner } from "./ComposerBanner"; |
| 12 | import ChatMarkdown from "../ChatMarkdown"; |
| 13 | |
| 14 | interface PendingUserInputPanelProps { |
| 15 | pendingUserInputs: PendingUserInput[]; |
| 16 | /** Resolves file links in the question text. */ |
| 17 | cwd: string | undefined; |
| 18 | threadRef: ScopedThreadRef; |
| 19 | disabled?: boolean; |
| 20 | respondingRequestIds: RuntimeRequestId[]; |
| 21 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 22 | questionIndex: number; |
| 23 | onToggleOption: (questionId: string, optionValue: string) => void; |
| 24 | onAdvance: () => void; |
| 25 | onDismiss: (requestId: RuntimeRequestId) => void; |
| 26 | } |
| 27 | |
| 28 | export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ |
| 29 | pendingUserInputs, |
| 30 | cwd, |
| 31 | threadRef, |
| 32 | disabled = false, |
| 33 | respondingRequestIds, |
| 34 | answers, |
| 35 | questionIndex, |
| 36 | onToggleOption, |
| 37 | onAdvance, |
| 38 | onDismiss, |
| 39 | }: PendingUserInputPanelProps) { |
| 40 | if (pendingUserInputs.length === 0) return null; |
| 41 | const activePrompt = pendingUserInputs[0]; |
| 42 | if (!activePrompt) return null; |
| 43 | |
| 44 | return ( |
| 45 | <ComposerPendingUserInputCard |
| 46 | key={activePrompt.requestId} |
| 47 | prompt={activePrompt} |
| 48 | cwd={cwd} |
| 49 | threadRef={threadRef} |
| 50 | disabled={disabled} |
| 51 | isResponding={respondingRequestIds.includes(activePrompt.requestId)} |
| 52 | answers={answers} |
| 53 | questionIndex={questionIndex} |
| 54 | onToggleOption={onToggleOption} |
| 55 | onAdvance={onAdvance} |
| 56 | onDismiss={onDismiss} |
| 57 | /> |
| 58 | ); |
| 59 | }); |
| 60 | |
| 61 | const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ |
| 62 | prompt, |
| 63 | cwd, |
| 64 | threadRef, |
| 65 | disabled, |
| 66 | isResponding, |
| 67 | answers, |
| 68 | questionIndex, |
| 69 | onToggleOption, |
| 70 | onAdvance, |
| 71 | onDismiss, |
| 72 | }: { |
| 73 | prompt: PendingUserInput; |
| 74 | cwd: string | undefined; |
| 75 | threadRef: ScopedThreadRef; |
| 76 | disabled: boolean; |
| 77 | isResponding: boolean; |
| 78 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 79 | questionIndex: number; |
| 80 | onToggleOption: (questionId: string, optionValue: string) => void; |
| 81 | onAdvance: () => void; |
| 82 | onDismiss: (requestId: RuntimeRequestId) => void; |
| 83 | }) { |
| 84 | // Message-mode requests remain answerable after their provider turn ends. |
| 85 | const canRespond = prompt.responseCapability !== "not_resumable"; |
| 86 | const responseDisabled = disabled || isResponding || !canRespond; |
| 87 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 88 | const activeQuestion = progress.activeQuestion; |
| 89 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| 90 | const onAdvanceRef = useRef(onAdvance); |
| 91 | const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ |
| 92 | questionId: string; |
| 93 | optionValue: string; |
| 94 | } | null>(null); |
| 95 | // Collapsing hides everything but the header so a tall prompt stops covering |
| 96 | // the thread the user is trying to read. Scoped to a single question: the card |
| 97 | // is keyed by request id so the next prompt starts expanded, and storing the |
| 98 | // collapsed question's id (rather than a bare flag) reopens the card when the |
| 99 | // prompt advances to its next question, which can happen without a click — |
| 100 | // sending from the composer advances the active question. |
| 101 | const [collapsedQuestionId, setCollapsedQuestionId] = useState<string | null>(null); |
| 102 | const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; |
| 103 | |
| 104 | useEffect(() => { |
| 105 | onAdvanceRef.current = onAdvance; |
| 106 | }, [onAdvance]); |
| 107 | |
| 108 | useEffect(() => { |
| 109 | if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { |
| 110 | return; |
| 111 | } |
| 112 | if (optimisticSingleSelect.questionId !== activeQuestion.id) { |
| 113 | setOptimisticSingleSelect(null); |
| 114 | return; |
| 115 | } |
| 116 | if ( |
| 117 | progress.customAnswer.trim().length === 0 && |
| 118 | progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue) |
| 119 | ) { |
| 120 | setOptimisticSingleSelect(null); |
| 121 | } |
| 122 | }, [ |
| 123 | activeQuestion, |
| 124 | optimisticSingleSelect, |
| 125 | progress.customAnswer, |
| 126 | progress.selectedOptionValues, |
| 127 | ]); |
| 128 | |
| 129 | // A delayed answer only advances its own question, while permission remains granted. |
| 130 | useEffect(() => { |
| 131 | const clearAutoAdvance = () => { |
| 132 | if (autoAdvanceTimerRef.current !== null) { |
| 133 | window.clearTimeout(autoAdvanceTimerRef.current); |
| 134 | autoAdvanceTimerRef.current = null; |
| 135 | } |
| 136 | }; |
| 137 | if (disabled || activeQuestion?.id === undefined) clearAutoAdvance(); |
| 138 | return clearAutoAdvance; |
| 139 | }, [activeQuestion?.id, disabled]); |
| 140 | |
| 141 | const handleOptionSelection = useCallback( |
| 142 | (questionId: string, optionValue: string) => { |
| 143 | if (disabled || isResponding) return; |
| 144 | if (activeQuestion?.multiSelect) { |
| 145 | onToggleOption(questionId, optionValue); |
| 146 | return; |
| 147 | } |
| 148 | setOptimisticSingleSelect({ questionId, optionValue }); |
| 149 | onToggleOption(questionId, optionValue); |
| 150 | if (autoAdvanceTimerRef.current !== null) { |
| 151 | window.clearTimeout(autoAdvanceTimerRef.current); |
| 152 | } |
| 153 | autoAdvanceTimerRef.current = window.setTimeout(() => { |
| 154 | autoAdvanceTimerRef.current = null; |
| 155 | onAdvanceRef.current(); |
| 156 | }, 200); |
| 157 | }, |
| 158 | [activeQuestion, disabled, isResponding, onToggleOption], |
| 159 | ); |
| 160 | |
| 161 | // Keyboard shortcut: number keys 1-9 select corresponding options when focus is |
| 162 | // outside editable fields. Multi-select prompts toggle options in place; single- |
| 163 | // select prompts keep the existing auto-advance behavior. Collapsed prompts opt |
| 164 | // out, since the numbers they refer to are not on screen. |
| 165 | useEffect(() => { |
| 166 | if (!activeQuestion || responseDisabled || isCollapsed) return; |
| 167 | const handler = (event: globalThis.KeyboardEvent) => { |
| 168 | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 169 | const target = event.target; |
| 170 | if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { |
| 171 | return; |
| 172 | } |
| 173 | if ( |
| 174 | target instanceof HTMLElement && |
| 175 | target.closest('[contenteditable]:not([contenteditable="false"])') |
| 176 | ) { |
| 177 | return; |
| 178 | } |
| 179 | const digit = Number.parseInt(event.key, 10); |
| 180 | if (Number.isNaN(digit) || digit < 1 || digit > 9) return; |
| 181 | const optionIndex = digit - 1; |
| 182 | if (optionIndex >= activeQuestion.options.length) return; |
| 183 | const option = activeQuestion.options[optionIndex]; |
| 184 | if (!option) return; |
| 185 | event.preventDefault(); |
| 186 | handleOptionSelection(activeQuestion.id, option.value ?? option.label); |
| 187 | }; |
| 188 | document.addEventListener("keydown", handler); |
| 189 | return () => document.removeEventListener("keydown", handler); |
| 190 | }, [activeQuestion, handleOptionSelection, isCollapsed, responseDisabled]); |
| 191 | |
| 192 | if (!activeQuestion) { |
| 193 | return null; |
| 194 | } |
| 195 | |
| 196 | const customAnswerActive = progress.customAnswer.trim().length > 0; |
| 197 | |
| 198 | return ( |
| 199 | <Collapsible |
| 200 | open={!isCollapsed} |
| 201 | onOpenChange={(open) => { |
| 202 | setCollapsedQuestionId(open ? null : activeQuestion.id); |
| 203 | }} |
| 204 | > |
| 205 | <CollapsibleTrigger |
| 206 | render={<ComposerBanner.Row render={<button type="button" />} />} |
| 207 | title={ |
| 208 | isCollapsed ? "Show the question and its options" : "Hide the question and its options" |
| 209 | } |
| 210 | data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} |
| 211 | > |
| 212 | <ComposerBanner.Icon /> |
| 213 | <ComposerBanner.Content> |
| 214 | <span className="shrink-0 font-medium text-muted-foreground"> |
| 215 | {activeQuestion.header} |
| 216 | </span> |
| 217 | {isCollapsed ? ( |
| 218 | <span className="min-w-0 flex-1 truncate text-secondary-label"> |
| 219 | {activeQuestion.question} |
| 220 | </span> |
| 221 | ) : null} |
| 222 | </ComposerBanner.Content> |
| 223 | <ComposerBanner.Actions> |
| 224 | {prompt.questions.length > 1 ? ( |
| 225 | <span className="text-3xs font-medium text-muted-foreground tabular-nums"> |
| 226 | {questionIndex + 1}/{prompt.questions.length} |
| 227 | </span> |
| 228 | ) : null} |
| 229 | <ComposerBanner.ToggleIcon expanded={!isCollapsed} /> |
| 230 | {prompt.dismissible ? ( |
| 231 | // Sits inside the trigger button, so stop the click from toggling |
| 232 | // the disclosure. Dismiss closes the question without a reply. |
| 233 | <ComposerBanner.Dismiss |
| 234 | render={<span role="button" tabIndex={0} />} |
| 235 | aria-label="Dismiss question without answering" |
| 236 | title="Dismiss question without answering" |
| 237 | disabled={isResponding} |
| 238 | data-pending-user-input-dismiss |
| 239 | onClick={(event) => { |
| 240 | event.preventDefault(); |
| 241 | event.stopPropagation(); |
| 242 | onDismiss(prompt.requestId); |
| 243 | }} |
| 244 | onKeyDown={(event) => { |
| 245 | if (event.key !== "Enter" && event.key !== " ") return; |
| 246 | event.preventDefault(); |
| 247 | event.stopPropagation(); |
| 248 | onDismiss(prompt.requestId); |
| 249 | }} |
| 250 | /> |
| 251 | ) : null} |
| 252 | </ComposerBanner.Actions> |
| 253 | </CollapsibleTrigger> |
| 254 | <CollapsiblePanel> |
| 255 | <ComposerBanner.Scroll> |
| 256 | <ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere"> |
| 257 | <ChatMarkdown |
| 258 | text={activeQuestion.question} |
| 259 | cwd={cwd} |
| 260 | threadRef={threadRef} |
| 261 | className="text-foreground/85" |
| 262 | /> |
| 263 | {activeQuestion.multiSelect ? ( |
| 264 | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> |
| 265 | ) : null} |
| 266 | <div className="mt-2 space-y-0.5"> |
| 267 | {activeQuestion.options.map((option, index) => { |
| 268 | const optionValue = option.value ?? option.label; |
| 269 | const isOptimisticallySelected = |
| 270 | optimisticSingleSelect?.questionId === activeQuestion.id && |
| 271 | optimisticSingleSelect.optionValue === optionValue; |
| 272 | const isSelected = |
| 273 | isOptimisticallySelected || |
| 274 | (!customAnswerActive && progress.selectedOptionValues.includes(optionValue)); |
| 275 | const shortcutKey = index < 9 ? index + 1 : null; |
| 276 | const className = cn( |
| 277 | "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", |
| 278 | isSelected |
| 279 | ? "bg-muted/55 text-foreground" |
| 280 | : "bg-transparent text-foreground/85 hover:bg-muted/30", |
| 281 | (disabled || isResponding) && "opacity-50 cursor-not-allowed", |
| 282 | !disabled && !isResponding && "cursor-pointer", |
| 283 | ); |
| 284 | const content = ( |
| 285 | <> |
| 286 | <div className="min-w-0 flex-1 flex flex-col gap-0.5"> |
| 287 | <span className="text-sm font-medium">{option.label}</span> |
| 288 | {option.description && option.description !== option.label ? ( |
| 289 | <span className="text-secondary-label text-2xs">{option.description}</span> |
| 290 | ) : null} |
| 291 | </div> |
| 292 | {isSelected ? ( |
| 293 | <CheckIcon className="size-3.5 shrink-0 text-primary" /> |
| 294 | ) : shortcutKey !== null ? ( |
| 295 | <kbd |
| 296 | className={cn( |
| 297 | "flex size-5 shrink-0 items-center justify-center text-3xs font-medium text-muted-foreground tabular-nums", |
| 298 | )} |
| 299 | > |
| 300 | {shortcutKey} |
| 301 | </kbd> |
| 302 | ) : null} |
| 303 | </> |
| 304 | ); |
| 305 | return ( |
| 306 | <button |
| 307 | key={`${activeQuestion.id}:${optionValue}`} |
| 308 | type="button" |
| 309 | disabled={disabled || isResponding} |
| 310 | onClick={() => { |
| 311 | handleOptionSelection(activeQuestion.id, optionValue); |
| 312 | }} |
| 313 | className={className} |
| 314 | > |
| 315 | {content} |
| 316 | </button> |
| 317 | ); |
| 318 | })} |
| 319 | </div> |
| 320 | </ComposerBanner.Body> |
| 321 | </ComposerBanner.Scroll> |
| 322 | </CollapsiblePanel> |
| 323 | </Collapsible> |
| 324 | ); |
| 325 | }); |