summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components')
-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
25 files changed, 4826 insertions, 211 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>
+ );
+}