diff options
Diffstat (limited to 'makima/frontend/src/components/contracts/ContractCliInput.tsx')
| -rw-r--r-- | makima/frontend/src/components/contracts/ContractCliInput.tsx | 974 |
1 files changed, 974 insertions, 0 deletions
diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx new file mode 100644 index 0000000..821d03c --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractCliInput.tsx @@ -0,0 +1,974 @@ +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<Message[]>([]); + const [expanded, setExpanded] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + 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()); + + // Task derivation state + const [parsedTasks, setParsedTasks] = useState<ParsedTask[] | null>(null); + const [parsedTaskGroups, setParsedTaskGroups] = useState<string[]>([]); + const [parsedTasksFileName, setParsedTasksFileName] = useState<string>(""); + const [creatingTasks, setCreatingTasks] = useState(false); + + // Supervisor state + const [supervisorStarting, setSupervisorStarting] = useState(false); + const [supervisorOutput, setSupervisorOutput] = useState<TaskOutputEvent[]>([]); + const [supervisorQuestion, setSupervisorQuestion] = useState<{ + id: string; + question: string; + options: string[]; + allowMultiple?: boolean; + allowCustom?: boolean; + } | null>(null); + + const inputRef = useRef<HTMLInputElement>(null); + const messagesRef = useRef<HTMLDivElement>(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<boolean> => { + 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<string[]>([]); + 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 ( + <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {/* Header bar with supervisor status and toggle */} + <div className="px-3 py-2 flex items-center justify-between border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-3"> + <span className="font-mono text-[10px] text-[#555] uppercase tracking-wide"> + Supervisor + </span> + {supervisorTask && ( + <span className={`font-mono text-[10px] px-2 py-0.5 border ${ + isSupervisorRunning + ? "text-green-400 border-green-400/30 bg-green-400/10" + : isSupervisorPending || supervisorStarting + ? "text-yellow-400 border-yellow-400/30 bg-yellow-400/10" + : "text-[#555] border-[rgba(117,170,252,0.2)]" + }`}> + {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"} + </span> + )} + {!supervisorTask && ( + <span className="font-mono text-[10px] text-red-400"> + No supervisor + </span> + )} + </div> + {messages.length > 0 && ( + <button + type="button" + onClick={() => setExpanded(!expanded)} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" + > + {expanded ? "Hide Messages" : `Show Messages (${messages.length})`} + </button> + )} + </div> + + {/* History loading indicator */} + {historyLoading && ( + <div className="px-3 py-2 text-[10px] font-mono text-[#555] flex items-center gap-2 border-b border-[rgba(117,170,252,0.2)]"> + <span className="animate-pulse">Loading history...</span> + </div> + )} + + {/* Messages Panel (expandable) */} + {expanded && messages.length > 0 && !historyLoading && ( + <div className="relative border-b border-[rgba(117,170,252,0.2)]"> + {/* Expand/Collapse button */} + <div className="absolute top-2 right-2 z-10 flex gap-1"> + <button + type="button" + onClick={() => setFullscreen(!fullscreen)} + className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + title={fullscreen ? "Collapse" : "Expand"} + > + {fullscreen ? "▼ Collapse" : "▲ Expand"} + </button> + </div> + <div + ref={messagesRef} + className={`overflow-y-auto p-3 pr-24 space-y-2 transition-all duration-200 ${ + fullscreen ? "max-h-[60vh]" : "max-h-48" + }`} + > + {messages.map((msg) => ( + <div key={msg.id} className="font-mono text-xs"> + {msg.type === "user" && ( + <div className="flex gap-2"> + <span className="text-[#9bc3ff]">></span> + <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span> + </div> + )} + {(msg.type === "assistant" || msg.type === "question") && ( + <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.success ? "text-green-500" : "text-red-400" + } + > + {tc.success ? "+" : "x"} + </span>{" "} + {tc.name}: {tc.message} + </div> + ))} + </div> + )} + {msg.quickActions && msg.quickActions.length > 0 && ( + <QuickActionButtons + actions={msg.quickActions} + onAction={handleQuickAction} + loading={loading} + /> + )} + </div> + )} + {msg.type === "error" && ( + <div className="pl-4 text-red-400">{msg.content}</div> + )} + </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 ? "[x]" : "[ ]"}</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> + )} + + {/* Supervisor Question UI */} + {supervisorQuestion && ( + <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3 bg-[rgba(117,170,252,0.05)]"> + <div className="text-green-400 font-mono text-xs uppercase tracking-wide flex items-center gap-2"> + <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" /> + Question from Supervisor + </div> + <div className="text-white/90 font-mono text-sm">{supervisorQuestion.question}</div> + <div className="flex flex-wrap gap-2"> + {supervisorQuestion.options.map((option) => { + const isSelected = supervisorAnswers.includes(option); + return ( + <button + key={option} + type="button" + onClick={() => handleSupervisorOptionToggle(option)} + className={`px-2 py-1 font-mono text-xs border transition-colors ${ + isSelected + ? "bg-green-500/30 border-green-400 text-white" + : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-green-400" + }`} + > + {supervisorQuestion.allowMultiple && ( + <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span> + )} + {option} + </button> + ); + })} + </div> + {supervisorQuestion.allowCustom && ( + <input + type="text" + value={supervisorCustomInput} + onChange={(e) => 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]" + /> + )} + <div className="flex gap-2 pt-2"> + <button + type="button" + onClick={handleSubmitSupervisorAnswer} + disabled={supervisorAnswers.length === 0 && !supervisorCustomInput.trim()} + className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/50 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + Send Answer + </button> + <button + type="button" + onClick={handleCancelSupervisorQuestion} + className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Dismiss + </button> + </div> + </div> + )} + + {/* Contract Context Badge */} + <div className="px-3 pt-2 pb-1 flex items-center gap-2 text-[10px] font-mono text-[#555]"> + <span className="text-[#75aafc]">{contract.phase}</span> + <span>|</span> + {supervisorTask && ( + <> + <span + className={ + isSupervisorRunning + ? "text-green-400" + : isSupervisorPending + ? "text-yellow-400" + : supervisorStarting + ? "text-cyan-400 animate-pulse" + : "text-[#555]" + } + > + Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status} + </span> + <span>|</span> + </> + )} + <span>{contract.files.length} files</span> + <span>|</span> + <span>{contract.tasks.length} tasks</span> + <span>|</span> + <span>{contract.repositories.length} repos</span> + <span>|</span> + <button + type="button" + onClick={() => { + const prompt = "Guide me to complete this phase and advance to the next. Analyze my current deliverables, identify what's missing, and suggest specific next steps."; + setInput(prompt); + // Auto-submit the prompt + setTimeout(() => { + const form = document.querySelector('form'); + if (form) form.requestSubmit(); + }, 0); + }} + disabled={loading || !!pendingQuestions} + className="text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50 transition-colors cursor-pointer" + > + Progress → + </button> + {messages.length > 0 && ( + <> + <span>|</span> + <button + type="button" + onClick={async () => { + if (window.confirm("Clear all chat history for this contract?")) { + try { + await clearContractChatHistory(contractId); + setMessages([]); + } catch (err) { + console.error("Failed to clear history:", err); + } + } + }} + disabled={loading} + className="text-[#555] hover:text-red-400 disabled:opacity-50 transition-colors cursor-pointer" + > + Clear + </button> + </> + )} + </div> + + {/* Input Bar */} + <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 pb-3"> + <span className="text-[#9bc3ff] font-mono text-sm">></span> + <input + ref={inputRef} + type="text" + value={input} + onChange={(e) => 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 && ( + <button + type="button" + onClick={clearMessages} + className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" + > + clear + </button> + )} + <button + type="submit" + disabled={loading || !input.trim() || !!pendingQuestions || !!supervisorQuestion} + 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> + + {/* Task Derivation Preview Modal */} + {parsedTasks && parsedTasks.length > 0 && ( + <TaskDerivationPreview + tasks={parsedTasks} + groups={parsedTaskGroups} + fileName={parsedTasksFileName} + onCreateTasks={handleCreateDerivedTasks} + onCancel={handleCancelTaskDerivation} + loading={creatingTasks} + /> + )} + </div> + ); +} |
