From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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<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>
+  );
+}
-- 
cgit v1.2.3

