import { useState, useCallback, useRef, useEffect } from "react"; import { type LlmModel, type UserQuestion, type UserAnswer, type MeshChatContext, } from "../../lib/api"; import { useMeshChatHistory } from "../../hooks/useMeshChatHistory"; import { SimpleMarkdown } from "../SimpleMarkdown"; interface UnifiedMeshChatInputProps { context: MeshChatContext; onUpdate?: () => void; } const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ { value: "claude-opus", label: "Claude Opus" }, { value: "claude-sonnet", label: "Claude Sonnet" }, { value: "groq", label: "Groq Kimi" }, ]; const DEFAULT_MODEL: LlmModel = "claude-opus"; // LocalStorage keys const STORAGE_KEY_MODEL = "makima-mesh-chat-model"; const STORAGE_KEY_CMD_HISTORY = "makima-mesh-chat-cmd-history"; const MAX_CMD_HISTORY = 100; function loadModel(): LlmModel { try { const modelStr = localStorage.getItem(STORAGE_KEY_MODEL); return (modelStr as LlmModel) || DEFAULT_MODEL; } catch { return DEFAULT_MODEL; } } function saveModel(model: LlmModel): void { try { localStorage.setItem(STORAGE_KEY_MODEL, model); } catch { // Ignore storage errors } } function loadCommandHistory(): string[] { try { const historyJson = localStorage.getItem(STORAGE_KEY_CMD_HISTORY); return historyJson ? JSON.parse(historyJson) : []; } catch { return []; } } function saveCommandHistory(history: string[]): void { try { localStorage.setItem( STORAGE_KEY_CMD_HISTORY, JSON.stringify(history.slice(-MAX_CMD_HISTORY)) ); } catch { // Ignore storage errors } } function getPlaceholder(context: MeshChatContext): string { switch (context.type) { case "mesh": return "Create task, list tasks, check status..."; case "task": return "Create subtask, run task, check status..."; case "subtask": return "Update plan, check siblings, merge..."; default: return "Ask anything..."; } } function getContextLabel(context: MeshChatContext): string { switch (context.type) { case "mesh": return "mesh"; case "task": return `task:${context.taskId?.slice(0, 8)}`; case "subtask": return `subtask:${context.taskId?.slice(0, 8)}`; default: return "chat"; } } export function UnifiedMeshChatInput({ context, onUpdate, }: UnifiedMeshChatInputProps) { const { messages, loading: historyLoading, error: historyError, sending, clearHistory, sendMessage, } = useMeshChatHistory(); const [input, setInput] = useState(""); const [expanded, setExpanded] = useState(false); const [model, setModel] = useState(DEFAULT_MODEL); // Pending questions state const [pendingQuestions, setPendingQuestions] = useState< UserQuestion[] | null >(null); const [userAnswers, setUserAnswers] = useState>( new Map() ); const [customInputs, setCustomInputs] = useState>( new Map() ); // Command history for arrow key navigation const [commandHistory, setCommandHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const [savedInput, setSavedInput] = useState(""); const inputRef = useRef(null); const messagesRef = useRef(null); // Load model preference on mount useEffect(() => { setModel(loadModel()); setCommandHistory(loadCommandHistory()); }, []); // Expand when messages exist useEffect(() => { if (messages.length > 0) { setExpanded(true); } }, [messages.length]); // Auto-scroll to bottom when messages change useEffect(() => { if (messagesRef.current) { messagesRef.current.scrollTop = messagesRef.current.scrollHeight; } }, [messages]); // Handle model change const handleModelChange = useCallback((newModel: LlmModel) => { setModel(newModel); saveModel(newModel); }, []); // Handle keyboard navigation for command history const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowUp") { e.preventDefault(); if (commandHistory.length === 0) return; if (historyIndex === -1) { setSavedInput(input); setHistoryIndex(commandHistory.length - 1); setInput(commandHistory[commandHistory.length - 1]); } else if (historyIndex > 0) { setHistoryIndex(historyIndex - 1); setInput(commandHistory[historyIndex - 1]); } } else if (e.key === "ArrowDown") { e.preventDefault(); if (historyIndex === -1) return; if (historyIndex < commandHistory.length - 1) { setHistoryIndex(historyIndex + 1); setInput(commandHistory[historyIndex + 1]); } else { setHistoryIndex(-1); setInput(savedInput); } } }, [commandHistory, historyIndex, input, savedInput] ); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || sending) return; const userMessage = input.trim(); // Update command history const newHistory = commandHistory[commandHistory.length - 1] !== userMessage ? [...commandHistory, userMessage] : commandHistory; setCommandHistory(newHistory); saveCommandHistory(newHistory); // Reset navigation state setHistoryIndex(-1); setSavedInput(""); setInput(""); setExpanded(true); // Send message via hook (uses DB-persisted history) const response = await sendMessage(userMessage, context, model); if (response) { // Handle 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()); } // Notify parent that something may have been updated // Always refresh when tool calls were made (state may have changed) if (response.toolCalls && response.toolCalls.length > 0) { onUpdate?.(); } } inputRef.current?.focus(); }, [input, sending, context, model, sendMessage, onUpdate, commandHistory] ); // 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) { if (currentAnswers.includes(option)) { newMap.set( questionId, currentAnswers.filter((a) => a !== option) ); } else { newMap.set(questionId, [...currentAnswers, option]); } } else { 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 || sending) return; // Build answers array const answers: UserAnswer[] = pendingQuestions.map((q) => { const selectedOptions = userAnswers.get(q.id) || []; const customInput = customInputs.get(q.id)?.trim(); 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()); // Send answers as the next message const response = await sendMessage(answerText, context, model); if (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()); } // Notify parent that something may have been updated if (response.toolCalls && response.toolCalls.length > 0) { onUpdate?.(); } } }, [ pendingQuestions, userAnswers, customInputs, sending, context, model, sendMessage, onUpdate, ]); // Cancel answering questions const handleCancelQuestions = useCallback(() => { setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); }, []); const handleClearHistory = useCallback(async () => { await clearHistory(); setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); }, [clearHistory]); const loading = sending || historyLoading; return (
{/* Error Display */} {historyError && (
{historyError}
)} {/* Messages Panel (expandable) */} {expanded && messages.length > 0 && (
{messages.map((msg) => (
{msg.role === "user" && (
> {msg.content} {msg.contextType !== "mesh" && ( [{msg.contextType}] )}
)} {msg.role === "assistant" && (
{msg.toolCalls && msg.toolCalls.length > 0 && (
{msg.toolCalls.map((tc, i) => (
{tc.result.success ? "+" : "x"} {" "} {tc.name}: {tc.result.message}
))}
)}
)} {msg.role === "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 */}
[{getContextLabel(context)}] > setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={ loading ? "Processing..." : pendingQuestions ? "Answer questions above first..." : getPlaceholder(context) } disabled={loading || !!pendingQuestions} className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" /> {messages.length > 0 && ( )}
); }