import React from 'react'; import { Checkbox } from '@/components/ui/checkbox'; import { Radio } from '@/components/ui/radio'; import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { isIMECompositionEvent } from '@/lib/ime'; import { copyTextToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui'; import type { QuestionRequest } from '@/types/question'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import * as sessionActions from '@/sync/session-actions'; import { useI18n } from '@/lib/i18n'; import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers'; interface QuestionCardProps { question: QuestionRequest; } type TabKey = string; const SUMMARY_TAB = 'summary'; export const QuestionCard: React.FC = ({ question }) => { const { t } = useI18n(); const respondToQuestion = sessionActions.respondToQuestion; const rejectQuestion = sessionActions.rejectQuestion;; const isMobile = useUIStore((state) => state.isMobile); const sessions = useSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const isFromSubagent = React.useMemo(() => { if (!currentSessionId || question.sessionID === currentSessionId) return false; const sourceSession = sessions.find((session) => session.id === question.sessionID); return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); }, [question.sessionID, currentSessionId, sessions]); const [activeTab, setActiveTab] = React.useState('0'); const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); const [selectedOptions, setSelectedOptions] = React.useState>({}); const [customMode, setCustomMode] = React.useState>({}); const [customText, setCustomText] = React.useState>({}); const questions = React.useMemo(() => question.questions ?? [], [question.questions]); const isSummaryTab = activeTab === SUMMARY_TAB; const activeIndex = isSummaryTab ? -1 : Math.max(0, Math.min(questions.length - 1, Number(activeTab) || 0)); const activeQuestion = isSummaryTab ? null : questions[activeIndex]; const activeHeader = React.useMemo(() => { if (isSummaryTab) return null; const header = activeQuestion?.header?.trim(); return header && header.length > 0 ? header : null; }, [activeQuestion?.header, isSummaryTab]); React.useEffect(() => { setActiveTab('0'); setSelectedOptions({}); setCustomMode({}); setCustomText({}); setHasResponded(false); }, [question.id]); const tabs = React.useMemo(() => { const questionTabs = questions.map((q, index) => ({ value: String(index), label: q.header?.trim() || `Q${index + 1}`, })); // Add summary tab when multiple questions if (questions.length > 1) { questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') }); } return questionTabs; }, [questions, t]); // Helper to get answer display for a question index const getAnswerDisplay = React.useCallback((index: number): string => { const isCustom = Boolean(customMode[index]); if (isCustom) { const value = (customText[index] ?? '').trim(); return value || t('chat.questionCard.noAnswer'); } const answers = selectedOptions[index] ?? []; return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer'); }, [customMode, customText, selectedOptions, t]); const isMultiple = Boolean(activeQuestion?.multiple); const selectedForActive = selectedOptions[activeIndex] ?? []; const isCustomActive = Boolean(customMode[activeIndex]); const unansweredIndexes = React.useMemo(() => { const pending: number[] = []; for (let index = 0; index < questions.length; index += 1) { const isCustom = Boolean(customMode[index]); if (isCustom) { const value = (customText[index] ?? '').trim(); if (!value) pending.push(index); continue; } const answers = selectedOptions[index] ?? []; if (answers.length === 0) { pending.push(index); } } return pending; }, [customMode, customText, questions.length, selectedOptions]); const requiredSatisfied = React.useMemo(() => { if (questions.length === 0) return false; return unansweredIndexes.length === 0; }, [questions.length, unansweredIndexes.length]); const handleNextUnanswered = React.useCallback(() => { if (questions.length === 0 || unansweredIndexes.length === 0) return; const start = isSummaryTab ? -1 : activeIndex; for (let offset = 1; offset <= questions.length; offset += 1) { const candidate = (start + offset + questions.length) % questions.length; if (unansweredIndexes.includes(candidate)) { setActiveTab(String(candidate)); return; } } setActiveTab(String(unansweredIndexes[0])); }, [activeIndex, isSummaryTab, questions.length, unansweredIndexes]); const buildAnswersPayload = React.useCallback((): string[][] => { const answers: string[][] = []; for (let index = 0; index < questions.length; index += 1) { const isCustom = Boolean(customMode[index]); if (isCustom) { const value = (customText[index] ?? '').trim(); answers.push(value ? [value] : []); continue; } answers.push(selectedOptions[index] ?? []); } return answers; }, [customMode, customText, questions.length, selectedOptions]); const handleToggleOption = React.useCallback( (label: string) => { if (!activeQuestion) return; setCustomMode((prev) => ({ ...prev, [activeIndex]: false })); setSelectedOptions((prev) => { const current = prev[activeIndex] ?? []; if (isMultiple) { const exists = current.includes(label); const next = exists ? current.filter((item) => item !== label) : [...current, label]; return { ...prev, [activeIndex]: next }; } return { ...prev, [activeIndex]: [label] }; }); }, [activeIndex, activeQuestion, isMultiple] ); const handleSelectCustom = React.useCallback(() => { setCustomMode((prev) => ({ ...prev, [activeIndex]: true })); setSelectedOptions((prev) => ({ ...prev, [activeIndex]: [] })); }, [activeIndex]); const handleConfirm = React.useCallback(async () => { if (!requiredSatisfied) return; setIsResponding(true); try { const answers = buildAnswersPayload(); await respondToQuestion(question.sessionID, question.id, answers); setHasResponded(true); } catch { // ignored } finally { setIsResponding(false); } }, [buildAnswersPayload, question.id, question.sessionID, requiredSatisfied, respondToQuestion]); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { if (isIMECompositionEvent(e)) return; if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) { e.preventDefault(); if (requiredSatisfied) { handleConfirm(); } else { handleNextUnanswered(); } } }, [handleConfirm, handleNextUnanswered, isMobile, requiredSatisfied] ); const handleDismiss = React.useCallback(async () => { setIsResponding(true); try { await rejectQuestion(question.sessionID, question.id); setHasResponded(true); } catch { // ignored } finally { setIsResponding(false); } }, [question.id, question.sessionID, rejectQuestion]); const handleCopyMarkdown = React.useCallback(async () => { const text = serializeQuestionAsMarkdown(question); const result = await copyTextToClipboard(text); if (result.ok) { toast.success(t('chat.questionCard.copiedMarkdown')); return; } toast.error(t('chat.questionCard.copyFailed')); }, [question, t]); const handleCopyJson = React.useCallback(async () => { const text = serializeQuestionAsJson(question); const result = await copyTextToClipboard(text); if (result.ok) { toast.success(t('chat.questionCard.copiedJson')); return; } toast.error(t('chat.questionCard.copyFailed')); }, [question, t]); if (hasResponded || questions.length === 0) { return null; } return (
{/* Header */}
{t('chat.questionCard.inputNeeded')} {isFromSubagent ? ( {t('chat.questionCard.fromSubagent')} ) : null} {activeHeader ? ( {activeHeader} ) : null}
{/* Minimal inline tabs for multiple questions */} {tabs.length > 1 ? (
{tabs.map((tab) => { const isActive = activeTab === tab.value; const isSummary = tab.value === SUMMARY_TAB; const tabIndex = isSummary ? -1 : Number(tab.value); const isAnswered = !isSummary && Number.isFinite(tabIndex) && !unansweredIndexes.includes(tabIndex); return ( ); })}
) : null} {/* Summary view */} {isSummaryTab ? (
{questions.map((q, index) => { const answer = getAnswerDisplay(index); const hasAnswer = answer !== t('chat.questionCard.noAnswer'); return ( ); })}
) : activeQuestion ? ( <>
{activeQuestion.question}
{isMultiple ? (
{t('chat.questionCard.selectMultiple')}
) : null}
{activeQuestion.options.map((option, index) => { const selected = selectedForActive.includes(option.label); const recommended = /\(recommended\)/i.test(option.label); return ( ); })} {/* Custom answer option */} {isCustomActive ? (