import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { getContractChatHistory, clearContractChatHistory, startTask, sendTaskMessage, type UserQuestion, type ContractWithRelations, type TaskStatus, } from "../../lib/api"; import { SimpleMarkdown } from "../SimpleMarkdown"; import { QuickActionButtons, type QuickAction, } from "./QuickActionButtons"; import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview"; import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription"; interface ContractCliInputProps { contractId: string; contract: ContractWithRelations; onUpdate: () => void; } interface Message { id: string; type: "user" | "assistant" | "error" | "question"; content: string; toolCalls?: { name: string; success: boolean; message: string }[]; questions?: UserQuestion[]; quickActions?: QuickAction[]; } export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(true); const [messages, setMessages] = useState([]); const [expanded, setExpanded] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [pendingQuestions, setPendingQuestions] = useState(null); const [userAnswers, setUserAnswers] = useState>(new Map()); const [customInputs, setCustomInputs] = useState>(new Map()); // Task derivation state const [parsedTasks, setParsedTasks] = useState(null); const [parsedTaskGroups, setParsedTaskGroups] = useState([]); const [parsedTasksFileName, setParsedTasksFileName] = useState(""); const [creatingTasks, setCreatingTasks] = useState(false); // Supervisor state const [supervisorStarting, setSupervisorStarting] = useState(false); const [supervisorOutput, setSupervisorOutput] = useState([]); const [supervisorQuestion, setSupervisorQuestion] = useState<{ id: string; question: string; options: string[]; allowMultiple?: boolean; allowCustom?: boolean; } | null>(null); const inputRef = useRef(null); const messagesRef = useRef(null); // Find the supervisor task for this contract // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag const supervisorTask = useMemo(() => { // Use contract.supervisorTaskId if available (most reliable) if (contract.supervisorTaskId) { const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId); if (taskById) return taskById; } // Fallback to finding by isSupervisor flag return contract.tasks.find((t) => t.isSupervisor); }, [contract.tasks, contract.supervisorTaskId]); // Log for debugging useEffect(() => { console.log("Supervisor lookup:", { contractId: contract.id, supervisorTaskId: contract.supervisorTaskId, tasksCount: contract.tasks.length, foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null, allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor })) }); }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]); const supervisorTaskId = supervisorTask?.id ?? null; const supervisorStatus = supervisorTask?.status as TaskStatus | undefined; const isSupervisorRunning = supervisorStatus === "running"; const isSupervisorPending = supervisorStatus === "pending"; // Subscribe to supervisor output when it's running const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => { // Check for question pattern in output // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}} if (!event.isPartial && event.content) { const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/); if (questionMatch) { try { const questionData = JSON.parse(questionMatch[1]); if (questionData.id && questionData.question && questionData.options) { setSupervisorQuestion({ id: questionData.id, question: questionData.question, options: questionData.options, allowMultiple: questionData.allowMultiple ?? false, allowCustom: questionData.allowCustom ?? true, }); // Don't add this to output since it's a control message return; } } catch { // Not valid JSON, continue as normal output } } } setSupervisorOutput((prev) => { // If it's a partial message, update the last message if (event.isPartial && prev.length > 0) { const lastEvent = prev[prev.length - 1]; if (lastEvent.messageType === event.messageType && lastEvent.isPartial) { return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }]; } } return [...prev, event]; }); }, []); useTaskSubscription({ taskId: supervisorTaskId, subscribeOutput: isSupervisorRunning, onOutput: handleSupervisorOutput, }); // Auto-start supervisor function - starts and waits for it to be running const ensureSupervisorStarted = useCallback(async (): Promise => { if (!supervisorTask) { console.warn("No supervisor task found for contract"); return false; } if (isSupervisorRunning) { return true; // Already running } if (isSupervisorPending) { try { setSupervisorStarting(true); await startTask(supervisorTask.id); // Poll for the task to be running (up to 10 seconds) for (let i = 0; i < 20; i++) { await new Promise(resolve => setTimeout(resolve, 500)); onUpdate(); // Refresh contract to get updated task status // Note: We can't check the new status here directly since state updates are async // The UI will update when onUpdate triggers a re-render } // Return true - the caller should check if supervisor is running after this return true; } catch (err) { console.error("Failed to start supervisor:", err); return false; } finally { setSupervisorStarting(false); } } // Supervisor exists but is in some other state (paused, done, failed, etc.) // Can still send messages to paused tasks return supervisorStatus === "paused"; }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]); // Handle answering supervisor questions const [supervisorAnswers, setSupervisorAnswers] = useState([]); const [supervisorCustomInput, setSupervisorCustomInput] = useState(""); const handleSupervisorOptionToggle = useCallback((option: string) => { setSupervisorAnswers((prev) => { if (supervisorQuestion?.allowMultiple) { if (prev.includes(option)) { return prev.filter((a) => a !== option); } return [...prev, option]; } return [option]; }); }, [supervisorQuestion?.allowMultiple]); const handleSubmitSupervisorAnswer = useCallback(async () => { if (!supervisorQuestion || !supervisorTask) return; const customAnswer = supervisorCustomInput.trim(); const allAnswers = customAnswer ? [...supervisorAnswers, customAnswer] : supervisorAnswers; if (allAnswers.length === 0) return; // Format answer message for supervisor const answerMessage = `__supervisor_answer__ ${JSON.stringify({ id: supervisorQuestion.id, answers: allAnswers, })}`; try { await sendTaskMessage(supervisorTask.id, answerMessage); // Add user message to chat const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`, }, ]); } catch (err) { console.error("Failed to send supervisor answer:", err); } finally { setSupervisorQuestion(null); setSupervisorAnswers([]); setSupervisorCustomInput(""); } }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]); const handleCancelSupervisorQuestion = useCallback(() => { setSupervisorQuestion(null); setSupervisorAnswers([]); setSupervisorCustomInput(""); }, []); // Load chat history on mount useEffect(() => { let mounted = true; async function loadHistory() { try { const history = await getContractChatHistory(contractId); if (!mounted) return; // Convert saved messages to display messages const displayMessages: Message[] = history.messages.map((msg) => ({ id: msg.id, type: msg.role as "user" | "assistant" | "error", content: msg.content, toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined, })); setMessages(displayMessages); // Auto-expand if there's history if (displayMessages.length > 0) { setExpanded(true); } } catch (err) { console.error("Failed to load contract chat history:", err); } finally { if (mounted) { setHistoryLoading(false); } } } loadHistory(); return () => { mounted = false; }; }, [contractId]); // Auto-scroll to bottom when messages change useEffect(() => { if (messagesRef.current) { messagesRef.current.scrollTop = messagesRef.current.scrollHeight; } }, [messages]); // Auto-start supervisor when component mounts if it's pending useEffect(() => { if (supervisorTask && isSupervisorPending && !supervisorStarting) { console.log("Auto-starting supervisor task on mount..."); ensureSupervisorStarted().then((started) => { if (started) { console.log("Supervisor started successfully"); } }); } }, [supervisorTask?.id]); // Only run when task ID changes, not on every render // Convert supervisor output events to messages useEffect(() => { if (supervisorOutput.length === 0) return; // Get the latest event const latestEvent = supervisorOutput[supervisorOutput.length - 1]; // Only add complete messages (not partials) to the message history if (!latestEvent.isPartial && latestEvent.content.trim()) { const msgId = `supervisor-${Date.now()}`; let msgType: "assistant" | "error" = "assistant"; let content = latestEvent.content; // Format based on message type switch (latestEvent.messageType) { case "assistant": content = latestEvent.content; break; case "tool_use": content = `_Using tool: ${latestEvent.toolName}_`; break; case "tool_result": content = latestEvent.isError ? `Tool error: ${latestEvent.content}` : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`; msgType = latestEvent.isError ? "error" : "assistant"; break; case "error": msgType = "error"; break; case "result": // Final result - show cost info if available if (latestEvent.costUsd) { content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`; } break; default: // system, raw, etc. break; } setMessages((prev) => { // Don't add duplicate messages if (prev.some((m) => m.content === content)) return prev; return [ ...prev, { id: msgId, type: msgType, content }, ]; }); } }, [supervisorOutput]); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || loading) return; const userMessage = input.trim(); setInput(""); setExpanded(true); const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: userMessage }, ]); setLoading(true); try { // Supervisor is the ONLY way to interact with contracts if (!supervisorTask) { throw new Error("No supervisor task found. Please create a contract with a supervisor."); } // Ensure supervisor is started (this will start it if pending) await ensureSupervisorStarted(); // Send message to supervisor task stdin await sendTaskMessage(supervisorTask.id, userMessage); // Response will come through WebSocket subscription // No need for a placeholder message - output will stream in } catch (err) { const errorMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: errorMsgId, type: "error", content: err instanceof Error ? err.message : "An error occurred", }, ]); } finally { setLoading(false); inputRef.current?.focus(); } }, [input, loading, supervisorTask, ensureSupervisorStarted] ); 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; }); }, []); const handleCustomInputChange = useCallback((questionId: string, value: string) => { setCustomInputs((prev) => { const newMap = new Map(prev); newMap.set(questionId, value); return newMap; }); }, []); const handleSubmitAnswers = useCallback(async () => { if (!pendingQuestions || loading) return; const answers = 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, }; }); const answerText = answers .map((a) => { const question = pendingQuestions.find((q) => q.id === a.id); return `${question?.question || a.id}: ${a.answers.join(", ")}`; }) .join("\n"); setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, ]); setLoading(true); try { if (!supervisorTask) { throw new Error("No supervisor task found"); } await ensureSupervisorStarted(); await sendTaskMessage(supervisorTask.id, answerText); // Response will come through WebSocket } catch (err) { const errorMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: errorMsgId, type: "error", content: err instanceof Error ? err.message : "An error occurred", }, ]); } finally { setLoading(false); } }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]); const handleCancelQuestions = useCallback(() => { setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); }, []); const clearMessages = useCallback(() => { setMessages([]); setPendingQuestions(null); setUserAnswers(new Map()); setCustomInputs(new Map()); setParsedTasks(null); setParsedTaskGroups([]); setParsedTasksFileName(""); setSupervisorOutput([]); setSupervisorQuestion(null); setSupervisorAnswers([]); setSupervisorCustomInput(""); }, []); // Handle creating tasks from the preview const handleCreateDerivedTasks = useCallback( async (selectedTasks: ParsedTask[]) => { if (selectedTasks.length === 0) { setParsedTasks(null); return; } setCreatingTasks(true); // Build a message asking the supervisor to create these tasks const taskList = selectedTasks .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`) .join("\n"); const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`; // Add user message const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: message }, ]); try { if (!supervisorTask) { throw new Error("No supervisor task found"); } await ensureSupervisorStarted(); await sendTaskMessage(supervisorTask.id, message); // Response will come through WebSocket } catch (err) { const errorMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: errorMsgId, type: "error", content: err instanceof Error ? err.message : "An error occurred", }, ]); } finally { setCreatingTasks(false); setParsedTasks(null); setParsedTaskGroups([]); setParsedTasksFileName(""); } }, [supervisorTask, ensureSupervisorStarted] ); const handleCancelTaskDerivation = useCallback(() => { setParsedTasks(null); setParsedTaskGroups([]); setParsedTasksFileName(""); }, []); const handleQuickAction = useCallback( async (action: QuickAction) => { // Convert the action into a chat message that triggers the appropriate behavior let message = ""; switch (action.type) { case "create_file": message = "Create the suggested file from the template."; break; case "create_task": message = "Yes, create the tasks."; break; case "derive_tasks": message = "Show me the tasks to review and create them."; break; case "run_task": message = "Run the next task."; break; case "advance_phase": if (action.data?.phase) { message = `Advance to the ${action.data.phase} phase.`; } else { message = "Advance to the next phase."; } break; case "update_file": message = "Update the file with the task output."; break; default: return; } setExpanded(true); // Submit the message const userMsgId = Date.now().toString(); setMessages((prev) => [ ...prev, { id: userMsgId, type: "user", content: message }, ]); setLoading(true); try { if (!supervisorTask) { throw new Error("No supervisor task found"); } await ensureSupervisorStarted(); await sendTaskMessage(supervisorTask.id, message); // Response will come through WebSocket } catch (err) { const errorMsgId = (Date.now() + 1).toString(); setMessages((prev) => [ ...prev, { id: errorMsgId, type: "error", content: err instanceof Error ? err.message : "An error occurred", }, ]); } finally { setLoading(false); } }, [supervisorTask, ensureSupervisorStarted] ); return (
{/* Header bar with supervisor status and toggle */}
Supervisor {supervisorTask && ( {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"} )} {!supervisorTask && ( No supervisor )}
{messages.length > 0 && ( )}
{/* History loading indicator */} {historyLoading && (
Loading history...
)} {/* Messages Panel (expandable) */} {expanded && messages.length > 0 && !historyLoading && (
{/* Expand/Collapse button */}
{messages.map((msg) => (
{msg.type === "user" && (
> {msg.content}
)} {(msg.type === "assistant" || msg.type === "question") && (
{msg.toolCalls && msg.toolCalls.length > 0 && (
{msg.toolCalls.map((tc, i) => (
{tc.success ? "+" : "x"} {" "} {tc.name}: {tc.message}
))}
)} {msg.quickActions && msg.quickActions.length > 0 && ( )}
)} {msg.type === "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]" /> )}
))}
)} {/* Supervisor Question UI */} {supervisorQuestion && (
Question from Supervisor
{supervisorQuestion.question}
{supervisorQuestion.options.map((option) => { const isSelected = supervisorAnswers.includes(option); return ( ); })}
{supervisorQuestion.allowCustom && ( setSupervisorCustomInput(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-green-400 placeholder-[#555]" /> )}
)} {/* Contract Context Badge */}
{contract.phase} | {supervisorTask && ( <> Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status} | )} {contract.files.length} files | {contract.tasks.length} tasks | {contract.repositories.length} repos | {messages.length > 0 && ( <> | )}
{/* Input Bar */}
> setInput(e.target.value)} placeholder={ loading ? "Processing..." : supervisorStarting ? "Starting supervisor..." : supervisorQuestion ? "Answer supervisor question above..." : pendingQuestions ? "Answer questions above first..." : isSupervisorRunning ? "Message supervisor..." : "Create a task, add a file, or ask about the contract..." } disabled={loading || !!pendingQuestions || !!supervisorQuestion} className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" /> {messages.length > 0 && ( )}
{/* Task Derivation Preview Modal */} {parsedTasks && parsedTasks.length > 0 && ( )}
); }