summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/contracts/ContractCliInput.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/contracts/ContractCliInput.tsx')
-rw-r--r--makima/frontend/src/components/contracts/ContractCliInput.tsx974
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]">&gt;</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">&gt;</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>
+ );
+}