| import { useMemo, useRef, useState } from 'react' |
| import { useChatStore } from '../../stores/chatStore' |
| import { useTabStore } from '../../stores/tabStore' |
| import { useTranslation } from '../../i18n' |
| import { Button } from '../shared/Button' |
|
|
| type QuestionOption = { |
| label: string |
| description?: string |
| } |
|
|
| type Question = { |
| question: string |
| header?: string |
| options?: QuestionOption[] |
| multiSelect?: boolean |
| } |
|
|
| type AskUserInput = { |
| questions?: Question[] |
| question?: string |
| header?: string |
| options?: QuestionOption[] |
| multiSelect?: boolean |
| } |
|
|
| type Props = { |
| sessionId?: string | null |
| toolUseId: string |
| input: unknown |
| result?: unknown |
| } |
|
|
| |
| |
| |
| function parseInput(input: unknown): Question[] { |
| if (!input || typeof input !== 'object') return [] |
| const obj = input as AskUserInput |
|
|
| |
| if (Array.isArray(obj.questions)) { |
| return obj.questions |
| } |
|
|
| |
| if (typeof obj.question === 'string') { |
| return [{ |
| question: obj.question, |
| header: obj.header, |
| options: obj.options, |
| multiSelect: obj.multiSelect, |
| }] |
| } |
|
|
| return [] |
| } |
|
|
| type QuestionSelections = Record<number, string[]> |
| type QuestionFreeTexts = Record<number, string> |
|
|
| function getSelectedAnswer(question: Question, selected: string[] | undefined) { |
| if (!selected || selected.length === 0) return '' |
| return question.multiSelect ? selected.join(', ') : selected[0] ?? '' |
| } |
|
|
| export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) { |
| const { respondToPermission } = useChatStore() |
| const activeTabId = useTabStore((s) => s.activeTabId) |
| const targetSessionId = sessionId ?? activeTabId |
| const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined) |
| const t = useTranslation() |
| const questions = parseInput(input) |
| const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {} |
| const [activeTab, setActiveTab] = useState(0) |
| const [selections, setSelections] = useState<QuestionSelections>({}) |
| const [freeTexts, setFreeTexts] = useState<QuestionFreeTexts>({}) |
| const [hasSubmitted, setHasSubmitted] = useState(false) |
| const composingRef = useRef(false) |
|
|
| if (questions.length === 0) return null |
| const safeActiveTab = Math.min(activeTab, questions.length - 1) |
| const activeQuestion = questions[safeActiveTab] |
|
|
| const resultAnswers = useMemo(() => { |
| if (!result || typeof result !== 'object') return {} |
| const answers = (result as { answers?: unknown }).answers |
| return answers && typeof answers === 'object' |
| ? answers as Record<string, string> |
| : {} |
| }, [result]) |
|
|
| const pendingRequest = pendingPermission?.toolUseId === toolUseId ? pendingPermission : null |
| const answeredText = useMemo(() => { |
| if (Object.keys(resultAnswers).length > 0) { |
| return questions |
| .map((question) => resultAnswers[question.question]) |
| .filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0) |
| .join(', ') |
| } |
| return questions |
| .map((question, index) => freeTexts[index]?.trim() || getSelectedAnswer(question, selections[index])) |
| .filter(Boolean) |
| .join('; ') |
| }, [freeTexts, questions, resultAnswers, selections]) |
| const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted |
|
|
| const handleSelect = (qIndex: number, label: string) => { |
| if (submitted) return |
| setSelections((prev) => { |
| const question = questions[qIndex] |
| const selected = prev[qIndex] ?? [] |
| if (question?.multiSelect) { |
| const nextSelected = selected.includes(label) |
| ? selected.filter((value) => value !== label) |
| : [...selected, label] |
| const next = { ...prev } |
| if (nextSelected.length > 0) { |
| next[qIndex] = nextSelected |
| } else { |
| delete next[qIndex] |
| } |
| return next |
| } |
| if (selected[0] === label) { |
| const next = { ...prev } |
| delete next[qIndex] |
| return next |
| } |
| return { ...prev, [qIndex]: [label] } |
| }) |
| setFreeTexts((prev) => { |
| if (!prev[qIndex]) return prev |
| const next = { ...prev } |
| delete next[qIndex] |
| return next |
| }) |
| } |
|
|
| const handleFreeTextChange = (qIndex: number, value: string) => { |
| if (submitted) return |
| setFreeTexts((prev) => { |
| const next = { ...prev } |
| if (value) { |
| next[qIndex] = value |
| } else { |
| delete next[qIndex] |
| } |
| return next |
| }) |
| if (value.trim()) { |
| setSelections((prev) => { |
| if (!prev[qIndex]) return prev |
| const next = { ...prev } |
| delete next[qIndex] |
| return next |
| }) |
| } |
| } |
|
|
| const handleSubmit = () => { |
| if (submitted) return |
|
|
| const parts: string[] = [] |
| for (let i = 0; i < questions.length; i++) { |
| const answer = freeTexts[i]?.trim() || getSelectedAnswer(questions[i]!, selections[i]) |
| if (answer) parts.push(answer) |
| } |
| const response = parts.join('; ') |
| if (!response) return |
|
|
| if (!targetSessionId || !pendingRequest) return |
|
|
| const answers = questions.reduce<Record<string, string>>((acc, question, index) => { |
| const freeText = freeTexts[index]?.trim() |
| if (freeText) { |
| acc[question.question] = freeText |
| } else { |
| const selected = getSelectedAnswer(question, selections[index]) |
| if (selected) acc[question.question] = selected |
| } |
| return acc |
| }, {}) |
|
|
| setHasSubmitted(true) |
| respondToPermission(targetSessionId, pendingRequest.requestId, true, { |
| updatedInput: { |
| ...inputObject, |
| answers, |
| }, |
| }) |
| } |
|
|
| |
| const allAnswered = questions.every((_, i) => |
| Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0, |
| ) |
|
|
| if (!activeQuestion) return null |
|
|
| return ( |
| <div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${ |
| submitted |
| ? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70' |
| : 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]' |
| }`}> |
| {/* Header */} |
| <div className={`flex items-center gap-3 px-4 py-3 ${ |
| submitted |
| ? 'bg-[var(--color-surface-container-low)]' |
| : 'bg-[var(--color-surface-container)]' |
| }`}> |
| <div className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10"> |
| <span className="material-symbols-outlined text-[18px] text-[var(--color-secondary)]"> |
| help |
| </span> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <span className="text-sm font-semibold text-[var(--color-text-primary)]"> |
| {t('question.needsInput')} |
| </span> |
| {submitted && ( |
| <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]"> |
| {t('question.answered')} |
| </span> |
| )} |
| </div> |
| </div> |
| |
| {/* Question tabs — horizontal tab bar (only show when multiple questions) */} |
| {questions.length > 1 && ( |
| <div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto"> |
| {questions.map((q, i) => { |
| const isActive = safeActiveTab === i |
| const isAnswered = Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0 |
| const tabLabel = q.header || `Q${i + 1}` |
| return ( |
| <button |
| key={i} |
| onClick={() => setActiveTab(i)} |
| className={`relative flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium whitespace-nowrap transition-colors ${ |
| isActive |
| ? 'text-[var(--color-secondary)]' |
| : 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]' |
| }`} |
| > |
| {isAnswered && ( |
| <span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span> |
| )} |
| {tabLabel} |
| {isActive && ( |
| <div className="absolute bottom-0 left-2 right-2 h-[2px] bg-[var(--color-secondary)] rounded-t" /> |
| )} |
| </button> |
| ) |
| })} |
| </div> |
| )} |
| |
| {/* Active question content */} |
| <div className="px-4 py-3"> |
| <p className="text-sm font-medium text-[var(--color-text-primary)] mb-3"> |
| {activeQuestion.question} |
| </p> |
| |
| {/* Option cards */} |
| {activeQuestion.options && activeQuestion.options.length > 0 && ( |
| <div className="space-y-2 mb-3"> |
| {activeQuestion.options.map((opt, optIndex) => { |
| const isSelected = selections[safeActiveTab]?.includes(opt.label) ?? false |
| const isMultiSelect = activeQuestion.multiSelect === true |
| return ( |
| <button |
| key={optIndex} |
| onClick={() => handleSelect(safeActiveTab, opt.label)} |
| disabled={submitted} |
| className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${ |
| isSelected |
| ? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]/8 ring-1 ring-[var(--color-secondary)]/30' |
| : 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] hover:border-[var(--color-outline-variant)] hover:bg-[var(--color-surface-container-low)]' |
| } ${submitted ? 'cursor-default' : ''}`} |
| > |
| <div className="flex items-start gap-3"> |
| {/* Selection indicator */} |
| <div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${ |
| isSelected |
| ? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]' |
| : 'border-[var(--color-outline)]' |
| } ${isMultiSelect ? 'rounded-[var(--radius-xs)]' : 'rounded-full'}`}> |
| {isSelected && ( |
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"> |
| <polyline points="20 6 9 17 4 12" /> |
| </svg> |
| )} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <span className={`text-sm font-medium ${ |
| isSelected |
| ? 'text-[var(--color-secondary)]' |
| : 'text-[var(--color-text-primary)]' |
| }`}> |
| {opt.label} |
| </span> |
| {opt.description && ( |
| <p className="text-xs text-[var(--color-text-secondary)] mt-0.5"> |
| {opt.description} |
| </p> |
| )} |
| </div> |
| </div> |
| </button> |
| ) |
| })} |
| </div> |
| )} |
| |
| {/* Free text input */} |
| {!submitted && ( |
| <div> |
| <label className="text-xs text-[var(--color-text-tertiary)] mb-1.5 block"> |
| {t('question.customResponse')} |
| </label> |
| <textarea |
| value={freeTexts[safeActiveTab] ?? ''} |
| onChange={(e) => handleFreeTextChange(safeActiveTab, e.target.value)} |
| onCompositionStart={() => { composingRef.current = true }} |
| onCompositionEnd={() => { composingRef.current = false }} |
| onKeyDown={(e) => { |
| if (composingRef.current || e.nativeEvent.isComposing || e.keyCode === 229) return |
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && allAnswered) { |
| e.preventDefault() |
| handleSubmit() |
| } |
| }} |
| placeholder={t('question.typePlaceholder')} |
| rows={3} |
| wrap="soft" |
| className="max-h-48 min-h-[84px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] px-3 py-2 text-sm leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-secondary)]/30" |
| /> |
| </div> |
| )} |
| |
| {/* Submitted answer display */} |
| {submitted && ( |
| <div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]"> |
| <span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span> |
| <span> |
| {t('question.answeredPrefix')}<strong>{answeredText}</strong> |
| </span> |
| </div> |
| )} |
| </div> |
| |
| {/* Submit button */} |
| {!submitted && ( |
| <div className="flex items-center gap-2 px-4 py-3 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)]"> |
| <Button |
| variant="primary" |
| size="sm" |
| disabled={!allAnswered || !pendingRequest} |
| onClick={handleSubmit} |
| icon={ |
| <span className="material-symbols-outlined text-[14px]">send</span> |
| } |
| > |
| {t('question.submit')} |
| </Button> |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|