From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 6 Jan 2026 04:08:11 +0000 Subject: Initial Control system --- .../src/components/mesh/UnifiedMeshChatInput.tsx | 536 +++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx (limited to 'makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx') diff --git a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx new file mode 100644 index 0000000..5caa3c4 --- /dev/null +++ b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx @@ -0,0 +1,536 @@ +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 && ( + + )} + +
+
+ ); +} -- cgit v1.2.3