import { useState, useCallback, useRef, useEffect } from "react"; import { chatWithFile, type BodyElement, type LlmModel, type ChatMessage, type UserQuestion, type UserAnswer, } from "../../lib/api"; import { SimpleMarkdown } from "../SimpleMarkdown"; import type { FocusedElement } from "./FileDetail"; interface CliInputProps { fileId: string; onUpdate: (body: BodyElement[], summary: string | null) => void; focusedElement?: FocusedElement | null; onClearFocus?: () => void; suggestedPrompt?: string | null; onClearSuggestedPrompt?: () => void; } interface Message { id: string; type: "user" | "assistant" | "error" | "question"; content: string; toolCalls?: { name: string; success: boolean; message: string }[]; questions?: UserQuestion[]; } const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ { value: "claude-opus", label: "Claude Opus" }, { value: "claude-sonnet", label: "Claude Sonnet" }, { value: "groq", label: "Groq Kimi" }, ]; export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState([]); const [expanded, setExpanded] = useState(false); const [model, setModel] = useState("claude-opus"); // Track conversation history for context continuity const [conversationHistory, setConversationHistory] = useState([]); // Track pending questions from the LLM const [pendingQuestions, setPendingQuestions] = useState(null); // Track user's answers to questions const [userAnswers, setUserAnswers] = useState>(new Map()); // Track custom input for each question const [customInputs, setCustomInputs] = useState>(new Map()); const inputRef = useRef(null); const messagesRef = useRef(null); // Auto-scroll to bottom when messages change useEffect(() => { if (messagesRef.current) { messagesRef.current.scrollTop = messagesRef.current.scrollHeight; } }, [messages]); // Auto-focus input when an element is focused useEffect(() => { if (focusedElement && inputRef.current) { inputRef.current.focus(); } }, [focusedElement]); // Handle suggested prompt from generate actions useEffect(() => { if (suggestedPrompt) { setInput(suggestedPrompt); onClearSuggestedPrompt?.(); } }, [suggestedPrompt, onClearSuggestedPrompt]); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || loading) return; const userMessage = input.trim(); setInput(""); setExpanded(true); // Add user message const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: userMessage }, ]); setLoading(true); try { // Send request with conversation history for context const response = await chatWithFile( fileId, userMessage, model, conversationHistory, focusedElement?.index ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: assistantMsgId, type: response.pendingQuestions?.length ? "question" : "assistant", content: response.response, toolCalls: response.toolCalls.map((tc) => ({ name: tc.name, success: tc.result.success, message: tc.result.message, })), questions: response.pendingQuestions, }, ]); // Update conversation history for next request setConversationHistory((prev) => [ ...prev, { role: "user", content: userMessage }, { role: "assistant", content: response.response }, ]); // Handle pending questions if (response.pendingQuestions?.length) { setPendingQuestions(response.pendingQuestions); // Initialize answers map const initialAnswers = new Map(); response.pendingQuestions.forEach((q) => { initialAnswers.set(q.id, []); }); setUserAnswers(initialAnswers); setCustomInputs(new Map()); } // Update parent with new body/summary onUpdate(response.updatedBody, response.updatedSummary); } catch (err) { const errorMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: errorMsgId, type: "error", content: err instanceof Error ? err.message : "An error occurred", }, ]); } finally { setLoading(false); inputRef.current?.focus(); } }, [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement] ); // Handle option selection for a question const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { setUserAnswers((prev) => { const newMap = new Map(prev); const currentAnswers = newMap.get(questionId) || []; if (allowMultiple) { // Toggle option in array if (currentAnswers.includes(option)) { newMap.set(questionId, currentAnswers.filter((a) => a !== option)); } else { newMap.set(questionId, [...currentAnswers, option]); } } else { // Single select - replace newMap.set(questionId, [option]); } return newMap; }); }, []); // Handle custom input change const handleCustomInputChange = useCallback((questionId: string, value: string) => { setCustomInputs((prev) => { const newMap = new Map(prev); newMap.set(questionId, value); return newMap; }); }, []); // Submit answers to questions const handleSubmitAnswers = useCallback(async () => { if (!pendingQuestions || loading) return; // Build answers array, including custom inputs const answers: UserAnswer[] = pendingQuestions.map((q) => { const selectedOptions = userAnswers.get(q.id) || []; const customInput = customInputs.get(q.id)?.trim(); // If there's a custom input, add it to answers const finalAnswers = customInput ? [...selectedOptions, customInput] : selectedOptions; return { id: q.id, answers: finalAnswers, }; }); // Format answers as a message const answerText = answers .map((a) => { const question = pendingQuestions.find((q) => q.id === a.id); return `${question?.question || a.id}: ${a.answers.join(", ")}`; }) .join("\n"); // Clear pending questions setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); // Add user answer message const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, ]); setLoading(true); try { // Send answers as the next message const response = await chatWithFile( fileId, answerText, model, conversationHistory, focusedElement?.index ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: assistantMsgId, type: response.pendingQuestions?.length ? "question" : "assistant", content: response.response, toolCalls: response.toolCalls.map((tc) => ({ name: tc.name, success: tc.result.success, message: tc.result.message, })), questions: response.pendingQuestions, }, ]); // Update conversation history setConversationHistory((prev) => [ ...prev, { role: "user", content: answerText }, { role: "assistant", content: response.response }, ]); // Handle more pending questions if (response.pendingQuestions?.length) { setPendingQuestions(response.pendingQuestions); const initialAnswers = new Map(); response.pendingQuestions.forEach((q) => { initialAnswers.set(q.id, []); }); setUserAnswers(initialAnswers); setCustomInputs(new Map()); } // Update parent with new body/summary onUpdate(response.updatedBody, response.updatedSummary); } catch (err) { const errorMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: errorMsgId, type: "error", content: err instanceof Error ? err.message : "An error occurred", }, ]); } finally { setLoading(false); } }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]); // Cancel answering questions const handleCancelQuestions = useCallback(() => { setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); }, []); const clearMessages = useCallback(() => { setMessages([]); setConversationHistory([]); setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); }, []); return (
{/* Messages Panel (expandable) */} {expanded && messages.length > 0 && (
{messages.map((msg) => (
{msg.type === "user" && (
> {msg.content}
)} {(msg.type === "assistant" || msg.type === "question") && (
{msg.toolCalls && msg.toolCalls.length > 0 && (
{msg.toolCalls.map((tc, i) => (
{tc.success ? "+" : "x"} {" "} {tc.name}: {tc.message}
))}
)}
)} {msg.type === "error" && (
{msg.content}
)}
))}
)} {/* Pending Questions UI */} {pendingQuestions && pendingQuestions.length > 0 && (
Questions from AI
{pendingQuestions.map((q) => (
{q.question}
{q.options.map((option) => { const isSelected = (userAnswers.get(q.id) || []).includes(option); return ( ); })}
{q.allowCustom && ( handleCustomInputChange(q.id, e.target.value)} placeholder="Or type a custom answer..." className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" /> )}
))}
)} {/* Input Bar */}
{/* Focus Badge */} {focusedElement && ( )} > setInput(e.target.value)} placeholder={ loading ? "Processing..." : pendingQuestions ? "Answer questions above first..." : "Add a heading, chart, or summary..." } disabled={loading || !!pendingQuestions} className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" /> {messages.length > 0 && ( )}
); }