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]">></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">></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>
);
}