summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/JapaneseHoverText.tsx77
-rw-r--r--makima/frontend/src/components/Masthead.tsx6
-rw-r--r--makima/frontend/src/components/NavStrip.tsx2
-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
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx376
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx2
-rw-r--r--makima/frontend/src/components/files/FileList.tsx179
-rw-r--r--makima/frontend/src/components/files/RepoSyncIndicator.tsx190
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx33
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx12
-rw-r--r--makima/frontend/src/components/mesh/TaskList.tsx215
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx96
-rw-r--r--makima/frontend/src/components/mesh/TaskTree.tsx390
-rw-r--r--makima/frontend/src/components/workflow/PhaseColumn.tsx123
-rw-r--r--makima/frontend/src/components/workflow/WorkflowBoard.tsx54
-rw-r--r--makima/frontend/src/components/workflow/WorkflowContractCard.tsx53
-rw-r--r--makima/frontend/src/hooks/useContracts.ts308
-rw-r--r--makima/frontend/src/hooks/useWebSocket.ts3
-rw-r--r--makima/frontend/src/lib/api.ts542
-rw-r--r--makima/frontend/src/lib/markdown.ts228
-rw-r--r--makima/frontend/src/main.tsx26
-rw-r--r--makima/frontend/src/routes/_index.tsx6
-rw-r--r--makima/frontend/src/routes/contracts.tsx614
-rw-r--r--makima/frontend/src/routes/files.tsx221
-rw-r--r--makima/frontend/src/routes/listen.tsx45
-rw-r--r--makima/frontend/src/routes/mesh.tsx250
-rw-r--r--makima/frontend/src/routes/settings.tsx113
-rw-r--r--makima/frontend/src/routes/workflow.tsx205
37 files changed, 7348 insertions, 250 deletions
diff --git a/makima/frontend/src/components/JapaneseHoverText.tsx b/makima/frontend/src/components/JapaneseHoverText.tsx
new file mode 100644
index 0000000..3e60ee2
--- /dev/null
+++ b/makima/frontend/src/components/JapaneseHoverText.tsx
@@ -0,0 +1,77 @@
+import { useState, useCallback, useRef } from "react";
+
+const GLYPHS = "▒▓░█#@*+:-/[]{}<>_";
+
+interface JapaneseHoverTextProps {
+ japanese: string;
+ english: string;
+ className?: string;
+}
+
+/**
+ * Displays Japanese text, transitions to English on hover with scramble effect
+ */
+export function JapaneseHoverText({
+ japanese,
+ english,
+ className = "",
+}: JapaneseHoverTextProps) {
+ const [isHovered, setIsHovered] = useState(false);
+ const [displayText, setDisplayText] = useState(english);
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+ const iterationRef = useRef(0);
+
+ const scrambleToEnglish = useCallback(() => {
+ setIsHovered(true);
+
+ // Clear any existing animation
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+
+ iterationRef.current = 0;
+
+ timerRef.current = setInterval(() => {
+ const text = english;
+ const iteration = iterationRef.current;
+
+ const display = text
+ .split("")
+ .map((char, index) => {
+ if (index < iteration) return char;
+ return GLYPHS.charAt(Math.floor(Math.random() * GLYPHS.length));
+ })
+ .join("");
+
+ setDisplayText(display);
+ iterationRef.current += 1;
+
+ if (iteration > text.length + 2) {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ setDisplayText(english);
+ }
+ }, 26);
+ }, [english]);
+
+ const resetToJapanese = useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ setIsHovered(false);
+ setDisplayText(english);
+ }, [english]);
+
+ return (
+ <span
+ className={`cursor-default ${className}`}
+ onMouseEnter={scrambleToEnglish}
+ onMouseLeave={resetToJapanese}
+ >
+ {isHovered ? displayText : japanese}
+ </span>
+ );
+}
diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx
index afe385e..bc184bd 100644
--- a/makima/frontend/src/components/Masthead.tsx
+++ b/makima/frontend/src/components/Masthead.tsx
@@ -1,6 +1,7 @@
import { Link } from "react-router";
import { LogoMark } from "./Logo";
import { NavStrip } from "./NavStrip";
+import { JapaneseHoverText } from "./JapaneseHoverText";
interface MastheadProps {
showTicker?: boolean;
@@ -18,7 +19,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
makima.jp
</h1>
<small className="block text-[#dbe7ff] text-xs tracking-wide">
- Control System
+ <JapaneseHoverText
+ japanese="支配する"
+ english="Control System"
+ />
</small>
</div>
</Link>
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 642e9a3..48abe09 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -11,6 +11,8 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Files", href: "/files", requiresAuth: true },
+ { label: "Contracts", href: "/contracts", requiresAuth: true },
+ { label: "Board", href: "/workflow", requiresAuth: true },
{ label: "Mesh", href: "/mesh", requiresAuth: true },
];
diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx
new file mode 100644
index 0000000..821d03c
--- /dev/null
+++ b/makima/frontend/src/components/contracts/ContractCliInput.tsx
@@ -0,0 +1,974 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import {
+ getContractChatHistory,
+ clearContractChatHistory,
+ startTask,
+ sendTaskMessage,
+ type UserQuestion,
+ type ContractWithRelations,
+ type TaskStatus,
+} from "../../lib/api";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import {
+ QuickActionButtons,
+ type QuickAction,
+} from "./QuickActionButtons";
+import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview";
+import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription";
+
+interface ContractCliInputProps {
+ contractId: string;
+ contract: ContractWithRelations;
+ onUpdate: () => void;
+}
+
+interface Message {
+ id: string;
+ type: "user" | "assistant" | "error" | "question";
+ content: string;
+ toolCalls?: { name: string; success: boolean; message: string }[];
+ questions?: UserQuestion[];
+ quickActions?: QuickAction[];
+}
+
+export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) {
+ const [input, setInput] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [historyLoading, setHistoryLoading] = useState(true);
+ const [messages, setMessages] = useState<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>
+ );
+}
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index cf99fde..c3f402e 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";
import { ElementContextMenu } from "./ElementContextMenu";
+import { copyMarkdownToClipboard } from "../../lib/markdown";
interface BodyRendererProps {
elements: BodyElement[];
@@ -42,6 +43,15 @@ export function BodyRenderer({
elementIndex: number;
selectedText?: string;
} | null>(null);
+ const [copiedMarkdown, setCopiedMarkdown] = useState(false);
+
+ const handleCopyMarkdown = async () => {
+ const success = await copyMarkdownToClipboard(elements);
+ if (success) {
+ setCopiedMarkdown(true);
+ setTimeout(() => setCopiedMarkdown(false), 2000);
+ }
+ };
const handleContextMenu = (index: number) => (e: React.MouseEvent) => {
e.preventDefault();
@@ -104,6 +114,31 @@ export function BodyRenderer({
return (
<div className="space-y-1">
+ {/* Markdown Export Toolbar */}
+ <div className="flex justify-end mb-2">
+ <button
+ onClick={handleCopyMarkdown}
+ className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Copy content as markdown"
+ >
+ {copiedMarkdown ? (
+ <>
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+ Copied!
+ </>
+ ) : (
+ <>
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
+ <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
+ </svg>
+ Copy as Markdown
+ </>
+ )}
+ </button>
+ </div>
{elements.map((element, index) => (
<div
key={index}
@@ -250,6 +285,20 @@ function BodyElementRenderer({
caption={element.caption}
/>
);
+ case "markdown":
+ return (
+ <MarkdownElement
+ content={element.content}
+ onUpdate={
+ onUpdate
+ ? (content) => onUpdate({ ...element, content })
+ : undefined
+ }
+ onEditingChange={onEditingChange}
+ hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+ onOverwrite={onOverwrite}
+ />
+ );
default:
return null;
}
@@ -621,3 +670,330 @@ function ListElement({
</ListTag>
);
}
+
+/**
+ * Simple inline markdown renderer.
+ * Renders basic markdown syntax to HTML elements.
+ */
+function renderMarkdown(content: string): React.ReactNode {
+ const lines = content.split('\n');
+ const elements: React.ReactNode[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // Code blocks
+ if (line.startsWith('```')) {
+ const lang = line.slice(3).trim();
+ const codeLines: string[] = [];
+ i++;
+ while (i < lines.length && !lines[i].startsWith('```')) {
+ codeLines.push(lines[i]);
+ i++;
+ }
+ i++; // skip closing ```
+ elements.push(
+ <div key={elements.length} className="relative my-2">
+ {lang && (
+ <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]">
+ {lang}
+ </div>
+ )}
+ <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto">
+ <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre">
+ {codeLines.join('\n')}
+ </code>
+ </pre>
+ </div>
+ );
+ continue;
+ }
+
+ // Headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ const level = headingMatch[1].length;
+ const text = headingMatch[2];
+ const HeadingTag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+ const sizeClasses: Record<number, string> = {
+ 1: "text-2xl font-bold",
+ 2: "text-xl font-bold",
+ 3: "text-lg font-semibold",
+ 4: "text-base font-semibold",
+ 5: "text-sm font-semibold",
+ 6: "text-xs font-semibold",
+ };
+ elements.push(
+ <HeadingTag key={elements.length} className={`font-mono text-[#9bc3ff] ${sizeClasses[level]} my-2`}>
+ {renderInlineMarkdown(text)}
+ </HeadingTag>
+ );
+ i++;
+ continue;
+ }
+
+ // Unordered lists
+ if (line.match(/^[-*]\s+/)) {
+ const items: string[] = [];
+ while (i < lines.length && lines[i].match(/^[-*]\s+/)) {
+ items.push(lines[i].replace(/^[-*]\s+/, ''));
+ i++;
+ }
+ elements.push(
+ <ul key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-disc my-2">
+ {items.map((item, idx) => (
+ <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li>
+ ))}
+ </ul>
+ );
+ continue;
+ }
+
+ // Ordered lists
+ if (line.match(/^\d+\.\s+/)) {
+ const items: string[] = [];
+ while (i < lines.length && lines[i].match(/^\d+\.\s+/)) {
+ items.push(lines[i].replace(/^\d+\.\s+/, ''));
+ i++;
+ }
+ elements.push(
+ <ol key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-decimal my-2">
+ {items.map((item, idx) => (
+ <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li>
+ ))}
+ </ol>
+ );
+ continue;
+ }
+
+ // Empty lines
+ if (line.trim() === '') {
+ i++;
+ continue;
+ }
+
+ // Regular paragraphs
+ elements.push(
+ <p key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed my-2">
+ {renderInlineMarkdown(line)}
+ </p>
+ );
+ i++;
+ }
+
+ return <>{elements}</>;
+}
+
+/**
+ * Render inline markdown (bold, italic, code, links).
+ */
+function renderInlineMarkdown(text: string): React.ReactNode {
+ // Process inline elements: **bold**, *italic*, `code`, [link](url)
+ const parts: React.ReactNode[] = [];
+ let remaining = text;
+ let keyCounter = 0;
+
+ while (remaining.length > 0) {
+ // Check for inline code
+ const codeMatch = remaining.match(/^`([^`]+)`/);
+ if (codeMatch) {
+ parts.push(
+ <code key={keyCounter++} className="bg-[#1a1a1a] px-1 py-0.5 text-[#9bc3ff] border border-[#333] text-xs">
+ {codeMatch[1]}
+ </code>
+ );
+ remaining = remaining.slice(codeMatch[0].length);
+ continue;
+ }
+
+ // Check for bold
+ const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
+ if (boldMatch) {
+ parts.push(<strong key={keyCounter++} className="font-bold">{boldMatch[1]}</strong>);
+ remaining = remaining.slice(boldMatch[0].length);
+ continue;
+ }
+
+ // Check for italic
+ const italicMatch = remaining.match(/^\*([^*]+)\*/);
+ if (italicMatch) {
+ parts.push(<em key={keyCounter++} className="italic">{italicMatch[1]}</em>);
+ remaining = remaining.slice(italicMatch[0].length);
+ continue;
+ }
+
+ // Check for links
+ const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
+ if (linkMatch) {
+ parts.push(
+ <a key={keyCounter++} href={linkMatch[2]} className="text-[#75aafc] hover:underline" target="_blank" rel="noopener noreferrer">
+ {linkMatch[1]}
+ </a>
+ );
+ remaining = remaining.slice(linkMatch[0].length);
+ continue;
+ }
+
+ // Find next special character or end
+ const nextSpecial = remaining.search(/[`*\[]/);
+ if (nextSpecial === -1) {
+ parts.push(remaining);
+ break;
+ } else if (nextSpecial === 0) {
+ // Special char at start but didn't match a pattern - treat as text
+ parts.push(remaining[0]);
+ remaining = remaining.slice(1);
+ } else {
+ parts.push(remaining.slice(0, nextSpecial));
+ remaining = remaining.slice(nextSpecial);
+ }
+ }
+
+ return <>{parts}</>;
+}
+
+function MarkdownElement({
+ content,
+ onUpdate,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
+}: {
+ content: string;
+ onUpdate?: (content: string) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editContent, setEditContent] = useState(content);
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+ useEffect(() => {
+ if (!isEditing) {
+ setEditContent(content);
+ }
+ }, [content, isEditing]);
+
+ useEffect(() => {
+ if (isEditing && textareaRef.current) {
+ textareaRef.current.focus();
+ textareaRef.current.style.height = "auto";
+ textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
+ }
+ }, [isEditing]);
+
+ const startEditing = () => {
+ setIsEditing(true);
+ onEditingChange?.(true);
+ };
+
+ const stopEditing = () => {
+ setIsEditing(false);
+ onEditingChange?.(false);
+ };
+
+ const handleSave = () => {
+ if (hasPendingRemoteUpdate) return;
+ if (onUpdate && editContent !== content) {
+ onUpdate(editContent);
+ }
+ stopEditing();
+ };
+
+ const handleOverwrite = () => {
+ if (onUpdate && editContent !== content) {
+ onUpdate(editContent);
+ }
+ onOverwrite?.();
+ stopEditing();
+ };
+
+ const handleCancel = () => {
+ setEditContent(content);
+ stopEditing();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ handleCancel();
+ }
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
+ handleSave();
+ }
+ };
+
+ const handleBlur = () => {
+ if (!hasPendingRemoteUpdate) {
+ handleSave();
+ }
+ };
+
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setEditContent(e.target.value);
+ e.target.style.height = "auto";
+ e.target.style.height = e.target.scrollHeight + "px";
+ };
+
+ if (isEditing && onUpdate) {
+ return (
+ <div className="relative">
+ <div className="text-[10px] text-[#555] font-mono mb-1 flex items-center gap-2">
+ <span className="text-[#75aafc]">Editing Markdown</span>
+ </div>
+ <textarea
+ ref={textareaRef}
+ value={editContent}
+ onChange={handleInput}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ className="font-mono text-sm text-white/80 leading-relaxed w-full bg-[#0d0d0d] border border-[#3f6fb3] outline-none p-3 resize-none min-h-[120px]"
+ placeholder="Enter markdown content..."
+ />
+ {hasPendingRemoteUpdate ? (
+ <div className="flex items-center gap-2 mt-2">
+ <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+ <button
+ onClick={handleOverwrite}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+ >
+ Overwrite
+ </button>
+ <button
+ onClick={handleCancel}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ ) : (
+ <div className="text-[10px] text-[#555] font-mono mt-1">
+ Ctrl+Enter to save, Esc to cancel
+ </div>
+ )}
+ </div>
+ );
+ }
+
+ return (
+ <div
+ className={`border border-[#333] bg-[#0a0a0a] p-4 rounded ${onUpdate ? "cursor-text hover:border-[#3f6fb3] transition-colors" : ""}`}
+ onClick={() => onUpdate && startEditing()}
+ >
+ <div className="text-[10px] text-[#555] font-mono mb-2 flex items-center gap-1">
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
+ <path d="M14 2v6h6" />
+ <path d="M16 13H8" />
+ <path d="M16 17H8" />
+ <path d="M10 9H8" />
+ </svg>
+ <span>Markdown</span>
+ </div>
+ {renderMarkdown(content)}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index 60458e9..a030c57 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -87,6 +87,8 @@ export function FileDetail({
return element.title || `${element.chartType} chart`;
case "image":
return element.alt || element.caption || "Image";
+ case "markdown":
+ return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : "");
default:
return "Element";
}
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
index c537846..188a1df 100644
--- a/makima/frontend/src/components/files/FileList.tsx
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -1,5 +1,7 @@
import { useRef } from "react";
-import type { FileSummary, BodyElement } from "../../lib/api";
+import { useNavigate } from "react-router";
+import type { FileSummary, BodyElement, ContractPhase } from "../../lib/api";
+import { markdownToBody } from "../../lib/markdown";
interface FileListProps {
files: FileSummary[];
@@ -10,153 +12,6 @@ interface FileListProps {
onUploadMarkdown?: (name: string, body: BodyElement[]) => void;
}
-/**
- * Parse markdown text into BodyElements.
- * Converts image embeds to links instead of images.
- */
-function parseMarkdown(markdown: string): BodyElement[] {
- const elements: BodyElement[] = [];
- const lines = markdown.split('\n');
- let currentParagraph: string[] = [];
- let inCodeBlock = false;
- let codeBlockLanguage: string | undefined;
- let codeBlockContent: string[] = [];
- let currentList: { ordered: boolean; items: string[] } | null = null;
-
- const flushParagraph = () => {
- if (currentParagraph.length > 0) {
- const text = currentParagraph.join('\n').trim();
- if (text) {
- elements.push({ type: "paragraph", text });
- }
- currentParagraph = [];
- }
- };
-
- const flushCodeBlock = () => {
- if (codeBlockContent.length > 0 || inCodeBlock) {
- elements.push({
- type: "code",
- language: codeBlockLanguage || undefined,
- content: codeBlockContent.join('\n'),
- });
- codeBlockContent = [];
- codeBlockLanguage = undefined;
- }
- };
-
- const flushList = () => {
- if (currentList && currentList.items.length > 0) {
- elements.push({
- type: "list",
- ordered: currentList.ordered,
- items: currentList.items,
- });
- currentList = null;
- }
- };
-
- // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
- const convertImagesToLinks = (text: string): string => {
- return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
- const linkText = alt || 'image';
- return `[${linkText}](${url})`;
- });
- };
-
- for (const rawLine of lines) {
- // Check for code block fence (``` or ~~~)
- const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
- if (codeFenceMatch) {
- if (!inCodeBlock) {
- // Starting a code block
- flushParagraph();
- flushList();
- inCodeBlock = true;
- codeBlockLanguage = codeFenceMatch[2] || undefined;
- codeBlockContent = [];
- } else {
- // Ending a code block
- flushCodeBlock();
- inCodeBlock = false;
- }
- continue;
- }
-
- // If inside a code block, add line as-is
- if (inCodeBlock) {
- codeBlockContent.push(rawLine);
- continue;
- }
-
- // Convert images to links in all lines
- const line = convertImagesToLinks(rawLine);
-
- // Check for headings
- const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
- if (headingMatch) {
- flushParagraph();
- flushList();
- const level = headingMatch[1].length;
- const text = headingMatch[2].trim();
- elements.push({ type: "heading", level, text });
- continue;
- }
-
- // Check for unordered list items (-, *, +)
- const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
- if (unorderedMatch) {
- flushParagraph();
- const itemText = unorderedMatch[1].trim();
- if (currentList && currentList.ordered) {
- // Switch from ordered to unordered
- flushList();
- }
- if (!currentList) {
- currentList = { ordered: false, items: [] };
- }
- currentList.items.push(itemText);
- continue;
- }
-
- // Check for ordered list items (1. 2. etc)
- const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
- if (orderedMatch) {
- flushParagraph();
- const itemText = orderedMatch[1].trim();
- if (currentList && !currentList.ordered) {
- // Switch from unordered to ordered
- flushList();
- }
- if (!currentList) {
- currentList = { ordered: true, items: [] };
- }
- currentList.items.push(itemText);
- continue;
- }
-
- // Empty line - flush everything
- if (line.trim() === '') {
- flushParagraph();
- flushList();
- continue;
- }
-
- // Regular text - flush list first, then add to paragraph
- flushList();
- currentParagraph.push(line);
- }
-
- // Flush any remaining content
- if (inCodeBlock) {
- flushCodeBlock();
- }
- flushParagraph();
- flushList();
-
- return elements;
-}
-
function formatDuration(seconds: number | null): string {
if (seconds === null) return "-";
const mins = Math.floor(seconds / 60);
@@ -175,6 +30,14 @@ function formatDate(dateStr: string): string {
});
}
+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",
+};
+
export function FileList({
files,
loading,
@@ -183,6 +46,7 @@ export function FileList({
onCreate,
onUploadMarkdown,
}: FileListProps) {
+ const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -193,7 +57,7 @@ export function FileList({
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
- const body = parseMarkdown(content);
+ const body = markdownToBody(content);
// Use filename without extension as the name
const name = file.name.replace(/\.md$/i, '') || 'Imported Document';
onUploadMarkdown(name, body);
@@ -273,10 +137,25 @@ export function FileList({
{file.description}
</p>
)}
- <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ <div className="flex items-center gap-4 font-mono text-[10px] text-[#75aafc]">
<span>{file.transcriptCount} segments</span>
<span>{formatDuration(file.duration)}</span>
<span>{formatDate(file.createdAt)}</span>
+ {file.contractId && file.contractName && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ navigate(`/contracts/${file.contractId}`);
+ }}
+ className={`px-2 py-0.5 text-[9px] font-mono uppercase border rounded ${
+ file.contractPhase ? phaseColors[file.contractPhase] : "bg-[#21262d] text-[#8b949e] border-[#30363d]"
+ } hover:opacity-80 transition-opacity`}
+ title={`View contract: ${file.contractName}`}
+ >
+ {file.contractName}
+ {file.contractPhase && ` · ${file.contractPhase}`}
+ </button>
+ )}
</div>
</button>
<button
diff --git a/makima/frontend/src/components/files/RepoSyncIndicator.tsx b/makima/frontend/src/components/files/RepoSyncIndicator.tsx
new file mode 100644
index 0000000..82d79f7
--- /dev/null
+++ b/makima/frontend/src/components/files/RepoSyncIndicator.tsx
@@ -0,0 +1,190 @@
+import { useState, useCallback } from "react";
+import { syncFileFromRepo } from "../../lib/api";
+
+interface RepoSyncIndicatorProps {
+ fileId: string;
+ repoFilePath: string | null | undefined;
+ repoSyncStatus: string | null | undefined;
+ repoSyncedAt: string | null | undefined;
+ onSyncComplete?: () => void;
+ /** Callback to push file content to repo (creates a task) */
+ onPushToRepo?: () => void;
+ /** Whether a push operation is in progress */
+ isPushing?: boolean;
+}
+
+/**
+ * Shows repository file link status and provides sync functionality.
+ * Displays the linked file path and allows updating from the repo via daemon,
+ * or pushing local changes back to the repo.
+ */
+export function RepoSyncIndicator({
+ fileId,
+ repoFilePath,
+ repoSyncStatus,
+ repoSyncedAt,
+ onSyncComplete,
+ onPushToRepo,
+ isPushing = false,
+}: RepoSyncIndicatorProps) {
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const handleSync = useCallback(async () => {
+ setIsSyncing(true);
+ setError(null);
+ try {
+ await syncFileFromRepo(fileId);
+ // The actual update happens via WebSocket notification
+ // Give a brief delay then notify parent
+ setTimeout(() => {
+ onSyncComplete?.();
+ }, 500);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Sync failed");
+ } finally {
+ setIsSyncing(false);
+ }
+ }, [fileId, onSyncComplete]);
+
+ // Don't render if no repo file path is set
+ if (!repoFilePath) {
+ return null;
+ }
+
+ const isActuallySyncing = isSyncing || repoSyncStatus === "syncing";
+ const isSynced = repoSyncStatus === "synced";
+ const isModified = repoSyncStatus === "modified";
+
+ // Format the synced timestamp
+ const syncedAtFormatted = repoSyncedAt
+ ? new Date(repoSyncedAt).toLocaleString()
+ : null;
+
+ return (
+ <div className="flex items-center gap-2 text-xs font-mono">
+ {/* File path icon and link */}
+ <div className="flex items-center gap-1 text-[#555]">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ className="flex-shrink-0"
+ >
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
+ <polyline points="14 2 14 8 20 8" />
+ </svg>
+ <span className="text-[#75aafc]" title={`Linked to repository file: ${repoFilePath}`}>
+ {repoFilePath}
+ </span>
+ </div>
+
+ {/* Status indicator */}
+ {isSynced && (
+ <span
+ className="text-green-500 flex items-center gap-1"
+ title={syncedAtFormatted ? `Last synced: ${syncedAtFormatted}` : "Synced"}
+ >
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+ </span>
+ )}
+ {isModified && (
+ <span className="text-yellow-500" title="File modified, may need sync">
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
+ <circle cx="12" cy="12" r="10" />
+ <line x1="12" y1="8" x2="12" y2="12" />
+ <line x1="12" y1="16" x2="12.01" y2="16" />
+ </svg>
+ </span>
+ )}
+
+ {/* Pull from repo button */}
+ <button
+ onClick={handleSync}
+ disabled={isActuallySyncing || isPushing}
+ className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${
+ isActuallySyncing || isPushing
+ ? "text-[#555] cursor-wait"
+ : "text-[#555] hover:text-[#75aafc] hover:bg-[rgba(117,170,252,0.1)]"
+ }`}
+ title="Pull latest from repository"
+ >
+ {isActuallySyncing ? (
+ <>
+ <svg
+ width="10"
+ height="10"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ className="animate-spin"
+ >
+ <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" />
+ </svg>
+ <span>Pulling...</span>
+ </>
+ ) : (
+ <>
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M12 19V5" />
+ <path d="M5 12l7-7 7 7" />
+ </svg>
+ <span>Pull</span>
+ </>
+ )}
+ </button>
+
+ {/* Push to repo button */}
+ {onPushToRepo && (
+ <button
+ onClick={onPushToRepo}
+ disabled={isActuallySyncing || isPushing}
+ className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${
+ isPushing
+ ? "text-[#555] cursor-wait"
+ : "text-[#555] hover:text-green-500 hover:bg-[rgba(34,197,94,0.1)]"
+ }`}
+ title="Push changes to repository (creates a task)"
+ >
+ {isPushing ? (
+ <>
+ <svg
+ width="10"
+ height="10"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ className="animate-spin"
+ >
+ <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" />
+ </svg>
+ <span>Pushing...</span>
+ </>
+ ) : (
+ <>
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M12 5v14" />
+ <path d="M19 12l-7 7-7-7" />
+ </svg>
+ <span>Push</span>
+ </>
+ )}
+ </button>
+ )}
+
+ {/* Error message */}
+ {error && (
+ <span className="text-red-500 text-[10px]" title={error}>
+ Failed
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx
index af2cd05..35834d4 100644
--- a/makima/frontend/src/components/listen/ControlPanel.tsx
+++ b/makima/frontend/src/components/listen/ControlPanel.tsx
@@ -1,6 +1,11 @@
import { Logo } from "../Logo";
import type { MicrophoneStatus } from "../../hooks/useMicrophone";
+export interface ContractOption {
+ id: string;
+ name: string;
+}
+
interface ControlPanelProps {
isListening: boolean;
isConnected: boolean;
@@ -10,6 +15,11 @@ interface ControlPanelProps {
onToggle: () => void;
onNew: () => void;
error?: string | null;
+ // Contract selection
+ contracts: ContractOption[];
+ selectedContractId: string | null;
+ onContractChange: (contractId: string | null) => void;
+ contractsLoading?: boolean;
}
function getStatusText(isListening: boolean, micStatus: MicrophoneStatus): string {
@@ -38,6 +48,10 @@ export function ControlPanel({
onToggle,
onNew,
error,
+ contracts,
+ selectedContractId,
+ onContractChange,
+ contractsLoading,
}: ControlPanelProps) {
const statusText = getStatusText(isListening, micStatus);
const isRequesting = micStatus === "requesting";
@@ -133,13 +147,20 @@ export function ControlPanel({
>
New
</button>
- <button
- disabled
- className="px-3 py-1.5 font-mono text-xs text-[#9aa9c6] bg-[#0b1423] border border-[rgba(117,170,252,0.25)] cursor-not-allowed uppercase tracking-wide opacity-50"
- title="File upload coming soon"
+ <select
+ value={selectedContractId || ""}
+ onChange={(e) => onContractChange(e.target.value || null)}
+ disabled={isListening || contractsLoading}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] focus:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide"
+ title={selectedContractId ? "Saving to selected contract" : "Transcript not saved"}
>
- Upload
- </button>
+ <option value="">Ephemeral Transcript</option>
+ {contracts.map((contract) => (
+ <option key={contract.id} value={contract.id}>
+ {contract.name}
+ </option>
+ ))}
+ </select>
</div>
</div>
);
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx
index be4fb80..967b1d1 100644
--- a/makima/frontend/src/components/mesh/TaskDetail.tsx
+++ b/makima/frontend/src/components/mesh/TaskDetail.tsx
@@ -23,6 +23,8 @@ interface TaskDetailProps {
onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void;
/** Which subtask's output is currently being viewed */
viewingSubtaskId?: string | null;
+ /** Navigate to view the contract */
+ onViewContract?: (contractId: string) => void;
// Optional advanced features
overlayDiff?: string;
changedFiles?: string[];
@@ -105,6 +107,7 @@ export function TaskDetail({
onCreateSubtask,
onToggleSubtaskOutput,
viewingSubtaskId,
+ onViewContract,
overlayDiff,
changedFiles,
onRequestDiff,
@@ -417,6 +420,15 @@ export function TaskDetail({
>
{task.status}
</span>
+ {/* Contract badge - clickable to view contract */}
+ {task.contractId && onViewContract && (
+ <button
+ onClick={() => onViewContract(task.contractId!)}
+ className="px-2 py-0.5 font-mono text-xs text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/20 transition-colors"
+ >
+ Contract
+ </button>
+ )}
{/* Orchestrator badge for depth 0 tasks with subtasks */}
{task.depth === 0 && task.subtasks.length > 0 && (
<span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx
index a37e564..d013782 100644
--- a/makima/frontend/src/components/mesh/TaskList.tsx
+++ b/makima/frontend/src/components/mesh/TaskList.tsx
@@ -1,4 +1,5 @@
-import type { TaskSummary, TaskStatus } from "../../lib/api";
+import { useMemo } from "react";
+import type { TaskSummary, TaskStatus, ContractPhase } from "../../lib/api";
interface TaskListProps {
tasks: TaskSummary[];
@@ -8,6 +9,13 @@ interface TaskListProps {
onCreate: () => void;
}
+interface GroupedTasks {
+ contractId: string | null;
+ contractName: string | null;
+ contractPhase: ContractPhase | null;
+ tasks: TaskSummary[];
+}
+
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
@@ -61,6 +69,17 @@ function getStatusBgColor(status: TaskStatus): string {
}
}
+function getPhaseColor(phase: ContractPhase | null): string {
+ switch (phase) {
+ case "research": return "text-blue-400";
+ case "specify": return "text-cyan-400";
+ case "plan": return "text-yellow-400";
+ case "execute": return "text-green-400";
+ case "review": return "text-purple-400";
+ default: return "text-[#8b949e]";
+ }
+}
+
export function TaskList({
tasks,
loading,
@@ -68,6 +87,53 @@ export function TaskList({
onDelete,
onCreate,
}: TaskListProps) {
+ // Group tasks by contract
+ const groupedTasks = useMemo(() => {
+ // Separate root tasks (no parent) from subtasks
+ const rootTasks = tasks.filter((t) => !t.parentTaskId);
+
+ // Group by contractId
+ const groups = new Map<string | null, GroupedTasks>();
+
+ for (const task of rootTasks) {
+ const key = task.contractId;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ contractId: task.contractId,
+ contractName: task.contractName,
+ contractPhase: task.contractPhase,
+ tasks: [],
+ });
+ }
+ groups.get(key)!.tasks.push(task);
+ }
+
+ // Sort tasks within each group: supervisors first, then by status (running first), then by date
+ for (const group of groups.values()) {
+ group.tasks.sort((a, b) => {
+ // Supervisors always first
+ if (a.isSupervisor && !b.isSupervisor) return -1;
+ if (!a.isSupervisor && b.isSupervisor) return 1;
+ // Running tasks next
+ if (a.status === "running" && b.status !== "running") return -1;
+ if (a.status !== "running" && b.status === "running") return 1;
+ // Then by date (newest first)
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ });
+ }
+
+ // Sort: contracts first (alphabetically by name), then orphan tasks
+ const sorted = Array.from(groups.values()).sort((a, b) => {
+ // Orphan tasks (no contract) go last
+ if (!a.contractId && b.contractId) return 1;
+ if (a.contractId && !b.contractId) return -1;
+ // Sort by contract name
+ return (a.contractName || "").localeCompare(b.contractName || "");
+ });
+
+ return sorted;
+ }, [tasks]);
+
if (loading) {
return (
<div className="panel h-full flex items-center justify-center">
@@ -76,8 +142,7 @@ export function TaskList({
);
}
- // Separate root tasks (no parent) from subtasks
- const rootTasks = tasks.filter((t) => !t.parentTaskId);
+ const totalTasks = groupedTasks.reduce((sum, g) => sum + g.tasks.length, 0);
return (
<div className="panel h-full flex flex-col">
@@ -94,65 +159,107 @@ export function TaskList({
</div>
<div className="flex-1 overflow-y-auto">
- {rootTasks.length === 0 ? (
+ {totalTasks === 0 ? (
<div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
No tasks yet. Create one to start orchestrating Claude Code instances.
</div>
) : (
- <div className="divide-y divide-[rgba(117,170,252,0.15)]">
- {rootTasks.map((task) => (
- <div
- key={task.id}
- className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
- >
- <div className="flex items-start justify-between gap-4">
- <button
- onClick={() => onSelect(task.id)}
- className="flex-1 text-left"
- >
- <div className="flex items-center gap-2 mb-1">
- <h3 className="font-mono text-sm text-[#dbe7ff]">
- {task.name}
- </h3>
- <span
- className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
- task.status
- )} ${getStatusBgColor(task.status)} border border-current/20`}
- >
- {task.status}
+ <div>
+ {groupedTasks.map((group) => (
+ <div key={group.contractId || "orphan"}>
+ {/* Contract header */}
+ <div className="sticky top-0 bg-[#0d1117] border-b border-[rgba(117,170,252,0.25)] px-4 py-2 flex items-center gap-2">
+ {group.contractId ? (
+ <>
+ <span className="font-mono text-xs text-[#dbe7ff] font-medium">
+ {group.contractName}
</span>
- {task.depth === 0 && task.subtaskCount > 0 && (
- <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
- Orchestrator
+ {group.contractPhase && (
+ <span className={`font-mono text-[10px] ${getPhaseColor(group.contractPhase)}`}>
+ [{group.contractPhase}]
</span>
)}
- {task.priority > 0 && (
- <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
- P{task.priority}
- </span>
- )}
- </div>
- {task.progressSummary && (
- <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
- {task.progressSummary}
- </p>
- )}
- <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
- {task.subtaskCount > 0 && (
- <span>{task.subtaskCount} subtasks</span>
- )}
- <span>{formatDate(task.createdAt)}</span>
+ <span className="font-mono text-[10px] text-[#8b949e]">
+ ({group.tasks.length})
+ </span>
+ </>
+ ) : (
+ <span className="font-mono text-xs text-[#8b949e] italic">
+ Unassigned Tasks ({group.tasks.length})
+ </span>
+ )}
+ </div>
+
+ {/* Tasks in this group */}
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {group.tasks.map((task) => (
+ <div
+ key={task.id}
+ className={`p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors ${
+ task.isSupervisor
+ ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]"
+ : ""
+ }`}
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left"
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <h3 className="font-mono text-sm text-[#dbe7ff]">
+ {task.name}
+ </h3>
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
+ task.status
+ )} ${getStatusBgColor(task.status)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {task.isSupervisor && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[#0f3c78] border border-[#3f6fb3] uppercase">
+ Supervisor
+ </span>
+ )}
+ {!task.isSupervisor && task.depth === 0 && task.subtaskCount > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+ {task.progressSummary && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {task.progressSummary}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ {task.subtaskCount > 0 && (
+ <span>{task.subtaskCount} subtasks</span>
+ )}
+ <span>{formatDate(task.createdAt)}</span>
+ </div>
+ </button>
+ {/* Supervisor tasks cannot be deleted directly - they are deleted with the contract */}
+ {!task.isSupervisor && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ )}
+ </div>
</div>
- </button>
- <button
- onClick={(e) => {
- e.stopPropagation();
- onDelete(task.id);
- }}
- className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
- >
- Delete
- </button>
+ ))}
</div>
</div>
))}
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
index 10de225..cb0eba3 100644
--- a/makima/frontend/src/components/mesh/TaskOutput.tsx
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -275,7 +275,103 @@ function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
</div>
);
+ case "auth_required":
+ return <AuthRequiredEntry entry={entry} />;
+
default:
return null;
}
}
+
+function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) {
+ const [authCode, setAuthCode] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const loginUrl = entry.toolInput?.loginUrl as string | undefined;
+ const hostname = entry.toolInput?.hostname as string | undefined;
+ // Get taskId from entry or fallback to toolInput (for robustness)
+ const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!authCode.trim() || !taskId) return;
+
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ // Send the auth code to the task via the message endpoint
+ await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`);
+ setSubmitted(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to submit code");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (submitted) {
+ return (
+ <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2">
+ <div className="flex items-center gap-2 text-green-400 font-semibold">
+ <span>✓</span>
+ <span>Authentication code submitted</span>
+ </div>
+ <p className="text-green-200/80 text-sm mt-1">
+ Waiting for authentication to complete...
+ </p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2">
+ <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
+ <span>🔐</span>
+ <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span>
+ </div>
+ <p className="text-amber-200/80 text-sm mb-3">
+ The daemon's OAuth token has expired. Click the button to login, then paste the code below:
+ </p>
+
+ <div className="flex flex-col gap-3">
+ {loginUrl ? (
+ <a
+ href={loginUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center"
+ >
+ 1. Login to Claude
+ </a>
+ ) : (
+ <p className="text-red-400 text-sm">Login URL not available</p>
+ )}
+
+ <form onSubmit={handleSubmit} className="flex gap-2">
+ <input
+ type="text"
+ value={authCode}
+ onChange={(e) => setAuthCode(e.target.value)}
+ placeholder="2. Paste authentication code here"
+ className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400"
+ disabled={submitting}
+ />
+ <button
+ type="submit"
+ disabled={submitting || !authCode.trim()}
+ className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors"
+ >
+ {submitting ? "..." : "Submit"}
+ </button>
+ </form>
+
+ {error && (
+ <p className="text-red-400 text-sm">{error}</p>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskTree.tsx b/makima/frontend/src/components/mesh/TaskTree.tsx
new file mode 100644
index 0000000..46ae78d
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskTree.tsx
@@ -0,0 +1,390 @@
+import { useState, useCallback } from "react";
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface TaskTreeProps {
+ tasks: TaskSummary[];
+ supervisorTaskId: string | null;
+ onSelect: (taskId: string) => void;
+ onStartSupervisor?: () => void;
+ loading?: boolean;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+interface TreeNodeProps {
+ task: TaskSummary;
+ isSupervisorTask: boolean;
+ onSelect: (taskId: string) => void;
+ depth: number;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusIcon(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "○";
+ case "running":
+ return "◉";
+ case "paused":
+ return "◎";
+ case "blocked":
+ return "◈";
+ case "done":
+ return "●";
+ case "failed":
+ return "✕";
+ case "merged":
+ return "◆";
+ default:
+ return "○";
+ }
+}
+
+function TreeNode({ task, isSupervisorTask, onSelect, depth, fetchSubtasks }: TreeNodeProps) {
+ const [expanded, setExpanded] = useState(isSupervisorTask); // Supervisor expanded by default
+ const [children, setChildren] = useState<TaskSummary[] | null>(null);
+ const [loadingChildren, setLoadingChildren] = useState(false);
+
+ const hasSubtasks = task.subtaskCount > 0;
+
+ const handleToggle = useCallback(async () => {
+ if (!hasSubtasks) return;
+
+ if (expanded) {
+ setExpanded(false);
+ } else {
+ if (!children && fetchSubtasks) {
+ setLoadingChildren(true);
+ try {
+ const subtasks = await fetchSubtasks(task.id);
+ setChildren(subtasks);
+ } catch (err) {
+ console.error("Failed to fetch subtasks:", err);
+ } finally {
+ setLoadingChildren(false);
+ }
+ }
+ setExpanded(true);
+ }
+ }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]);
+
+ const indent = depth * 16;
+
+ return (
+ <div className="select-none">
+ <div
+ className={`flex items-center gap-2 py-2 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group ${
+ isSupervisorTask ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]" : ""
+ }`}
+ style={{ paddingLeft: `${indent + 8}px` }}
+ >
+ {/* Expand/Collapse button */}
+ <button
+ onClick={handleToggle}
+ className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${
+ hasSubtasks
+ ? "text-[#75aafc] hover:text-[#9bc3ff]"
+ : "text-transparent cursor-default"
+ }`}
+ disabled={!hasSubtasks}
+ >
+ {loadingChildren ? (
+ <span className="animate-spin">...</span>
+ ) : hasSubtasks ? (
+ expanded ? "▼" : "▶"
+ ) : (
+ ""
+ )}
+ </button>
+
+ {/* Supervisor badge or status icon */}
+ {isSupervisorTask ? (
+ <span className="px-1.5 py-0.5 font-mono text-[9px] bg-[#0f3c78] text-[#75aafc] border border-[#3f6fb3] uppercase">
+ Supervisor
+ </span>
+ ) : (
+ <span
+ className={`font-mono text-xs ${getStatusColor(task.status)}`}
+ title={task.status}
+ >
+ {getStatusIcon(task.status)}
+ </span>
+ )}
+
+ {/* Task name - clickable */}
+ <button
+ onClick={() => onSelect(task.id)}
+ className={`flex-1 text-left font-mono text-sm transition-colors truncate ${
+ isSupervisorTask
+ ? "text-[#9bc3ff] hover:text-white font-medium"
+ : "text-[#dbe7ff] hover:text-white"
+ }`}
+ >
+ {task.name}
+ </button>
+
+ {/* Status for supervisor */}
+ {isSupervisorTask && (
+ <span
+ className={`font-mono text-[10px] ${getStatusColor(task.status)}`}
+ >
+ {task.status}
+ </span>
+ )}
+
+ {/* Subtask count badge */}
+ {hasSubtasks && !isSupervisorTask && (
+ <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]">
+ {task.subtaskCount} sub
+ </span>
+ )}
+
+ {/* Priority indicator */}
+ {task.priority > 0 && (
+ <span className="font-mono text-[9px] text-orange-400">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+
+ {/* Children */}
+ {expanded && children && children.length > 0 && (
+ <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}>
+ {children.map((child) => (
+ <TreeNode
+ key={child.id}
+ task={child}
+ isSupervisorTask={false}
+ onSelect={onSelect}
+ depth={depth + 1}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+// Stats summary component
+export interface TaskTreeStats {
+ total: number;
+ pending: number;
+ running: number;
+ paused: number;
+ blocked: number;
+ done: number;
+ failed: number;
+ merged: number;
+}
+
+export function calculateTreeStats(tasks: TaskSummary[]): TaskTreeStats {
+ const stats: TaskTreeStats = {
+ total: tasks.length,
+ pending: 0,
+ running: 0,
+ paused: 0,
+ blocked: 0,
+ done: 0,
+ failed: 0,
+ merged: 0,
+ };
+
+ for (const task of tasks) {
+ // Skip supervisor task in stats
+ if (task.isSupervisor) continue;
+
+ switch (task.status) {
+ case "pending":
+ stats.pending++;
+ break;
+ case "running":
+ stats.running++;
+ break;
+ case "paused":
+ stats.paused++;
+ break;
+ case "blocked":
+ stats.blocked++;
+ break;
+ case "done":
+ stats.done++;
+ break;
+ case "failed":
+ stats.failed++;
+ break;
+ case "merged":
+ stats.merged++;
+ break;
+ }
+ }
+
+ // Adjust total to exclude supervisor
+ stats.total = tasks.filter(t => !t.isSupervisor).length;
+
+ return stats;
+}
+
+// Progress bar for task tree
+export function TaskTreeProgressBar({ stats }: { stats: TaskTreeStats }) {
+ if (stats.total === 0) return null;
+
+ const completedPercent = ((stats.done + stats.merged) / stats.total) * 100;
+ const runningPercent = (stats.running / stats.total) * 100;
+ const failedPercent = (stats.failed / stats.total) * 100;
+
+ return (
+ <div className="space-y-2">
+ {/* Progress bar */}
+ <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden flex">
+ <div
+ className="bg-emerald-400 transition-all"
+ style={{ width: `${completedPercent}%` }}
+ title={`Completed: ${stats.done + stats.merged}`}
+ />
+ <div
+ className="bg-green-400 transition-all"
+ style={{ width: `${runningPercent}%` }}
+ title={`Running: ${stats.running}`}
+ />
+ <div
+ className="bg-red-400 transition-all"
+ style={{ width: `${failedPercent}%` }}
+ title={`Failed: ${stats.failed}`}
+ />
+ </div>
+
+ {/* Summary */}
+ <div className="flex items-center justify-between font-mono text-[10px]">
+ <span className="text-[#555]">
+ {stats.done + stats.merged} / {stats.total} completed
+ </span>
+ {stats.running > 0 && (
+ <span className="text-green-400">{stats.running} running</span>
+ )}
+ {stats.failed > 0 && (
+ <span className="text-red-400">{stats.failed} failed</span>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function TaskTree({
+ tasks,
+ supervisorTaskId,
+ onSelect,
+ onStartSupervisor,
+ loading = false,
+ fetchSubtasks,
+}: TaskTreeProps) {
+ if (loading) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ Loading tasks...
+ </div>
+ );
+ }
+
+ // Separate supervisor from other tasks
+ const supervisorTask = tasks.find(t => t.id === supervisorTaskId || t.isSupervisor);
+ const workerTasks = tasks.filter(t => t.id !== supervisorTaskId && !t.isSupervisor && !t.parentTaskId);
+
+ // Calculate stats for worker tasks
+ const stats = calculateTreeStats(tasks);
+
+ return (
+ <div className="space-y-4">
+ {/* Supervisor Section */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Contract Supervisor
+ </h3>
+ {supervisorTask && supervisorTask.status === "pending" && onStartSupervisor && (
+ <button
+ onClick={onStartSupervisor}
+ className="px-2 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
+ >
+ Start Supervisor
+ </button>
+ )}
+ </div>
+
+ {supervisorTask ? (
+ <TreeNode
+ task={supervisorTask}
+ isSupervisorTask={true}
+ onSelect={onSelect}
+ depth={0}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ) : (
+ <div className="p-3 border border-dashed border-[rgba(117,170,252,0.2)] text-center">
+ <p className="font-mono text-xs text-[#555]">
+ No supervisor task found
+ </p>
+ </div>
+ )}
+ </div>
+
+ {/* Progress Section */}
+ {stats.total > 0 && (
+ <div className="space-y-2">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Task Progress
+ </h3>
+ <TaskTreeProgressBar stats={stats} />
+ </div>
+ )}
+
+ {/* Worker Tasks Section */}
+ {workerTasks.length > 0 && (
+ <div className="space-y-2">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Worker Tasks ({workerTasks.length})
+ </h3>
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {workerTasks.map((task) => (
+ <TreeNode
+ key={task.id}
+ task={task}
+ isSupervisorTask={false}
+ onSelect={onSelect}
+ depth={0}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Empty State */}
+ {workerTasks.length === 0 && !supervisorTask && (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ No tasks in this contract
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx
new file mode 100644
index 0000000..ddea85f
--- /dev/null
+++ b/makima/frontend/src/components/workflow/PhaseColumn.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import type { ContractSummary, ContractPhase } from "../../lib/api";
+import { WorkflowContractCard } from "./WorkflowContractCard";
+
+interface PhaseColumnProps {
+ phase: ContractPhase;
+ contracts: ContractSummary[];
+ onContractClick: (contractId: string) => void;
+ onDrop: (contractId: string, phase: ContractPhase) => void;
+}
+
+const phaseConfig: Record<
+ ContractPhase,
+ { label: string; color: string; bgColor: string; borderColor: string }
+> = {
+ research: {
+ label: "Research",
+ color: "text-purple-400",
+ bgColor: "bg-purple-400/10",
+ borderColor: "border-purple-400/30",
+ },
+ specify: {
+ label: "Specify",
+ color: "text-blue-400",
+ bgColor: "bg-blue-400/10",
+ borderColor: "border-blue-400/30",
+ },
+ plan: {
+ label: "Plan",
+ color: "text-cyan-400",
+ bgColor: "bg-cyan-400/10",
+ borderColor: "border-cyan-400/30",
+ },
+ execute: {
+ label: "Execute",
+ color: "text-yellow-400",
+ bgColor: "bg-yellow-400/10",
+ borderColor: "border-yellow-400/30",
+ },
+ review: {
+ label: "Review",
+ color: "text-green-400",
+ bgColor: "bg-green-400/10",
+ borderColor: "border-green-400/30",
+ },
+};
+
+export function PhaseColumn({
+ phase,
+ contracts,
+ onContractClick,
+ onDrop,
+}: PhaseColumnProps) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const config = phaseConfig[phase];
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(true);
+ };
+
+ const handleDragLeave = () => {
+ setIsDragOver(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ const contractId = e.dataTransfer.getData("contractId");
+ if (contractId) {
+ onDrop(contractId, phase);
+ }
+ };
+
+ return (
+ <div
+ className={`
+ flex flex-col min-w-[220px] flex-1 border border-[rgba(117,170,252,0.15)]
+ ${isDragOver ? "bg-[rgba(117,170,252,0.05)]" : "bg-transparent"}
+ transition-colors
+ `}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+ {/* Column header */}
+ <div
+ className={`
+ p-3 border-b ${config.borderColor} ${config.bgColor}
+ flex items-center justify-between
+ `}
+ >
+ <span className={`font-mono text-xs uppercase tracking-wider ${config.color}`}>
+ {config.label}
+ </span>
+ <span className="font-mono text-[10px] text-[#555]">
+ ({contracts.length})
+ </span>
+ </div>
+
+ {/* Cards container */}
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
+ {contracts.length === 0 ? (
+ <div className="p-4 text-center font-mono text-[10px] text-[#555]">
+ No contracts
+ </div>
+ ) : (
+ contracts.map((contract) => (
+ <WorkflowContractCard
+ key={contract.id}
+ contract={contract}
+ onClick={() => onContractClick(contract.id)}
+ onDragStart={(e) => {
+ e.dataTransfer.setData("contractId", contract.id);
+ e.dataTransfer.effectAllowed = "move";
+ }}
+ />
+ ))
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx
new file mode 100644
index 0000000..af4aec7
--- /dev/null
+++ b/makima/frontend/src/components/workflow/WorkflowBoard.tsx
@@ -0,0 +1,54 @@
+import { useMemo } from "react";
+import type { ContractSummary, ContractPhase } from "../../lib/api";
+import { PhaseColumn } from "./PhaseColumn";
+
+interface WorkflowBoardProps {
+ contracts: ContractSummary[];
+ onContractClick: (contractId: string) => void;
+ onPhaseChange: (contractId: string, newPhase: ContractPhase) => void;
+}
+
+const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
+
+export function WorkflowBoard({
+ contracts,
+ onContractClick,
+ onPhaseChange,
+}: WorkflowBoardProps) {
+ // Group contracts by phase
+ const contractsByPhase = useMemo(() => {
+ const grouped: Record<ContractPhase, ContractSummary[]> = {
+ research: [],
+ specify: [],
+ plan: [],
+ execute: [],
+ review: [],
+ };
+
+ for (const contract of contracts) {
+ const phase = contract.phase as ContractPhase;
+ if (grouped[phase]) {
+ grouped[phase].push(contract);
+ } else {
+ // Default to research if unknown phase
+ grouped.research.push(contract);
+ }
+ }
+
+ return grouped;
+ }, [contracts]);
+
+ return (
+ <div className="flex gap-2 h-full overflow-x-auto">
+ {phases.map((phase) => (
+ <PhaseColumn
+ key={phase}
+ phase={phase}
+ contracts={contractsByPhase[phase]}
+ onContractClick={onContractClick}
+ onDrop={onPhaseChange}
+ />
+ ))}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx
new file mode 100644
index 0000000..e6c8a1c
--- /dev/null
+++ b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx
@@ -0,0 +1,53 @@
+import type { ContractSummary, ContractStatus } from "../../lib/api";
+
+interface WorkflowContractCardProps {
+ contract: ContractSummary;
+ onClick: () => void;
+ onDragStart: (e: React.DragEvent) => void;
+}
+
+const statusConfig: Record<ContractStatus, { label: string; color: string }> = {
+ active: { label: "Active", color: "text-green-400" },
+ completed: { label: "Done", color: "text-blue-400" },
+ archived: { label: "Archived", color: "text-[#555]" },
+};
+
+export function WorkflowContractCard({
+ contract,
+ onClick,
+ onDragStart,
+}: WorkflowContractCardProps) {
+ const status = statusConfig[contract.status] || statusConfig.active;
+
+ return (
+ <div
+ draggable
+ onDragStart={onDragStart}
+ onClick={onClick}
+ className="p-3 bg-[rgba(9,13,20,0.8)] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] cursor-pointer transition-colors select-none"
+ >
+ {/* Name */}
+ <div className="font-mono text-sm text-[#dbe7ff] truncate mb-1">
+ {contract.name}
+ </div>
+
+ {/* Status and counts row */}
+ <div className="flex items-center justify-between">
+ <span className={`font-mono text-[10px] uppercase ${status.color}`}>
+ {status.label}
+ </span>
+ <div className="flex items-center gap-2 font-mono text-[10px] text-[#555]">
+ <span title="Files">{contract.fileCount} files</span>
+ <span title="Tasks">{contract.taskCount} tasks</span>
+ </div>
+ </div>
+
+ {/* Description preview if exists */}
+ {contract.description && (
+ <div className="mt-1 font-mono text-[10px] text-[#555] truncate">
+ {contract.description}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useContracts.ts b/makima/frontend/src/hooks/useContracts.ts
new file mode 100644
index 0000000..f803527
--- /dev/null
+++ b/makima/frontend/src/hooks/useContracts.ts
@@ -0,0 +1,308 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listContracts,
+ getContract,
+ createContract,
+ updateContract,
+ deleteContract,
+ changeContractPhase,
+ getContractEvents,
+ addRemoteRepository,
+ addLocalRepository,
+ createManagedRepository,
+ deleteContractRepository,
+ setRepositoryPrimary,
+ addTaskToContract,
+ removeTaskFromContract,
+ VersionConflictError,
+ type ContractSummary,
+ type ContractWithRelations,
+ type ContractEvent,
+ type ContractRepository,
+ type ContractPhase,
+ type CreateContractRequest,
+ type UpdateContractRequest,
+ type AddRemoteRepositoryRequest,
+ type AddLocalRepositoryRequest,
+ type CreateManagedRepositoryRequest,
+} from "../lib/api";
+
+export interface ConflictState {
+ hasConflict: boolean;
+ expectedVersion: number;
+ actualVersion: number;
+}
+
+export function useContracts() {
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
+
+ const fetchContracts = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch contracts");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchContract = useCallback(
+ async (id: string): Promise<ContractWithRelations | null> => {
+ setError(null);
+ try {
+ return await getContract(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch contract");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveContract = useCallback(
+ async (data: CreateContractRequest): Promise<ContractSummary | null> => {
+ setError(null);
+ try {
+ const contract = await createContract(data);
+ await fetchContracts(); // Refresh list
+ return contract;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save contract");
+ return null;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const editContract = useCallback(
+ async (
+ id: string,
+ data: UpdateContractRequest
+ ): Promise<ContractSummary | null> => {
+ setError(null);
+ setConflict(null);
+ try {
+ const contract = await updateContract(id, data);
+ await fetchContracts(); // Refresh list
+ return contract;
+ } catch (e) {
+ if (e instanceof VersionConflictError) {
+ setConflict({
+ hasConflict: true,
+ expectedVersion: e.expectedVersion,
+ actualVersion: e.actualVersion,
+ });
+ return null;
+ }
+ setError(e instanceof Error ? e.message : "Failed to update contract");
+ return null;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const clearConflict = useCallback(() => {
+ setConflict(null);
+ }, []);
+
+ const removeContract = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteContract(id);
+ await fetchContracts(); // Refresh list
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete contract");
+ return false;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const changePhase = useCallback(
+ async (
+ id: string,
+ phase: ContractPhase
+ ): Promise<ContractSummary | null> => {
+ setError(null);
+ try {
+ const contract = await changeContractPhase(id, phase);
+ await fetchContracts(); // Refresh list
+ return contract;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to change phase");
+ return null;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const fetchEvents = useCallback(
+ async (id: string): Promise<ContractEvent[]> => {
+ setError(null);
+ try {
+ return await getContractEvents(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch events");
+ return [];
+ }
+ },
+ []
+ );
+
+ // Repository management
+ const addRemoteRepo = useCallback(
+ async (
+ contractId: string,
+ data: AddRemoteRepositoryRequest
+ ): Promise<ContractRepository | null> => {
+ setError(null);
+ try {
+ return await addRemoteRepository(contractId, data);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to add remote repository"
+ );
+ return null;
+ }
+ },
+ []
+ );
+
+ const addLocalRepo = useCallback(
+ async (
+ contractId: string,
+ data: AddLocalRepositoryRequest
+ ): Promise<ContractRepository | null> => {
+ setError(null);
+ try {
+ return await addLocalRepository(contractId, data);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to add local repository"
+ );
+ return null;
+ }
+ },
+ []
+ );
+
+ const createManagedRepo = useCallback(
+ async (
+ contractId: string,
+ data: CreateManagedRepositoryRequest
+ ): Promise<ContractRepository | null> => {
+ setError(null);
+ try {
+ return await createManagedRepository(contractId, data);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to create managed repository"
+ );
+ return null;
+ }
+ },
+ []
+ );
+
+ const removeRepo = useCallback(
+ async (contractId: string, repoId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteContractRepository(contractId, repoId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to delete repository"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ const setRepoPrimary = useCallback(
+ async (contractId: string, repoId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await setRepositoryPrimary(contractId, repoId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to set repository as primary"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ // Task association
+ const addTask = useCallback(
+ async (contractId: string, taskId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await addTaskToContract(contractId, taskId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to add task to contract"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ const removeTask = useCallback(
+ async (contractId: string, taskId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await removeTaskFromContract(contractId, taskId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to remove task from contract"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchContracts();
+ }, [fetchContracts]);
+
+ return {
+ contracts,
+ loading,
+ error,
+ conflict,
+ clearConflict,
+ fetchContracts,
+ fetchContract,
+ saveContract,
+ editContract,
+ removeContract,
+ changePhase,
+ fetchEvents,
+ // Repository management
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ removeRepo,
+ setRepoPrimary,
+ // Task association
+ addTask,
+ removeTask,
+ };
+}
diff --git a/makima/frontend/src/hooks/useWebSocket.ts b/makima/frontend/src/hooks/useWebSocket.ts
index 961951f..c593621 100644
--- a/makima/frontend/src/hooks/useWebSocket.ts
+++ b/makima/frontend/src/hooks/useWebSocket.ts
@@ -214,12 +214,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
}, []);
const startSession = useCallback(
- (sampleRate: number, channels: number = 1) => {
+ (sampleRate: number, channels: number = 1, contractId?: string | null, authToken?: string | null) => {
sendMessage({
type: "start",
sampleRate,
channels,
encoding: "pcm32f",
+ ...(contractId && authToken ? { contractId, authToken } : {}),
});
},
[sendMessage]
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index a11f15e..d77c85c 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -132,7 +132,8 @@ export type BodyElement =
data: Record<string, unknown>[];
config?: Record<string, unknown>;
}
- | { type: "image"; src: string; alt?: string; caption?: string };
+ | { type: "image"; src: string; alt?: string; caption?: string }
+ | { type: "markdown"; content: string };
export interface FileSummary {
id: string;
@@ -141,8 +142,16 @@ export interface FileSummary {
transcriptCount: number;
duration: number | null;
version: number;
+ /** Path to linked repository file (e.g., "README.md") */
+ repoFilePath: string | null;
+ /** Sync status: 'none', 'synced', 'modified', 'conflict' */
+ repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null;
createdAt: string;
updatedAt: string;
+ // Contract info (joined from contracts table)
+ contractId: string | null;
+ contractName: string | null;
+ contractPhase: ContractPhase | null;
}
export interface FileDetail {
@@ -155,6 +164,12 @@ export interface FileDetail {
summary: string | null;
body: BodyElement[];
version: number;
+ /** Path to linked repository file (e.g., "README.md") */
+ repoFilePath: string | null;
+ /** When file was last synced from repository */
+ repoSyncedAt: string | null;
+ /** Sync status: 'none', 'synced', 'modified', 'conflict' */
+ repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null;
createdAt: string;
updatedAt: string;
}
@@ -165,10 +180,14 @@ export interface FileListResponse {
}
export interface CreateFileRequest {
+ /** Contract this file belongs to (required - files must belong to a contract) */
+ contractId: string;
name?: string;
description?: string;
- transcript: TranscriptEntry[];
+ transcript?: TranscriptEntry[];
location?: string;
+ /** Initial body elements (e.g., from a template) */
+ body?: BodyElement[];
}
export interface UpdateFileRequest {
@@ -400,6 +419,23 @@ export async function restoreFileVersion(
return res.json();
}
+/**
+ * Sync a file from its linked repository file.
+ * Triggers an async operation - the file will be updated when the daemon responds.
+ * Returns 202 Accepted if the sync started successfully.
+ */
+export async function syncFileFromRepo(fileId: string): Promise<{ message: string; fileId: string }> {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/sync-from-repo`, {
+ method: "POST",
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.message || `Failed to sync file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
// =============================================================================
// LLM Tool Definitions for Version History
// =============================================================================
@@ -490,6 +526,12 @@ export type DaemonStatus = "connected" | "disconnected" | "unhealthy";
export interface TaskSummary {
id: string;
+ /** Contract this task belongs to */
+ contractId: string | null;
+ /** Contract name (joined from contracts table) */
+ contractName: string | null;
+ /** Contract phase (joined from contracts table) */
+ contractPhase: ContractPhase | null;
parentTaskId: string | null;
depth: number;
name: string;
@@ -497,6 +539,8 @@ export interface TaskSummary {
priority: number;
progressSummary: string | null;
subtaskCount: number;
+ /** Whether this is a supervisor task (contract orchestrator) */
+ isSupervisor: boolean;
version: number;
createdAt: string;
updatedAt: string;
@@ -505,6 +549,8 @@ export interface TaskSummary {
export interface Task {
id: string;
ownerId: string;
+ /** Contract this task belongs to */
+ contractId: string | null;
parentTaskId: string | null;
depth: number;
name: string;
@@ -556,6 +602,8 @@ export interface TaskListResponse {
}
export interface CreateTaskRequest {
+ /** Contract this task belongs to (required) */
+ contractId: string;
name: string;
description?: string;
plan: string;
@@ -1289,3 +1337,493 @@ export async function deleteAccount(
}
return res.json();
}
+
+// =============================================================================
+// Contract Types for Workflow Management
+// =============================================================================
+
+export type ContractPhase = "research" | "specify" | "plan" | "execute" | "review";
+export type ContractStatus = "active" | "completed" | "archived";
+export type RepositorySourceType = "remote" | "local" | "managed";
+export type RepositoryStatus = "ready" | "pending" | "creating" | "failed";
+
+export interface ContractRepository {
+ id: string;
+ contractId: string;
+ name: string;
+ repositoryUrl: string | null;
+ localPath: string | null;
+ sourceType: RepositorySourceType;
+ status: RepositoryStatus;
+ isPrimary: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ContractSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ phase: ContractPhase;
+ status: ContractStatus;
+ fileCount: number;
+ taskCount: number;
+ repositoryCount: number;
+ version: number;
+ createdAt: string;
+}
+
+export interface Contract {
+ id: string;
+ ownerId: string;
+ name: string;
+ description: string | null;
+ phase: ContractPhase;
+ status: ContractStatus;
+ /** Supervisor task ID for contract orchestration */
+ supervisorTaskId: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ContractWithRelations extends Contract {
+ repositories: ContractRepository[];
+ files: FileSummary[];
+ tasks: TaskSummary[];
+}
+
+export interface ContractEvent {
+ id: string;
+ contractId: string;
+ eventType: string;
+ previousPhase: string | null;
+ newPhase: string | null;
+ eventData: Record<string, unknown> | null;
+ createdAt: string;
+}
+
+export interface ContractListResponse {
+ contracts: ContractSummary[];
+ total: number;
+}
+
+export interface CreateContractRequest {
+ name: string;
+ description?: string;
+ /** Initial phase to start in (defaults to "research") */
+ initialPhase?: ContractPhase;
+}
+
+export interface UpdateContractRequest {
+ name?: string;
+ description?: string;
+ phase?: ContractPhase;
+ status?: ContractStatus;
+ version?: number;
+}
+
+export interface AddRemoteRepositoryRequest {
+ name: string;
+ repositoryUrl: string;
+ isPrimary?: boolean;
+}
+
+export interface AddLocalRepositoryRequest {
+ name: string;
+ localPath: string;
+ isPrimary?: boolean;
+}
+
+export interface CreateManagedRepositoryRequest {
+ name: string;
+ isPrimary?: boolean;
+}
+
+export interface ChangePhaseRequest {
+ phase: ContractPhase;
+}
+
+// =============================================================================
+// Contract API Functions
+// =============================================================================
+
+/**
+ * List all contracts.
+ */
+export async function listContracts(): Promise<ContractListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts`);
+ if (!res.ok) {
+ throw new Error(`Failed to list contracts: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get a contract with all its relations.
+ */
+export async function getContract(id: string): Promise<ContractWithRelations> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a new contract.
+ */
+export async function createContract(
+ data: CreateContractRequest
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Update a contract.
+ */
+export async function updateContract(
+ id: string,
+ data: UpdateContractRequest
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+
+ if (res.status === 409) {
+ const conflict = (await res.json()) as ConflictErrorResponse;
+ throw new VersionConflictError(conflict);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Failed to update contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Delete a contract.
+ */
+export async function deleteContract(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete contract: ${res.statusText}`);
+ }
+}
+
+/**
+ * Change contract phase.
+ */
+export async function changeContractPhase(
+ id: string,
+ phase: ContractPhase
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/phase`, {
+ method: "POST",
+ body: JSON.stringify({ phase }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to change phase: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get contract event history.
+ */
+export async function getContractEvents(
+ id: string
+): Promise<ContractEvent[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/events`);
+ if (!res.ok) {
+ throw new Error(`Failed to get events: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Contract Repository Management
+// =============================================================================
+
+/**
+ * Add a remote repository to a contract.
+ */
+export async function addRemoteRepository(
+ contractId: string,
+ data: AddRemoteRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/remote`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add remote repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Add a local repository to a contract.
+ */
+export async function addLocalRepository(
+ contractId: string,
+ data: AddLocalRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/local`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add local repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a managed repository (daemon will create it).
+ */
+export async function createManagedRepository(
+ contractId: string,
+ data: CreateManagedRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/managed`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to create managed repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Delete a repository from a contract.
+ */
+export async function deleteContractRepository(
+ contractId: string,
+ repoId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to delete repository: ${res.statusText}`);
+ }
+}
+
+/**
+ * Set a repository as primary.
+ */
+export async function setRepositoryPrimary(
+ contractId: string,
+ repoId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}/primary`,
+ {
+ method: "PUT",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to set repository as primary: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Contract Task Association
+// =============================================================================
+
+/**
+ * Add a task to a contract.
+ */
+export async function addTaskToContract(
+ contractId: string,
+ taskId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`,
+ {
+ method: "POST",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add task to contract: ${res.statusText}`);
+ }
+}
+
+/**
+ * Remove a task from a contract.
+ */
+export async function removeTaskFromContract(
+ contractId: string,
+ taskId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to remove task from contract: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Contract Chat Types and API
+// =============================================================================
+
+export interface ContractChatRequest {
+ message: string;
+ model?: LlmModel;
+ history?: ChatMessage[];
+}
+
+export interface ContractToolCallInfo {
+ name: string;
+ result: {
+ success: boolean;
+ message: string;
+ };
+}
+
+export interface ContractChatResponse {
+ response: string;
+ toolCalls: ContractToolCallInfo[];
+ pendingQuestions?: UserQuestion[];
+}
+
+/**
+ * Chat with a contract using LLM-powered management tools.
+ */
+export async function chatWithContract(
+ contractId: string,
+ message: string,
+ model?: LlmModel,
+ history?: ChatMessage[]
+): Promise<ContractChatResponse> {
+ const body: ContractChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Contract chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// Contract chat history types
+export interface ContractChatMessage {
+ id: string;
+ conversationId: string;
+ role: "user" | "assistant" | "error";
+ content: string;
+ toolCalls?: unknown;
+ pendingQuestions?: unknown;
+ createdAt: string;
+}
+
+export interface ContractChatHistoryResponse {
+ contractId: string;
+ conversationId: string;
+ messages: ContractChatMessage[];
+}
+
+/** Get contract chat history */
+export async function getContractChatHistory(
+ contractId: string
+): Promise<ContractChatHistoryResponse> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/chat/history`
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to fetch contract chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Clear contract chat history (starts a new conversation) */
+export async function clearContractChatHistory(
+ contractId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/chat/history`,
+ { method: "DELETE" }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to clear contract chat history: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Template Types and API
+// =============================================================================
+
+export interface TemplateSummary {
+ id: string;
+ name: string;
+ phase: ContractPhase;
+ description: string;
+ elementCount: number;
+}
+
+export interface FileTemplate {
+ id: string;
+ name: string;
+ phase: ContractPhase;
+ description: string;
+ suggestedBody: BodyElement[];
+}
+
+export interface ListTemplatesResponse {
+ templates: TemplateSummary[];
+}
+
+export async function listTemplates(
+ phase?: ContractPhase
+): Promise<ListTemplatesResponse> {
+ const params = phase ? `?phase=${phase}` : "";
+ const res = await authFetch(`${API_BASE}/api/v1/templates${params}`);
+ if (!res.ok) {
+ throw new Error(`Failed to list templates: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getTemplate(id: string): Promise<FileTemplate> {
+ const res = await authFetch(`${API_BASE}/api/v1/templates/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get template: ${res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/lib/markdown.ts b/makima/frontend/src/lib/markdown.ts
new file mode 100644
index 0000000..b6e860a
--- /dev/null
+++ b/makima/frontend/src/lib/markdown.ts
@@ -0,0 +1,228 @@
+/**
+ * Markdown conversion utilities for BodyElement arrays.
+ *
+ * Provides bidirectional conversion between structured BodyElement[] and markdown strings.
+ */
+
+import { BodyElement } from "./api";
+
+/**
+ * Convert an array of BodyElements to a markdown string.
+ *
+ * Handles:
+ * - Headings: # through ###### based on level
+ * - Paragraphs: plain text with blank lines between
+ * - Code blocks: ```language\ncontent\n```
+ * - Lists: ordered (1. 2. 3.) and unordered (- - -)
+ * - Charts: rendered as fenced JSON
+ * - Images: rendered as markdown image syntax
+ */
+export function bodyToMarkdown(elements: BodyElement[]): string {
+ return elements
+ .map((elem) => {
+ switch (elem.type) {
+ case "heading": {
+ const hashes = "#".repeat(Math.min(elem.level, 6));
+ return `${hashes} ${elem.text}`;
+ }
+ case "paragraph":
+ return elem.text;
+ case "code": {
+ const lang = elem.language || "";
+ return `\`\`\`${lang}\n${elem.content}\n\`\`\``;
+ }
+ case "list": {
+ return elem.items
+ .map((item, i) => (elem.ordered ? `${i + 1}. ${item}` : `- ${item}`))
+ .join("\n");
+ }
+ case "chart": {
+ const titleStr = elem.title ? ` - ${elem.title}` : "";
+ const dataStr = JSON.stringify(elem.data, null, 2);
+ return `\`\`\`chart:${elem.chartType}${titleStr}\n${dataStr}\n\`\`\``;
+ }
+ case "image": {
+ const alt = elem.alt || "image";
+ const caption = elem.caption ? `\n*${elem.caption}*` : "";
+ return `![${alt}](${elem.src})${caption}`;
+ }
+ case "markdown":
+ // Markdown elements output their content directly
+ return elem.content;
+ default:
+ return "";
+ }
+ })
+ .filter((s) => s !== "")
+ .join("\n\n");
+}
+
+/**
+ * Parse a markdown string into an array of BodyElements.
+ *
+ * Handles:
+ * - Headings: lines starting with # through ######
+ * - Code blocks: ```language ... ```
+ * - Ordered lists: lines starting with 1. 2. etc.
+ * - Unordered lists: lines starting with - or * or +
+ * - Paragraphs: all other non-empty lines
+ */
+export function markdownToBody(markdown: string): BodyElement[] {
+ const elements: BodyElement[] = [];
+ const lines = markdown.split("\n");
+ let currentParagraph: string[] = [];
+ let inCodeBlock = false;
+ let codeBlockLanguage: string | undefined;
+ let codeBlockContent: string[] = [];
+ let currentList: { ordered: boolean; items: string[] } | null = null;
+
+ const flushParagraph = () => {
+ if (currentParagraph.length > 0) {
+ const text = currentParagraph.join("\n").trim();
+ if (text) {
+ elements.push({ type: "paragraph", text });
+ }
+ currentParagraph = [];
+ }
+ };
+
+ const flushCodeBlock = () => {
+ if (codeBlockContent.length > 0 || inCodeBlock) {
+ elements.push({
+ type: "code",
+ language: codeBlockLanguage || undefined,
+ content: codeBlockContent.join("\n"),
+ });
+ codeBlockContent = [];
+ codeBlockLanguage = undefined;
+ }
+ };
+
+ const flushList = () => {
+ if (currentList && currentList.items.length > 0) {
+ elements.push({
+ type: "list",
+ ordered: currentList.ordered,
+ items: currentList.items,
+ });
+ currentList = null;
+ }
+ };
+
+ // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
+ const convertImagesToLinks = (text: string): string => {
+ return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
+ const linkText = alt || "image";
+ return `[${linkText}](${url})`;
+ });
+ };
+
+ for (const rawLine of lines) {
+ // Check for code block fence (``` or ~~~)
+ const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
+ if (codeFenceMatch) {
+ if (!inCodeBlock) {
+ // Starting a code block
+ flushParagraph();
+ flushList();
+ inCodeBlock = true;
+ codeBlockLanguage = codeFenceMatch[2] || undefined;
+ codeBlockContent = [];
+ } else {
+ // Ending a code block
+ flushCodeBlock();
+ inCodeBlock = false;
+ }
+ continue;
+ }
+
+ // If inside a code block, add line as-is
+ if (inCodeBlock) {
+ codeBlockContent.push(rawLine);
+ continue;
+ }
+
+ // Convert images to links in all lines
+ const line = convertImagesToLinks(rawLine);
+
+ // Check for headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ flushParagraph();
+ flushList();
+ const level = headingMatch[1].length;
+ const text = headingMatch[2].trim();
+ elements.push({ type: "heading", level, text });
+ continue;
+ }
+
+ // Check for unordered list items (-, *, +)
+ const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
+ if (unorderedMatch) {
+ flushParagraph();
+ const itemText = unorderedMatch[1].trim();
+ if (currentList && currentList.ordered) {
+ // Switch from ordered to unordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: false, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Check for ordered list items (1. 2. etc)
+ const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
+ if (orderedMatch) {
+ flushParagraph();
+ const itemText = orderedMatch[1].trim();
+ if (currentList && !currentList.ordered) {
+ // Switch from unordered to ordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: true, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Empty line - flush everything
+ if (line.trim() === "") {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+
+ // Regular text - flush list first, then add to paragraph
+ flushList();
+ currentParagraph.push(line);
+ }
+
+ // Flush any remaining content
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+ flushParagraph();
+ flushList();
+
+ return elements;
+}
+
+/**
+ * Copy markdown to clipboard.
+ * Returns true if successful, false otherwise.
+ */
+export async function copyMarkdownToClipboard(
+ elements: BodyElement[]
+): Promise<boolean> {
+ try {
+ const markdown = bodyToMarkdown(elements);
+ await navigator.clipboard.writeText(markdown);
+ return true;
+ } catch (error) {
+ console.error("Failed to copy markdown to clipboard:", error);
+ return false;
+ }
+}
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index d4ca13a..496a569 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -8,6 +8,8 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
+import ContractsPage from "./routes/contracts";
+import WorkflowPage from "./routes/workflow";
import MeshPage from "./routes/mesh";
import LoginPage from "./routes/login";
import SettingsPage from "./routes/settings";
@@ -45,6 +47,30 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
+ path="/contracts"
+ element={
+ <ProtectedRoute>
+ <ContractsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/contracts/:id"
+ element={
+ <ProtectedRoute>
+ <ContractsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/workflow"
+ element={
+ <ProtectedRoute>
+ <WorkflowPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
path="/mesh"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx
index 7084c2e..ecdd7f2 100644
--- a/makima/frontend/src/routes/_index.tsx
+++ b/makima/frontend/src/routes/_index.tsx
@@ -1,5 +1,6 @@
import { Masthead } from "../components/Masthead";
import { Logo } from "../components/Logo";
+import { JapaneseHoverText } from "../components/JapaneseHoverText";
export default function HomePage() {
return (
@@ -13,7 +14,10 @@ export default function HomePage() {
</div>
<span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3">
- Control System
+ <JapaneseHoverText
+ japanese="支配する"
+ english="Control System"
+ />
</span>
<h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide">
Mesh Orchestration Platform
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
new file mode 100644
index 0000000..8c90804
--- /dev/null
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -0,0 +1,614 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { ContractList } from "../components/contracts/ContractList";
+import { ContractDetail } from "../components/contracts/ContractDetail";
+import { DirectoryInput } from "../components/mesh/DirectoryInput";
+import { useContracts } from "../hooks/useContracts";
+import { useAuth } from "../contexts/AuthContext";
+import { createTask, getDaemonDirectories } from "../lib/api";
+import type {
+ ContractWithRelations,
+ ContractPhase,
+ ContractStatus,
+ CreateContractRequest,
+ RepositorySourceType,
+ DaemonDirectory,
+} from "../lib/api";
+
+export default function ContractsPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <ContractsPageContent />;
+}
+
+function ContractsPageContent() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const {
+ contracts,
+ loading,
+ error,
+ fetchContract,
+ saveContract,
+ editContract,
+ removeContract,
+ changePhase,
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ removeRepo,
+ setRepoPrimary,
+ } = useContracts();
+
+ const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+ const [newContractDescription, setNewContractDescription] = useState("");
+ const [initialPhase, setInitialPhase] = useState<ContractPhase>("research");
+ const [repoType, setRepoType] = useState<RepositorySourceType>("remote");
+ const [repoName, setRepoName] = useState("");
+ const [repoUrl, setRepoUrl] = useState("");
+ const [repoPath, setRepoPath] = useState("");
+ const [createError, setCreateError] = useState<string | null>(null);
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+
+ // Fetch daemon directories when "local" repo type is selected
+ useEffect(() => {
+ if (repoType === "local" && isCreating) {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [repoType, isCreating]);
+
+ // Load contract detail when ID changes
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchContract(id).then((contract) => {
+ setContractDetail(contract);
+ setDetailLoading(false);
+ });
+ } else {
+ setContractDetail(null);
+ }
+ }, [id, fetchContract]);
+
+ const handleSelect = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/contracts");
+ }, [navigate]);
+
+ const handleCreate = useCallback(() => {
+ setIsCreating(true);
+ }, []);
+
+ // Validate repository configuration
+ const isRepoValid = useCallback(() => {
+ if (!repoName.trim()) return false;
+ if (repoType === "remote" && !repoUrl.trim()) return false;
+ if (repoType === "local" && !repoPath.trim()) return false;
+ return true;
+ }, [repoType, repoName, repoUrl, repoPath]);
+
+ const handleCreateSubmit = useCallback(async () => {
+ if (!newContractName.trim()) return;
+ if (!isRepoValid()) {
+ setCreateError("Repository configuration is required");
+ return;
+ }
+
+ setCreateError(null);
+
+ const data: CreateContractRequest = {
+ name: newContractName.trim(),
+ description: newContractDescription.trim() || undefined,
+ initialPhase: initialPhase !== "research" ? initialPhase : undefined,
+ };
+
+ try {
+ const contract = await saveContract(data);
+ if (contract) {
+ // Add the repository after contract creation
+ try {
+ if (repoType === "remote") {
+ await addRemoteRepo(contract.id, {
+ name: repoName.trim(),
+ repositoryUrl: repoUrl.trim(),
+ isPrimary: true,
+ });
+ } else if (repoType === "local") {
+ await addLocalRepo(contract.id, {
+ name: repoName.trim(),
+ localPath: repoPath.trim(),
+ isPrimary: true,
+ });
+ } else if (repoType === "managed") {
+ await createManagedRepo(contract.id, {
+ name: repoName.trim(),
+ isPrimary: true,
+ });
+ }
+ } catch (repoError) {
+ console.error("Failed to add repository:", repoError);
+ // Still navigate to the contract - repo can be added later
+ }
+
+ // Clear form state
+ setIsCreating(false);
+ setNewContractName("");
+ setNewContractDescription("");
+ setInitialPhase("research");
+ setRepoType("remote");
+ setRepoName("");
+ setRepoUrl("");
+ setRepoPath("");
+ navigate(`/contracts/${contract.id}`);
+ }
+ } catch (err) {
+ setCreateError(err instanceof Error ? err.message : "Failed to create contract");
+ }
+ }, [
+ newContractName,
+ newContractDescription,
+ repoType,
+ repoName,
+ repoUrl,
+ repoPath,
+ isRepoValid,
+ saveContract,
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ navigate,
+ ]);
+
+ const handleCreateCancel = useCallback(() => {
+ setIsCreating(false);
+ setNewContractName("");
+ setNewContractDescription("");
+ setInitialPhase("research");
+ setRepoType("remote");
+ setRepoName("");
+ setRepoUrl("");
+ setRepoPath("");
+ setCreateError(null);
+ }, []);
+
+ const handleUpdate = useCallback(
+ async (name: string, description: string) => {
+ if (contractDetail) {
+ const updated = await editContract(contractDetail.id, {
+ name,
+ description: description || undefined,
+ version: contractDetail.version,
+ });
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, editContract, fetchContract]
+ );
+
+ const handleDelete = useCallback(async () => {
+ if (contractDetail && confirm("Are you sure you want to delete this contract?")) {
+ const success = await removeContract(contractDetail.id);
+ if (success) {
+ navigate("/contracts");
+ }
+ }
+ }, [contractDetail, removeContract, navigate]);
+
+ const handlePhaseChange = useCallback(
+ async (phase: ContractPhase) => {
+ if (contractDetail) {
+ const updated = await changePhase(contractDetail.id, phase);
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, changePhase, fetchContract]
+ );
+
+ const handleStatusChange = useCallback(
+ async (status: ContractStatus) => {
+ if (contractDetail) {
+ const updated = await editContract(contractDetail.id, {
+ status,
+ version: contractDetail.version,
+ });
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, editContract, fetchContract]
+ );
+
+ // Repository handlers
+ const handleAddRemoteRepo = useCallback(
+ async (name: string, url: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, addRemoteRepo, fetchContract]
+ );
+
+ const handleAddLocalRepo = useCallback(
+ async (name: string, path: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, addLocalRepo, fetchContract]
+ );
+
+ const handleCreateManagedRepo = useCallback(
+ async (name: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await createManagedRepo(contractDetail.id, { name, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, createManagedRepo, fetchContract]
+ );
+
+ const handleDeleteRepo = useCallback(
+ async (repoId: string) => {
+ if (contractDetail) {
+ await removeRepo(contractDetail.id, repoId);
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, removeRepo, fetchContract]
+ );
+
+ const handleSetRepoPrimary = useCallback(
+ async (repoId: string) => {
+ if (contractDetail) {
+ await setRepoPrimary(contractDetail.id, repoId);
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, setRepoPrimary, fetchContract]
+ );
+
+ // Refresh contract detail (used after file/task operations)
+ const handleRefresh = useCallback(async () => {
+ if (contractDetail) {
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }, [contractDetail, fetchContract]);
+
+ // File/task navigation handlers
+ const handleFileSelect = useCallback(
+ (fileId: string) => {
+ navigate(`/files/${fileId}`);
+ },
+ [navigate]
+ );
+
+ const handleTaskSelect = useCallback(
+ (taskId: string) => {
+ navigate(`/mesh/${taskId}`);
+ },
+ [navigate]
+ );
+
+ // Create task within contract context
+ const handleTaskCreate = useCallback(
+ async (name: string, plan: string, repositoryUrl?: string) => {
+ if (!contractDetail) return;
+ try {
+ // Create the task with contract_id (task is automatically associated)
+ const task = await createTask({
+ contractId: contractDetail.id,
+ name,
+ plan,
+ repositoryUrl,
+ });
+ // Refresh contract detail to show new task
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ // Navigate to the new task
+ navigate(`/mesh/${task.id}`);
+ } catch (e) {
+ console.error("Failed to create task:", e);
+ alert(e instanceof Error ? e.message : "Failed to create task");
+ }
+ },
+ [contractDetail, fetchContract, navigate]
+ );
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm">
+ {error}
+ </div>
+ )}
+
+ {/* Create contract 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 Contract
+ </h3>
+
+ {createError && (
+ <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs">
+ {createError}
+ </div>
+ )}
+
+ <div className="space-y-4">
+ {/* Contract name */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Contract Name
+ </label>
+ <input
+ type="text"
+ value={newContractName}
+ onChange={(e) => setNewContractName(e.target.value)}
+ placeholder="Contract 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>
+
+ {/* Description */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Description (optional)
+ </label>
+ <textarea
+ value={newContractDescription}
+ onChange={(e) => setNewContractDescription(e.target.value)}
+ placeholder="Describe what this contract is for..."
+ rows={2}
+ 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>
+
+ {/* Starting Phase */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Starting Phase
+ </label>
+ <select
+ value={initialPhase}
+ onChange={(e) => setInitialPhase(e.target.value as ContractPhase)}
+ 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="research">Research</option>
+ <option value="specify">Specify</option>
+ <option value="plan">Plan</option>
+ <option value="execute">Execute</option>
+ <option value="review">Review</option>
+ </select>
+ <p className="mt-1 font-mono text-xs text-[#8b949e]">
+ Skip earlier phases if you already have requirements defined
+ </p>
+ </div>
+
+ {/* Repository Configuration */}
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3">
+ Repository Configuration (Required)
+ </label>
+
+ {/* Repository type selector */}
+ <div className="flex gap-2 mb-3">
+ <button
+ type="button"
+ onClick={() => setRepoType("remote")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "remote"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Remote
+ </button>
+ <button
+ type="button"
+ onClick={() => setRepoType("local")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "local"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Local
+ </button>
+ <button
+ type="button"
+ onClick={() => setRepoType("managed")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "managed"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Managed
+ </button>
+ </div>
+
+ {/* Repository name */}
+ <div className="mb-3">
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Repository Name
+ </label>
+ <input
+ type="text"
+ value={repoName}
+ onChange={(e) => setRepoName(e.target.value)}
+ placeholder="e.g., my-project"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ />
+ </div>
+
+ {/* Repository URL (for remote) */}
+ {repoType === "remote" && (
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Repository URL
+ </label>
+ <input
+ type="text"
+ value={repoUrl}
+ onChange={(e) => setRepoUrl(e.target.value)}
+ placeholder="https://github.com/user/repo.git"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ />
+ </div>
+ )}
+
+ {/* Repository path (for local) */}
+ {repoType === "local" && (
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Local Path
+ </label>
+ <DirectoryInput
+ value={repoPath}
+ onChange={setRepoPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/repository"
+ />
+ </div>
+ )}
+
+ {/* Managed description */}
+ {repoType === "managed" && (
+ <p className="font-mono text-xs text-[#8b949e]">
+ A managed repository will be created automatically by the daemon.
+ </p>
+ )}
+ </div>
+
+ {/* Actions */}
+ <div className="flex gap-2 justify-end pt-2">
+ <button
+ onClick={handleCreateCancel}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateSubmit}
+ disabled={!newContractName.trim() || !isRepoValid()}
+ 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 className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0">
+ {/* Contract list */}
+ <ContractList
+ contracts={contracts}
+ loading={loading}
+ onSelect={handleSelect}
+ onCreate={handleCreate}
+ selectedId={id}
+ />
+
+ {/* Contract detail or empty state */}
+ {contractDetail ? (
+ <ContractDetail
+ contract={contractDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onUpdate={handleUpdate}
+ onDelete={handleDelete}
+ onPhaseChange={handlePhaseChange}
+ onStatusChange={handleStatusChange}
+ onFileSelect={handleFileSelect}
+ onTaskSelect={handleTaskSelect}
+ onTaskCreate={handleTaskCreate}
+ onRefresh={handleRefresh}
+ onAddRemoteRepo={handleAddRemoteRepo}
+ onAddLocalRepo={handleAddLocalRepo}
+ onCreateManagedRepo={handleCreateManagedRepo}
+ onDeleteRepo={handleDeleteRepo}
+ onSetRepoPrimary={handleSetRepoPrimary}
+ />
+ ) : (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ Select a contract or create a new one
+ </p>
+ <button
+ onClick={handleCreate}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + New Contract
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 3ba2d52..6cfb3ca 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -12,8 +12,8 @@ import {
useFileSubscription,
type FileUpdateEvent,
} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api";
-import { createTask } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement, Task, ContractSummary } from "../lib/api";
+import { createTask, listContracts } from "../lib/api";
import { useAuth } from "../contexts/AuthContext";
export default function FilesPage() {
@@ -59,6 +59,14 @@ function FilesPageContent() {
const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
const [createdTask, setCreatedTask] = useState<Task | null>(null);
+ // Contract selection modal state for task creation
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
+ const [pendingTaskData, setPendingTaskData] = useState<{ name: string; plan: string } | null>(null);
+ // Contract selection modal state for file creation
+ const [showFileContractModal, setShowFileContractModal] = useState(false);
+ const [pendingFileData, setPendingFileData] = useState<{ name: string; body?: BodyElement[] } | null>(null);
const pendingUpdateRef = useRef(false);
// Track the last version we sent to detect our own updates
const lastSentVersionRef = useRef<number | null>(null);
@@ -548,10 +556,10 @@ function FilesPageContent() {
[fileDetail]
);
- // Create a mesh task from an element
+ // Create a mesh task from an element - shows contract selection modal
const handleCreateTaskFromElement = useCallback(
async (index: number, selectedText?: string) => {
- if (!fileDetail) return;
+ if (!fileDetail || contractsLoading) return;
const element = fileDetail.body[index];
if (!element) return;
@@ -578,57 +586,98 @@ function FilesPageContent() {
// Create a task name from the content
const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
+ // Store pending task data and show contract selection modal
+ setPendingTaskData({ name, plan: content });
+ setContractsLoading(true);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setShowContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ },
+ [fileDetail, contractsLoading]
+ );
+
+ // Create task with selected contract
+ const handleCreateTaskWithContract = useCallback(
+ async (contractId: string) => {
+ if (!pendingTaskData || !fileDetail) return;
+ setShowContractModal(false);
try {
const task = await createTask({
- name,
- plan: content,
+ contractId,
+ name: pendingTaskData.name,
+ plan: pendingTaskData.plan,
description: `Created from ${fileDetail.name}`,
});
setCreatedTask(task);
+ setPendingTaskData(null);
} catch (err) {
console.error("Failed to create task:", err);
}
},
- [fileDetail]
+ [pendingTaskData, fileDetail]
);
+ // Open contract selection modal for file creation
const handleCreate = useCallback(async () => {
- if (creating) return;
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setPendingFileData({ name: `Untitled ${new Date().toLocaleDateString()}` });
+ setShowFileContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ }, [creating, contractsLoading]);
+
+ // Create file with selected contract
+ const handleCreateFileWithContract = useCallback(async (contractId: string) => {
+ if (creating || !pendingFileData) return;
+ setShowFileContractModal(false);
setCreating(true);
try {
const newFile = await saveFile({
- name: `Untitled ${new Date().toLocaleDateString()}`,
+ contractId,
+ name: pendingFileData.name,
+ body: pendingFileData.body,
transcript: [],
});
if (newFile) {
+ // If there's body content, update it
+ if (pendingFileData.body && pendingFileData.body.length > 0) {
+ await editFile(newFile.id, { body: pendingFileData.body, version: newFile.version });
+ }
navigate(`/files/${newFile.id}`);
}
} finally {
setCreating(false);
+ setPendingFileData(null);
}
- }, [creating, saveFile, navigate]);
+ }, [creating, pendingFileData, saveFile, editFile, navigate]);
const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
- if (creating) return;
- setCreating(true);
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
try {
- const newFile = await saveFile({
- name,
- transcript: [],
- });
- if (newFile) {
- // Update with the parsed body
- const updated = await editFile(newFile.id, { body, version: newFile.version });
- if (updated) {
- navigate(`/files/${updated.id}`);
- } else {
- navigate(`/files/${newFile.id}`);
- }
- }
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setPendingFileData({ name, body });
+ setShowFileContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
} finally {
- setCreating(false);
+ setContractsLoading(false);
}
- }, [creating, saveFile, editFile, navigate]);
+ }, [creating, contractsLoading]);
// Conflict resolution handlers
const handleConflictReload = useCallback(async () => {
@@ -808,6 +857,124 @@ function FilesPageContent() {
</div>
</div>
)}
+
+ {/* Contract Selection Modal for Task Creation */}
+ {showContractModal && (
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+ <div className="bg-[#0d1117] border border-[#30363d] rounded-lg max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[#30363d] flex justify-between items-center">
+ <h2 className="text-lg font-semibold text-white">Select Contract for Task</h2>
+ <button
+ onClick={() => {
+ setShowContractModal(false);
+ setPendingTaskData(null);
+ }}
+ className="text-[#8b949e] hover:text-white"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 overflow-y-auto flex-1">
+ {contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#8b949e] mb-4">No contracts found. Create a contract first.</p>
+ <button
+ onClick={() => {
+ setShowContractModal(false);
+ setPendingTaskData(null);
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#238636] hover:bg-[#2ea043] text-white rounded-md text-sm"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleCreateTaskWithContract(contract.id)}
+ className="w-full text-left p-3 rounded-md border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-white font-medium">{contract.name}</span>
+ <span className="text-xs px-2 py-0.5 rounded bg-[#21262d] text-[#8b949e]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-sm text-[#8b949e] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Contract Selection Modal for File Creation */}
+ {showFileContractModal && (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
+ <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Select Contract for File</h2>
+ <button
+ onClick={() => {
+ setShowFileContractModal(false);
+ setPendingFileData(null);
+ }}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 overflow-y-auto flex-1">
+ {contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found. Create a contract first.</p>
+ <button
+ onClick={() => {
+ setShowFileContractModal(false);
+ setPendingFileData(null);
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleCreateFileWithContract(contract.id)}
+ className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span>
+ <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx
index aaba90c..36c468b 100644
--- a/makima/frontend/src/routes/listen.tsx
+++ b/makima/frontend/src/routes/listen.tsx
@@ -2,9 +2,11 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { Masthead } from "../components/Masthead";
import { SpeakerPanel } from "../components/listen/SpeakerPanel";
import { TranscriptPanel } from "../components/listen/TranscriptPanel";
-import { ControlPanel } from "../components/listen/ControlPanel";
+import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel";
import { useMicrophone } from "../hooks/useMicrophone";
import { useWebSocket } from "../hooks/useWebSocket";
+import { listContracts } from "../lib/api";
+import { useAuth } from "../contexts/AuthContext";
export default function ListenPage() {
const [isListening, setIsListening] = useState(false);
@@ -12,6 +14,37 @@ export default function ListenPage() {
const [permissionRequested, setPermissionRequested] = useState(false);
const isListeningRef = useRef(false);
+ // Contract selection state
+ const [contracts, setContracts] = useState<ContractOption[]>([]);
+ const [selectedContractId, setSelectedContractId] = useState<string | null>(null);
+ const [contractsLoading, setContractsLoading] = useState(true);
+ const { session, isAuthenticated } = useAuth();
+
+ // Fetch contracts on mount
+ useEffect(() => {
+ if (!isAuthenticated) {
+ setContractsLoading(false);
+ return;
+ }
+
+ async function fetchContracts() {
+ try {
+ const response = await listContracts();
+ setContracts(
+ response.contracts.map((c) => ({
+ id: c.id,
+ name: c.name,
+ }))
+ );
+ } catch (err) {
+ console.error("Failed to fetch contracts:", err);
+ } finally {
+ setContractsLoading(false);
+ }
+ }
+ fetchContracts();
+ }, [isAuthenticated]);
+
// Keep ref in sync with state for use in callbacks
useEffect(() => {
isListeningRef.current = isListening;
@@ -108,9 +141,11 @@ export default function ListenPage() {
}
// Both microphone and WebSocket are ready - start the session
- ws.startSession(mic.sampleRate, mic.channels);
+ // Pass contract_id and auth token if available
+ const authToken = session?.access_token || null;
+ ws.startSession(mic.sampleRate, mic.channels, selectedContractId, authToken);
setIsListening(true);
- }, [isListening, mic, ws]);
+ }, [isListening, mic, ws, selectedContractId, session]);
const handleNew = useCallback(() => {
// Stop current session - backend auto-saves transcript on disconnect
@@ -152,6 +187,10 @@ export default function ListenPage() {
onToggle={handleToggle}
onNew={handleNew}
error={error}
+ contracts={contracts}
+ selectedContractId={selectedContractId}
+ onContractChange={setSelectedContractId}
+ contractsLoading={contractsLoading}
/>
</div>
</main>
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index 7ecf96d..d067865 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -7,8 +7,9 @@ import { TaskOutput } from "../components/mesh/TaskOutput";
import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
import { useTasks } from "../hooks/useTasks";
import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
-import type { TaskWithSubtasks, MeshChatContext } from "../lib/api";
-import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api";
+import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api";
+import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories } from "../lib/api";
+import { DirectoryInput } from "../components/mesh/DirectoryInput";
import { useAuth } from "../contexts/AuthContext";
// View modes for the task detail page
@@ -91,6 +92,17 @@ export default function MeshPage() {
const [creating, setCreating] = useState(false);
const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
+ // Contract selection modal state
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
+ // Task creation modal (step 2)
+ const [modalStep, setModalStep] = useState<1 | 2>(1);
+ const [selectedContract, setSelectedContract] = useState<ContractWithRelations | null>(null);
+ const [daemonDirectories, setDaemonDirectories] = useState<DaemonDirectory[]>([]);
+ const [newTaskName, setNewTaskName] = useState("");
+ const [newTaskRepoUrl, setNewTaskRepoUrl] = useState<string | null>(null);
+ const [newTaskTargetPath, setNewTaskTargetPath] = useState("");
// Track which subtask's output we're viewing (null = parent task)
const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null);
const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null);
@@ -139,6 +151,14 @@ export default function MeshPage() {
// Only process output for the task we're currently viewing
if (event.taskId === activeOutputTaskId) {
setTaskOutputEntries((prev) => {
+ // For auth_required, only allow one per task (replace existing)
+ if (event.messageType === "auth_required") {
+ const hasExisting = prev.some(e => e.messageType === "auth_required");
+ if (hasExisting) {
+ return prev; // Skip duplicate auth_required
+ }
+ }
+
// Deduplicate by checking if last entry is identical
// This prevents duplicates from React StrictMode or WebSocket reconnects
const lastEntry = prev[prev.length - 1];
@@ -383,13 +403,63 @@ export default function MeshPage() {
[editTask, taskDetail]
);
+ // Open contract selection modal
const handleCreate = useCallback(async () => {
- if (creating) return;
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
+ try {
+ const [contractsResponse, directoriesResponse] = await Promise.all([
+ listContracts(),
+ getDaemonDirectories().catch(() => ({ directories: [] })),
+ ]);
+ setContracts(contractsResponse.contracts);
+ setDaemonDirectories(directoriesResponse.directories);
+ setModalStep(1);
+ setSelectedContract(null);
+ setNewTaskName("");
+ setNewTaskRepoUrl(null);
+ setNewTaskTargetPath("");
+ setShowContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ }, [creating, contractsLoading]);
+
+ // Handle contract selection and move to step 2
+ const handleSelectContract = useCallback(async (contractSummary: ContractSummary) => {
+ try {
+ const contract = await getContract(contractSummary.id);
+ setSelectedContract(contract);
+ setNewTaskName(`Task for ${contract.name}`);
+ // Pre-select primary repository if available
+ const primaryRepo = contract.repositories.find((r) => r.isPrimary && r.status === "ready");
+ if (primaryRepo) {
+ setNewTaskRepoUrl(primaryRepo.repositoryUrl);
+ } else {
+ // Otherwise select first ready repository
+ const firstReady = contract.repositories.find((r) => r.status === "ready");
+ setNewTaskRepoUrl(firstReady?.repositoryUrl || null);
+ }
+ setModalStep(2);
+ } catch (e) {
+ console.error("Failed to load contract details:", e);
+ }
+ }, []);
+
+ // Create task with configured options
+ const handleCreateTask = useCallback(async () => {
+ if (creating || !selectedContract) return;
+ setShowContractModal(false);
setCreating(true);
try {
const newTask = await saveTask({
- name: `Task ${new Date().toLocaleDateString()}`,
+ contractId: selectedContract.id,
+ name: newTaskName || `Task for ${selectedContract.name}`,
plan: "# Plan\n\nDescribe what this task should accomplish...",
+ repositoryUrl: newTaskRepoUrl || undefined,
+ targetRepoPath: newTaskTargetPath || undefined,
});
if (newTask) {
navigate(`/mesh/${newTask.id}`);
@@ -397,13 +467,29 @@ export default function MeshPage() {
} finally {
setCreating(false);
}
- }, [creating, saveTask, navigate]);
+ }, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath]);
+
+ // Close modal and reset state
+ const handleCloseModal = useCallback(() => {
+ setShowContractModal(false);
+ setModalStep(1);
+ setSelectedContract(null);
+ setNewTaskName("");
+ setNewTaskRepoUrl(null);
+ setNewTaskTargetPath("");
+ }, []);
const handleCreateSubtask = useCallback(async () => {
if (!taskDetail || creating) return;
+ // Subtasks inherit contract_id from parent
+ if (!taskDetail.contractId) {
+ console.error("Parent task has no contract_id");
+ return;
+ }
setCreating(true);
try {
const newTask = await saveTask({
+ contractId: taskDetail.contractId,
name: `Subtask of ${taskDetail.name}`,
plan: "# Plan\n\nDescribe what this subtask should accomplish...",
parentTaskId: taskDetail.id,
@@ -597,6 +683,7 @@ export default function MeshPage() {
onCreateSubtask={handleCreateSubtask}
onToggleSubtaskOutput={handleToggleSubtaskOutput}
viewingSubtaskId={viewingSubtaskId}
+ onViewContract={(contractId) => navigate(`/contracts/${contractId}`)}
/>
</div>
)}
@@ -662,6 +749,159 @@ export default function MeshPage() {
</div>
</div>
</main>
+
+ {/* Task Creation Modal (Two Steps) */}
+ {showContractModal && (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ {modalStep === 2 && (
+ <button
+ onClick={() => setModalStep(1)}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ title="Back to contract selection"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
+ </svg>
+ </button>
+ )}
+ <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">
+ {modalStep === 1 ? "Select Contract" : "Configure Task"}
+ </h2>
+ </div>
+ <button
+ onClick={handleCloseModal}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 overflow-y-auto flex-1">
+ {modalStep === 1 ? (
+ // Step 1: Select Contract
+ contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found.</p>
+ <button
+ onClick={() => {
+ handleCloseModal();
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleSelectContract(contract)}
+ className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span>
+ <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ <div className="flex gap-3 mt-2 text-[10px] font-mono text-[#556677]">
+ <span>{contract.taskCount} tasks</span>
+ <span>{contract.repositoryCount} repos</span>
+ </div>
+ </button>
+ ))}
+ </div>
+ )
+ ) : (
+ // Step 2: Configure Task
+ selectedContract && (
+ <div className="space-y-4">
+ {/* Contract badge */}
+ <div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]">
+ <span>Contract:</span>
+ <span className="text-[#9bc3ff]">{selectedContract.name}</span>
+ </div>
+
+ {/* Task name */}
+ <div className="space-y-1">
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Task Name</label>
+ <input
+ type="text"
+ value={newTaskName}
+ onChange={(e) => setNewTaskName(e.target.value)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="Task name"
+ />
+ </div>
+
+ {/* Repository selection */}
+ {selectedContract.repositories.length > 0 && (
+ <div className="space-y-1">
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository</label>
+ <select
+ value={newTaskRepoUrl || ""}
+ onChange={(e) => setNewTaskRepoUrl(e.target.value || null)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ >
+ <option value="">No repository</option>
+ {selectedContract.repositories
+ .filter((r) => r.status === "ready")
+ .map((repo) => (
+ <option key={repo.id} value={repo.repositoryUrl || repo.localPath || ""}>
+ {repo.name}
+ {repo.isPrimary && " (primary)"}
+ </option>
+ ))}
+ </select>
+ <p className="text-[10px] font-mono text-[#556677]">
+ The repository this task will work on.
+ </p>
+ </div>
+ )}
+
+ {/* Target repo path with DirectoryInput */}
+ {newTaskRepoUrl && (
+ <div className="space-y-1">
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Target Repository Path</label>
+ <DirectoryInput
+ value={newTaskTargetPath}
+ onChange={setNewTaskTargetPath}
+ suggestions={daemonDirectories}
+ placeholder="/path/to/your/local/repo"
+ repoUrl={newTaskRepoUrl}
+ />
+ <p className="text-[10px] font-mono text-[#556677]">
+ Path where the task will push/merge changes. Leave empty to configure later.
+ </p>
+ </div>
+ )}
+
+ {/* Create button */}
+ <div className="pt-2">
+ <button
+ onClick={handleCreateTask}
+ disabled={creating}
+ className="w-full px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] disabled:opacity-50 transition-colors"
+ >
+ {creating ? "Creating..." : "Create Task"}
+ </button>
+ </div>
+ </div>
+ )
+ )}
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
index 6d56e67..7ca40ba 100644
--- a/makima/frontend/src/routes/settings.tsx
+++ b/makima/frontend/src/routes/settings.tsx
@@ -10,8 +10,10 @@ import {
changePassword,
changeEmail,
deleteAccount,
+ listDaemons,
type ApiKeyInfo,
type CreateApiKeyResponse,
+ type Daemon,
} from "../lib/api";
// =============================================================================
@@ -297,8 +299,22 @@ export default function SettingsPage() {
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
+ // Daemon state
+ const [daemons, setDaemons] = useState<Daemon[]>([]);
+ const [daemonsLoading, setDaemonsLoading] = useState(true);
+ const [daemonsError, setDaemonsError] = useState<string | null>(null);
+
useEffect(() => {
loadApiKey();
+ loadDaemons();
+ }, []);
+
+ // Auto-refresh daemons every 30 seconds
+ useEffect(() => {
+ const interval = setInterval(() => {
+ loadDaemons();
+ }, 30000);
+ return () => clearInterval(interval);
}, []);
const loadApiKey = async () => {
@@ -314,6 +330,18 @@ export default function SettingsPage() {
}
};
+ const loadDaemons = async () => {
+ try {
+ setDaemonsError(null);
+ const response = await listDaemons();
+ setDaemons(response.daemons);
+ } catch (err) {
+ setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons");
+ } finally {
+ setDaemonsLoading(false);
+ }
+ };
+
const handleCreate = async () => {
try {
setActionLoading(true);
@@ -579,6 +607,91 @@ export default function SettingsPage() {
Then run: <code className="text-green-400">makima-daemon</code>
</p>
</section>
+
+ {/* Connected Daemons */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
+ Daemons
+ </h2>
+ {daemons.length > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
+ </span>
+ )}
+ </div>
+ <button
+ onClick={loadDaemons}
+ disabled={daemonsLoading}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
+ title="Refresh"
+ >
+ {daemonsLoading ? "..." : "↻"}
+ </button>
+ </div>
+
+ {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>}
+
+ {daemonsLoading && daemons.length === 0 ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : daemons.length === 0 ? (
+ <div className="text-center py-4">
+ <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p>
+ <p className="text-[#556677] font-mono text-[10px]">
+ Start a daemon to enable task execution
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {daemons.map((daemon) => (
+ <div
+ key={daemon.id}
+ className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
+ >
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ {daemon.hostname || "Unknown Host"}
+ </span>
+ <span
+ className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${
+ daemon.status === "connected"
+ ? "text-green-400 border-green-700/50 bg-green-900/20"
+ : daemon.status === "unhealthy"
+ ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20"
+ : "text-[#8899aa] border-[rgba(117,170,252,0.25)]"
+ }`}
+ >
+ {daemon.status}
+ </span>
+ </div>
+ <div className="font-mono text-[10px] text-[#7788aa] space-y-1">
+ <div className="flex justify-between">
+ <span>Tasks</span>
+ <span className="text-[#9bc3ff]">
+ {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span>Connected</span>
+ <span className="text-[#75aafc]">
+ {new Date(daemon.connectedAt).toLocaleString()}
+ </span>
+ </div>
+ {daemon.machineId && (
+ <div className="flex justify-between">
+ <span>Machine</span>
+ <span className="text-[#556677] truncate ml-2" title={daemon.machineId}>
+ {daemon.machineId.substring(0, 16)}...
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
</div>
{/* Right Column */}
diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx
new file mode 100644
index 0000000..cb72e9e
--- /dev/null
+++ b/makima/frontend/src/routes/workflow.tsx
@@ -0,0 +1,205 @@
+import { useState, useCallback, useEffect, useMemo } from "react";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { WorkflowBoard } from "../components/workflow/WorkflowBoard";
+import { useContracts } from "../hooks/useContracts";
+import { useAuth } from "../contexts/AuthContext";
+import type { ContractPhase, ContractStatus } from "../lib/api";
+
+type StatusFilter = "all" | ContractStatus;
+
+export default function WorkflowPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <WorkflowPageContent />;
+}
+
+function WorkflowPageContent() {
+ const navigate = useNavigate();
+ const { contracts, loading, error, changePhase, saveContract } = useContracts();
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+
+ // Filter contracts by status
+ const filteredContracts = useMemo(() => {
+ if (statusFilter === "all") {
+ return contracts;
+ }
+ return contracts.filter((c) => c.status === statusFilter);
+ }, [contracts, statusFilter]);
+
+ const handleContractClick = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ const handlePhaseChange = useCallback(
+ async (contractId: string, newPhase: ContractPhase) => {
+ await changePhase(contractId, newPhase);
+ },
+ [changePhase]
+ );
+
+ const handleCreateContract = useCallback(async () => {
+ if (!newContractName.trim()) return;
+ const contract = await saveContract({
+ name: newContractName.trim(),
+ });
+ if (contract) {
+ setNewContractName("");
+ setIsCreating(false);
+ navigate(`/contracts/${contract.id}`);
+ }
+ }, [newContractName, saveContract, navigate]);
+
+ const handleCancelCreate = useCallback(() => {
+ setNewContractName("");
+ setIsCreating(false);
+ }, []);
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0">
+ {error}
+ </div>
+ )}
+
+ {/* Header with filter and create button */}
+ <div className="flex items-center justify-between shrink-0">
+ <div className="flex items-center gap-4">
+ <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
+ Board
+ </h1>
+ {/* Status filter */}
+ <div className="flex items-center gap-1">
+ {(["all", "active", "completed", "archived"] as StatusFilter[]).map(
+ (status) => (
+ <button
+ key={status}
+ onClick={() => setStatusFilter(status)}
+ className={`
+ px-2 py-1 font-mono text-[10px] uppercase transition-colors
+ ${
+ statusFilter === 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>
+ <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"
+ >
+ + New Contract
+ </button>
+ </div>
+
+ {/* Create contract modal */}
+ {isCreating && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Contract
+ </h3>
+ <div className="space-y-4">
+ <input
+ type="text"
+ value={newContractName}
+ onChange={(e) => setNewContractName(e.target.value)}
+ placeholder="Contract 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
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreateContract();
+ if (e.key === "Escape") handleCancelCreate();
+ }}
+ />
+ <div className="flex gap-2 justify-end">
+ <button
+ onClick={handleCancelCreate}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateContract}
+ disabled={!newContractName.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>
+ )}
+
+ {/* Board */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ {loading ? (
+ <div className="h-full flex items-center justify-center">
+ <p className="font-mono text-sm text-[#555]">Loading...</p>
+ </div>
+ ) : filteredContracts.length === 0 && statusFilter === "all" ? (
+ <div className="h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ No contracts yet
+ </p>
+ <button
+ onClick={() => setIsCreating(true)}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + Create First Contract
+ </button>
+ </div>
+ </div>
+ ) : (
+ <WorkflowBoard
+ contracts={filteredContracts}
+ onContractClick={handleContractClick}
+ onPhaseChange={handlePhaseChange}
+ />
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}