diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/contracts | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/frontend/src/components/contracts')
10 files changed, 3229 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> + ); +} diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx new file mode 100644 index 0000000..cf5f8f2 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractDetail.tsx @@ -0,0 +1,794 @@ +import { useState, useEffect, useCallback } from "react"; +import type { + ContractWithRelations, + ContractPhase, + ContractStatus, + ContractRepository, + FileSummary, + TaskSummary, + TemplateSummary, +} from "../../lib/api"; +import { + listTemplates, + getTemplate, + createFile, +} from "../../lib/api"; +import { PhaseProgressBar } from "./PhaseProgressBar"; +import { PhaseHint } from "./PhaseHint"; +import { RepositoryPanel } from "./RepositoryPanel"; +import { ContractCliInput } from "./ContractCliInput"; +import { PhaseDeliverablesPanel } from "./PhaseDeliverablesPanel"; +import { TaskTree } from "../mesh/TaskTree"; + +type Tab = "overview" | "repos" | "files" | "tasks"; + +interface ContractDetailProps { + contract: ContractWithRelations; + loading: boolean; + onBack: () => void; + onUpdate: (name: string, description: string) => void; + onDelete: () => void; + onPhaseChange: (phase: ContractPhase) => void; + onStatusChange: (status: ContractStatus) => void; + onFileSelect: (id: string) => void; + onTaskSelect: (id: string) => void; + onTaskCreate: (name: string, plan: string, repositoryUrl?: string) => void; + onRefresh: () => void; + // Repository callbacks + onAddRemoteRepo: (name: string, url: string, isPrimary: boolean) => void; + onAddLocalRepo: (name: string, path: string, isPrimary: boolean) => void; + onCreateManagedRepo: (name: string, isPrimary: boolean) => void; + onDeleteRepo: (repoId: string) => void; + onSetRepoPrimary: (repoId: string) => void; + // File creation callback for phase deliverables + onCreateFileFromTemplate?: (templateId: string, suggestedName: string) => void; +} + +const statusConfig: Record<ContractStatus, { label: string; color: string }> = { + active: { label: "Active", color: "text-green-400" }, + completed: { label: "Completed", color: "text-blue-400" }, + archived: { label: "Archived", color: "text-[#555]" }, +}; + +export function ContractDetail({ + contract, + loading, + onBack, + onUpdate, + onDelete, + onPhaseChange, + onStatusChange, + onFileSelect, + onTaskSelect, + onTaskCreate, + onRefresh, + onAddRemoteRepo, + onAddLocalRepo, + onCreateManagedRepo, + onDeleteRepo, + onSetRepoPrimary, + onCreateFileFromTemplate, +}: ContractDetailProps) { + const [activeTab, setActiveTab] = useState<Tab>("overview"); + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(contract.name); + const [description, setDescription] = useState(contract.description || ""); + + const handleSave = () => { + onUpdate(name, description); + setIsEditing(false); + }; + + const handleCancel = () => { + setName(contract.name); + setDescription(contract.description || ""); + setIsEditing(false); + }; + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ); + } + + const tabs: { key: Tab; label: string; count?: number }[] = [ + { key: "overview", label: "Overview" }, + { key: "repos", label: "Repositories", count: contract.repositories.length }, + { key: "files", label: "Files", count: contract.files.length }, + { key: "tasks", label: "Tasks", count: contract.tasks.length }, + ]; + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="flex items-center justify-between mb-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + ← Back to list + </button> + <div className="flex items-center gap-2"> + {isEditing ? ( + <> + <button + onClick={handleCancel} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Cancel + </button> + <button + onClick={handleSave} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + Save + </button> + </> + ) : ( + <> + <button + onClick={() => setIsEditing(true)} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase" + > + Edit + </button> + <button + onClick={onDelete} + className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + </> + )} + </div> + </div> + + {isEditing ? ( + <div className="space-y-3"> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + placeholder="Contract name" + /> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + rows={2} + placeholder="Description (optional)" + /> + </div> + ) : ( + <> + <div className="flex items-center gap-3 mb-2"> + <h2 className="font-mono text-lg text-[#dbe7ff]"> + {contract.name} + </h2> + <span + className={`font-mono text-xs uppercase ${ + statusConfig[contract.status].color + }`} + > + {statusConfig[contract.status].label} + </span> + </div> + {contract.description && ( + <p className="font-mono text-sm text-[#9bc3ff] mb-3"> + {contract.description} + </p> + )} + </> + )} + + {/* Phase progress */} + <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]"> + <PhaseProgressBar + currentPhase={contract.phase} + onPhaseClick={onPhaseChange} + /> + </div> + </div> + + {/* Tabs */} + <div className="flex border-b border-[rgba(117,170,252,0.2)]"> + {tabs.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={` + px-4 py-2 font-mono text-xs uppercase tracking-wider transition-colors + ${ + activeTab === tab.key + ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" + : "text-[#555] hover:text-[#9bc3ff]" + } + `} + > + {tab.label} + {tab.count !== undefined && tab.count > 0 && ( + <span className="ml-1 text-[10px]">({tab.count})</span> + )} + </button> + ))} + </div> + + {/* Tab content */} + <div className="flex-1 overflow-y-auto p-4"> + {activeTab === "overview" && ( + <OverviewTab + contract={contract} + onStatusChange={onStatusChange} + onPhaseChange={onPhaseChange} + onCreateFile={onCreateFileFromTemplate} + /> + )} + + {activeTab === "repos" && ( + <RepositoryPanel + repositories={contract.repositories} + onAddRemote={onAddRemoteRepo} + onAddLocal={onAddLocalRepo} + onCreateManaged={onCreateManagedRepo} + onDelete={onDeleteRepo} + onSetPrimary={onSetRepoPrimary} + /> + )} + + {activeTab === "files" && ( + <FilesTab + files={contract.files} + contractId={contract.id} + contractPhase={contract.phase} + onSelect={onFileSelect} + onRefresh={onRefresh} + /> + )} + + {activeTab === "tasks" && ( + <TasksTab + tasks={contract.tasks} + repositories={contract.repositories} + supervisorTaskId={contract.supervisorTaskId} + onSelect={onTaskSelect} + onCreate={onTaskCreate} + /> + )} + </div> + + {/* Chat Input */} + <ContractCliInput + contractId={contract.id} + contract={contract} + onUpdate={onRefresh} + /> + </div> + ); +} + +// Overview tab +function OverviewTab({ + contract, + onStatusChange, + onPhaseChange, + onCreateFile, +}: { + contract: ContractWithRelations; + onStatusChange: (status: ContractStatus) => void; + onPhaseChange: (phase: ContractPhase) => void; + onCreateFile?: (templateId: string, suggestedName: string) => void; +}) { + return ( + <div className="space-y-6"> + {/* Phase deliverables checklist */} + <PhaseDeliverablesPanel + contract={contract} + onCreateFile={onCreateFile} + /> + + {/* Phase hint */} + <PhaseHint contract={contract} onAdvancePhase={onPhaseChange} /> + + {/* Task progress summary */} + <TaskStatusSummary tasks={contract.tasks} /> + + {/* Stats */} + <div className="grid grid-cols-3 gap-4"> + <StatCard label="Repositories" value={contract.repositories.length} /> + <StatCard label="Files" value={contract.files.length} /> + <StatCard label="Tasks" value={contract.tasks.length} /> + </div> + + {/* Status change */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Status + </h3> + <div className="flex gap-2"> + {(["active", "completed", "archived"] as ContractStatus[]).map( + (status) => ( + <button + key={status} + onClick={() => onStatusChange(status)} + className={` + px-3 py-1.5 font-mono text-xs uppercase transition-colors + ${ + contract.status === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] border border-transparent hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ) + )} + </div> + </div> + + {/* Metadata */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Details + </h3> + <div className="space-y-1 font-mono text-xs text-[#555]"> + <p>Created: {new Date(contract.createdAt).toLocaleString()}</p> + <p>Updated: {new Date(contract.updatedAt).toLocaleString()}</p> + <p>Version: {contract.version}</p> + </div> + </div> + </div> + ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( + <div className="p-3 border border-[rgba(117,170,252,0.2)]"> + <div className="font-mono text-2xl text-[#dbe7ff]">{value}</div> + <div className="font-mono text-[10px] text-[#555] uppercase">{label}</div> + </div> + ); +} + +// Task status summary with progress bar +function TaskStatusSummary({ tasks }: { tasks: TaskSummary[] }) { + if (tasks.length === 0) return null; + + // Count tasks by status + const statusCounts = { + done: 0, + merged: 0, + running: 0, + pending: 0, + failed: 0, + other: 0, + }; + + for (const task of tasks) { + switch (task.status) { + case "done": + statusCounts.done++; + break; + case "merged": + statusCounts.merged++; + break; + case "running": + case "initializing": + case "starting": + statusCounts.running++; + break; + case "pending": + statusCounts.pending++; + break; + case "failed": + statusCounts.failed++; + break; + default: + statusCounts.other++; + } + } + + const completedCount = statusCounts.done + statusCounts.merged; + const progressPercent = (completedCount / tasks.length) * 100; + + // Build summary parts + const parts: string[] = []; + if (completedCount > 0) parts.push(`${completedCount} done`); + if (statusCounts.running > 0) parts.push(`${statusCounts.running} running`); + if (statusCounts.pending > 0) parts.push(`${statusCounts.pending} pending`); + if (statusCounts.failed > 0) parts.push(`${statusCounts.failed} failed`); + + return ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Task Progress + </h3> + + {/* Progress bar */} + <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> + <div + className="h-full bg-green-400 transition-all duration-300" + style={{ width: `${progressPercent}%` }} + /> + </div> + + {/* Summary text */} + <div className="flex items-center justify-between"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {parts.join(", ")} + </span> + <span className="font-mono text-xs text-[#555]"> + {completedCount}/{tasks.length} completed + </span> + </div> + </div> + ); +} + +// Phase color mapping for badges +const phaseColors: Record<ContractPhase, string> = { + research: "bg-purple-500/20 text-purple-400 border-purple-400/30", + specify: "bg-blue-500/20 text-blue-400 border-blue-400/30", + plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30", + execute: "bg-green-500/20 text-green-400 border-green-400/30", + review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30", +}; + +// Files tab with template creation +function FilesTab({ + files, + contractId, + contractPhase, + onSelect, + onRefresh, +}: { + files: FileSummary[]; + contractId: string; + contractPhase: ContractPhase; + onSelect: (id: string) => void; + onRefresh: () => void; +}) { + const [showTemplateModal, setShowTemplateModal] = useState(false); + const [templates, setTemplates] = useState<TemplateSummary[]>([]); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [creating, setCreating] = useState(false); + const [fileName, setFileName] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); + + // Load templates when modal opens + useEffect(() => { + if (showTemplateModal) { + setLoadingTemplates(true); + listTemplates(contractPhase) + .then((res) => setTemplates(res.templates)) + .catch((err) => console.error("Failed to load templates:", err)) + .finally(() => setLoadingTemplates(false)); + } + }, [showTemplateModal, contractPhase]); + + const handleCreateFromTemplate = useCallback(async () => { + if (!fileName.trim() || !selectedTemplateId) return; + + setCreating(true); + try { + // Get the full template with body + const template = await getTemplate(selectedTemplateId); + + // Create the file with contract (files must belong to contracts) + await createFile({ + contractId, + name: fileName.trim(), + description: template.description, + body: template.suggestedBody, + }); + + // Reset and close + setShowTemplateModal(false); + setFileName(""); + setSelectedTemplateId(null); + onRefresh(); + } catch (err) { + console.error("Failed to create file from template:", err); + } finally { + setCreating(false); + } + }, [fileName, selectedTemplateId, contractId, onRefresh]); + + const handleCloseModal = () => { + setShowTemplateModal(false); + setFileName(""); + setSelectedTemplateId(null); + }; + + return ( + <div className="space-y-4"> + {/* Create from template button */} + <button + onClick={() => setShowTemplateModal(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Create from Template + </button> + + {/* Template Selection Modal */} + {showTemplateModal && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase"> + Create File from Template + </h3> + <span className={`px-2 py-0.5 text-[10px] font-mono uppercase border rounded ${phaseColors[contractPhase]}`}> + {contractPhase} phase + </span> + </div> + + <div className="space-y-4 flex-1 overflow-y-auto"> + {/* File name input */} + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + File Name + </label> + <input + type="text" + value={fileName} + onChange={(e) => setFileName(e.target.value)} + placeholder="e.g., Project Requirements" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Template selection */} + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-2"> + Select Template + </label> + {loadingTemplates ? ( + <p className="font-mono text-xs text-[#555]">Loading templates...</p> + ) : templates.length === 0 ? ( + <p className="font-mono text-xs text-[#555]">No templates available for {contractPhase} phase</p> + ) : ( + <div className="space-y-2 max-h-60 overflow-y-auto"> + {templates.map((template) => ( + <button + key={template.id} + onClick={() => setSelectedTemplateId(template.id)} + className={`w-full text-left p-3 border transition-colors ${ + selectedTemplateId === template.id + ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" + : "border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)]" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-sm text-[#dbe7ff]"> + {template.name} + </span> + <span className="font-mono text-[10px] text-[#555]"> + {template.elementCount} elements + </span> + </div> + <p className="font-mono text-xs text-[#555]"> + {template.description} + </p> + </button> + ))} + </div> + )} + </div> + </div> + + <div className="flex gap-2 justify-end mt-4 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <button + onClick={handleCloseModal} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateFromTemplate} + disabled={!fileName.trim() || !selectedTemplateId || creating} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {creating ? "Creating..." : "Create File"} + </button> + </div> + </div> + </div> + )} + + {/* File list */} + {files.length === 0 ? ( + <p className="font-mono text-xs text-[#555]"> + No files in this contract. Create one from a template above. + </p> + ) : ( + <div className="space-y-2"> + {files.map((file) => ( + <button + key={file.id} + onClick={() => onSelect(file.id)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]"> + {file.name} + </span> + {file.contractPhase && ( + <span + className={`px-1.5 py-0.5 text-[9px] font-mono uppercase border rounded ${ + phaseColors[file.contractPhase] + }`} + title={`Added during ${file.contractPhase} phase`} + > + {file.contractPhase} + </span> + )} + </div> + <span className="font-mono text-[10px] text-[#555]"> + v{file.version} + </span> + </div> + {file.description && ( + <p className="font-mono text-xs text-[#555] mt-1 truncate"> + {file.description} + </p> + )} + </button> + ))} + </div> + )} + </div> + ); +} + +// Tasks tab - now using TaskTree for supervisor view +function TasksTab({ + tasks, + repositories, + supervisorTaskId, + onSelect, + onCreate, +}: { + tasks: TaskSummary[]; + repositories: ContractRepository[]; + supervisorTaskId: string | null; + onSelect: (id: string) => void; + onCreate: (name: string, plan: string, repositoryUrl?: string) => void; +}) { + const [isCreating, setIsCreating] = useState(false); + const [taskName, setTaskName] = useState(""); + const [taskPlan, setTaskPlan] = useState("# Plan\n\nDescribe what this task should accomplish..."); + + // Find primary repository or first ready one + const readyRepos = repositories.filter((r) => r.status === "ready"); + const primaryRepo = readyRepos.find((r) => r.isPrimary) || readyRepos[0]; + const [selectedRepoId, setSelectedRepoId] = useState<string>(primaryRepo?.id || ""); + + const handleCreate = () => { + if (!taskName.trim()) return; + const selectedRepo = repositories.find((r) => r.id === selectedRepoId); + // Get the URL - for remote repos it's repositoryUrl, for local it's the local path + const repoUrl = selectedRepo?.repositoryUrl || selectedRepo?.localPath; + onCreate(taskName.trim(), taskPlan, repoUrl || undefined); + setIsCreating(false); + setTaskName(""); + setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); + setSelectedRepoId(primaryRepo?.id || ""); + }; + + const handleCancel = () => { + setIsCreating(false); + setTaskName(""); + setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); + setSelectedRepoId(primaryRepo?.id || ""); + }; + + return ( + <div className="space-y-4"> + {/* TaskTree with supervisor view */} + <TaskTree + tasks={tasks} + supervisorTaskId={supervisorTaskId} + onSelect={onSelect} + /> + + {/* Manual task creation (hidden when supervisor exists - supervisor creates tasks) */} + {!supervisorTaskId && ( + <> + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <button + onClick={() => setIsCreating(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Create Task Manually + </button> + </div> + + {/* Create Task Modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Task + </h3> + <div className="space-y-4"> + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Name + </label> + <input + type="text" + value={taskName} + onChange={(e) => setTaskName(e.target.value)} + placeholder="Task name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Repository selection */} + {readyRepos.length > 0 && ( + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Repository + </label> + <select + value={selectedRepoId} + onChange={(e) => setSelectedRepoId(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="">No repository</option> + {readyRepos.map((repo) => ( + <option key={repo.id} value={repo.id}> + {repo.name} + {repo.isPrimary ? " (Primary)" : ""} + {" - "} + {repo.sourceType} + </option> + ))} + </select> + </div> + )} + + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Plan + </label> + <textarea + value={taskPlan} + onChange={(e) => setTaskPlan(e.target.value)} + rows={6} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + <div className="flex gap-2 justify-end"> + <button + onClick={handleCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreate} + disabled={!taskName.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + </> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx new file mode 100644 index 0000000..3a7b163 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractList.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import type { ContractSummary, ContractStatus } from "../../lib/api"; +import { PhaseBadge } from "./PhaseBadge"; +import { PhaseProgressBarCompact } from "./PhaseProgressBar"; + +interface ContractListProps { + contracts: ContractSummary[]; + loading: boolean; + onSelect: (id: string) => void; + onCreate: () => void; + selectedId?: string; +} + +const statusColors: Record<ContractStatus, string> = { + active: "text-green-400", + completed: "text-blue-400", + archived: "text-[#555]", +}; + +export function ContractList({ + contracts, + loading, + onSelect, + onCreate, + selectedId, +}: ContractListProps) { + const [filter, setFilter] = useState<ContractStatus | "all">("all"); + + const filteredContracts = + filter === "all" + ? contracts + : contracts.filter((c) => c.status === filter); + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ); + } + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="flex items-center justify-between mb-3"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> + Contracts + </h2> + <button + onClick={onCreate} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New + </button> + </div> + + {/* Filter tabs */} + <div className="flex gap-1"> + {(["all", "active", "completed", "archived"] as const).map((status) => ( + <button + key={status} + onClick={() => setFilter(status)} + className={` + px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors + ${ + filter === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ))} + </div> + </div> + + {/* Contract list */} + <div className="flex-1 overflow-y-auto"> + {filteredContracts.length === 0 ? ( + <div className="p-4 text-center"> + <p className="font-mono text-sm text-[#555]"> + {filter === "all" + ? "No contracts yet" + : `No ${filter} contracts`} + </p> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.15)]"> + {filteredContracts.map((contract) => ( + <button + key={contract.id} + onClick={() => onSelect(contract.id)} + className={` + w-full text-left p-4 transition-colors + ${ + selectedId === contract.id + ? "bg-[rgba(117,170,252,0.1)]" + : "hover:bg-[rgba(117,170,252,0.05)]" + } + `} + > + <div className="flex items-start justify-between gap-2 mb-2"> + <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> + {contract.name} + </h3> + <span + className={`text-[10px] font-mono uppercase ${ + statusColors[contract.status] + }`} + > + {contract.status} + </span> + </div> + + {contract.description && ( + <p className="font-mono text-xs text-[#555] mb-2 line-clamp-2"> + {contract.description} + </p> + )} + + <div className="flex items-center justify-between"> + <PhaseProgressBarCompact currentPhase={contract.phase} /> + <div className="flex items-center gap-3 text-[10px] font-mono text-[#555]"> + {contract.fileCount > 0 && ( + <span>{contract.fileCount} files</span> + )} + {contract.taskCount > 0 && ( + <span>{contract.taskCount} tasks</span> + )} + {contract.repositoryCount > 0 && ( + <span>{contract.repositoryCount} repos</span> + )} + </div> + </div> + </button> + ))} + </div> + )} + </div> + </div> + ); +} + +export function ContractCard({ + contract, + onClick, +}: { + contract: ContractSummary; + onClick: () => void; +}) { + return ( + <button + onClick={onClick} + className="w-full text-left p-4 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + > + <div className="flex items-start justify-between gap-2 mb-2"> + <h3 className="font-mono text-sm text-[#dbe7ff]">{contract.name}</h3> + <PhaseBadge phase={contract.phase} /> + </div> + + {contract.description && ( + <p className="font-mono text-xs text-[#555] mb-3 line-clamp-2"> + {contract.description} + </p> + )} + + <div className="flex items-center gap-4 text-[10px] font-mono text-[#555]"> + <span>{contract.fileCount} files</span> + <span>{contract.taskCount} tasks</span> + <span>{contract.repositoryCount} repos</span> + </div> + </button> + ); +} diff --git a/makima/frontend/src/components/contracts/PhaseBadge.tsx b/makima/frontend/src/components/contracts/PhaseBadge.tsx new file mode 100644 index 0000000..0f46b9b --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseBadge.tsx @@ -0,0 +1,54 @@ +import type { ContractPhase } from "../../lib/api"; + +interface PhaseBadgeProps { + phase: ContractPhase; + size?: "sm" | "md"; +} + +const phaseConfig: Record< + ContractPhase, + { label: string; color: string; bgColor: string } +> = { + research: { + label: "Research", + color: "text-purple-400", + bgColor: "bg-purple-400/10 border-purple-400/30", + }, + specify: { + label: "Specify", + color: "text-blue-400", + bgColor: "bg-blue-400/10 border-blue-400/30", + }, + plan: { + label: "Plan", + color: "text-cyan-400", + bgColor: "bg-cyan-400/10 border-cyan-400/30", + }, + execute: { + label: "Execute", + color: "text-yellow-400", + bgColor: "bg-yellow-400/10 border-yellow-400/30", + }, + review: { + label: "Review", + color: "text-green-400", + bgColor: "bg-green-400/10 border-green-400/30", + }, +}; + +export function PhaseBadge({ phase, size = "sm" }: PhaseBadgeProps) { + const config = phaseConfig[phase]; + const sizeClasses = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"; + + return ( + <span + className={`${sizeClasses} ${config.color} ${config.bgColor} border font-mono uppercase tracking-wider`} + > + {config.label} + </span> + ); +} + +export function getPhaseLabel(phase: ContractPhase): string { + return phaseConfig[phase].label; +} diff --git a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx new file mode 100644 index 0000000..da5025b --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo } from "react"; +import type { ContractWithRelations, ContractPhase } from "../../lib/api"; + +// Phase deliverables configuration (mirrors backend phase_guidance.rs) +interface RecommendedFile { + templateId: string; + name: string; + priority: "required" | "recommended" | "optional"; + description: string; +} + +interface PhaseDeliverables { + phase: ContractPhase; + files: RecommendedFile[]; + requiresRepository: boolean; + requiresTasks: boolean; + guidance: string; +} + +const PHASE_DELIVERABLES: Record<ContractPhase, PhaseDeliverables> = { + research: { + phase: "research", + files: [ + { templateId: "research-notes", name: "Research Notes", priority: "recommended", description: "Document findings and insights" }, + { templateId: "competitor-analysis", name: "Competitor Analysis", priority: "recommended", description: "Analyze competitors" }, + { templateId: "user-research", name: "User Research", priority: "optional", description: "User interviews and personas" }, + ], + requiresRepository: false, + requiresTasks: false, + guidance: "Gather information and document findings before moving to Specify.", + }, + specify: { + phase: "specify", + files: [ + { templateId: "requirements", name: "Requirements Document", priority: "required", description: "Functional and non-functional requirements" }, + { templateId: "user-stories", name: "User Stories", priority: "recommended", description: "Features from user perspective" }, + { templateId: "acceptance-criteria", name: "Acceptance Criteria", priority: "recommended", description: "Testable conditions for completion" }, + ], + requiresRepository: false, + requiresTasks: false, + guidance: "Define clear requirements and acceptance criteria.", + }, + plan: { + phase: "plan", + files: [ + { templateId: "architecture", name: "Architecture Document", priority: "recommended", description: "System architecture and design" }, + { templateId: "task-breakdown", name: "Task Breakdown", priority: "required", description: "Work broken into tasks" }, + { templateId: "technical-design", name: "Technical Design", priority: "optional", description: "Detailed technical specs" }, + ], + requiresRepository: true, + requiresTasks: false, + guidance: "Design the solution and create a task breakdown. Configure a repository.", + }, + execute: { + phase: "execute", + files: [ + { templateId: "dev-notes", name: "Development Notes", priority: "recommended", description: "Implementation details" }, + { templateId: "test-plan", name: "Test Plan", priority: "optional", description: "Testing strategy" }, + { templateId: "implementation-log", name: "Implementation Log", priority: "optional", description: "Progress log" }, + ], + requiresRepository: true, + requiresTasks: true, + guidance: "Execute tasks and track implementation progress.", + }, + review: { + phase: "review", + files: [ + { templateId: "release-notes", name: "Release Notes", priority: "required", description: "Changes for release" }, + { templateId: "review-checklist", name: "Review Checklist", priority: "recommended", description: "Code and feature review" }, + { templateId: "retrospective", name: "Retrospective", priority: "optional", description: "Project learnings" }, + ], + requiresRepository: false, + requiresTasks: false, + guidance: "Review work and document the release.", + }, +}; + +interface DeliverableStatus { + templateId: string; + name: string; + priority: "required" | "recommended" | "optional"; + description: string; + completed: boolean; + fileId?: string; + actualName?: string; +} + +interface PhaseDeliverablesProps { + contract: ContractWithRelations; + onCreateFile?: (templateId: string, suggestedName: string) => void; +} + +export function PhaseDeliverablesPanel({ contract, onCreateFile }: PhaseDeliverablesProps) { + const deliverables = PHASE_DELIVERABLES[contract.phase]; + + // Calculate deliverable status + const fileStatuses = useMemo((): DeliverableStatus[] => { + return deliverables.files.map((rec) => { + // Find matching file by name similarity + const matchedFile = contract.files.find((f) => { + const nameLower = f.name.toLowerCase(); + const recLower = rec.name.toLowerCase(); + return ( + f.contractPhase === contract.phase && + (nameLower.includes(recLower) || recLower.includes(nameLower) || nameLower.includes(rec.templateId.replace("-", " "))) + ); + }); + + return { + ...rec, + completed: !!matchedFile, + fileId: matchedFile?.id, + actualName: matchedFile?.name, + }; + }); + }, [contract.files, contract.phase, deliverables.files]); + + // Check repository status + const hasRepository = contract.repositories.length > 0; + + // Check task status + const taskStats = useMemo(() => { + const total = contract.tasks.length; + const done = contract.tasks.filter((t) => t.status === "done" || t.status === "merged").length; + const pending = contract.tasks.filter((t) => t.status === "pending").length; + const running = contract.tasks.filter((t) => ["running", "initializing", "starting"].includes(t.status)).length; + const failed = contract.tasks.filter((t) => t.status === "failed").length; + return { total, done, pending, running, failed }; + }, [contract.tasks]); + + // Calculate completion percentage + const completionPercent = useMemo(() => { + let completed = 0; + let total = 0; + + // Count required and recommended files + fileStatuses.forEach((s) => { + if (s.priority !== "optional") { + total++; + if (s.completed) completed++; + } + }); + + // Count repository if required + if (deliverables.requiresRepository) { + total++; + if (hasRepository) completed++; + } + + // Count tasks if in execute phase + if (deliverables.requiresTasks && taskStats.total > 0) { + total++; + if (taskStats.done === taskStats.total) completed++; + } + + return total > 0 ? Math.round((completed / total) * 100) : 100; + }, [fileStatuses, hasRepository, deliverables, taskStats]); + + const priorityColors = { + required: "text-red-400", + recommended: "text-yellow-400", + optional: "text-[#555]", + }; + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Phase Deliverables + </h3> + <div className="flex items-center gap-2"> + <div className="w-24 h-1.5 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> + <div + className={`h-full transition-all duration-300 ${ + completionPercent === 100 ? "bg-green-400" : "bg-[#75aafc]" + }`} + style={{ width: `${completionPercent}%` }} + /> + </div> + <span className="font-mono text-[10px] text-[#555]">{completionPercent}%</span> + </div> + </div> + + {/* Guidance text */} + <p className="font-mono text-xs text-[#555] italic">{deliverables.guidance}</p> + + {/* File deliverables */} + <div className="space-y-2"> + {fileStatuses.map((status) => ( + <div + key={status.templateId} + className={`flex items-center justify-between p-2 border ${ + status.completed + ? "border-green-400/20 bg-green-400/5" + : "border-[rgba(117,170,252,0.15)]" + }`} + > + <div className="flex items-center gap-2"> + <span + className={`font-mono text-xs ${ + status.completed ? "text-green-400" : "text-[#555]" + }`} + > + {status.completed ? "[+]" : "[ ]"} + </span> + <div> + <div className="flex items-center gap-2"> + <span className="font-mono text-xs text-[#dbe7ff]"> + {status.completed ? status.actualName : status.name} + </span> + {!status.completed && ( + <span className={`font-mono text-[9px] uppercase ${priorityColors[status.priority]}`}> + {status.priority} + </span> + )} + </div> + <span className="font-mono text-[10px] text-[#555]"> + {status.description} + </span> + </div> + </div> + {!status.completed && onCreateFile && ( + <button + onClick={() => onCreateFile(status.templateId, status.name)} + className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + Create + </button> + )} + </div> + ))} + </div> + + {/* Repository status */} + {deliverables.requiresRepository && ( + <div + className={`flex items-center gap-2 p-2 border ${ + hasRepository + ? "border-green-400/20 bg-green-400/5" + : "border-[rgba(117,170,252,0.15)]" + }`} + > + <span + className={`font-mono text-xs ${ + hasRepository ? "text-green-400" : "text-[#555]" + }`} + > + {hasRepository ? "[+]" : "[ ]"} + </span> + <div> + <span className="font-mono text-xs text-[#dbe7ff]"> + Repository Configured + </span> + {!hasRepository && ( + <span className="font-mono text-[9px] uppercase text-red-400 ml-2"> + required + </span> + )} + </div> + </div> + )} + + {/* Task status (execute phase) */} + {deliverables.requiresTasks && ( + <div + className={`flex items-center justify-between p-2 border ${ + taskStats.total > 0 && taskStats.done === taskStats.total + ? "border-green-400/20 bg-green-400/5" + : "border-[rgba(117,170,252,0.15)]" + }`} + > + <div className="flex items-center gap-2"> + <span + className={`font-mono text-xs ${ + taskStats.total > 0 && taskStats.done === taskStats.total + ? "text-green-400" + : "text-[#555]" + }`} + > + {taskStats.total > 0 && taskStats.done === taskStats.total ? "[+]" : "[ ]"} + </span> + <span className="font-mono text-xs text-[#dbe7ff]"> + Tasks Completed + </span> + </div> + {taskStats.total > 0 ? ( + <span className="font-mono text-[10px] text-[#9bc3ff]"> + {taskStats.done}/{taskStats.total} + {taskStats.running > 0 && ` (${taskStats.running} running)`} + {taskStats.failed > 0 && ( + <span className="text-red-400"> ({taskStats.failed} failed)</span> + )} + </span> + ) : ( + <span className="font-mono text-[10px] text-[#555]">No tasks yet</span> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/PhaseHint.tsx b/makima/frontend/src/components/contracts/PhaseHint.tsx new file mode 100644 index 0000000..95573ed --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseHint.tsx @@ -0,0 +1,90 @@ +import type { ContractPhase, ContractWithRelations } from "../../lib/api"; + +interface PhaseHintProps { + contract: ContractWithRelations; + onAdvancePhase: (phase: ContractPhase) => void; +} + +const phaseOrder: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; + +interface HintConfig { + condition: (contract: ContractWithRelations) => boolean; + message: (contract: ContractWithRelations) => string; + nextPhase: ContractPhase; +} + +const phaseHints: Record<ContractPhase, HintConfig | null> = { + research: { + condition: (c) => c.files.length >= 1, + message: (c) => + `You have ${c.files.length} file${c.files.length === 1 ? "" : "s"}. Ready to specify requirements?`, + nextPhase: "specify", + }, + specify: { + condition: (c) => c.files.length >= 2, + message: () => "Spec files ready. Create implementation plan?", + nextPhase: "plan", + }, + plan: { + condition: (c) => c.files.length >= 1 && c.repositories.length >= 1, + message: () => "Plan documented. Ready to create tasks?", + nextPhase: "execute", + }, + execute: { + condition: (c) => { + // Show hint only when all tasks are complete + const doneTasks = c.tasks.filter( + (t) => t.status === "done" || t.status === "merged" + ); + return c.tasks.length > 0 && doneTasks.length === c.tasks.length; + }, + message: (c) => { + const doneTasks = c.tasks.filter( + (t) => t.status === "done" || t.status === "merged" + ); + return `All ${doneTasks.length} task${doneTasks.length === 1 ? "" : "s"} complete. Ready for review?`; + }, + nextPhase: "review", + }, + review: null, // No hint for review phase - it's the final phase +}; + +export function PhaseHint({ contract, onAdvancePhase }: PhaseHintProps) { + const hintConfig = phaseHints[contract.phase]; + + // No hint for this phase + if (!hintConfig) return null; + + // Condition not met + if (!hintConfig.condition(contract)) return null; + + return ( + <div className="flex items-center gap-3 p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {hintConfig.message(contract)} + </span> + <button + onClick={() => onAdvancePhase(hintConfig.nextPhase)} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase whitespace-nowrap" + > + Advance to {hintConfig.nextPhase} + </button> + </div> + ); +} + +export function getNextPhase(currentPhase: ContractPhase): ContractPhase | null { + const currentIndex = phaseOrder.indexOf(currentPhase); + if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) { + return null; + } + return phaseOrder[currentIndex + 1]; +} + +export function getPreviousPhase(currentPhase: ContractPhase): ContractPhase | null { + const currentIndex = phaseOrder.indexOf(currentPhase); + if (currentIndex <= 0) { + return null; + } + return phaseOrder[currentIndex - 1]; +} diff --git a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx new file mode 100644 index 0000000..5ee7999 --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx @@ -0,0 +1,142 @@ +import type { ContractPhase } from "../../lib/api"; + +interface PhaseProgressBarProps { + currentPhase: ContractPhase; + onPhaseClick?: (phase: ContractPhase) => void; + readonly?: boolean; +} + +const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; + +const phaseLabels: Record<ContractPhase, string> = { + research: "Research", + specify: "Specify", + plan: "Plan", + execute: "Execute", + review: "Review", +}; + +const phaseColors: Record<ContractPhase, { active: string; inactive: string; completed: string }> = { + research: { + active: "bg-purple-400 border-purple-400", + inactive: "bg-transparent border-purple-400/30", + completed: "bg-purple-400/50 border-purple-400/50", + }, + specify: { + active: "bg-blue-400 border-blue-400", + inactive: "bg-transparent border-blue-400/30", + completed: "bg-blue-400/50 border-blue-400/50", + }, + plan: { + active: "bg-cyan-400 border-cyan-400", + inactive: "bg-transparent border-cyan-400/30", + completed: "bg-cyan-400/50 border-cyan-400/50", + }, + execute: { + active: "bg-yellow-400 border-yellow-400", + inactive: "bg-transparent border-yellow-400/30", + completed: "bg-yellow-400/50 border-yellow-400/50", + }, + review: { + active: "bg-green-400 border-green-400", + inactive: "bg-transparent border-green-400/30", + completed: "bg-green-400/50 border-green-400/50", + }, +}; + +export function PhaseProgressBar({ + currentPhase, + onPhaseClick, + readonly = false, +}: PhaseProgressBarProps) { + const currentIndex = phases.indexOf(currentPhase); + + return ( + <div className="flex items-center gap-1"> + {phases.map((phase, index) => { + const isActive = phase === currentPhase; + const isCompleted = index < currentIndex; + const colors = phaseColors[phase]; + const colorClass = isActive + ? colors.active + : isCompleted + ? colors.completed + : colors.inactive; + + const canClick = !readonly && onPhaseClick; + + return ( + <div key={phase} className="flex items-center"> + {/* Phase node */} + <button + onClick={() => canClick && onPhaseClick(phase)} + disabled={readonly} + className={` + relative group flex flex-col items-center + ${canClick ? "cursor-pointer" : "cursor-default"} + `} + > + {/* Circle */} + <div + className={` + w-3 h-3 rounded-full border-2 transition-all + ${colorClass} + ${canClick && !isActive ? "hover:scale-110" : ""} + `} + /> + {/* Label */} + <span + className={` + absolute top-4 font-mono text-[9px] uppercase tracking-wide whitespace-nowrap + ${isActive ? "text-[#dbe7ff]" : "text-[#555]"} + ${canClick && !isActive ? "group-hover:text-[#75aafc]" : ""} + `} + > + {phaseLabels[phase]} + </span> + </button> + + {/* Connector line */} + {index < phases.length - 1 && ( + <div + className={` + w-8 h-0.5 mx-1 + ${index < currentIndex ? "bg-[#3f6fb3]" : "bg-[rgba(117,170,252,0.15)]"} + `} + /> + )} + </div> + ); + })} + </div> + ); +} + +export function PhaseProgressBarCompact({ + currentPhase, +}: { + currentPhase: ContractPhase; +}) { + const currentIndex = phases.indexOf(currentPhase); + + return ( + <div className="flex items-center gap-0.5"> + {phases.map((phase, index) => { + const isActive = phase === currentPhase; + const isCompleted = index < currentIndex; + const colors = phaseColors[phase]; + + return ( + <div + key={phase} + className={` + w-2 h-2 rounded-full border + ${isActive ? colors.active : isCompleted ? colors.completed : colors.inactive} + `} + title={phaseLabels[phase]} + /> + ); + })} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/QuickActionButtons.tsx b/makima/frontend/src/components/contracts/QuickActionButtons.tsx new file mode 100644 index 0000000..4dbb90c --- /dev/null +++ b/makima/frontend/src/components/contracts/QuickActionButtons.tsx @@ -0,0 +1,217 @@ +import { useCallback } from "react"; + +export type QuickActionType = + | "create_file" + | "create_task" + | "run_task" + | "advance_phase" + | "derive_tasks" + | "update_file"; + +export interface QuickAction { + type: QuickActionType; + label: string; + description?: string; + data?: Record<string, unknown>; +} + +interface QuickActionButtonsProps { + actions: QuickAction[]; + onAction: (action: QuickAction) => void; + loading?: boolean; +} + +const ACTION_ICONS: Record<QuickActionType, string> = { + create_file: "[+]", + create_task: "[T]", + run_task: "[>]", + advance_phase: "[→]", + derive_tasks: "[≡]", + update_file: "[*]", +}; + +const ACTION_COLORS: Record<QuickActionType, string> = { + create_file: "border-blue-400/30 hover:border-blue-400/60 text-blue-400", + create_task: "border-green-400/30 hover:border-green-400/60 text-green-400", + run_task: "border-yellow-400/30 hover:border-yellow-400/60 text-yellow-400", + advance_phase: "border-purple-400/30 hover:border-purple-400/60 text-purple-400", + derive_tasks: "border-cyan-400/30 hover:border-cyan-400/60 text-cyan-400", + update_file: "border-orange-400/30 hover:border-orange-400/60 text-orange-400", +}; + +export function QuickActionButtons({ + actions, + onAction, + loading = false, +}: QuickActionButtonsProps) { + const handleClick = useCallback( + (action: QuickAction) => { + if (!loading) { + onAction(action); + } + }, + [onAction, loading] + ); + + if (actions.length === 0) return null; + + return ( + <div className="flex flex-wrap gap-2 mt-2"> + {actions.map((action, index) => ( + <button + key={`${action.type}-${index}`} + onClick={() => handleClick(action)} + disabled={loading} + className={` + flex items-center gap-1.5 px-2 py-1 + font-mono text-[10px] uppercase + border transition-colors + disabled:opacity-50 disabled:cursor-not-allowed + ${ACTION_COLORS[action.type]} + `} + title={action.description} + > + <span>{ACTION_ICONS[action.type]}</span> + <span>{action.label}</span> + </button> + ))} + </div> + ); +} + +/** + * Parse tool call results to extract suggested quick actions. + * This is used by ContractCliInput to detect actionable results. + */ +export function parseActionsFromToolCalls( + toolCalls: { name: string; success: boolean; message: string }[] +): QuickAction[] { + const actions: QuickAction[] = []; + + for (const tc of toolCalls) { + if (!tc.success) continue; + + switch (tc.name) { + case "derive_tasks_from_file": + // When tasks are parsed, offer to create them + if (tc.message.includes("task") || tc.message.includes("Found")) { + actions.push({ + type: "derive_tasks", + label: "Review & Create Tasks", + description: "Review parsed tasks and create them with chaining", + }); + } + break; + + case "process_task_completion": + // Check for suggested actions in the result + if (tc.message.includes("next task")) { + actions.push({ + type: "run_task", + label: "Run Next Task", + description: "Continue with the next chained task", + }); + } + if (tc.message.includes("advance") || tc.message.includes("phase")) { + actions.push({ + type: "advance_phase", + label: "Advance Phase", + description: "Move to the next contract phase", + }); + } + break; + + case "get_phase_checklist": + // When checklist shows missing items, offer to create them + if (tc.message.includes("missing") || tc.message.includes("not created")) { + actions.push({ + type: "create_file", + label: "Create Missing Files", + description: "Create files from recommended templates", + }); + } + break; + + case "advance_phase": + // After phase transition, suggest creating files + actions.push({ + type: "create_file", + label: "Create Phase Files", + description: "Create recommended files for this phase", + }); + break; + } + } + + return actions; +} + +/** + * Parse LLM response text to detect suggested actions. + * Used as a fallback when structured action data isn't available. + */ +export function parseActionsFromText(text: string): QuickAction[] { + const actions: QuickAction[] = []; + const lower = text.toLowerCase(); + + // Detect file creation suggestions + if ( + lower.includes("create a file") || + lower.includes("create the file") || + lower.includes("should i create") + ) { + actions.push({ + type: "create_file", + label: "Create File", + description: "Create the suggested file", + }); + } + + // Detect task creation suggestions + if ( + lower.includes("create tasks") || + lower.includes("create these tasks") || + lower.includes("create chained tasks") + ) { + actions.push({ + type: "create_task", + label: "Create Tasks", + description: "Create the suggested tasks", + }); + } + + // Detect phase advancement suggestions + if ( + lower.includes("advance to") || + lower.includes("ready to move to") || + lower.includes("transition to") + ) { + const phases = ["specify", "plan", "execute", "review"]; + for (const phase of phases) { + if (lower.includes(phase)) { + actions.push({ + type: "advance_phase", + label: `Advance to ${phase.charAt(0).toUpperCase() + phase.slice(1)}`, + description: `Move to the ${phase} phase`, + data: { phase }, + }); + break; + } + } + } + + // Detect run task suggestions + if ( + lower.includes("run the task") || + lower.includes("start the task") || + lower.includes("run task") + ) { + actions.push({ + type: "run_task", + label: "Run Task", + description: "Start the suggested task", + }); + } + + return actions; +} diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx new file mode 100644 index 0000000..4170cfb --- /dev/null +++ b/makima/frontend/src/components/contracts/RepositoryPanel.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from "react"; +import type { + ContractRepository, + RepositorySourceType, + RepositoryStatus, + DaemonDirectory, +} from "../../lib/api"; +import { getDaemonDirectories } from "../../lib/api"; +import { DirectoryInput } from "../mesh/DirectoryInput"; + +interface RepositoryPanelProps { + repositories: ContractRepository[]; + onAddRemote: (name: string, url: string, isPrimary: boolean) => void; + onAddLocal: (name: string, path: string, isPrimary: boolean) => void; + onCreateManaged: (name: string, isPrimary: boolean) => void; + onDelete: (repoId: string) => void; + onSetPrimary: (repoId: string) => void; +} + +type AddMode = "remote" | "local" | "managed" | null; + +const sourceTypeLabels: Record<RepositorySourceType, string> = { + remote: "Remote", + local: "Local", + managed: "Managed", +}; + +const sourceTypeIcons: Record<RepositorySourceType, string> = { + remote: "GH", + local: "FS", + managed: "MK", +}; + +const statusColors: Record<RepositoryStatus, string> = { + ready: "text-green-400", + pending: "text-yellow-400", + creating: "text-cyan-400", + failed: "text-red-400", +}; + +export function RepositoryPanel({ + repositories, + onAddRemote, + onAddLocal, + onCreateManaged, + onDelete, + onSetPrimary, +}: RepositoryPanelProps) { + const [addMode, setAddMode] = useState<AddMode>(null); + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [path, setPath] = useState(""); + const [isPrimary, setIsPrimary] = useState(false); + // Daemon directory suggestions for local repositories + const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + + // Fetch daemon directories when "local" mode is selected + useEffect(() => { + if (addMode === "local") { + getDaemonDirectories() + .then((res) => setSuggestedDirectories(res.directories)) + .catch(() => setSuggestedDirectories([])); + } + }, [addMode]); + + const handleAdd = () => { + if (!name.trim()) return; + + if (addMode === "remote" && url.trim()) { + onAddRemote(name.trim(), url.trim(), isPrimary); + } else if (addMode === "local" && path.trim()) { + onAddLocal(name.trim(), path.trim(), isPrimary); + } else if (addMode === "managed") { + onCreateManaged(name.trim(), isPrimary); + } + + // Reset form + setAddMode(null); + setName(""); + setUrl(""); + setPath(""); + setIsPrimary(false); + }; + + const handleCancel = () => { + setAddMode(null); + setName(""); + setUrl(""); + setPath(""); + setIsPrimary(false); + }; + + return ( + <div className="space-y-4"> + {/* Repository list */} + {repositories.length === 0 ? ( + <p className="font-mono text-xs text-[#555]"> + No repositories configured + </p> + ) : ( + <div className="space-y-2"> + {repositories.map((repo) => ( + <div + key={repo.id} + className="flex items-center gap-3 p-3 border border-[rgba(117,170,252,0.2)]" + > + {/* Type icon */} + <span className="font-mono text-[10px] text-[#555] uppercase w-6"> + {sourceTypeIcons[repo.sourceType]} + </span> + + {/* Name and details */} + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff] truncate"> + {repo.name} + </span> + {repo.isPrimary && ( + <span className="px-1 py-0.5 text-[8px] font-mono uppercase bg-[rgba(117,170,252,0.1)] text-[#75aafc] border border-[rgba(117,170,252,0.3)]"> + Primary + </span> + )} + </div> + <div className="font-mono text-[10px] text-[#555] truncate"> + {repo.repositoryUrl || repo.localPath || "(pending creation)"} + </div> + </div> + + {/* Status */} + <span + className={`font-mono text-[10px] uppercase ${ + statusColors[repo.status] + }`} + > + {repo.status} + </span> + + {/* Actions */} + <div className="flex items-center gap-1"> + {!repo.isPrimary && repo.status === "ready" && ( + <button + onClick={() => onSetPrimary(repo.id)} + className="p-1 font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors" + title="Set as primary" + > + * + </button> + )} + <button + onClick={() => onDelete(repo.id)} + className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors" + title="Remove" + > + x + </button> + </div> + </div> + ))} + </div> + )} + + {/* Add repository form */} + {addMode ? ( + <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3"> + <div className="flex items-center gap-2 mb-2"> + <span className="font-mono text-xs text-[#75aafc] uppercase"> + Add {sourceTypeLabels[addMode]} Repository + </span> + </div> + + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Repository name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + + {addMode === "remote" && ( + <input + type="text" + value={url} + onChange={(e) => setUrl(e.target.value)} + placeholder="https://github.com/owner/repo" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + )} + + {addMode === "local" && ( + <DirectoryInput + value={path} + onChange={setPath} + suggestions={suggestedDirectories} + placeholder="/path/to/repository" + /> + )} + + {addMode === "managed" && ( + <p className="font-mono text-xs text-[#555]"> + Makima will create this repository via the daemon. + </p> + )} + + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + checked={isPrimary} + onChange={(e) => setIsPrimary(e.target.checked)} + className="w-3 h-3" + /> + <span className="font-mono text-xs text-[#9bc3ff]"> + Set as primary repository + </span> + </label> + + <div className="flex gap-2"> + <button + onClick={handleCancel} + className="px-3 py-1.5 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleAdd} + disabled={ + !name.trim() || + (addMode === "remote" && !url.trim()) || + (addMode === "local" && !path.trim()) + } + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Add Repository + </button> + </div> + </div> + ) : ( + <div className="flex gap-2"> + <button + onClick={() => setAddMode("remote")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Remote + </button> + <button + onClick={() => setAddMode("local")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Local + </button> + <button + onClick={() => setAddMode("managed")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Managed + </button> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx new file mode 100644 index 0000000..07421ef --- /dev/null +++ b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx @@ -0,0 +1,221 @@ +import { useState, useCallback } from "react"; + +export interface ParsedTask { + name: string; + description?: string; + group?: string; + order: number; + completed: boolean; + dependencies: string[]; +} + +interface TaskDerivationPreviewProps { + tasks: ParsedTask[]; + groups: string[]; + fileName: string; + onCreateTasks: (selectedTasks: ParsedTask[]) => void; + onCancel: () => void; + loading?: boolean; +} + +export function TaskDerivationPreview({ + tasks, + groups, + fileName, + onCreateTasks, + onCancel, + loading = false, +}: TaskDerivationPreviewProps) { + const [selectedIndices, setSelectedIndices] = useState<Set<number>>( + new Set(tasks.map((_, i) => i)) // Select all by default + ); + + const toggleTask = useCallback((index: number) => { + setSelectedIndices((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }, []); + + const selectAll = useCallback(() => { + setSelectedIndices(new Set(tasks.map((_, i) => i))); + }, [tasks]); + + const selectNone = useCallback(() => { + setSelectedIndices(new Set()); + }, []); + + const handleCreate = useCallback(() => { + const selectedTasks = tasks.filter((_, i) => selectedIndices.has(i)); + onCreateTasks(selectedTasks); + }, [tasks, selectedIndices, onCreateTasks]); + + // Group tasks by their group property + const tasksByGroup = tasks.reduce((acc, task, index) => { + const groupKey = task.group || "Ungrouped"; + if (!acc[groupKey]) { + acc[groupKey] = []; + } + acc[groupKey].push({ task, index }); + return acc; + }, {} as Record<string, { task: ParsedTask; index: number }[]>); + + const selectedCount = selectedIndices.size; + const totalCount = tasks.length; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-2xl p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> + {/* Header */} + <div className="flex items-center justify-between mb-4"> + <div> + <h3 className="font-mono text-sm text-[#75aafc] uppercase"> + Create Tasks from Document + </h3> + <p className="font-mono text-xs text-[#555] mt-1"> + Source: {fileName} + </p> + </div> + <div className="flex items-center gap-2"> + <button + onClick={selectAll} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Select All + </button> + <span className="text-[#555]">|</span> + <button + onClick={selectNone} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Select None + </button> + </div> + </div> + + {/* Task List */} + <div className="flex-1 overflow-y-auto space-y-4 mb-4"> + {groups.length > 0 ? ( + // Grouped view + Object.entries(tasksByGroup).map(([groupName, groupTasks]) => ( + <div key={groupName} className="space-y-2"> + <h4 className="font-mono text-xs text-[#9bc3ff] uppercase border-b border-[rgba(117,170,252,0.2)] pb-1"> + {groupName} + </h4> + {groupTasks.map(({ task, index }) => ( + <TaskItem + key={index} + task={task} + index={index} + selected={selectedIndices.has(index)} + onToggle={() => toggleTask(index)} + /> + ))} + </div> + )) + ) : ( + // Flat view + tasks.map((task, index) => ( + <TaskItem + key={index} + task={task} + index={index} + selected={selectedIndices.has(index)} + onToggle={() => toggleTask(index)} + /> + )) + )} + </div> + + {/* Footer */} + <div className="flex items-center justify-between pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <span className="font-mono text-xs text-[#555]"> + {selectedCount} of {totalCount} tasks selected + </span> + <div className="flex gap-2"> + <button + onClick={onCancel} + disabled={loading} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + onClick={handleCreate} + disabled={loading || selectedCount === 0} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {loading ? "Creating..." : `Create ${selectedCount} Task${selectedCount !== 1 ? "s" : ""}`} + </button> + </div> + </div> + + {/* Chaining info */} + {selectedCount > 1 && ( + <p className="font-mono text-[10px] text-[#555] mt-2 text-center"> + Tasks will be chained: each task continues from the previous one's work + </p> + )} + </div> + </div> + ); +} + +function TaskItem({ + task, + index, + selected, + onToggle, +}: { + task: ParsedTask; + index: number; + selected: boolean; + onToggle: () => void; +}) { + return ( + <button + onClick={onToggle} + className={`w-full text-left p-3 border transition-colors ${ + selected + ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" + : "border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)]" + }`} + > + <div className="flex items-start gap-2"> + <span + className={`font-mono text-xs mt-0.5 ${ + selected ? "text-[#75aafc]" : "text-[#555]" + }`} + > + {selected ? "[x]" : "[ ]"} + </span> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-mono text-[10px] text-[#555]">#{index + 1}</span> + <span className="font-mono text-sm text-[#dbe7ff]">{task.name}</span> + {task.completed && ( + <span className="font-mono text-[9px] text-green-400 uppercase"> + done in source + </span> + )} + </div> + {task.description && ( + <p className="font-mono text-xs text-[#555] mt-1 truncate"> + {task.description} + </p> + )} + {task.dependencies.length > 0 && ( + <p className="font-mono text-[10px] text-[#75aafc] mt-1"> + Depends on: {task.dependencies.join(", ")} + </p> + )} + </div> + </div> + </button> + ); +} |
