From 87044a747b47bd83249d61a45842c7f7b2eae56d Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 11 Jan 2026 05:52:14 +0000 Subject: Contract system --- .../frontend/src/components/JapaneseHoverText.tsx | 77 ++ makima/frontend/src/components/Masthead.tsx | 6 +- makima/frontend/src/components/NavStrip.tsx | 2 + .../src/components/contracts/ContractCliInput.tsx | 974 +++++++++++++++++++++ .../src/components/contracts/ContractDetail.tsx | 794 +++++++++++++++++ .../src/components/contracts/ContractList.tsx | 176 ++++ .../src/components/contracts/PhaseBadge.tsx | 54 ++ .../contracts/PhaseDeliverablesPanel.tsx | 301 +++++++ .../src/components/contracts/PhaseHint.tsx | 90 ++ .../src/components/contracts/PhaseProgressBar.tsx | 142 +++ .../components/contracts/QuickActionButtons.tsx | 217 +++++ .../src/components/contracts/RepositoryPanel.tsx | 260 ++++++ .../components/contracts/TaskDerivationPreview.tsx | 221 +++++ .../frontend/src/components/files/BodyRenderer.tsx | 376 ++++++++ .../frontend/src/components/files/FileDetail.tsx | 2 + makima/frontend/src/components/files/FileList.tsx | 179 +--- .../src/components/files/RepoSyncIndicator.tsx | 190 ++++ .../src/components/listen/ControlPanel.tsx | 33 +- makima/frontend/src/components/mesh/TaskDetail.tsx | 12 + makima/frontend/src/components/mesh/TaskList.tsx | 215 +++-- makima/frontend/src/components/mesh/TaskOutput.tsx | 96 ++ makima/frontend/src/components/mesh/TaskTree.tsx | 390 +++++++++ .../src/components/workflow/PhaseColumn.tsx | 123 +++ .../src/components/workflow/WorkflowBoard.tsx | 54 ++ .../components/workflow/WorkflowContractCard.tsx | 53 ++ 25 files changed, 4826 insertions(+), 211 deletions(-) create mode 100644 makima/frontend/src/components/JapaneseHoverText.tsx create mode 100644 makima/frontend/src/components/contracts/ContractCliInput.tsx create mode 100644 makima/frontend/src/components/contracts/ContractDetail.tsx create mode 100644 makima/frontend/src/components/contracts/ContractList.tsx create mode 100644 makima/frontend/src/components/contracts/PhaseBadge.tsx create mode 100644 makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx create mode 100644 makima/frontend/src/components/contracts/PhaseHint.tsx create mode 100644 makima/frontend/src/components/contracts/PhaseProgressBar.tsx create mode 100644 makima/frontend/src/components/contracts/QuickActionButtons.tsx create mode 100644 makima/frontend/src/components/contracts/RepositoryPanel.tsx create mode 100644 makima/frontend/src/components/contracts/TaskDerivationPreview.tsx create mode 100644 makima/frontend/src/components/files/RepoSyncIndicator.tsx create mode 100644 makima/frontend/src/components/mesh/TaskTree.tsx create mode 100644 makima/frontend/src/components/workflow/PhaseColumn.tsx create mode 100644 makima/frontend/src/components/workflow/WorkflowBoard.tsx create mode 100644 makima/frontend/src/components/workflow/WorkflowContractCard.tsx (limited to 'makima/frontend/src/components') diff --git a/makima/frontend/src/components/JapaneseHoverText.tsx b/makima/frontend/src/components/JapaneseHoverText.tsx new file mode 100644 index 0000000..3e60ee2 --- /dev/null +++ b/makima/frontend/src/components/JapaneseHoverText.tsx @@ -0,0 +1,77 @@ +import { useState, useCallback, useRef } from "react"; + +const GLYPHS = "▒▓░█#@*+:-/[]{}<>_"; + +interface JapaneseHoverTextProps { + japanese: string; + english: string; + className?: string; +} + +/** + * Displays Japanese text, transitions to English on hover with scramble effect + */ +export function JapaneseHoverText({ + japanese, + english, + className = "", +}: JapaneseHoverTextProps) { + const [isHovered, setIsHovered] = useState(false); + const [displayText, setDisplayText] = useState(english); + const timerRef = useRef | null>(null); + const iterationRef = useRef(0); + + const scrambleToEnglish = useCallback(() => { + setIsHovered(true); + + // Clear any existing animation + if (timerRef.current) { + clearInterval(timerRef.current); + } + + iterationRef.current = 0; + + timerRef.current = setInterval(() => { + const text = english; + const iteration = iterationRef.current; + + const display = text + .split("") + .map((char, index) => { + if (index < iteration) return char; + return GLYPHS.charAt(Math.floor(Math.random() * GLYPHS.length)); + }) + .join(""); + + setDisplayText(display); + iterationRef.current += 1; + + if (iteration > text.length + 2) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setDisplayText(english); + } + }, 26); + }, [english]); + + const resetToJapanese = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsHovered(false); + setDisplayText(english); + }, [english]); + + return ( + + {isHovered ? displayText : japanese} + + ); +} diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx index afe385e..bc184bd 100644 --- a/makima/frontend/src/components/Masthead.tsx +++ b/makima/frontend/src/components/Masthead.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router"; import { LogoMark } from "./Logo"; import { NavStrip } from "./NavStrip"; +import { JapaneseHoverText } from "./JapaneseHoverText"; interface MastheadProps { showTicker?: boolean; @@ -18,7 +19,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps) makima.jp - Control System + diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 642e9a3..48abe09 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -11,6 +11,8 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Files", href: "/files", requiresAuth: true }, + { label: "Contracts", href: "/contracts", requiresAuth: true }, + { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, ]; 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([]); + const [expanded, setExpanded] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [pendingQuestions, setPendingQuestions] = useState(null); + const [userAnswers, setUserAnswers] = useState>(new Map()); + const [customInputs, setCustomInputs] = useState>(new Map()); + + // Task derivation state + const [parsedTasks, setParsedTasks] = useState(null); + const [parsedTaskGroups, setParsedTaskGroups] = useState([]); + const [parsedTasksFileName, setParsedTasksFileName] = useState(""); + const [creatingTasks, setCreatingTasks] = useState(false); + + // Supervisor state + const [supervisorStarting, setSupervisorStarting] = useState(false); + const [supervisorOutput, setSupervisorOutput] = useState([]); + const [supervisorQuestion, setSupervisorQuestion] = useState<{ + id: string; + question: string; + options: string[]; + allowMultiple?: boolean; + allowCustom?: boolean; + } | null>(null); + + const inputRef = useRef(null); + const messagesRef = useRef(null); + + // Find the supervisor task for this contract + // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag + const supervisorTask = useMemo(() => { + // Use contract.supervisorTaskId if available (most reliable) + if (contract.supervisorTaskId) { + const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId); + if (taskById) return taskById; + } + // Fallback to finding by isSupervisor flag + return contract.tasks.find((t) => t.isSupervisor); + }, [contract.tasks, contract.supervisorTaskId]); + + // Log for debugging + useEffect(() => { + console.log("Supervisor lookup:", { + contractId: contract.id, + supervisorTaskId: contract.supervisorTaskId, + tasksCount: contract.tasks.length, + foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null, + allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor })) + }); + }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]); + + const supervisorTaskId = supervisorTask?.id ?? null; + const supervisorStatus = supervisorTask?.status as TaskStatus | undefined; + const isSupervisorRunning = supervisorStatus === "running"; + const isSupervisorPending = supervisorStatus === "pending"; + + // Subscribe to supervisor output when it's running + const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => { + // Check for question pattern in output + // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}} + if (!event.isPartial && event.content) { + const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/); + if (questionMatch) { + try { + const questionData = JSON.parse(questionMatch[1]); + if (questionData.id && questionData.question && questionData.options) { + setSupervisorQuestion({ + id: questionData.id, + question: questionData.question, + options: questionData.options, + allowMultiple: questionData.allowMultiple ?? false, + allowCustom: questionData.allowCustom ?? true, + }); + // Don't add this to output since it's a control message + return; + } + } catch { + // Not valid JSON, continue as normal output + } + } + } + + setSupervisorOutput((prev) => { + // If it's a partial message, update the last message + if (event.isPartial && prev.length > 0) { + const lastEvent = prev[prev.length - 1]; + if (lastEvent.messageType === event.messageType && lastEvent.isPartial) { + return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }]; + } + } + return [...prev, event]; + }); + }, []); + + useTaskSubscription({ + taskId: supervisorTaskId, + subscribeOutput: isSupervisorRunning, + onOutput: handleSupervisorOutput, + }); + + // Auto-start supervisor function - starts and waits for it to be running + const ensureSupervisorStarted = useCallback(async (): Promise => { + if (!supervisorTask) { + console.warn("No supervisor task found for contract"); + return false; + } + + if (isSupervisorRunning) { + return true; // Already running + } + + if (isSupervisorPending) { + try { + setSupervisorStarting(true); + await startTask(supervisorTask.id); + + // Poll for the task to be running (up to 10 seconds) + for (let i = 0; i < 20; i++) { + await new Promise(resolve => setTimeout(resolve, 500)); + onUpdate(); // Refresh contract to get updated task status + // Note: We can't check the new status here directly since state updates are async + // The UI will update when onUpdate triggers a re-render + } + + // Return true - the caller should check if supervisor is running after this + return true; + } catch (err) { + console.error("Failed to start supervisor:", err); + return false; + } finally { + setSupervisorStarting(false); + } + } + + // Supervisor exists but is in some other state (paused, done, failed, etc.) + // Can still send messages to paused tasks + return supervisorStatus === "paused"; + }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]); + + // Handle answering supervisor questions + const [supervisorAnswers, setSupervisorAnswers] = useState([]); + const [supervisorCustomInput, setSupervisorCustomInput] = useState(""); + + const handleSupervisorOptionToggle = useCallback((option: string) => { + setSupervisorAnswers((prev) => { + if (supervisorQuestion?.allowMultiple) { + if (prev.includes(option)) { + return prev.filter((a) => a !== option); + } + return [...prev, option]; + } + return [option]; + }); + }, [supervisorQuestion?.allowMultiple]); + + const handleSubmitSupervisorAnswer = useCallback(async () => { + if (!supervisorQuestion || !supervisorTask) return; + + const customAnswer = supervisorCustomInput.trim(); + const allAnswers = customAnswer + ? [...supervisorAnswers, customAnswer] + : supervisorAnswers; + + if (allAnswers.length === 0) return; + + // Format answer message for supervisor + const answerMessage = `__supervisor_answer__ ${JSON.stringify({ + id: supervisorQuestion.id, + answers: allAnswers, + })}`; + + try { + await sendTaskMessage(supervisorTask.id, answerMessage); + + // Add user message to chat + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { + id: userMsgId, + type: "user", + content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`, + }, + ]); + } catch (err) { + console.error("Failed to send supervisor answer:", err); + } finally { + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + } + }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]); + + const handleCancelSupervisorQuestion = useCallback(() => { + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + }, []); + + // Load chat history on mount + useEffect(() => { + let mounted = true; + + async function loadHistory() { + try { + const history = await getContractChatHistory(contractId); + if (!mounted) return; + + // Convert saved messages to display messages + const displayMessages: Message[] = history.messages.map((msg) => ({ + id: msg.id, + type: msg.role as "user" | "assistant" | "error", + content: msg.content, + toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined, + })); + + setMessages(displayMessages); + + // Auto-expand if there's history + if (displayMessages.length > 0) { + setExpanded(true); + } + } catch (err) { + console.error("Failed to load contract chat history:", err); + } finally { + if (mounted) { + setHistoryLoading(false); + } + } + } + + loadHistory(); + + return () => { + mounted = false; + }; + }, [contractId]); + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + } + }, [messages]); + + // Auto-start supervisor when component mounts if it's pending + useEffect(() => { + if (supervisorTask && isSupervisorPending && !supervisorStarting) { + console.log("Auto-starting supervisor task on mount..."); + ensureSupervisorStarted().then((started) => { + if (started) { + console.log("Supervisor started successfully"); + } + }); + } + }, [supervisorTask?.id]); // Only run when task ID changes, not on every render + + // Convert supervisor output events to messages + useEffect(() => { + if (supervisorOutput.length === 0) return; + + // Get the latest event + const latestEvent = supervisorOutput[supervisorOutput.length - 1]; + + // Only add complete messages (not partials) to the message history + if (!latestEvent.isPartial && latestEvent.content.trim()) { + const msgId = `supervisor-${Date.now()}`; + let msgType: "assistant" | "error" = "assistant"; + let content = latestEvent.content; + + // Format based on message type + switch (latestEvent.messageType) { + case "assistant": + content = latestEvent.content; + break; + case "tool_use": + content = `_Using tool: ${latestEvent.toolName}_`; + break; + case "tool_result": + content = latestEvent.isError + ? `Tool error: ${latestEvent.content}` + : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`; + msgType = latestEvent.isError ? "error" : "assistant"; + break; + case "error": + msgType = "error"; + break; + case "result": + // Final result - show cost info if available + if (latestEvent.costUsd) { + content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`; + } + break; + default: + // system, raw, etc. + break; + } + + setMessages((prev) => { + // Don't add duplicate messages + if (prev.some((m) => m.content === content)) return prev; + return [ + ...prev, + { id: msgId, type: msgType, content }, + ]; + }); + } + }, [supervisorOutput]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || loading) return; + + const userMessage = input.trim(); + setInput(""); + setExpanded(true); + + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: userMessage }, + ]); + + setLoading(true); + + try { + // Supervisor is the ONLY way to interact with contracts + if (!supervisorTask) { + throw new Error("No supervisor task found. Please create a contract with a supervisor."); + } + + // Ensure supervisor is started (this will start it if pending) + await ensureSupervisorStarted(); + + // Send message to supervisor task stdin + await sendTaskMessage(supervisorTask.id, userMessage); + + // Response will come through WebSocket subscription + // No need for a placeholder message - output will stream in + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + inputRef.current?.focus(); + } + }, + [input, loading, supervisorTask, ensureSupervisorStarted] + ); + + const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { + setUserAnswers((prev) => { + const newMap = new Map(prev); + const currentAnswers = newMap.get(questionId) || []; + + if (allowMultiple) { + if (currentAnswers.includes(option)) { + newMap.set(questionId, currentAnswers.filter((a) => a !== option)); + } else { + newMap.set(questionId, [...currentAnswers, option]); + } + } else { + newMap.set(questionId, [option]); + } + + return newMap; + }); + }, []); + + const handleCustomInputChange = useCallback((questionId: string, value: string) => { + setCustomInputs((prev) => { + const newMap = new Map(prev); + newMap.set(questionId, value); + return newMap; + }); + }, []); + + const handleSubmitAnswers = useCallback(async () => { + if (!pendingQuestions || loading) return; + + const answers = pendingQuestions.map((q) => { + const selectedOptions = userAnswers.get(q.id) || []; + const customInput = customInputs.get(q.id)?.trim(); + const finalAnswers = customInput + ? [...selectedOptions, customInput] + : selectedOptions; + + return { + id: q.id, + answers: finalAnswers, + }; + }); + + const answerText = answers + .map((a) => { + const question = pendingQuestions.find((q) => q.id === a.id); + return `${question?.question || a.id}: ${a.answers.join(", ")}`; + }) + .join("\n"); + + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, + ]); + + setLoading(true); + + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, answerText); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]); + + const handleCancelQuestions = useCallback(() => { + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + setSupervisorOutput([]); + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + }, []); + + // Handle creating tasks from the preview + const handleCreateDerivedTasks = useCallback( + async (selectedTasks: ParsedTask[]) => { + if (selectedTasks.length === 0) { + setParsedTasks(null); + return; + } + + setCreatingTasks(true); + + // Build a message asking the supervisor to create these tasks + const taskList = selectedTasks + .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`) + .join("\n"); + + const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`; + + // Add user message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: message }, + ]); + + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, message); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setCreatingTasks(false); + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + } + }, + [supervisorTask, ensureSupervisorStarted] + ); + + const handleCancelTaskDerivation = useCallback(() => { + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + }, []); + + const handleQuickAction = useCallback( + async (action: QuickAction) => { + // Convert the action into a chat message that triggers the appropriate behavior + let message = ""; + switch (action.type) { + case "create_file": + message = "Create the suggested file from the template."; + break; + case "create_task": + message = "Yes, create the tasks."; + break; + case "derive_tasks": + message = "Show me the tasks to review and create them."; + break; + case "run_task": + message = "Run the next task."; + break; + case "advance_phase": + if (action.data?.phase) { + message = `Advance to the ${action.data.phase} phase.`; + } else { + message = "Advance to the next phase."; + } + break; + case "update_file": + message = "Update the file with the task output."; + break; + default: + return; + } + + setExpanded(true); + + // Submit the message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: message }, + ]); + + setLoading(true); + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, message); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, + [supervisorTask, ensureSupervisorStarted] + ); + + return ( +
+ {/* Header bar with supervisor status and toggle */} +
+
+ + Supervisor + + {supervisorTask && ( + + {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"} + + )} + {!supervisorTask && ( + + No supervisor + + )} +
+ {messages.length > 0 && ( + + )} +
+ + {/* History loading indicator */} + {historyLoading && ( +
+ Loading history... +
+ )} + + {/* Messages Panel (expandable) */} + {expanded && messages.length > 0 && !historyLoading && ( +
+ {/* Expand/Collapse button */} +
+ +
+
+ {messages.map((msg) => ( +
+ {msg.type === "user" && ( +
+ > + {msg.content} +
+ )} + {(msg.type === "assistant" || msg.type === "question") && ( +
+ + {msg.toolCalls && msg.toolCalls.length > 0 && ( +
+ {msg.toolCalls.map((tc, i) => ( +
+ + {tc.success ? "+" : "x"} + {" "} + {tc.name}: {tc.message} +
+ ))} +
+ )} + {msg.quickActions && msg.quickActions.length > 0 && ( + + )} +
+ )} + {msg.type === "error" && ( +
{msg.content}
+ )} +
+ ))} +
+
+ )} + + {/* Pending Questions UI */} + {pendingQuestions && pendingQuestions.length > 0 && ( +
+
+ Questions from AI +
+ {pendingQuestions.map((q) => ( +
+
{q.question}
+
+ {q.options.map((option) => { + const isSelected = (userAnswers.get(q.id) || []).includes(option); + return ( + + ); + })} +
+ {q.allowCustom && ( + handleCustomInputChange(q.id, e.target.value)} + placeholder="Or type a custom answer..." + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" + /> + )} +
+ ))} +
+ + +
+
+ )} + + {/* Supervisor Question UI */} + {supervisorQuestion && ( +
+
+ + Question from Supervisor +
+
{supervisorQuestion.question}
+
+ {supervisorQuestion.options.map((option) => { + const isSelected = supervisorAnswers.includes(option); + return ( + + ); + })} +
+ {supervisorQuestion.allowCustom && ( + setSupervisorCustomInput(e.target.value)} + placeholder="Or type a custom answer..." + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-green-400 placeholder-[#555]" + /> + )} +
+ + +
+
+ )} + + {/* Contract Context Badge */} +
+ {contract.phase} + | + {supervisorTask && ( + <> + + Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status} + + | + + )} + {contract.files.length} files + | + {contract.tasks.length} tasks + | + {contract.repositories.length} repos + | + + {messages.length > 0 && ( + <> + | + + + )} +
+ + {/* Input Bar */} +
+ > + setInput(e.target.value)} + placeholder={ + loading + ? "Processing..." + : supervisorStarting + ? "Starting supervisor..." + : supervisorQuestion + ? "Answer supervisor question above..." + : pendingQuestions + ? "Answer questions above first..." + : isSupervisorRunning + ? "Message supervisor..." + : "Create a task, add a file, or ask about the contract..." + } + disabled={loading || !!pendingQuestions || !!supervisorQuestion} + className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" + /> + {messages.length > 0 && ( + + )} + +
+ + {/* Task Derivation Preview Modal */} + {parsedTasks && parsedTasks.length > 0 && ( + + )} +
+ ); +} 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 = { + 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("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 ( +
+
Loading...
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+ +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + {isEditing ? ( +
+ 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" + /> +