import React, { useCallback, useMemo, useState } from 'react' import type { ChatMessageMeta, CheckpointReplyMetadata, TaskUserInputAnswer, TaskUserInputQuestion, } from '../types/chat' import { MarkdownBody } from './MarkdownBody' interface TaskUserInputPanelProps { meta: ChatMessageMeta onReply: (text: string, metadata?: Partial) => void responded: boolean } interface QuestionState { selectedOptionId?: string freeformText: string } const OTHER_OPTION_ID = '__other__' const OPTION_LETTERS = ['A', 'B', 'C'] function cleanText(value: unknown): string { return String(value ?? '').trim() } function normalizeQuestion(raw: TaskUserInputQuestion, index: number): TaskUserInputQuestion | null { const question = cleanText(raw.question) const header = cleanText(raw.header) if (!question && !header) return null const options = (raw.options ?? []) .slice(0, 3) .map((option, optionIndex) => ({ id: cleanText(option.id) || String.fromCharCode(97 + optionIndex), label: cleanText(option.label), description: cleanText(option.description), })) .filter((option) => option.label) return { id: cleanText(raw.id) || `question_${index + 1}`, header, question: question || header, options, allow_freeform: raw.allow_freeform !== false, required: raw.required !== false, } } export const TaskUserInputPanel = React.memo(function TaskUserInputPanel({ meta, onReply, responded, }: TaskUserInputPanelProps) { const [reply, setReply] = useState('') const [answers, setAnswers] = useState>({}) const isResponded = responded const title = String(meta.work_item_projection_title ?? meta.work_item_projection_id ?? 'Input Needed').trim() || 'Input Needed' const summary = String(meta.summary ?? '').trim() const prompt = String(meta.prompt ?? '').trim() const contextNote = String(meta.context_note ?? '').trim() const resumeHint = String(meta.resume_hint ?? '').trim() const questions = useMemo( () => (meta.questions ?? []).map((item) => String(item).trim()).filter(Boolean), [meta.questions], ) const inputQuestions = useMemo( () => (meta.input_questions ?? []) .map((item, index) => normalizeQuestion(item, index)) .filter((item): item is TaskUserInputQuestion => item !== null), [meta.input_questions], ) const usesChoiceMode = inputQuestions.some((question) => (question.options ?? []).length > 0) const requiredFields = useMemo( () => (meta.required_fields ?? []).map((item) => String(item).trim()).filter(Boolean), [meta.required_fields], ) const activeSubagents = useMemo( () => (meta.active_subagents ?? []).filter((item) => !!item && typeof item === 'object'), [meta.active_subagents], ) const permissionRequests = useMemo( () => (meta.permission_requests ?? []).filter((item) => !!item && typeof item === 'object'), [meta.permission_requests], ) const worktreePath = String(meta.worktree_path ?? '').trim() const requestingRoleId = String(meta.requesting_role_id ?? '').trim() const requestingTaskId = String(meta.requesting_task_id ?? '').trim() const requestingWorkItemId = String(meta.requesting_work_item_id ?? '').trim() const seatId = String(meta.seat_id ?? '').trim() const hasRequesterState = !!requestingRoleId || !!requestingTaskId || !!requestingWorkItemId || !!seatId const hasRuntimeState = hasRequesterState || activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath const setSelectedOption = useCallback((questionId: string, optionId: string) => { setAnswers((current) => ({ ...current, [questionId]: { freeformText: current[questionId]?.freeformText ?? '', selectedOptionId: optionId, }, })) }, []) const setFreeformAnswer = useCallback((questionId: string, value: string) => { setAnswers((current) => ({ ...current, [questionId]: { selectedOptionId: current[questionId]?.selectedOptionId, freeformText: value, }, })) }, []) const questionComplete = useCallback((question: TaskUserInputQuestion) => { if (question.required === false) return true const state = answers[question.id] const selected = state?.selectedOptionId if (selected && selected !== OTHER_OPTION_ID) return true if (question.allow_freeform !== false && cleanText(state?.freeformText)) return true return false }, [answers]) const canSubmitStructured = usesChoiceMode && inputQuestions.every(questionComplete) const handleSubmitLegacy = useCallback(() => { const text = reply.trim() if (isResponded || !text) return onReply(text) }, [isResponded, onReply, reply]) const handleSubmitStructured = useCallback(() => { if (isResponded || !canSubmitStructured) return const answerMetadata: Record = {} const lines: string[] = [] inputQuestions.forEach((question) => { const state = answers[question.id] ?? { freeformText: '' } const selectedOption = (question.options ?? []).find((option) => option.id === state.selectedOptionId) const freeformText = cleanText(state.freeformText) const label = selectedOption?.label ?? '' const answerText = [label, freeformText].filter(Boolean).join('; ') answerMetadata[question.id] = { question_id: question.id, question: question.question, ...(selectedOption ? { selected_option_id: selectedOption.id, selected_label: selectedOption.label, } : {}), ...(freeformText ? { freeform_text: freeformText } : {}), answer_text: answerText, } const displayQuestion = cleanText(question.header) || cleanText(question.question) || question.id lines.push(`- ${displayQuestion}: ${answerText || '(no answer)'}`) }) onReply(lines.join('\n'), { user_input_answers: answerMetadata }) }, [answers, canSubmitStructured, inputQuestions, isResponded, onReply]) return (
{title}
awaiting input {isResponded && Responded}
{summary && (
Summary
)} {prompt && (
Request
)} {usesChoiceMode ? (
Questions
{inputQuestions.map((question) => { const state = answers[question.id] ?? { freeformText: '' } const options = question.options ?? [] const showOther = question.allow_freeform !== false const showOtherInput = showOther && (options.length === 0 || state.selectedOptionId === OTHER_OPTION_ID) return (
{question.header &&
{question.header}
} {options.length > 0 && (
{options.map((option, optionIndex) => { const selected = state.selectedOptionId === option.id return ( ) })} {showOther && ( )}
)} {showOtherInput && (