summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/contracts
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/contracts
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/frontend/src/components/contracts')
-rw-r--r--makima/frontend/src/components/contracts/ContractCliInput.tsx974
-rw-r--r--makima/frontend/src/components/contracts/ContractDetail.tsx794
-rw-r--r--makima/frontend/src/components/contracts/ContractList.tsx176
-rw-r--r--makima/frontend/src/components/contracts/PhaseBadge.tsx54
-rw-r--r--makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx301
-rw-r--r--makima/frontend/src/components/contracts/PhaseHint.tsx90
-rw-r--r--makima/frontend/src/components/contracts/PhaseProgressBar.tsx142
-rw-r--r--makima/frontend/src/components/contracts/QuickActionButtons.tsx217
-rw-r--r--makima/frontend/src/components/contracts/RepositoryPanel.tsx260
-rw-r--r--makima/frontend/src/components/contracts/TaskDerivationPreview.tsx221
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]">&gt;</span>
+ <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span>
+ </div>
+ )}
+ {(msg.type === "assistant" || msg.type === "question") && (
+ <div className="pl-4 space-y-1">
+ <SimpleMarkdown content={msg.content} className="text-[#75aafc]" />
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.success ? "text-green-500" : "text-red-400"
+ }
+ >
+ {tc.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.message}
+ </div>
+ ))}
+ </div>
+ )}
+ {msg.quickActions && msg.quickActions.length > 0 && (
+ <QuickActionButtons
+ actions={msg.quickActions}
+ onAction={handleQuickAction}
+ loading={loading}
+ />
+ )}
+ </div>
+ )}
+ {msg.type === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Pending Questions UI */}
+ {pendingQuestions && pendingQuestions.length > 0 && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
+ <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
+ Questions from AI
+ </div>
+ {pendingQuestions.map((q) => (
+ <div key={q.id} className="space-y-2">
+ <div className="text-white/90 font-mono text-sm">{q.question}</div>
+ <div className="flex flex-wrap gap-2">
+ {q.options.map((option) => {
+ const isSelected = (userAnswers.get(q.id) || []).includes(option);
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)}
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-[#3f6fb3] border-[#75aafc] text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
+ }`}
+ >
+ {q.allowMultiple && (
+ <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {q.allowCustom && (
+ <input
+ type="text"
+ value={customInputs.get(q.id) || ""}
+ onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
+ />
+ )}
+ </div>
+ ))}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitAnswers}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Submit Answers"}
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelQuestions}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Supervisor Question UI */}
+ {supervisorQuestion && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3 bg-[rgba(117,170,252,0.05)]">
+ <div className="text-green-400 font-mono text-xs uppercase tracking-wide flex items-center gap-2">
+ <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
+ Question from Supervisor
+ </div>
+ <div className="text-white/90 font-mono text-sm">{supervisorQuestion.question}</div>
+ <div className="flex flex-wrap gap-2">
+ {supervisorQuestion.options.map((option) => {
+ const isSelected = supervisorAnswers.includes(option);
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() => handleSupervisorOptionToggle(option)}
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-green-500/30 border-green-400 text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-green-400"
+ }`}
+ >
+ {supervisorQuestion.allowMultiple && (
+ <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {supervisorQuestion.allowCustom && (
+ <input
+ type="text"
+ value={supervisorCustomInput}
+ onChange={(e) => setSupervisorCustomInput(e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-green-400 placeholder-[#555]"
+ />
+ )}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitSupervisorAnswer}
+ disabled={supervisorAnswers.length === 0 && !supervisorCustomInput.trim()}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/50 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ Send Answer
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelSupervisorQuestion}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Contract Context Badge */}
+ <div className="px-3 pt-2 pb-1 flex items-center gap-2 text-[10px] font-mono text-[#555]">
+ <span className="text-[#75aafc]">{contract.phase}</span>
+ <span>|</span>
+ {supervisorTask && (
+ <>
+ <span
+ className={
+ isSupervisorRunning
+ ? "text-green-400"
+ : isSupervisorPending
+ ? "text-yellow-400"
+ : supervisorStarting
+ ? "text-cyan-400 animate-pulse"
+ : "text-[#555]"
+ }
+ >
+ Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status}
+ </span>
+ <span>|</span>
+ </>
+ )}
+ <span>{contract.files.length} files</span>
+ <span>|</span>
+ <span>{contract.tasks.length} tasks</span>
+ <span>|</span>
+ <span>{contract.repositories.length} repos</span>
+ <span>|</span>
+ <button
+ type="button"
+ onClick={() => {
+ const prompt = "Guide me to complete this phase and advance to the next. Analyze my current deliverables, identify what's missing, and suggest specific next steps.";
+ setInput(prompt);
+ // Auto-submit the prompt
+ setTimeout(() => {
+ const form = document.querySelector('form');
+ if (form) form.requestSubmit();
+ }, 0);
+ }}
+ disabled={loading || !!pendingQuestions}
+ className="text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50 transition-colors cursor-pointer"
+ >
+ Progress →
+ </button>
+ {messages.length > 0 && (
+ <>
+ <span>|</span>
+ <button
+ type="button"
+ onClick={async () => {
+ if (window.confirm("Clear all chat history for this contract?")) {
+ try {
+ await clearContractChatHistory(contractId);
+ setMessages([]);
+ } catch (err) {
+ console.error("Failed to clear history:", err);
+ }
+ }
+ }}
+ disabled={loading}
+ className="text-[#555] hover:text-red-400 disabled:opacity-50 transition-colors cursor-pointer"
+ >
+ Clear
+ </button>
+ </>
+ )}
+ </div>
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 pb-3">
+ <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ placeholder={
+ loading
+ ? "Processing..."
+ : supervisorStarting
+ ? "Starting supervisor..."
+ : supervisorQuestion
+ ? "Answer supervisor question above..."
+ : pendingQuestions
+ ? "Answer questions above first..."
+ : isSupervisorRunning
+ ? "Message supervisor..."
+ : "Create a task, add a file, or ask about the contract..."
+ }
+ disabled={loading || !!pendingQuestions || !!supervisorQuestion}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={clearMessages}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim() || !!pendingQuestions || !!supervisorQuestion}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+
+ {/* Task Derivation Preview Modal */}
+ {parsedTasks && parsedTasks.length > 0 && (
+ <TaskDerivationPreview
+ tasks={parsedTasks}
+ groups={parsedTaskGroups}
+ fileName={parsedTasksFileName}
+ onCreateTasks={handleCreateDerivedTasks}
+ onCancel={handleCancelTaskDerivation}
+ loading={creatingTasks}
+ />
+ )}
+ </div>
+ );
+}
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"
+ >
+ &larr; 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>
+ );
+}