summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx')
-rw-r--r--makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx536
1 files changed, 536 insertions, 0 deletions
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<LlmModel>(DEFAULT_MODEL);
+
+ // Pending questions state
+ const [pendingQuestions, setPendingQuestions] = useState<
+ UserQuestion[] | null
+ >(null);
+ const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(
+ new Map()
+ );
+ const [customInputs, setCustomInputs] = useState<Map<string, string>>(
+ new Map()
+ );
+
+ // Command history for arrow key navigation
+ const [commandHistory, setCommandHistory] = useState<string[]>([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [savedInput, setSavedInput] = useState("");
+
+ const inputRef = useRef<HTMLInputElement>(null);
+ const messagesRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
+ 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<string, string[]>();
+ 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<string, string[]>();
+ 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 (
+ <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {/* Error Display */}
+ {historyError && (
+ <div className="px-3 py-2 bg-red-900/20 text-red-400 text-xs font-mono">
+ {historyError}
+ </div>
+ )}
+
+ {/* Messages Panel (expandable) */}
+ {expanded && messages.length > 0 && (
+ <div
+ ref={messagesRef}
+ className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
+ >
+ {messages.map((msg) => (
+ <div key={msg.id} className="font-mono text-xs">
+ {msg.role === "user" && (
+ <div className="flex gap-2">
+ <span className="text-[#9bc3ff]">&gt;</span>
+ <span className="text-white/80 whitespace-pre-wrap">
+ {msg.content}
+ </span>
+ {msg.contextType !== "mesh" && (
+ <span className="text-[#555] text-[10px]">
+ [{msg.contextType}]
+ </span>
+ )}
+ </div>
+ )}
+ {msg.role === "assistant" && (
+ <div className="pl-4 space-y-1">
+ <SimpleMarkdown
+ content={msg.content}
+ className="text-[#75aafc]"
+ />
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.result.success
+ ? "text-green-500"
+ : "text-red-400"
+ }
+ >
+ {tc.result.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.result.message}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ {msg.role === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Pending Questions UI */}
+ {pendingQuestions && pendingQuestions.length > 0 && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
+ <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
+ Questions from AI
+ </div>
+ {pendingQuestions.map((q) => (
+ <div key={q.id} className="space-y-2">
+ <div className="text-white/90 font-mono text-sm">
+ {q.question}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {q.options.map((option) => {
+ const isSelected = (userAnswers.get(q.id) || []).includes(
+ option
+ );
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() =>
+ handleOptionToggle(q.id, option, q.allowMultiple)
+ }
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-[#3f6fb3] border-[#75aafc] text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
+ }`}
+ >
+ {q.allowMultiple && (
+ <span className="mr-1">{isSelected ? "+" : "-"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {q.allowCustom && (
+ <input
+ type="text"
+ value={customInputs.get(q.id) || ""}
+ onChange={(e) => 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]"
+ />
+ )}
+ </div>
+ ))}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitAnswers}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Submit Answers"}
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelQuestions}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3">
+ <select
+ value={model}
+ onChange={(e) => handleModelChange(e.target.value as LlmModel)}
+ disabled={loading || !!pendingQuestions}
+ className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ >
+ {MODEL_OPTIONS.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <span className="text-[#555] font-mono text-[10px]">
+ [{getContextLabel(context)}]
+ </span>
+ <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => 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 && (
+ <button
+ type="button"
+ onClick={handleClearHistory}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim() || !!pendingQuestions}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ );
+}