diff options
Diffstat (limited to 'makima/frontend/src')
37 files changed, 7348 insertions, 250 deletions
diff --git a/makima/frontend/src/components/JapaneseHoverText.tsx b/makima/frontend/src/components/JapaneseHoverText.tsx new file mode 100644 index 0000000..3e60ee2 --- /dev/null +++ b/makima/frontend/src/components/JapaneseHoverText.tsx @@ -0,0 +1,77 @@ +import { useState, useCallback, useRef } from "react"; + +const GLYPHS = "▒▓░█#@*+:-/[]{}<>_"; + +interface JapaneseHoverTextProps { + japanese: string; + english: string; + className?: string; +} + +/** + * Displays Japanese text, transitions to English on hover with scramble effect + */ +export function JapaneseHoverText({ + japanese, + english, + className = "", +}: JapaneseHoverTextProps) { + const [isHovered, setIsHovered] = useState(false); + const [displayText, setDisplayText] = useState(english); + const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); + const iterationRef = useRef(0); + + const scrambleToEnglish = useCallback(() => { + setIsHovered(true); + + // Clear any existing animation + if (timerRef.current) { + clearInterval(timerRef.current); + } + + iterationRef.current = 0; + + timerRef.current = setInterval(() => { + const text = english; + const iteration = iterationRef.current; + + const display = text + .split("") + .map((char, index) => { + if (index < iteration) return char; + return GLYPHS.charAt(Math.floor(Math.random() * GLYPHS.length)); + }) + .join(""); + + setDisplayText(display); + iterationRef.current += 1; + + if (iteration > text.length + 2) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setDisplayText(english); + } + }, 26); + }, [english]); + + const resetToJapanese = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsHovered(false); + setDisplayText(english); + }, [english]); + + return ( + <span + className={`cursor-default ${className}`} + onMouseEnter={scrambleToEnglish} + onMouseLeave={resetToJapanese} + > + {isHovered ? displayText : japanese} + </span> + ); +} diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx index afe385e..bc184bd 100644 --- a/makima/frontend/src/components/Masthead.tsx +++ b/makima/frontend/src/components/Masthead.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router"; import { LogoMark } from "./Logo"; import { NavStrip } from "./NavStrip"; +import { JapaneseHoverText } from "./JapaneseHoverText"; interface MastheadProps { showTicker?: boolean; @@ -18,7 +19,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps) makima.jp </h1> <small className="block text-[#dbe7ff] text-xs tracking-wide"> - Control System + <JapaneseHoverText + japanese="支配する" + english="Control System" + /> </small> </div> </Link> diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 642e9a3..48abe09 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -11,6 +11,8 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Files", href: "/files", requiresAuth: true }, + { label: "Contracts", href: "/contracts", requiresAuth: true }, + { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx new file mode 100644 index 0000000..821d03c --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractCliInput.tsx @@ -0,0 +1,974 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { + getContractChatHistory, + clearContractChatHistory, + startTask, + sendTaskMessage, + type UserQuestion, + type ContractWithRelations, + type TaskStatus, +} from "../../lib/api"; +import { SimpleMarkdown } from "../SimpleMarkdown"; +import { + QuickActionButtons, + type QuickAction, +} from "./QuickActionButtons"; +import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview"; +import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription"; + +interface ContractCliInputProps { + contractId: string; + contract: ContractWithRelations; + onUpdate: () => void; +} + +interface Message { + id: string; + type: "user" | "assistant" | "error" | "question"; + content: string; + toolCalls?: { name: string; success: boolean; message: string }[]; + questions?: UserQuestion[]; + quickActions?: QuickAction[]; +} + +export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) { + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [historyLoading, setHistoryLoading] = useState(true); + const [messages, setMessages] = useState<Message[]>([]); + const [expanded, setExpanded] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null); + const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map()); + const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map()); + + // Task derivation state + const [parsedTasks, setParsedTasks] = useState<ParsedTask[] | null>(null); + const [parsedTaskGroups, setParsedTaskGroups] = useState<string[]>([]); + const [parsedTasksFileName, setParsedTasksFileName] = useState<string>(""); + const [creatingTasks, setCreatingTasks] = useState(false); + + // Supervisor state + const [supervisorStarting, setSupervisorStarting] = useState(false); + const [supervisorOutput, setSupervisorOutput] = useState<TaskOutputEvent[]>([]); + const [supervisorQuestion, setSupervisorQuestion] = useState<{ + id: string; + question: string; + options: string[]; + allowMultiple?: boolean; + allowCustom?: boolean; + } | null>(null); + + const inputRef = useRef<HTMLInputElement>(null); + const messagesRef = useRef<HTMLDivElement>(null); + + // Find the supervisor task for this contract + // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag + const supervisorTask = useMemo(() => { + // Use contract.supervisorTaskId if available (most reliable) + if (contract.supervisorTaskId) { + const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId); + if (taskById) return taskById; + } + // Fallback to finding by isSupervisor flag + return contract.tasks.find((t) => t.isSupervisor); + }, [contract.tasks, contract.supervisorTaskId]); + + // Log for debugging + useEffect(() => { + console.log("Supervisor lookup:", { + contractId: contract.id, + supervisorTaskId: contract.supervisorTaskId, + tasksCount: contract.tasks.length, + foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null, + allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor })) + }); + }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]); + + const supervisorTaskId = supervisorTask?.id ?? null; + const supervisorStatus = supervisorTask?.status as TaskStatus | undefined; + const isSupervisorRunning = supervisorStatus === "running"; + const isSupervisorPending = supervisorStatus === "pending"; + + // Subscribe to supervisor output when it's running + const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => { + // Check for question pattern in output + // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}} + if (!event.isPartial && event.content) { + const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/); + if (questionMatch) { + try { + const questionData = JSON.parse(questionMatch[1]); + if (questionData.id && questionData.question && questionData.options) { + setSupervisorQuestion({ + id: questionData.id, + question: questionData.question, + options: questionData.options, + allowMultiple: questionData.allowMultiple ?? false, + allowCustom: questionData.allowCustom ?? true, + }); + // Don't add this to output since it's a control message + return; + } + } catch { + // Not valid JSON, continue as normal output + } + } + } + + setSupervisorOutput((prev) => { + // If it's a partial message, update the last message + if (event.isPartial && prev.length > 0) { + const lastEvent = prev[prev.length - 1]; + if (lastEvent.messageType === event.messageType && lastEvent.isPartial) { + return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }]; + } + } + return [...prev, event]; + }); + }, []); + + useTaskSubscription({ + taskId: supervisorTaskId, + subscribeOutput: isSupervisorRunning, + onOutput: handleSupervisorOutput, + }); + + // Auto-start supervisor function - starts and waits for it to be running + const ensureSupervisorStarted = useCallback(async (): Promise<boolean> => { + if (!supervisorTask) { + console.warn("No supervisor task found for contract"); + return false; + } + + if (isSupervisorRunning) { + return true; // Already running + } + + if (isSupervisorPending) { + try { + setSupervisorStarting(true); + await startTask(supervisorTask.id); + + // Poll for the task to be running (up to 10 seconds) + for (let i = 0; i < 20; i++) { + await new Promise(resolve => setTimeout(resolve, 500)); + onUpdate(); // Refresh contract to get updated task status + // Note: We can't check the new status here directly since state updates are async + // The UI will update when onUpdate triggers a re-render + } + + // Return true - the caller should check if supervisor is running after this + return true; + } catch (err) { + console.error("Failed to start supervisor:", err); + return false; + } finally { + setSupervisorStarting(false); + } + } + + // Supervisor exists but is in some other state (paused, done, failed, etc.) + // Can still send messages to paused tasks + return supervisorStatus === "paused"; + }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]); + + // Handle answering supervisor questions + const [supervisorAnswers, setSupervisorAnswers] = useState<string[]>([]); + const [supervisorCustomInput, setSupervisorCustomInput] = useState(""); + + const handleSupervisorOptionToggle = useCallback((option: string) => { + setSupervisorAnswers((prev) => { + if (supervisorQuestion?.allowMultiple) { + if (prev.includes(option)) { + return prev.filter((a) => a !== option); + } + return [...prev, option]; + } + return [option]; + }); + }, [supervisorQuestion?.allowMultiple]); + + const handleSubmitSupervisorAnswer = useCallback(async () => { + if (!supervisorQuestion || !supervisorTask) return; + + const customAnswer = supervisorCustomInput.trim(); + const allAnswers = customAnswer + ? [...supervisorAnswers, customAnswer] + : supervisorAnswers; + + if (allAnswers.length === 0) return; + + // Format answer message for supervisor + const answerMessage = `__supervisor_answer__ ${JSON.stringify({ + id: supervisorQuestion.id, + answers: allAnswers, + })}`; + + try { + await sendTaskMessage(supervisorTask.id, answerMessage); + + // Add user message to chat + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { + id: userMsgId, + type: "user", + content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`, + }, + ]); + } catch (err) { + console.error("Failed to send supervisor answer:", err); + } finally { + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + } + }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]); + + const handleCancelSupervisorQuestion = useCallback(() => { + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + }, []); + + // Load chat history on mount + useEffect(() => { + let mounted = true; + + async function loadHistory() { + try { + const history = await getContractChatHistory(contractId); + if (!mounted) return; + + // Convert saved messages to display messages + const displayMessages: Message[] = history.messages.map((msg) => ({ + id: msg.id, + type: msg.role as "user" | "assistant" | "error", + content: msg.content, + toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined, + })); + + setMessages(displayMessages); + + // Auto-expand if there's history + if (displayMessages.length > 0) { + setExpanded(true); + } + } catch (err) { + console.error("Failed to load contract chat history:", err); + } finally { + if (mounted) { + setHistoryLoading(false); + } + } + } + + loadHistory(); + + return () => { + mounted = false; + }; + }, [contractId]); + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + } + }, [messages]); + + // Auto-start supervisor when component mounts if it's pending + useEffect(() => { + if (supervisorTask && isSupervisorPending && !supervisorStarting) { + console.log("Auto-starting supervisor task on mount..."); + ensureSupervisorStarted().then((started) => { + if (started) { + console.log("Supervisor started successfully"); + } + }); + } + }, [supervisorTask?.id]); // Only run when task ID changes, not on every render + + // Convert supervisor output events to messages + useEffect(() => { + if (supervisorOutput.length === 0) return; + + // Get the latest event + const latestEvent = supervisorOutput[supervisorOutput.length - 1]; + + // Only add complete messages (not partials) to the message history + if (!latestEvent.isPartial && latestEvent.content.trim()) { + const msgId = `supervisor-${Date.now()}`; + let msgType: "assistant" | "error" = "assistant"; + let content = latestEvent.content; + + // Format based on message type + switch (latestEvent.messageType) { + case "assistant": + content = latestEvent.content; + break; + case "tool_use": + content = `_Using tool: ${latestEvent.toolName}_`; + break; + case "tool_result": + content = latestEvent.isError + ? `Tool error: ${latestEvent.content}` + : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`; + msgType = latestEvent.isError ? "error" : "assistant"; + break; + case "error": + msgType = "error"; + break; + case "result": + // Final result - show cost info if available + if (latestEvent.costUsd) { + content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`; + } + break; + default: + // system, raw, etc. + break; + } + + setMessages((prev) => { + // Don't add duplicate messages + if (prev.some((m) => m.content === content)) return prev; + return [ + ...prev, + { id: msgId, type: msgType, content }, + ]; + }); + } + }, [supervisorOutput]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || loading) return; + + const userMessage = input.trim(); + setInput(""); + setExpanded(true); + + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: userMessage }, + ]); + + setLoading(true); + + try { + // Supervisor is the ONLY way to interact with contracts + if (!supervisorTask) { + throw new Error("No supervisor task found. Please create a contract with a supervisor."); + } + + // Ensure supervisor is started (this will start it if pending) + await ensureSupervisorStarted(); + + // Send message to supervisor task stdin + await sendTaskMessage(supervisorTask.id, userMessage); + + // Response will come through WebSocket subscription + // No need for a placeholder message - output will stream in + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + inputRef.current?.focus(); + } + }, + [input, loading, supervisorTask, ensureSupervisorStarted] + ); + + const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { + setUserAnswers((prev) => { + const newMap = new Map(prev); + const currentAnswers = newMap.get(questionId) || []; + + if (allowMultiple) { + if (currentAnswers.includes(option)) { + newMap.set(questionId, currentAnswers.filter((a) => a !== option)); + } else { + newMap.set(questionId, [...currentAnswers, option]); + } + } else { + newMap.set(questionId, [option]); + } + + return newMap; + }); + }, []); + + const handleCustomInputChange = useCallback((questionId: string, value: string) => { + setCustomInputs((prev) => { + const newMap = new Map(prev); + newMap.set(questionId, value); + return newMap; + }); + }, []); + + const handleSubmitAnswers = useCallback(async () => { + if (!pendingQuestions || loading) return; + + const answers = pendingQuestions.map((q) => { + const selectedOptions = userAnswers.get(q.id) || []; + const customInput = customInputs.get(q.id)?.trim(); + const finalAnswers = customInput + ? [...selectedOptions, customInput] + : selectedOptions; + + return { + id: q.id, + answers: finalAnswers, + }; + }); + + const answerText = answers + .map((a) => { + const question = pendingQuestions.find((q) => q.id === a.id); + return `${question?.question || a.id}: ${a.answers.join(", ")}`; + }) + .join("\n"); + + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, + ]); + + setLoading(true); + + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, answerText); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]); + + const handleCancelQuestions = useCallback(() => { + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + setSupervisorOutput([]); + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + }, []); + + // Handle creating tasks from the preview + const handleCreateDerivedTasks = useCallback( + async (selectedTasks: ParsedTask[]) => { + if (selectedTasks.length === 0) { + setParsedTasks(null); + return; + } + + setCreatingTasks(true); + + // Build a message asking the supervisor to create these tasks + const taskList = selectedTasks + .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`) + .join("\n"); + + const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`; + + // Add user message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: message }, + ]); + + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, message); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setCreatingTasks(false); + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + } + }, + [supervisorTask, ensureSupervisorStarted] + ); + + const handleCancelTaskDerivation = useCallback(() => { + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + }, []); + + const handleQuickAction = useCallback( + async (action: QuickAction) => { + // Convert the action into a chat message that triggers the appropriate behavior + let message = ""; + switch (action.type) { + case "create_file": + message = "Create the suggested file from the template."; + break; + case "create_task": + message = "Yes, create the tasks."; + break; + case "derive_tasks": + message = "Show me the tasks to review and create them."; + break; + case "run_task": + message = "Run the next task."; + break; + case "advance_phase": + if (action.data?.phase) { + message = `Advance to the ${action.data.phase} phase.`; + } else { + message = "Advance to the next phase."; + } + break; + case "update_file": + message = "Update the file with the task output."; + break; + default: + return; + } + + setExpanded(true); + + // Submit the message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: message }, + ]); + + setLoading(true); + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, message); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, + [supervisorTask, ensureSupervisorStarted] + ); + + return ( + <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {/* Header bar with supervisor status and toggle */} + <div className="px-3 py-2 flex items-center justify-between border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-3"> + <span className="font-mono text-[10px] text-[#555] uppercase tracking-wide"> + Supervisor + </span> + {supervisorTask && ( + <span className={`font-mono text-[10px] px-2 py-0.5 border ${ + isSupervisorRunning + ? "text-green-400 border-green-400/30 bg-green-400/10" + : isSupervisorPending || supervisorStarting + ? "text-yellow-400 border-yellow-400/30 bg-yellow-400/10" + : "text-[#555] border-[rgba(117,170,252,0.2)]" + }`}> + {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"} + </span> + )} + {!supervisorTask && ( + <span className="font-mono text-[10px] text-red-400"> + No supervisor + </span> + )} + </div> + {messages.length > 0 && ( + <button + type="button" + onClick={() => setExpanded(!expanded)} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" + > + {expanded ? "Hide Messages" : `Show Messages (${messages.length})`} + </button> + )} + </div> + + {/* History loading indicator */} + {historyLoading && ( + <div className="px-3 py-2 text-[10px] font-mono text-[#555] flex items-center gap-2 border-b border-[rgba(117,170,252,0.2)]"> + <span className="animate-pulse">Loading history...</span> + </div> + )} + + {/* Messages Panel (expandable) */} + {expanded && messages.length > 0 && !historyLoading && ( + <div className="relative border-b border-[rgba(117,170,252,0.2)]"> + {/* Expand/Collapse button */} + <div className="absolute top-2 right-2 z-10 flex gap-1"> + <button + type="button" + onClick={() => setFullscreen(!fullscreen)} + className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + title={fullscreen ? "Collapse" : "Expand"} + > + {fullscreen ? "▼ Collapse" : "▲ Expand"} + </button> + </div> + <div + ref={messagesRef} + className={`overflow-y-auto p-3 pr-24 space-y-2 transition-all duration-200 ${ + fullscreen ? "max-h-[60vh]" : "max-h-48" + }`} + > + {messages.map((msg) => ( + <div key={msg.id} className="font-mono text-xs"> + {msg.type === "user" && ( + <div className="flex gap-2"> + <span className="text-[#9bc3ff]">></span> + <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span> + </div> + )} + {(msg.type === "assistant" || msg.type === "question") && ( + <div className="pl-4 space-y-1"> + <SimpleMarkdown content={msg.content} className="text-[#75aafc]" /> + {msg.toolCalls && msg.toolCalls.length > 0 && ( + <div className="text-[#555] text-[10px] space-y-0.5"> + {msg.toolCalls.map((tc, i) => ( + <div key={i}> + <span + className={ + tc.success ? "text-green-500" : "text-red-400" + } + > + {tc.success ? "+" : "x"} + </span>{" "} + {tc.name}: {tc.message} + </div> + ))} + </div> + )} + {msg.quickActions && msg.quickActions.length > 0 && ( + <QuickActionButtons + actions={msg.quickActions} + onAction={handleQuickAction} + loading={loading} + /> + )} + </div> + )} + {msg.type === "error" && ( + <div className="pl-4 text-red-400">{msg.content}</div> + )} + </div> + ))} + </div> + </div> + )} + + {/* Pending Questions UI */} + {pendingQuestions && pendingQuestions.length > 0 && ( + <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3"> + <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide"> + Questions from AI + </div> + {pendingQuestions.map((q) => ( + <div key={q.id} className="space-y-2"> + <div className="text-white/90 font-mono text-sm">{q.question}</div> + <div className="flex flex-wrap gap-2"> + {q.options.map((option) => { + const isSelected = (userAnswers.get(q.id) || []).includes(option); + return ( + <button + key={option} + type="button" + onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)} + className={`px-2 py-1 font-mono text-xs border transition-colors ${ + isSelected + ? "bg-[#3f6fb3] border-[#75aafc] text-white" + : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]" + }`} + > + {q.allowMultiple && ( + <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span> + )} + {option} + </button> + ); + })} + </div> + {q.allowCustom && ( + <input + type="text" + value={customInputs.get(q.id) || ""} + onChange={(e) => handleCustomInputChange(q.id, e.target.value)} + placeholder="Or type a custom answer..." + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" + /> + )} + </div> + ))} + <div className="flex gap-2 pt-2"> + <button + type="button" + onClick={handleSubmitAnswers} + disabled={loading} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {loading ? "..." : "Submit Answers"} + </button> + <button + type="button" + onClick={handleCancelQuestions} + disabled={loading} + className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + </div> + </div> + )} + + {/* Supervisor Question UI */} + {supervisorQuestion && ( + <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3 bg-[rgba(117,170,252,0.05)]"> + <div className="text-green-400 font-mono text-xs uppercase tracking-wide flex items-center gap-2"> + <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" /> + Question from Supervisor + </div> + <div className="text-white/90 font-mono text-sm">{supervisorQuestion.question}</div> + <div className="flex flex-wrap gap-2"> + {supervisorQuestion.options.map((option) => { + const isSelected = supervisorAnswers.includes(option); + return ( + <button + key={option} + type="button" + onClick={() => handleSupervisorOptionToggle(option)} + className={`px-2 py-1 font-mono text-xs border transition-colors ${ + isSelected + ? "bg-green-500/30 border-green-400 text-white" + : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-green-400" + }`} + > + {supervisorQuestion.allowMultiple && ( + <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span> + )} + {option} + </button> + ); + })} + </div> + {supervisorQuestion.allowCustom && ( + <input + type="text" + value={supervisorCustomInput} + onChange={(e) => setSupervisorCustomInput(e.target.value)} + placeholder="Or type a custom answer..." + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-green-400 placeholder-[#555]" + /> + )} + <div className="flex gap-2 pt-2"> + <button + type="button" + onClick={handleSubmitSupervisorAnswer} + disabled={supervisorAnswers.length === 0 && !supervisorCustomInput.trim()} + className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/50 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + Send Answer + </button> + <button + type="button" + onClick={handleCancelSupervisorQuestion} + className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Dismiss + </button> + </div> + </div> + )} + + {/* Contract Context Badge */} + <div className="px-3 pt-2 pb-1 flex items-center gap-2 text-[10px] font-mono text-[#555]"> + <span className="text-[#75aafc]">{contract.phase}</span> + <span>|</span> + {supervisorTask && ( + <> + <span + className={ + isSupervisorRunning + ? "text-green-400" + : isSupervisorPending + ? "text-yellow-400" + : supervisorStarting + ? "text-cyan-400 animate-pulse" + : "text-[#555]" + } + > + Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status} + </span> + <span>|</span> + </> + )} + <span>{contract.files.length} files</span> + <span>|</span> + <span>{contract.tasks.length} tasks</span> + <span>|</span> + <span>{contract.repositories.length} repos</span> + <span>|</span> + <button + type="button" + onClick={() => { + const prompt = "Guide me to complete this phase and advance to the next. Analyze my current deliverables, identify what's missing, and suggest specific next steps."; + setInput(prompt); + // Auto-submit the prompt + setTimeout(() => { + const form = document.querySelector('form'); + if (form) form.requestSubmit(); + }, 0); + }} + disabled={loading || !!pendingQuestions} + className="text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50 transition-colors cursor-pointer" + > + Progress → + </button> + {messages.length > 0 && ( + <> + <span>|</span> + <button + type="button" + onClick={async () => { + if (window.confirm("Clear all chat history for this contract?")) { + try { + await clearContractChatHistory(contractId); + setMessages([]); + } catch (err) { + console.error("Failed to clear history:", err); + } + } + }} + disabled={loading} + className="text-[#555] hover:text-red-400 disabled:opacity-50 transition-colors cursor-pointer" + > + Clear + </button> + </> + )} + </div> + + {/* Input Bar */} + <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 pb-3"> + <span className="text-[#9bc3ff] font-mono text-sm">></span> + <input + ref={inputRef} + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + placeholder={ + loading + ? "Processing..." + : supervisorStarting + ? "Starting supervisor..." + : supervisorQuestion + ? "Answer supervisor question above..." + : pendingQuestions + ? "Answer questions above first..." + : isSupervisorRunning + ? "Message supervisor..." + : "Create a task, add a file, or ask about the contract..." + } + disabled={loading || !!pendingQuestions || !!supervisorQuestion} + className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" + /> + {messages.length > 0 && ( + <button + type="button" + onClick={clearMessages} + className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" + > + clear + </button> + )} + <button + type="submit" + disabled={loading || !input.trim() || !!pendingQuestions || !!supervisorQuestion} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {loading ? "..." : "Send"} + </button> + </form> + + {/* Task Derivation Preview Modal */} + {parsedTasks && parsedTasks.length > 0 && ( + <TaskDerivationPreview + tasks={parsedTasks} + groups={parsedTaskGroups} + fileName={parsedTasksFileName} + onCreateTasks={handleCreateDerivedTasks} + onCancel={handleCancelTaskDerivation} + loading={creatingTasks} + /> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx new file mode 100644 index 0000000..cf5f8f2 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractDetail.tsx @@ -0,0 +1,794 @@ +import { useState, useEffect, useCallback } from "react"; +import type { + ContractWithRelations, + ContractPhase, + ContractStatus, + ContractRepository, + FileSummary, + TaskSummary, + TemplateSummary, +} from "../../lib/api"; +import { + listTemplates, + getTemplate, + createFile, +} from "../../lib/api"; +import { PhaseProgressBar } from "./PhaseProgressBar"; +import { PhaseHint } from "./PhaseHint"; +import { RepositoryPanel } from "./RepositoryPanel"; +import { ContractCliInput } from "./ContractCliInput"; +import { PhaseDeliverablesPanel } from "./PhaseDeliverablesPanel"; +import { TaskTree } from "../mesh/TaskTree"; + +type Tab = "overview" | "repos" | "files" | "tasks"; + +interface ContractDetailProps { + contract: ContractWithRelations; + loading: boolean; + onBack: () => void; + onUpdate: (name: string, description: string) => void; + onDelete: () => void; + onPhaseChange: (phase: ContractPhase) => void; + onStatusChange: (status: ContractStatus) => void; + onFileSelect: (id: string) => void; + onTaskSelect: (id: string) => void; + onTaskCreate: (name: string, plan: string, repositoryUrl?: string) => void; + onRefresh: () => void; + // Repository callbacks + onAddRemoteRepo: (name: string, url: string, isPrimary: boolean) => void; + onAddLocalRepo: (name: string, path: string, isPrimary: boolean) => void; + onCreateManagedRepo: (name: string, isPrimary: boolean) => void; + onDeleteRepo: (repoId: string) => void; + onSetRepoPrimary: (repoId: string) => void; + // File creation callback for phase deliverables + onCreateFileFromTemplate?: (templateId: string, suggestedName: string) => void; +} + +const statusConfig: Record<ContractStatus, { label: string; color: string }> = { + active: { label: "Active", color: "text-green-400" }, + completed: { label: "Completed", color: "text-blue-400" }, + archived: { label: "Archived", color: "text-[#555]" }, +}; + +export function ContractDetail({ + contract, + loading, + onBack, + onUpdate, + onDelete, + onPhaseChange, + onStatusChange, + onFileSelect, + onTaskSelect, + onTaskCreate, + onRefresh, + onAddRemoteRepo, + onAddLocalRepo, + onCreateManagedRepo, + onDeleteRepo, + onSetRepoPrimary, + onCreateFileFromTemplate, +}: ContractDetailProps) { + const [activeTab, setActiveTab] = useState<Tab>("overview"); + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(contract.name); + const [description, setDescription] = useState(contract.description || ""); + + const handleSave = () => { + onUpdate(name, description); + setIsEditing(false); + }; + + const handleCancel = () => { + setName(contract.name); + setDescription(contract.description || ""); + setIsEditing(false); + }; + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ); + } + + const tabs: { key: Tab; label: string; count?: number }[] = [ + { key: "overview", label: "Overview" }, + { key: "repos", label: "Repositories", count: contract.repositories.length }, + { key: "files", label: "Files", count: contract.files.length }, + { key: "tasks", label: "Tasks", count: contract.tasks.length }, + ]; + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="flex items-center justify-between mb-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + ← Back to list + </button> + <div className="flex items-center gap-2"> + {isEditing ? ( + <> + <button + onClick={handleCancel} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Cancel + </button> + <button + onClick={handleSave} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + Save + </button> + </> + ) : ( + <> + <button + onClick={() => setIsEditing(true)} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase" + > + Edit + </button> + <button + onClick={onDelete} + className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + </> + )} + </div> + </div> + + {isEditing ? ( + <div className="space-y-3"> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + placeholder="Contract name" + /> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + rows={2} + placeholder="Description (optional)" + /> + </div> + ) : ( + <> + <div className="flex items-center gap-3 mb-2"> + <h2 className="font-mono text-lg text-[#dbe7ff]"> + {contract.name} + </h2> + <span + className={`font-mono text-xs uppercase ${ + statusConfig[contract.status].color + }`} + > + {statusConfig[contract.status].label} + </span> + </div> + {contract.description && ( + <p className="font-mono text-sm text-[#9bc3ff] mb-3"> + {contract.description} + </p> + )} + </> + )} + + {/* Phase progress */} + <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]"> + <PhaseProgressBar + currentPhase={contract.phase} + onPhaseClick={onPhaseChange} + /> + </div> + </div> + + {/* Tabs */} + <div className="flex border-b border-[rgba(117,170,252,0.2)]"> + {tabs.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={` + px-4 py-2 font-mono text-xs uppercase tracking-wider transition-colors + ${ + activeTab === tab.key + ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" + : "text-[#555] hover:text-[#9bc3ff]" + } + `} + > + {tab.label} + {tab.count !== undefined && tab.count > 0 && ( + <span className="ml-1 text-[10px]">({tab.count})</span> + )} + </button> + ))} + </div> + + {/* Tab content */} + <div className="flex-1 overflow-y-auto p-4"> + {activeTab === "overview" && ( + <OverviewTab + contract={contract} + onStatusChange={onStatusChange} + onPhaseChange={onPhaseChange} + onCreateFile={onCreateFileFromTemplate} + /> + )} + + {activeTab === "repos" && ( + <RepositoryPanel + repositories={contract.repositories} + onAddRemote={onAddRemoteRepo} + onAddLocal={onAddLocalRepo} + onCreateManaged={onCreateManagedRepo} + onDelete={onDeleteRepo} + onSetPrimary={onSetRepoPrimary} + /> + )} + + {activeTab === "files" && ( + <FilesTab + files={contract.files} + contractId={contract.id} + contractPhase={contract.phase} + onSelect={onFileSelect} + onRefresh={onRefresh} + /> + )} + + {activeTab === "tasks" && ( + <TasksTab + tasks={contract.tasks} + repositories={contract.repositories} + supervisorTaskId={contract.supervisorTaskId} + onSelect={onTaskSelect} + onCreate={onTaskCreate} + /> + )} + </div> + + {/* Chat Input */} + <ContractCliInput + contractId={contract.id} + contract={contract} + onUpdate={onRefresh} + /> + </div> + ); +} + +// Overview tab +function OverviewTab({ + contract, + onStatusChange, + onPhaseChange, + onCreateFile, +}: { + contract: ContractWithRelations; + onStatusChange: (status: ContractStatus) => void; + onPhaseChange: (phase: ContractPhase) => void; + onCreateFile?: (templateId: string, suggestedName: string) => void; +}) { + return ( + <div className="space-y-6"> + {/* Phase deliverables checklist */} + <PhaseDeliverablesPanel + contract={contract} + onCreateFile={onCreateFile} + /> + + {/* Phase hint */} + <PhaseHint contract={contract} onAdvancePhase={onPhaseChange} /> + + {/* Task progress summary */} + <TaskStatusSummary tasks={contract.tasks} /> + + {/* Stats */} + <div className="grid grid-cols-3 gap-4"> + <StatCard label="Repositories" value={contract.repositories.length} /> + <StatCard label="Files" value={contract.files.length} /> + <StatCard label="Tasks" value={contract.tasks.length} /> + </div> + + {/* Status change */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Status + </h3> + <div className="flex gap-2"> + {(["active", "completed", "archived"] as ContractStatus[]).map( + (status) => ( + <button + key={status} + onClick={() => onStatusChange(status)} + className={` + px-3 py-1.5 font-mono text-xs uppercase transition-colors + ${ + contract.status === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] border border-transparent hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ) + )} + </div> + </div> + + {/* Metadata */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Details + </h3> + <div className="space-y-1 font-mono text-xs text-[#555]"> + <p>Created: {new Date(contract.createdAt).toLocaleString()}</p> + <p>Updated: {new Date(contract.updatedAt).toLocaleString()}</p> + <p>Version: {contract.version}</p> + </div> + </div> + </div> + ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( + <div className="p-3 border border-[rgba(117,170,252,0.2)]"> + <div className="font-mono text-2xl text-[#dbe7ff]">{value}</div> + <div className="font-mono text-[10px] text-[#555] uppercase">{label}</div> + </div> + ); +} + +// Task status summary with progress bar +function TaskStatusSummary({ tasks }: { tasks: TaskSummary[] }) { + if (tasks.length === 0) return null; + + // Count tasks by status + const statusCounts = { + done: 0, + merged: 0, + running: 0, + pending: 0, + failed: 0, + other: 0, + }; + + for (const task of tasks) { + switch (task.status) { + case "done": + statusCounts.done++; + break; + case "merged": + statusCounts.merged++; + break; + case "running": + case "initializing": + case "starting": + statusCounts.running++; + break; + case "pending": + statusCounts.pending++; + break; + case "failed": + statusCounts.failed++; + break; + default: + statusCounts.other++; + } + } + + const completedCount = statusCounts.done + statusCounts.merged; + const progressPercent = (completedCount / tasks.length) * 100; + + // Build summary parts + const parts: string[] = []; + if (completedCount > 0) parts.push(`${completedCount} done`); + if (statusCounts.running > 0) parts.push(`${statusCounts.running} running`); + if (statusCounts.pending > 0) parts.push(`${statusCounts.pending} pending`); + if (statusCounts.failed > 0) parts.push(`${statusCounts.failed} failed`); + + return ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Task Progress + </h3> + + {/* Progress bar */} + <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> + <div + className="h-full bg-green-400 transition-all duration-300" + style={{ width: `${progressPercent}%` }} + /> + </div> + + {/* Summary text */} + <div className="flex items-center justify-between"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {parts.join(", ")} + </span> + <span className="font-mono text-xs text-[#555]"> + {completedCount}/{tasks.length} completed + </span> + </div> + </div> + ); +} + +// Phase color mapping for badges +const phaseColors: Record<ContractPhase, string> = { + research: "bg-purple-500/20 text-purple-400 border-purple-400/30", + specify: "bg-blue-500/20 text-blue-400 border-blue-400/30", + plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30", + execute: "bg-green-500/20 text-green-400 border-green-400/30", + review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30", +}; + +// Files tab with template creation +function FilesTab({ + files, + contractId, + contractPhase, + onSelect, + onRefresh, +}: { + files: FileSummary[]; + contractId: string; + contractPhase: ContractPhase; + onSelect: (id: string) => void; + onRefresh: () => void; +}) { + const [showTemplateModal, setShowTemplateModal] = useState(false); + const [templates, setTemplates] = useState<TemplateSummary[]>([]); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [creating, setCreating] = useState(false); + const [fileName, setFileName] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); + + // Load templates when modal opens + useEffect(() => { + if (showTemplateModal) { + setLoadingTemplates(true); + listTemplates(contractPhase) + .then((res) => setTemplates(res.templates)) + .catch((err) => console.error("Failed to load templates:", err)) + .finally(() => setLoadingTemplates(false)); + } + }, [showTemplateModal, contractPhase]); + + const handleCreateFromTemplate = useCallback(async () => { + if (!fileName.trim() || !selectedTemplateId) return; + + setCreating(true); + try { + // Get the full template with body + const template = await getTemplate(selectedTemplateId); + + // Create the file with contract (files must belong to contracts) + await createFile({ + contractId, + name: fileName.trim(), + description: template.description, + body: template.suggestedBody, + }); + + // Reset and close + setShowTemplateModal(false); + setFileName(""); + setSelectedTemplateId(null); + onRefresh(); + } catch (err) { + console.error("Failed to create file from template:", err); + } finally { + setCreating(false); + } + }, [fileName, selectedTemplateId, contractId, onRefresh]); + + const handleCloseModal = () => { + setShowTemplateModal(false); + setFileName(""); + setSelectedTemplateId(null); + }; + + return ( + <div className="space-y-4"> + {/* Create from template button */} + <button + onClick={() => setShowTemplateModal(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Create from Template + </button> + + {/* Template Selection Modal */} + {showTemplateModal && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase"> + Create File from Template + </h3> + <span className={`px-2 py-0.5 text-[10px] font-mono uppercase border rounded ${phaseColors[contractPhase]}`}> + {contractPhase} phase + </span> + </div> + + <div className="space-y-4 flex-1 overflow-y-auto"> + {/* File name input */} + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + File Name + </label> + <input + type="text" + value={fileName} + onChange={(e) => setFileName(e.target.value)} + placeholder="e.g., Project Requirements" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Template selection */} + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-2"> + Select Template + </label> + {loadingTemplates ? ( + <p className="font-mono text-xs text-[#555]">Loading templates...</p> + ) : templates.length === 0 ? ( + <p className="font-mono text-xs text-[#555]">No templates available for {contractPhase} phase</p> + ) : ( + <div className="space-y-2 max-h-60 overflow-y-auto"> + {templates.map((template) => ( + <button + key={template.id} + onClick={() => setSelectedTemplateId(template.id)} + className={`w-full text-left p-3 border transition-colors ${ + selectedTemplateId === template.id + ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" + : "border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)]" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-sm text-[#dbe7ff]"> + {template.name} + </span> + <span className="font-mono text-[10px] text-[#555]"> + {template.elementCount} elements + </span> + </div> + <p className="font-mono text-xs text-[#555]"> + {template.description} + </p> + </button> + ))} + </div> + )} + </div> + </div> + + <div className="flex gap-2 justify-end mt-4 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <button + onClick={handleCloseModal} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateFromTemplate} + disabled={!fileName.trim() || !selectedTemplateId || creating} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {creating ? "Creating..." : "Create File"} + </button> + </div> + </div> + </div> + )} + + {/* File list */} + {files.length === 0 ? ( + <p className="font-mono text-xs text-[#555]"> + No files in this contract. Create one from a template above. + </p> + ) : ( + <div className="space-y-2"> + {files.map((file) => ( + <button + key={file.id} + onClick={() => onSelect(file.id)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]"> + {file.name} + </span> + {file.contractPhase && ( + <span + className={`px-1.5 py-0.5 text-[9px] font-mono uppercase border rounded ${ + phaseColors[file.contractPhase] + }`} + title={`Added during ${file.contractPhase} phase`} + > + {file.contractPhase} + </span> + )} + </div> + <span className="font-mono text-[10px] text-[#555]"> + v{file.version} + </span> + </div> + {file.description && ( + <p className="font-mono text-xs text-[#555] mt-1 truncate"> + {file.description} + </p> + )} + </button> + ))} + </div> + )} + </div> + ); +} + +// Tasks tab - now using TaskTree for supervisor view +function TasksTab({ + tasks, + repositories, + supervisorTaskId, + onSelect, + onCreate, +}: { + tasks: TaskSummary[]; + repositories: ContractRepository[]; + supervisorTaskId: string | null; + onSelect: (id: string) => void; + onCreate: (name: string, plan: string, repositoryUrl?: string) => void; +}) { + const [isCreating, setIsCreating] = useState(false); + const [taskName, setTaskName] = useState(""); + const [taskPlan, setTaskPlan] = useState("# Plan\n\nDescribe what this task should accomplish..."); + + // Find primary repository or first ready one + const readyRepos = repositories.filter((r) => r.status === "ready"); + const primaryRepo = readyRepos.find((r) => r.isPrimary) || readyRepos[0]; + const [selectedRepoId, setSelectedRepoId] = useState<string>(primaryRepo?.id || ""); + + const handleCreate = () => { + if (!taskName.trim()) return; + const selectedRepo = repositories.find((r) => r.id === selectedRepoId); + // Get the URL - for remote repos it's repositoryUrl, for local it's the local path + const repoUrl = selectedRepo?.repositoryUrl || selectedRepo?.localPath; + onCreate(taskName.trim(), taskPlan, repoUrl || undefined); + setIsCreating(false); + setTaskName(""); + setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); + setSelectedRepoId(primaryRepo?.id || ""); + }; + + const handleCancel = () => { + setIsCreating(false); + setTaskName(""); + setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); + setSelectedRepoId(primaryRepo?.id || ""); + }; + + return ( + <div className="space-y-4"> + {/* TaskTree with supervisor view */} + <TaskTree + tasks={tasks} + supervisorTaskId={supervisorTaskId} + onSelect={onSelect} + /> + + {/* Manual task creation (hidden when supervisor exists - supervisor creates tasks) */} + {!supervisorTaskId && ( + <> + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <button + onClick={() => setIsCreating(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Create Task Manually + </button> + </div> + + {/* Create Task Modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Task + </h3> + <div className="space-y-4"> + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Name + </label> + <input + type="text" + value={taskName} + onChange={(e) => setTaskName(e.target.value)} + placeholder="Task name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Repository selection */} + {readyRepos.length > 0 && ( + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Repository + </label> + <select + value={selectedRepoId} + onChange={(e) => setSelectedRepoId(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="">No repository</option> + {readyRepos.map((repo) => ( + <option key={repo.id} value={repo.id}> + {repo.name} + {repo.isPrimary ? " (Primary)" : ""} + {" - "} + {repo.sourceType} + </option> + ))} + </select> + </div> + )} + + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Plan + </label> + <textarea + value={taskPlan} + onChange={(e) => setTaskPlan(e.target.value)} + rows={6} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + <div className="flex gap-2 justify-end"> + <button + onClick={handleCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreate} + disabled={!taskName.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + </> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx new file mode 100644 index 0000000..3a7b163 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractList.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import type { ContractSummary, ContractStatus } from "../../lib/api"; +import { PhaseBadge } from "./PhaseBadge"; +import { PhaseProgressBarCompact } from "./PhaseProgressBar"; + +interface ContractListProps { + contracts: ContractSummary[]; + loading: boolean; + onSelect: (id: string) => void; + onCreate: () => void; + selectedId?: string; +} + +const statusColors: Record<ContractStatus, string> = { + active: "text-green-400", + completed: "text-blue-400", + archived: "text-[#555]", +}; + +export function ContractList({ + contracts, + loading, + onSelect, + onCreate, + selectedId, +}: ContractListProps) { + const [filter, setFilter] = useState<ContractStatus | "all">("all"); + + const filteredContracts = + filter === "all" + ? contracts + : contracts.filter((c) => c.status === filter); + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ); + } + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="flex items-center justify-between mb-3"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> + Contracts + </h2> + <button + onClick={onCreate} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New + </button> + </div> + + {/* Filter tabs */} + <div className="flex gap-1"> + {(["all", "active", "completed", "archived"] as const).map((status) => ( + <button + key={status} + onClick={() => setFilter(status)} + className={` + px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors + ${ + filter === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ))} + </div> + </div> + + {/* Contract list */} + <div className="flex-1 overflow-y-auto"> + {filteredContracts.length === 0 ? ( + <div className="p-4 text-center"> + <p className="font-mono text-sm text-[#555]"> + {filter === "all" + ? "No contracts yet" + : `No ${filter} contracts`} + </p> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.15)]"> + {filteredContracts.map((contract) => ( + <button + key={contract.id} + onClick={() => onSelect(contract.id)} + className={` + w-full text-left p-4 transition-colors + ${ + selectedId === contract.id + ? "bg-[rgba(117,170,252,0.1)]" + : "hover:bg-[rgba(117,170,252,0.05)]" + } + `} + > + <div className="flex items-start justify-between gap-2 mb-2"> + <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> + {contract.name} + </h3> + <span + className={`text-[10px] font-mono uppercase ${ + statusColors[contract.status] + }`} + > + {contract.status} + </span> + </div> + + {contract.description && ( + <p className="font-mono text-xs text-[#555] mb-2 line-clamp-2"> + {contract.description} + </p> + )} + + <div className="flex items-center justify-between"> + <PhaseProgressBarCompact currentPhase={contract.phase} /> + <div className="flex items-center gap-3 text-[10px] font-mono text-[#555]"> + {contract.fileCount > 0 && ( + <span>{contract.fileCount} files</span> + )} + {contract.taskCount > 0 && ( + <span>{contract.taskCount} tasks</span> + )} + {contract.repositoryCount > 0 && ( + <span>{contract.repositoryCount} repos</span> + )} + </div> + </div> + </button> + ))} + </div> + )} + </div> + </div> + ); +} + +export function ContractCard({ + contract, + onClick, +}: { + contract: ContractSummary; + onClick: () => void; +}) { + return ( + <button + onClick={onClick} + className="w-full text-left p-4 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + > + <div className="flex items-start justify-between gap-2 mb-2"> + <h3 className="font-mono text-sm text-[#dbe7ff]">{contract.name}</h3> + <PhaseBadge phase={contract.phase} /> + </div> + + {contract.description && ( + <p className="font-mono text-xs text-[#555] mb-3 line-clamp-2"> + {contract.description} + </p> + )} + + <div className="flex items-center gap-4 text-[10px] font-mono text-[#555]"> + <span>{contract.fileCount} files</span> + <span>{contract.taskCount} tasks</span> + <span>{contract.repositoryCount} repos</span> + </div> + </button> + ); +} diff --git a/makima/frontend/src/components/contracts/PhaseBadge.tsx b/makima/frontend/src/components/contracts/PhaseBadge.tsx new file mode 100644 index 0000000..0f46b9b --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseBadge.tsx @@ -0,0 +1,54 @@ +import type { ContractPhase } from "../../lib/api"; + +interface PhaseBadgeProps { + phase: ContractPhase; + size?: "sm" | "md"; +} + +const phaseConfig: Record< + ContractPhase, + { label: string; color: string; bgColor: string } +> = { + research: { + label: "Research", + color: "text-purple-400", + bgColor: "bg-purple-400/10 border-purple-400/30", + }, + specify: { + label: "Specify", + color: "text-blue-400", + bgColor: "bg-blue-400/10 border-blue-400/30", + }, + plan: { + label: "Plan", + color: "text-cyan-400", + bgColor: "bg-cyan-400/10 border-cyan-400/30", + }, + execute: { + label: "Execute", + color: "text-yellow-400", + bgColor: "bg-yellow-400/10 border-yellow-400/30", + }, + review: { + label: "Review", + color: "text-green-400", + bgColor: "bg-green-400/10 border-green-400/30", + }, +}; + +export function PhaseBadge({ phase, size = "sm" }: PhaseBadgeProps) { + const config = phaseConfig[phase]; + const sizeClasses = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"; + + return ( + <span + className={`${sizeClasses} ${config.color} ${config.bgColor} border font-mono uppercase tracking-wider`} + > + {config.label} + </span> + ); +} + +export function getPhaseLabel(phase: ContractPhase): string { + return phaseConfig[phase].label; +} diff --git a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx new file mode 100644 index 0000000..da5025b --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo } from "react"; +import type { ContractWithRelations, ContractPhase } from "../../lib/api"; + +// Phase deliverables configuration (mirrors backend phase_guidance.rs) +interface RecommendedFile { + templateId: string; + name: string; + priority: "required" | "recommended" | "optional"; + description: string; +} + +interface PhaseDeliverables { + phase: ContractPhase; + files: RecommendedFile[]; + requiresRepository: boolean; + requiresTasks: boolean; + guidance: string; +} + +const PHASE_DELIVERABLES: Record<ContractPhase, PhaseDeliverables> = { + research: { + phase: "research", + files: [ + { templateId: "research-notes", name: "Research Notes", priority: "recommended", description: "Document findings and insights" }, + { templateId: "competitor-analysis", name: "Competitor Analysis", priority: "recommended", description: "Analyze competitors" }, + { templateId: "user-research", name: "User Research", priority: "optional", description: "User interviews and personas" }, + ], + requiresRepository: false, + requiresTasks: false, + guidance: "Gather information and document findings before moving to Specify.", + }, + specify: { + phase: "specify", + files: [ + { templateId: "requirements", name: "Requirements Document", priority: "required", description: "Functional and non-functional requirements" }, + { templateId: "user-stories", name: "User Stories", priority: "recommended", description: "Features from user perspective" }, + { templateId: "acceptance-criteria", name: "Acceptance Criteria", priority: "recommended", description: "Testable conditions for completion" }, + ], + requiresRepository: false, + requiresTasks: false, + guidance: "Define clear requirements and acceptance criteria.", + }, + plan: { + phase: "plan", + files: [ + { templateId: "architecture", name: "Architecture Document", priority: "recommended", description: "System architecture and design" }, + { templateId: "task-breakdown", name: "Task Breakdown", priority: "required", description: "Work broken into tasks" }, + { templateId: "technical-design", name: "Technical Design", priority: "optional", description: "Detailed technical specs" }, + ], + requiresRepository: true, + requiresTasks: false, + guidance: "Design the solution and create a task breakdown. Configure a repository.", + }, + execute: { + phase: "execute", + files: [ + { templateId: "dev-notes", name: "Development Notes", priority: "recommended", description: "Implementation details" }, + { templateId: "test-plan", name: "Test Plan", priority: "optional", description: "Testing strategy" }, + { templateId: "implementation-log", name: "Implementation Log", priority: "optional", description: "Progress log" }, + ], + requiresRepository: true, + requiresTasks: true, + guidance: "Execute tasks and track implementation progress.", + }, + review: { + phase: "review", + files: [ + { templateId: "release-notes", name: "Release Notes", priority: "required", description: "Changes for release" }, + { templateId: "review-checklist", name: "Review Checklist", priority: "recommended", description: "Code and feature review" }, + { templateId: "retrospective", name: "Retrospective", priority: "optional", description: "Project learnings" }, + ], + requiresRepository: false, + requiresTasks: false, + guidance: "Review work and document the release.", + }, +}; + +interface DeliverableStatus { + templateId: string; + name: string; + priority: "required" | "recommended" | "optional"; + description: string; + completed: boolean; + fileId?: string; + actualName?: string; +} + +interface PhaseDeliverablesProps { + contract: ContractWithRelations; + onCreateFile?: (templateId: string, suggestedName: string) => void; +} + +export function PhaseDeliverablesPanel({ contract, onCreateFile }: PhaseDeliverablesProps) { + const deliverables = PHASE_DELIVERABLES[contract.phase]; + + // Calculate deliverable status + const fileStatuses = useMemo((): DeliverableStatus[] => { + return deliverables.files.map((rec) => { + // Find matching file by name similarity + const matchedFile = contract.files.find((f) => { + const nameLower = f.name.toLowerCase(); + const recLower = rec.name.toLowerCase(); + return ( + f.contractPhase === contract.phase && + (nameLower.includes(recLower) || recLower.includes(nameLower) || nameLower.includes(rec.templateId.replace("-", " "))) + ); + }); + + return { + ...rec, + completed: !!matchedFile, + fileId: matchedFile?.id, + actualName: matchedFile?.name, + }; + }); + }, [contract.files, contract.phase, deliverables.files]); + + // Check repository status + const hasRepository = contract.repositories.length > 0; + + // Check task status + const taskStats = useMemo(() => { + const total = contract.tasks.length; + const done = contract.tasks.filter((t) => t.status === "done" || t.status === "merged").length; + const pending = contract.tasks.filter((t) => t.status === "pending").length; + const running = contract.tasks.filter((t) => ["running", "initializing", "starting"].includes(t.status)).length; + const failed = contract.tasks.filter((t) => t.status === "failed").length; + return { total, done, pending, running, failed }; + }, [contract.tasks]); + + // Calculate completion percentage + const completionPercent = useMemo(() => { + let completed = 0; + let total = 0; + + // Count required and recommended files + fileStatuses.forEach((s) => { + if (s.priority !== "optional") { + total++; + if (s.completed) completed++; + } + }); + + // Count repository if required + if (deliverables.requiresRepository) { + total++; + if (hasRepository) completed++; + } + + // Count tasks if in execute phase + if (deliverables.requiresTasks && taskStats.total > 0) { + total++; + if (taskStats.done === taskStats.total) completed++; + } + + return total > 0 ? Math.round((completed / total) * 100) : 100; + }, [fileStatuses, hasRepository, deliverables, taskStats]); + + const priorityColors = { + required: "text-red-400", + recommended: "text-yellow-400", + optional: "text-[#555]", + }; + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Phase Deliverables + </h3> + <div className="flex items-center gap-2"> + <div className="w-24 h-1.5 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> + <div + className={`h-full transition-all duration-300 ${ + completionPercent === 100 ? "bg-green-400" : "bg-[#75aafc]" + }`} + style={{ width: `${completionPercent}%` }} + /> + </div> + <span className="font-mono text-[10px] text-[#555]">{completionPercent}%</span> + </div> + </div> + + {/* Guidance text */} + <p className="font-mono text-xs text-[#555] italic">{deliverables.guidance}</p> + + {/* File deliverables */} + <div className="space-y-2"> + {fileStatuses.map((status) => ( + <div + key={status.templateId} + className={`flex items-center justify-between p-2 border ${ + status.completed + ? "border-green-400/20 bg-green-400/5" + : "border-[rgba(117,170,252,0.15)]" + }`} + > + <div className="flex items-center gap-2"> + <span + className={`font-mono text-xs ${ + status.completed ? "text-green-400" : "text-[#555]" + }`} + > + {status.completed ? "[+]" : "[ ]"} + </span> + <div> + <div className="flex items-center gap-2"> + <span className="font-mono text-xs text-[#dbe7ff]"> + {status.completed ? status.actualName : status.name} + </span> + {!status.completed && ( + <span className={`font-mono text-[9px] uppercase ${priorityColors[status.priority]}`}> + {status.priority} + </span> + )} + </div> + <span className="font-mono text-[10px] text-[#555]"> + {status.description} + </span> + </div> + </div> + {!status.completed && onCreateFile && ( + <button + onClick={() => onCreateFile(status.templateId, status.name)} + className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + Create + </button> + )} + </div> + ))} + </div> + + {/* Repository status */} + {deliverables.requiresRepository && ( + <div + className={`flex items-center gap-2 p-2 border ${ + hasRepository + ? "border-green-400/20 bg-green-400/5" + : "border-[rgba(117,170,252,0.15)]" + }`} + > + <span + className={`font-mono text-xs ${ + hasRepository ? "text-green-400" : "text-[#555]" + }`} + > + {hasRepository ? "[+]" : "[ ]"} + </span> + <div> + <span className="font-mono text-xs text-[#dbe7ff]"> + Repository Configured + </span> + {!hasRepository && ( + <span className="font-mono text-[9px] uppercase text-red-400 ml-2"> + required + </span> + )} + </div> + </div> + )} + + {/* Task status (execute phase) */} + {deliverables.requiresTasks && ( + <div + className={`flex items-center justify-between p-2 border ${ + taskStats.total > 0 && taskStats.done === taskStats.total + ? "border-green-400/20 bg-green-400/5" + : "border-[rgba(117,170,252,0.15)]" + }`} + > + <div className="flex items-center gap-2"> + <span + className={`font-mono text-xs ${ + taskStats.total > 0 && taskStats.done === taskStats.total + ? "text-green-400" + : "text-[#555]" + }`} + > + {taskStats.total > 0 && taskStats.done === taskStats.total ? "[+]" : "[ ]"} + </span> + <span className="font-mono text-xs text-[#dbe7ff]"> + Tasks Completed + </span> + </div> + {taskStats.total > 0 ? ( + <span className="font-mono text-[10px] text-[#9bc3ff]"> + {taskStats.done}/{taskStats.total} + {taskStats.running > 0 && ` (${taskStats.running} running)`} + {taskStats.failed > 0 && ( + <span className="text-red-400"> ({taskStats.failed} failed)</span> + )} + </span> + ) : ( + <span className="font-mono text-[10px] text-[#555]">No tasks yet</span> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/PhaseHint.tsx b/makima/frontend/src/components/contracts/PhaseHint.tsx new file mode 100644 index 0000000..95573ed --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseHint.tsx @@ -0,0 +1,90 @@ +import type { ContractPhase, ContractWithRelations } from "../../lib/api"; + +interface PhaseHintProps { + contract: ContractWithRelations; + onAdvancePhase: (phase: ContractPhase) => void; +} + +const phaseOrder: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; + +interface HintConfig { + condition: (contract: ContractWithRelations) => boolean; + message: (contract: ContractWithRelations) => string; + nextPhase: ContractPhase; +} + +const phaseHints: Record<ContractPhase, HintConfig | null> = { + research: { + condition: (c) => c.files.length >= 1, + message: (c) => + `You have ${c.files.length} file${c.files.length === 1 ? "" : "s"}. Ready to specify requirements?`, + nextPhase: "specify", + }, + specify: { + condition: (c) => c.files.length >= 2, + message: () => "Spec files ready. Create implementation plan?", + nextPhase: "plan", + }, + plan: { + condition: (c) => c.files.length >= 1 && c.repositories.length >= 1, + message: () => "Plan documented. Ready to create tasks?", + nextPhase: "execute", + }, + execute: { + condition: (c) => { + // Show hint only when all tasks are complete + const doneTasks = c.tasks.filter( + (t) => t.status === "done" || t.status === "merged" + ); + return c.tasks.length > 0 && doneTasks.length === c.tasks.length; + }, + message: (c) => { + const doneTasks = c.tasks.filter( + (t) => t.status === "done" || t.status === "merged" + ); + return `All ${doneTasks.length} task${doneTasks.length === 1 ? "" : "s"} complete. Ready for review?`; + }, + nextPhase: "review", + }, + review: null, // No hint for review phase - it's the final phase +}; + +export function PhaseHint({ contract, onAdvancePhase }: PhaseHintProps) { + const hintConfig = phaseHints[contract.phase]; + + // No hint for this phase + if (!hintConfig) return null; + + // Condition not met + if (!hintConfig.condition(contract)) return null; + + return ( + <div className="flex items-center gap-3 p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {hintConfig.message(contract)} + </span> + <button + onClick={() => onAdvancePhase(hintConfig.nextPhase)} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase whitespace-nowrap" + > + Advance to {hintConfig.nextPhase} + </button> + </div> + ); +} + +export function getNextPhase(currentPhase: ContractPhase): ContractPhase | null { + const currentIndex = phaseOrder.indexOf(currentPhase); + if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) { + return null; + } + return phaseOrder[currentIndex + 1]; +} + +export function getPreviousPhase(currentPhase: ContractPhase): ContractPhase | null { + const currentIndex = phaseOrder.indexOf(currentPhase); + if (currentIndex <= 0) { + return null; + } + return phaseOrder[currentIndex - 1]; +} diff --git a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx new file mode 100644 index 0000000..5ee7999 --- /dev/null +++ b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx @@ -0,0 +1,142 @@ +import type { ContractPhase } from "../../lib/api"; + +interface PhaseProgressBarProps { + currentPhase: ContractPhase; + onPhaseClick?: (phase: ContractPhase) => void; + readonly?: boolean; +} + +const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; + +const phaseLabels: Record<ContractPhase, string> = { + research: "Research", + specify: "Specify", + plan: "Plan", + execute: "Execute", + review: "Review", +}; + +const phaseColors: Record<ContractPhase, { active: string; inactive: string; completed: string }> = { + research: { + active: "bg-purple-400 border-purple-400", + inactive: "bg-transparent border-purple-400/30", + completed: "bg-purple-400/50 border-purple-400/50", + }, + specify: { + active: "bg-blue-400 border-blue-400", + inactive: "bg-transparent border-blue-400/30", + completed: "bg-blue-400/50 border-blue-400/50", + }, + plan: { + active: "bg-cyan-400 border-cyan-400", + inactive: "bg-transparent border-cyan-400/30", + completed: "bg-cyan-400/50 border-cyan-400/50", + }, + execute: { + active: "bg-yellow-400 border-yellow-400", + inactive: "bg-transparent border-yellow-400/30", + completed: "bg-yellow-400/50 border-yellow-400/50", + }, + review: { + active: "bg-green-400 border-green-400", + inactive: "bg-transparent border-green-400/30", + completed: "bg-green-400/50 border-green-400/50", + }, +}; + +export function PhaseProgressBar({ + currentPhase, + onPhaseClick, + readonly = false, +}: PhaseProgressBarProps) { + const currentIndex = phases.indexOf(currentPhase); + + return ( + <div className="flex items-center gap-1"> + {phases.map((phase, index) => { + const isActive = phase === currentPhase; + const isCompleted = index < currentIndex; + const colors = phaseColors[phase]; + const colorClass = isActive + ? colors.active + : isCompleted + ? colors.completed + : colors.inactive; + + const canClick = !readonly && onPhaseClick; + + return ( + <div key={phase} className="flex items-center"> + {/* Phase node */} + <button + onClick={() => canClick && onPhaseClick(phase)} + disabled={readonly} + className={` + relative group flex flex-col items-center + ${canClick ? "cursor-pointer" : "cursor-default"} + `} + > + {/* Circle */} + <div + className={` + w-3 h-3 rounded-full border-2 transition-all + ${colorClass} + ${canClick && !isActive ? "hover:scale-110" : ""} + `} + /> + {/* Label */} + <span + className={` + absolute top-4 font-mono text-[9px] uppercase tracking-wide whitespace-nowrap + ${isActive ? "text-[#dbe7ff]" : "text-[#555]"} + ${canClick && !isActive ? "group-hover:text-[#75aafc]" : ""} + `} + > + {phaseLabels[phase]} + </span> + </button> + + {/* Connector line */} + {index < phases.length - 1 && ( + <div + className={` + w-8 h-0.5 mx-1 + ${index < currentIndex ? "bg-[#3f6fb3]" : "bg-[rgba(117,170,252,0.15)]"} + `} + /> + )} + </div> + ); + })} + </div> + ); +} + +export function PhaseProgressBarCompact({ + currentPhase, +}: { + currentPhase: ContractPhase; +}) { + const currentIndex = phases.indexOf(currentPhase); + + return ( + <div className="flex items-center gap-0.5"> + {phases.map((phase, index) => { + const isActive = phase === currentPhase; + const isCompleted = index < currentIndex; + const colors = phaseColors[phase]; + + return ( + <div + key={phase} + className={` + w-2 h-2 rounded-full border + ${isActive ? colors.active : isCompleted ? colors.completed : colors.inactive} + `} + title={phaseLabels[phase]} + /> + ); + })} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/QuickActionButtons.tsx b/makima/frontend/src/components/contracts/QuickActionButtons.tsx new file mode 100644 index 0000000..4dbb90c --- /dev/null +++ b/makima/frontend/src/components/contracts/QuickActionButtons.tsx @@ -0,0 +1,217 @@ +import { useCallback } from "react"; + +export type QuickActionType = + | "create_file" + | "create_task" + | "run_task" + | "advance_phase" + | "derive_tasks" + | "update_file"; + +export interface QuickAction { + type: QuickActionType; + label: string; + description?: string; + data?: Record<string, unknown>; +} + +interface QuickActionButtonsProps { + actions: QuickAction[]; + onAction: (action: QuickAction) => void; + loading?: boolean; +} + +const ACTION_ICONS: Record<QuickActionType, string> = { + create_file: "[+]", + create_task: "[T]", + run_task: "[>]", + advance_phase: "[→]", + derive_tasks: "[≡]", + update_file: "[*]", +}; + +const ACTION_COLORS: Record<QuickActionType, string> = { + create_file: "border-blue-400/30 hover:border-blue-400/60 text-blue-400", + create_task: "border-green-400/30 hover:border-green-400/60 text-green-400", + run_task: "border-yellow-400/30 hover:border-yellow-400/60 text-yellow-400", + advance_phase: "border-purple-400/30 hover:border-purple-400/60 text-purple-400", + derive_tasks: "border-cyan-400/30 hover:border-cyan-400/60 text-cyan-400", + update_file: "border-orange-400/30 hover:border-orange-400/60 text-orange-400", +}; + +export function QuickActionButtons({ + actions, + onAction, + loading = false, +}: QuickActionButtonsProps) { + const handleClick = useCallback( + (action: QuickAction) => { + if (!loading) { + onAction(action); + } + }, + [onAction, loading] + ); + + if (actions.length === 0) return null; + + return ( + <div className="flex flex-wrap gap-2 mt-2"> + {actions.map((action, index) => ( + <button + key={`${action.type}-${index}`} + onClick={() => handleClick(action)} + disabled={loading} + className={` + flex items-center gap-1.5 px-2 py-1 + font-mono text-[10px] uppercase + border transition-colors + disabled:opacity-50 disabled:cursor-not-allowed + ${ACTION_COLORS[action.type]} + `} + title={action.description} + > + <span>{ACTION_ICONS[action.type]}</span> + <span>{action.label}</span> + </button> + ))} + </div> + ); +} + +/** + * Parse tool call results to extract suggested quick actions. + * This is used by ContractCliInput to detect actionable results. + */ +export function parseActionsFromToolCalls( + toolCalls: { name: string; success: boolean; message: string }[] +): QuickAction[] { + const actions: QuickAction[] = []; + + for (const tc of toolCalls) { + if (!tc.success) continue; + + switch (tc.name) { + case "derive_tasks_from_file": + // When tasks are parsed, offer to create them + if (tc.message.includes("task") || tc.message.includes("Found")) { + actions.push({ + type: "derive_tasks", + label: "Review & Create Tasks", + description: "Review parsed tasks and create them with chaining", + }); + } + break; + + case "process_task_completion": + // Check for suggested actions in the result + if (tc.message.includes("next task")) { + actions.push({ + type: "run_task", + label: "Run Next Task", + description: "Continue with the next chained task", + }); + } + if (tc.message.includes("advance") || tc.message.includes("phase")) { + actions.push({ + type: "advance_phase", + label: "Advance Phase", + description: "Move to the next contract phase", + }); + } + break; + + case "get_phase_checklist": + // When checklist shows missing items, offer to create them + if (tc.message.includes("missing") || tc.message.includes("not created")) { + actions.push({ + type: "create_file", + label: "Create Missing Files", + description: "Create files from recommended templates", + }); + } + break; + + case "advance_phase": + // After phase transition, suggest creating files + actions.push({ + type: "create_file", + label: "Create Phase Files", + description: "Create recommended files for this phase", + }); + break; + } + } + + return actions; +} + +/** + * Parse LLM response text to detect suggested actions. + * Used as a fallback when structured action data isn't available. + */ +export function parseActionsFromText(text: string): QuickAction[] { + const actions: QuickAction[] = []; + const lower = text.toLowerCase(); + + // Detect file creation suggestions + if ( + lower.includes("create a file") || + lower.includes("create the file") || + lower.includes("should i create") + ) { + actions.push({ + type: "create_file", + label: "Create File", + description: "Create the suggested file", + }); + } + + // Detect task creation suggestions + if ( + lower.includes("create tasks") || + lower.includes("create these tasks") || + lower.includes("create chained tasks") + ) { + actions.push({ + type: "create_task", + label: "Create Tasks", + description: "Create the suggested tasks", + }); + } + + // Detect phase advancement suggestions + if ( + lower.includes("advance to") || + lower.includes("ready to move to") || + lower.includes("transition to") + ) { + const phases = ["specify", "plan", "execute", "review"]; + for (const phase of phases) { + if (lower.includes(phase)) { + actions.push({ + type: "advance_phase", + label: `Advance to ${phase.charAt(0).toUpperCase() + phase.slice(1)}`, + description: `Move to the ${phase} phase`, + data: { phase }, + }); + break; + } + } + } + + // Detect run task suggestions + if ( + lower.includes("run the task") || + lower.includes("start the task") || + lower.includes("run task") + ) { + actions.push({ + type: "run_task", + label: "Run Task", + description: "Start the suggested task", + }); + } + + return actions; +} diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx new file mode 100644 index 0000000..4170cfb --- /dev/null +++ b/makima/frontend/src/components/contracts/RepositoryPanel.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from "react"; +import type { + ContractRepository, + RepositorySourceType, + RepositoryStatus, + DaemonDirectory, +} from "../../lib/api"; +import { getDaemonDirectories } from "../../lib/api"; +import { DirectoryInput } from "../mesh/DirectoryInput"; + +interface RepositoryPanelProps { + repositories: ContractRepository[]; + onAddRemote: (name: string, url: string, isPrimary: boolean) => void; + onAddLocal: (name: string, path: string, isPrimary: boolean) => void; + onCreateManaged: (name: string, isPrimary: boolean) => void; + onDelete: (repoId: string) => void; + onSetPrimary: (repoId: string) => void; +} + +type AddMode = "remote" | "local" | "managed" | null; + +const sourceTypeLabels: Record<RepositorySourceType, string> = { + remote: "Remote", + local: "Local", + managed: "Managed", +}; + +const sourceTypeIcons: Record<RepositorySourceType, string> = { + remote: "GH", + local: "FS", + managed: "MK", +}; + +const statusColors: Record<RepositoryStatus, string> = { + ready: "text-green-400", + pending: "text-yellow-400", + creating: "text-cyan-400", + failed: "text-red-400", +}; + +export function RepositoryPanel({ + repositories, + onAddRemote, + onAddLocal, + onCreateManaged, + onDelete, + onSetPrimary, +}: RepositoryPanelProps) { + const [addMode, setAddMode] = useState<AddMode>(null); + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [path, setPath] = useState(""); + const [isPrimary, setIsPrimary] = useState(false); + // Daemon directory suggestions for local repositories + const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + + // Fetch daemon directories when "local" mode is selected + useEffect(() => { + if (addMode === "local") { + getDaemonDirectories() + .then((res) => setSuggestedDirectories(res.directories)) + .catch(() => setSuggestedDirectories([])); + } + }, [addMode]); + + const handleAdd = () => { + if (!name.trim()) return; + + if (addMode === "remote" && url.trim()) { + onAddRemote(name.trim(), url.trim(), isPrimary); + } else if (addMode === "local" && path.trim()) { + onAddLocal(name.trim(), path.trim(), isPrimary); + } else if (addMode === "managed") { + onCreateManaged(name.trim(), isPrimary); + } + + // Reset form + setAddMode(null); + setName(""); + setUrl(""); + setPath(""); + setIsPrimary(false); + }; + + const handleCancel = () => { + setAddMode(null); + setName(""); + setUrl(""); + setPath(""); + setIsPrimary(false); + }; + + return ( + <div className="space-y-4"> + {/* Repository list */} + {repositories.length === 0 ? ( + <p className="font-mono text-xs text-[#555]"> + No repositories configured + </p> + ) : ( + <div className="space-y-2"> + {repositories.map((repo) => ( + <div + key={repo.id} + className="flex items-center gap-3 p-3 border border-[rgba(117,170,252,0.2)]" + > + {/* Type icon */} + <span className="font-mono text-[10px] text-[#555] uppercase w-6"> + {sourceTypeIcons[repo.sourceType]} + </span> + + {/* Name and details */} + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff] truncate"> + {repo.name} + </span> + {repo.isPrimary && ( + <span className="px-1 py-0.5 text-[8px] font-mono uppercase bg-[rgba(117,170,252,0.1)] text-[#75aafc] border border-[rgba(117,170,252,0.3)]"> + Primary + </span> + )} + </div> + <div className="font-mono text-[10px] text-[#555] truncate"> + {repo.repositoryUrl || repo.localPath || "(pending creation)"} + </div> + </div> + + {/* Status */} + <span + className={`font-mono text-[10px] uppercase ${ + statusColors[repo.status] + }`} + > + {repo.status} + </span> + + {/* Actions */} + <div className="flex items-center gap-1"> + {!repo.isPrimary && repo.status === "ready" && ( + <button + onClick={() => onSetPrimary(repo.id)} + className="p-1 font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors" + title="Set as primary" + > + * + </button> + )} + <button + onClick={() => onDelete(repo.id)} + className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors" + title="Remove" + > + x + </button> + </div> + </div> + ))} + </div> + )} + + {/* Add repository form */} + {addMode ? ( + <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3"> + <div className="flex items-center gap-2 mb-2"> + <span className="font-mono text-xs text-[#75aafc] uppercase"> + Add {sourceTypeLabels[addMode]} Repository + </span> + </div> + + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Repository name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + + {addMode === "remote" && ( + <input + type="text" + value={url} + onChange={(e) => setUrl(e.target.value)} + placeholder="https://github.com/owner/repo" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" + /> + )} + + {addMode === "local" && ( + <DirectoryInput + value={path} + onChange={setPath} + suggestions={suggestedDirectories} + placeholder="/path/to/repository" + /> + )} + + {addMode === "managed" && ( + <p className="font-mono text-xs text-[#555]"> + Makima will create this repository via the daemon. + </p> + )} + + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + checked={isPrimary} + onChange={(e) => setIsPrimary(e.target.checked)} + className="w-3 h-3" + /> + <span className="font-mono text-xs text-[#9bc3ff]"> + Set as primary repository + </span> + </label> + + <div className="flex gap-2"> + <button + onClick={handleCancel} + className="px-3 py-1.5 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleAdd} + disabled={ + !name.trim() || + (addMode === "remote" && !url.trim()) || + (addMode === "local" && !path.trim()) + } + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Add Repository + </button> + </div> + </div> + ) : ( + <div className="flex gap-2"> + <button + onClick={() => setAddMode("remote")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Remote + </button> + <button + onClick={() => setAddMode("local")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Local + </button> + <button + onClick={() => setAddMode("managed")} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Managed + </button> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx new file mode 100644 index 0000000..07421ef --- /dev/null +++ b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx @@ -0,0 +1,221 @@ +import { useState, useCallback } from "react"; + +export interface ParsedTask { + name: string; + description?: string; + group?: string; + order: number; + completed: boolean; + dependencies: string[]; +} + +interface TaskDerivationPreviewProps { + tasks: ParsedTask[]; + groups: string[]; + fileName: string; + onCreateTasks: (selectedTasks: ParsedTask[]) => void; + onCancel: () => void; + loading?: boolean; +} + +export function TaskDerivationPreview({ + tasks, + groups, + fileName, + onCreateTasks, + onCancel, + loading = false, +}: TaskDerivationPreviewProps) { + const [selectedIndices, setSelectedIndices] = useState<Set<number>>( + new Set(tasks.map((_, i) => i)) // Select all by default + ); + + const toggleTask = useCallback((index: number) => { + setSelectedIndices((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }, []); + + const selectAll = useCallback(() => { + setSelectedIndices(new Set(tasks.map((_, i) => i))); + }, [tasks]); + + const selectNone = useCallback(() => { + setSelectedIndices(new Set()); + }, []); + + const handleCreate = useCallback(() => { + const selectedTasks = tasks.filter((_, i) => selectedIndices.has(i)); + onCreateTasks(selectedTasks); + }, [tasks, selectedIndices, onCreateTasks]); + + // Group tasks by their group property + const tasksByGroup = tasks.reduce((acc, task, index) => { + const groupKey = task.group || "Ungrouped"; + if (!acc[groupKey]) { + acc[groupKey] = []; + } + acc[groupKey].push({ task, index }); + return acc; + }, {} as Record<string, { task: ParsedTask; index: number }[]>); + + const selectedCount = selectedIndices.size; + const totalCount = tasks.length; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-2xl p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> + {/* Header */} + <div className="flex items-center justify-between mb-4"> + <div> + <h3 className="font-mono text-sm text-[#75aafc] uppercase"> + Create Tasks from Document + </h3> + <p className="font-mono text-xs text-[#555] mt-1"> + Source: {fileName} + </p> + </div> + <div className="flex items-center gap-2"> + <button + onClick={selectAll} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Select All + </button> + <span className="text-[#555]">|</span> + <button + onClick={selectNone} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Select None + </button> + </div> + </div> + + {/* Task List */} + <div className="flex-1 overflow-y-auto space-y-4 mb-4"> + {groups.length > 0 ? ( + // Grouped view + Object.entries(tasksByGroup).map(([groupName, groupTasks]) => ( + <div key={groupName} className="space-y-2"> + <h4 className="font-mono text-xs text-[#9bc3ff] uppercase border-b border-[rgba(117,170,252,0.2)] pb-1"> + {groupName} + </h4> + {groupTasks.map(({ task, index }) => ( + <TaskItem + key={index} + task={task} + index={index} + selected={selectedIndices.has(index)} + onToggle={() => toggleTask(index)} + /> + ))} + </div> + )) + ) : ( + // Flat view + tasks.map((task, index) => ( + <TaskItem + key={index} + task={task} + index={index} + selected={selectedIndices.has(index)} + onToggle={() => toggleTask(index)} + /> + )) + )} + </div> + + {/* Footer */} + <div className="flex items-center justify-between pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <span className="font-mono text-xs text-[#555]"> + {selectedCount} of {totalCount} tasks selected + </span> + <div className="flex gap-2"> + <button + onClick={onCancel} + disabled={loading} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + onClick={handleCreate} + disabled={loading || selectedCount === 0} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {loading ? "Creating..." : `Create ${selectedCount} Task${selectedCount !== 1 ? "s" : ""}`} + </button> + </div> + </div> + + {/* Chaining info */} + {selectedCount > 1 && ( + <p className="font-mono text-[10px] text-[#555] mt-2 text-center"> + Tasks will be chained: each task continues from the previous one's work + </p> + )} + </div> + </div> + ); +} + +function TaskItem({ + task, + index, + selected, + onToggle, +}: { + task: ParsedTask; + index: number; + selected: boolean; + onToggle: () => void; +}) { + return ( + <button + onClick={onToggle} + className={`w-full text-left p-3 border transition-colors ${ + selected + ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" + : "border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)]" + }`} + > + <div className="flex items-start gap-2"> + <span + className={`font-mono text-xs mt-0.5 ${ + selected ? "text-[#75aafc]" : "text-[#555]" + }`} + > + {selected ? "[x]" : "[ ]"} + </span> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-mono text-[10px] text-[#555]">#{index + 1}</span> + <span className="font-mono text-sm text-[#dbe7ff]">{task.name}</span> + {task.completed && ( + <span className="font-mono text-[9px] text-green-400 uppercase"> + done in source + </span> + )} + </div> + {task.description && ( + <p className="font-mono text-xs text-[#555] mt-1 truncate"> + {task.description} + </p> + )} + {task.dependencies.length > 0 && ( + <p className="font-mono text-[10px] text-[#75aafc] mt-1"> + Depends on: {task.dependencies.join(", ")} + </p> + )} + </div> + </div> + </button> + ); +} 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  to link syntax [alt](url) or [image](url) - const convertImagesToLinks = (text: string): string => { - return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { - const linkText = alt || 'image'; - return `[${linkText}](${url})`; - }); - }; - - for (const rawLine of lines) { - // Check for code block fence (``` or ~~~) - const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); - if (codeFenceMatch) { - if (!inCodeBlock) { - // Starting a code block - flushParagraph(); - flushList(); - inCodeBlock = true; - codeBlockLanguage = codeFenceMatch[2] || undefined; - codeBlockContent = []; - } else { - // Ending a code block - flushCodeBlock(); - inCodeBlock = false; - } - continue; - } - - // If inside a code block, add line as-is - if (inCodeBlock) { - codeBlockContent.push(rawLine); - continue; - } - - // Convert images to links in all lines - const line = convertImagesToLinks(rawLine); - - // Check for headings - const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); - if (headingMatch) { - flushParagraph(); - flushList(); - const level = headingMatch[1].length; - const text = headingMatch[2].trim(); - elements.push({ type: "heading", level, text }); - continue; - } - - // Check for unordered list items (-, *, +) - const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); - if (unorderedMatch) { - flushParagraph(); - const itemText = unorderedMatch[1].trim(); - if (currentList && currentList.ordered) { - // Switch from ordered to unordered - flushList(); - } - if (!currentList) { - currentList = { ordered: false, items: [] }; - } - currentList.items.push(itemText); - continue; - } - - // Check for ordered list items (1. 2. etc) - const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); - if (orderedMatch) { - flushParagraph(); - const itemText = orderedMatch[1].trim(); - if (currentList && !currentList.ordered) { - // Switch from unordered to ordered - flushList(); - } - if (!currentList) { - currentList = { ordered: true, items: [] }; - } - currentList.items.push(itemText); - continue; - } - - // Empty line - flush everything - if (line.trim() === '') { - flushParagraph(); - flushList(); - continue; - } - - // Regular text - flush list first, then add to paragraph - flushList(); - currentParagraph.push(line); - } - - // Flush any remaining content - if (inCodeBlock) { - flushCodeBlock(); - } - flushParagraph(); - flushList(); - - return elements; -} - function formatDuration(seconds: number | null): string { if (seconds === null) return "-"; const mins = Math.floor(seconds / 60); @@ -175,6 +30,14 @@ function formatDate(dateStr: string): string { }); } +const phaseColors: Record<ContractPhase, string> = { + research: "bg-purple-500/20 text-purple-400 border-purple-400/30", + specify: "bg-blue-500/20 text-blue-400 border-blue-400/30", + plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30", + execute: "bg-green-500/20 text-green-400 border-green-400/30", + review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30", +}; + export function FileList({ files, loading, @@ -183,6 +46,7 @@ export function FileList({ onCreate, onUploadMarkdown, }: FileListProps) { + const navigate = useNavigate(); const fileInputRef = useRef<HTMLInputElement>(null); const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { @@ -193,7 +57,7 @@ export function FileList({ reader.onload = (e) => { const content = e.target?.result as string; if (content) { - const body = parseMarkdown(content); + const body = markdownToBody(content); // Use filename without extension as the name const name = file.name.replace(/\.md$/i, '') || 'Imported Document'; onUploadMarkdown(name, body); @@ -273,10 +137,25 @@ export function FileList({ {file.description} </p> )} - <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> + <div className="flex items-center gap-4 font-mono text-[10px] text-[#75aafc]"> <span>{file.transcriptCount} segments</span> <span>{formatDuration(file.duration)}</span> <span>{formatDate(file.createdAt)}</span> + {file.contractId && file.contractName && ( + <button + onClick={(e) => { + e.stopPropagation(); + navigate(`/contracts/${file.contractId}`); + }} + className={`px-2 py-0.5 text-[9px] font-mono uppercase border rounded ${ + file.contractPhase ? phaseColors[file.contractPhase] : "bg-[#21262d] text-[#8b949e] border-[#30363d]" + } hover:opacity-80 transition-opacity`} + title={`View contract: ${file.contractName}`} + > + {file.contractName} + {file.contractPhase && ` · ${file.contractPhase}`} + </button> + )} </div> </button> <button diff --git a/makima/frontend/src/components/files/RepoSyncIndicator.tsx b/makima/frontend/src/components/files/RepoSyncIndicator.tsx new file mode 100644 index 0000000..82d79f7 --- /dev/null +++ b/makima/frontend/src/components/files/RepoSyncIndicator.tsx @@ -0,0 +1,190 @@ +import { useState, useCallback } from "react"; +import { syncFileFromRepo } from "../../lib/api"; + +interface RepoSyncIndicatorProps { + fileId: string; + repoFilePath: string | null | undefined; + repoSyncStatus: string | null | undefined; + repoSyncedAt: string | null | undefined; + onSyncComplete?: () => void; + /** Callback to push file content to repo (creates a task) */ + onPushToRepo?: () => void; + /** Whether a push operation is in progress */ + isPushing?: boolean; +} + +/** + * Shows repository file link status and provides sync functionality. + * Displays the linked file path and allows updating from the repo via daemon, + * or pushing local changes back to the repo. + */ +export function RepoSyncIndicator({ + fileId, + repoFilePath, + repoSyncStatus, + repoSyncedAt, + onSyncComplete, + onPushToRepo, + isPushing = false, +}: RepoSyncIndicatorProps) { + const [isSyncing, setIsSyncing] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleSync = useCallback(async () => { + setIsSyncing(true); + setError(null); + try { + await syncFileFromRepo(fileId); + // The actual update happens via WebSocket notification + // Give a brief delay then notify parent + setTimeout(() => { + onSyncComplete?.(); + }, 500); + } catch (err) { + setError(err instanceof Error ? err.message : "Sync failed"); + } finally { + setIsSyncing(false); + } + }, [fileId, onSyncComplete]); + + // Don't render if no repo file path is set + if (!repoFilePath) { + return null; + } + + const isActuallySyncing = isSyncing || repoSyncStatus === "syncing"; + const isSynced = repoSyncStatus === "synced"; + const isModified = repoSyncStatus === "modified"; + + // Format the synced timestamp + const syncedAtFormatted = repoSyncedAt + ? new Date(repoSyncedAt).toLocaleString() + : null; + + return ( + <div className="flex items-center gap-2 text-xs font-mono"> + {/* File path icon and link */} + <div className="flex items-center gap-1 text-[#555]"> + <svg + width="12" + height="12" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + className="flex-shrink-0" + > + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> + <polyline points="14 2 14 8 20 8" /> + </svg> + <span className="text-[#75aafc]" title={`Linked to repository file: ${repoFilePath}`}> + {repoFilePath} + </span> + </div> + + {/* Status indicator */} + {isSynced && ( + <span + className="text-green-500 flex items-center gap-1" + title={syncedAtFormatted ? `Last synced: ${syncedAtFormatted}` : "Synced"} + > + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <polyline points="20 6 9 17 4 12" /> + </svg> + </span> + )} + {isModified && ( + <span className="text-yellow-500" title="File modified, may need sync"> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <circle cx="12" cy="12" r="10" /> + <line x1="12" y1="8" x2="12" y2="12" /> + <line x1="12" y1="16" x2="12.01" y2="16" /> + </svg> + </span> + )} + + {/* Pull from repo button */} + <button + onClick={handleSync} + disabled={isActuallySyncing || isPushing} + className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${ + isActuallySyncing || isPushing + ? "text-[#555] cursor-wait" + : "text-[#555] hover:text-[#75aafc] hover:bg-[rgba(117,170,252,0.1)]" + }`} + title="Pull latest from repository" + > + {isActuallySyncing ? ( + <> + <svg + width="10" + height="10" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + className="animate-spin" + > + <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" /> + </svg> + <span>Pulling...</span> + </> + ) : ( + <> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M12 19V5" /> + <path d="M5 12l7-7 7 7" /> + </svg> + <span>Pull</span> + </> + )} + </button> + + {/* Push to repo button */} + {onPushToRepo && ( + <button + onClick={onPushToRepo} + disabled={isActuallySyncing || isPushing} + className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${ + isPushing + ? "text-[#555] cursor-wait" + : "text-[#555] hover:text-green-500 hover:bg-[rgba(34,197,94,0.1)]" + }`} + title="Push changes to repository (creates a task)" + > + {isPushing ? ( + <> + <svg + width="10" + height="10" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + className="animate-spin" + > + <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" /> + </svg> + <span>Pushing...</span> + </> + ) : ( + <> + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M12 5v14" /> + <path d="M19 12l-7 7-7-7" /> + </svg> + <span>Push</span> + </> + )} + </button> + )} + + {/* Error message */} + {error && ( + <span className="text-red-500 text-[10px]" title={error}> + Failed + </span> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx index af2cd05..35834d4 100644 --- a/makima/frontend/src/components/listen/ControlPanel.tsx +++ b/makima/frontend/src/components/listen/ControlPanel.tsx @@ -1,6 +1,11 @@ import { Logo } from "../Logo"; import type { MicrophoneStatus } from "../../hooks/useMicrophone"; +export interface ContractOption { + id: string; + name: string; +} + interface ControlPanelProps { isListening: boolean; isConnected: boolean; @@ -10,6 +15,11 @@ interface ControlPanelProps { onToggle: () => void; onNew: () => void; error?: string | null; + // Contract selection + contracts: ContractOption[]; + selectedContractId: string | null; + onContractChange: (contractId: string | null) => void; + contractsLoading?: boolean; } function getStatusText(isListening: boolean, micStatus: MicrophoneStatus): string { @@ -38,6 +48,10 @@ export function ControlPanel({ onToggle, onNew, error, + contracts, + selectedContractId, + onContractChange, + contractsLoading, }: ControlPanelProps) { const statusText = getStatusText(isListening, micStatus); const isRequesting = micStatus === "requesting"; @@ -133,13 +147,20 @@ export function ControlPanel({ > New </button> - <button - disabled - className="px-3 py-1.5 font-mono text-xs text-[#9aa9c6] bg-[#0b1423] border border-[rgba(117,170,252,0.25)] cursor-not-allowed uppercase tracking-wide opacity-50" - title="File upload coming soon" + <select + value={selectedContractId || ""} + onChange={(e) => onContractChange(e.target.value || null)} + disabled={isListening || contractsLoading} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] focus:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide" + title={selectedContractId ? "Saving to selected contract" : "Transcript not saved"} > - Upload - </button> + <option value="">Ephemeral Transcript</option> + {contracts.map((contract) => ( + <option key={contract.id} value={contract.id}> + {contract.name} + </option> + ))} + </select> </div> </div> ); diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index be4fb80..967b1d1 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -23,6 +23,8 @@ interface TaskDetailProps { onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void; /** Which subtask's output is currently being viewed */ viewingSubtaskId?: string | null; + /** Navigate to view the contract */ + onViewContract?: (contractId: string) => void; // Optional advanced features overlayDiff?: string; changedFiles?: string[]; @@ -105,6 +107,7 @@ export function TaskDetail({ onCreateSubtask, onToggleSubtaskOutput, viewingSubtaskId, + onViewContract, overlayDiff, changedFiles, onRequestDiff, @@ -417,6 +420,15 @@ export function TaskDetail({ > {task.status} </span> + {/* Contract badge - clickable to view contract */} + {task.contractId && onViewContract && ( + <button + onClick={() => onViewContract(task.contractId!)} + className="px-2 py-0.5 font-mono text-xs text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/20 transition-colors" + > + Contract + </button> + )} {/* Orchestrator badge for depth 0 tasks with subtasks */} {task.depth === 0 && task.subtasks.length > 0 && ( <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20"> diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx index a37e564..d013782 100644 --- a/makima/frontend/src/components/mesh/TaskList.tsx +++ b/makima/frontend/src/components/mesh/TaskList.tsx @@ -1,4 +1,5 @@ -import type { TaskSummary, TaskStatus } from "../../lib/api"; +import { useMemo } from "react"; +import type { TaskSummary, TaskStatus, ContractPhase } from "../../lib/api"; interface TaskListProps { tasks: TaskSummary[]; @@ -8,6 +9,13 @@ interface TaskListProps { onCreate: () => void; } +interface GroupedTasks { + contractId: string | null; + contractName: string | null; + contractPhase: ContractPhase | null; + tasks: TaskSummary[]; +} + function formatDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleDateString("en-US", { @@ -61,6 +69,17 @@ function getStatusBgColor(status: TaskStatus): string { } } +function getPhaseColor(phase: ContractPhase | null): string { + switch (phase) { + case "research": return "text-blue-400"; + case "specify": return "text-cyan-400"; + case "plan": return "text-yellow-400"; + case "execute": return "text-green-400"; + case "review": return "text-purple-400"; + default: return "text-[#8b949e]"; + } +} + export function TaskList({ tasks, loading, @@ -68,6 +87,53 @@ export function TaskList({ onDelete, onCreate, }: TaskListProps) { + // Group tasks by contract + const groupedTasks = useMemo(() => { + // Separate root tasks (no parent) from subtasks + const rootTasks = tasks.filter((t) => !t.parentTaskId); + + // Group by contractId + const groups = new Map<string | null, GroupedTasks>(); + + for (const task of rootTasks) { + const key = task.contractId; + if (!groups.has(key)) { + groups.set(key, { + contractId: task.contractId, + contractName: task.contractName, + contractPhase: task.contractPhase, + tasks: [], + }); + } + groups.get(key)!.tasks.push(task); + } + + // Sort tasks within each group: supervisors first, then by status (running first), then by date + for (const group of groups.values()) { + group.tasks.sort((a, b) => { + // Supervisors always first + if (a.isSupervisor && !b.isSupervisor) return -1; + if (!a.isSupervisor && b.isSupervisor) return 1; + // Running tasks next + if (a.status === "running" && b.status !== "running") return -1; + if (a.status !== "running" && b.status === "running") return 1; + // Then by date (newest first) + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + } + + // Sort: contracts first (alphabetically by name), then orphan tasks + const sorted = Array.from(groups.values()).sort((a, b) => { + // Orphan tasks (no contract) go last + if (!a.contractId && b.contractId) return 1; + if (a.contractId && !b.contractId) return -1; + // Sort by contract name + return (a.contractName || "").localeCompare(b.contractName || ""); + }); + + return sorted; + }, [tasks]); + if (loading) { return ( <div className="panel h-full flex items-center justify-center"> @@ -76,8 +142,7 @@ export function TaskList({ ); } - // Separate root tasks (no parent) from subtasks - const rootTasks = tasks.filter((t) => !t.parentTaskId); + const totalTasks = groupedTasks.reduce((sum, g) => sum + g.tasks.length, 0); return ( <div className="panel h-full flex flex-col"> @@ -94,65 +159,107 @@ export function TaskList({ </div> <div className="flex-1 overflow-y-auto"> - {rootTasks.length === 0 ? ( + {totalTasks === 0 ? ( <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> No tasks yet. Create one to start orchestrating Claude Code instances. </div> ) : ( - <div className="divide-y divide-[rgba(117,170,252,0.15)]"> - {rootTasks.map((task) => ( - <div - key={task.id} - className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors" - > - <div className="flex items-start justify-between gap-4"> - <button - onClick={() => onSelect(task.id)} - className="flex-1 text-left" - > - <div className="flex items-center gap-2 mb-1"> - <h3 className="font-mono text-sm text-[#dbe7ff]"> - {task.name} - </h3> - <span - className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor( - task.status - )} ${getStatusBgColor(task.status)} border border-current/20`} - > - {task.status} + <div> + {groupedTasks.map((group) => ( + <div key={group.contractId || "orphan"}> + {/* Contract header */} + <div className="sticky top-0 bg-[#0d1117] border-b border-[rgba(117,170,252,0.25)] px-4 py-2 flex items-center gap-2"> + {group.contractId ? ( + <> + <span className="font-mono text-xs text-[#dbe7ff] font-medium"> + {group.contractName} </span> - {task.depth === 0 && task.subtaskCount > 0 && ( - <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20"> - Orchestrator + {group.contractPhase && ( + <span className={`font-mono text-[10px] ${getPhaseColor(group.contractPhase)}`}> + [{group.contractPhase}] </span> )} - {task.priority > 0 && ( - <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20"> - P{task.priority} - </span> - )} - </div> - {task.progressSummary && ( - <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2"> - {task.progressSummary} - </p> - )} - <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> - {task.subtaskCount > 0 && ( - <span>{task.subtaskCount} subtasks</span> - )} - <span>{formatDate(task.createdAt)}</span> + <span className="font-mono text-[10px] text-[#8b949e]"> + ({group.tasks.length}) + </span> + </> + ) : ( + <span className="font-mono text-xs text-[#8b949e] italic"> + Unassigned Tasks ({group.tasks.length}) + </span> + )} + </div> + + {/* Tasks in this group */} + <div className="divide-y divide-[rgba(117,170,252,0.15)]"> + {group.tasks.map((task) => ( + <div + key={task.id} + className={`p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors ${ + task.isSupervisor + ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]" + : "" + }`} + > + <div className="flex items-start justify-between gap-4"> + <button + onClick={() => onSelect(task.id)} + className="flex-1 text-left" + > + <div className="flex items-center gap-2 mb-1"> + <h3 className="font-mono text-sm text-[#dbe7ff]"> + {task.name} + </h3> + <span + className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor( + task.status + )} ${getStatusBgColor(task.status)} border border-current/20`} + > + {task.status} + </span> + {task.isSupervisor && ( + <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[#0f3c78] border border-[#3f6fb3] uppercase"> + Supervisor + </span> + )} + {!task.isSupervisor && task.depth === 0 && task.subtaskCount > 0 && ( + <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20"> + Orchestrator + </span> + )} + {task.priority > 0 && ( + <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20"> + P{task.priority} + </span> + )} + </div> + {task.progressSummary && ( + <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2"> + {task.progressSummary} + </p> + )} + <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> + {task.subtaskCount > 0 && ( + <span>{task.subtaskCount} subtasks</span> + )} + <span>{formatDate(task.createdAt)}</span> + </div> + </button> + {/* Supervisor tasks cannot be deleted directly - they are deleted with the contract */} + {!task.isSupervisor && ( + <button + onClick={(e) => { + e.stopPropagation(); + onDelete(task.id); + }} + className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + )} + </div> </div> - </button> - <button - onClick={(e) => { - e.stopPropagation(); - onDelete(task.id); - }} - className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" - > - Delete - </button> + ))} </div> </div> ))} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index 10de225..cb0eba3 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -275,7 +275,103 @@ function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) { </div> ); + case "auth_required": + return <AuthRequiredEntry entry={entry} />; + default: return null; } } + +function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { + const [authCode, setAuthCode] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState<string | null>(null); + + const loginUrl = entry.toolInput?.loginUrl as string | undefined; + const hostname = entry.toolInput?.hostname as string | undefined; + // Get taskId from entry or fallback to toolInput (for robustness) + const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!authCode.trim() || !taskId) return; + + setSubmitting(true); + setError(null); + + try { + // Send the auth code to the task via the message endpoint + await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`); + setSubmitted(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit code"); + } finally { + setSubmitting(false); + } + }; + + if (submitted) { + return ( + <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-green-400 font-semibold"> + <span>✓</span> + <span>Authentication code submitted</span> + </div> + <p className="text-green-200/80 text-sm mt-1"> + Waiting for authentication to complete... + </p> + </div> + ); + } + + return ( + <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2"> + <span>🔐</span> + <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span> + </div> + <p className="text-amber-200/80 text-sm mb-3"> + The daemon's OAuth token has expired. Click the button to login, then paste the code below: + </p> + + <div className="flex flex-col gap-3"> + {loginUrl ? ( + <a + href={loginUrl} + target="_blank" + rel="noopener noreferrer" + className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center" + > + 1. Login to Claude + </a> + ) : ( + <p className="text-red-400 text-sm">Login URL not available</p> + )} + + <form onSubmit={handleSubmit} className="flex gap-2"> + <input + type="text" + value={authCode} + onChange={(e) => setAuthCode(e.target.value)} + placeholder="2. Paste authentication code here" + className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" + disabled={submitting} + /> + <button + type="submit" + disabled={submitting || !authCode.trim()} + className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors" + > + {submitting ? "..." : "Submit"} + </button> + </form> + + {error && ( + <p className="text-red-400 text-sm">{error}</p> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskTree.tsx b/makima/frontend/src/components/mesh/TaskTree.tsx new file mode 100644 index 0000000..46ae78d --- /dev/null +++ b/makima/frontend/src/components/mesh/TaskTree.tsx @@ -0,0 +1,390 @@ +import { useState, useCallback } from "react"; +import type { TaskSummary, TaskStatus } from "../../lib/api"; + +interface TaskTreeProps { + tasks: TaskSummary[]; + supervisorTaskId: string | null; + onSelect: (taskId: string) => void; + onStartSupervisor?: () => void; + loading?: boolean; + fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; +} + +interface TreeNodeProps { + task: TaskSummary; + isSupervisorTask: boolean; + onSelect: (taskId: string) => void; + depth: number; + fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; +} + +function getStatusColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "text-[#9bc3ff]"; + case "running": + return "text-green-400"; + case "paused": + return "text-yellow-400"; + case "blocked": + return "text-orange-400"; + case "done": + return "text-emerald-400"; + case "failed": + return "text-red-400"; + case "merged": + return "text-purple-400"; + default: + return "text-[#9bc3ff]"; + } +} + +function getStatusIcon(status: TaskStatus): string { + switch (status) { + case "pending": + return "○"; + case "running": + return "◉"; + case "paused": + return "◎"; + case "blocked": + return "◈"; + case "done": + return "●"; + case "failed": + return "✕"; + case "merged": + return "◆"; + default: + return "○"; + } +} + +function TreeNode({ task, isSupervisorTask, onSelect, depth, fetchSubtasks }: TreeNodeProps) { + const [expanded, setExpanded] = useState(isSupervisorTask); // Supervisor expanded by default + const [children, setChildren] = useState<TaskSummary[] | null>(null); + const [loadingChildren, setLoadingChildren] = useState(false); + + const hasSubtasks = task.subtaskCount > 0; + + const handleToggle = useCallback(async () => { + if (!hasSubtasks) return; + + if (expanded) { + setExpanded(false); + } else { + if (!children && fetchSubtasks) { + setLoadingChildren(true); + try { + const subtasks = await fetchSubtasks(task.id); + setChildren(subtasks); + } catch (err) { + console.error("Failed to fetch subtasks:", err); + } finally { + setLoadingChildren(false); + } + } + setExpanded(true); + } + }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]); + + const indent = depth * 16; + + return ( + <div className="select-none"> + <div + className={`flex items-center gap-2 py-2 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group ${ + isSupervisorTask ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]" : "" + }`} + style={{ paddingLeft: `${indent + 8}px` }} + > + {/* Expand/Collapse button */} + <button + onClick={handleToggle} + className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${ + hasSubtasks + ? "text-[#75aafc] hover:text-[#9bc3ff]" + : "text-transparent cursor-default" + }`} + disabled={!hasSubtasks} + > + {loadingChildren ? ( + <span className="animate-spin">...</span> + ) : hasSubtasks ? ( + expanded ? "▼" : "▶" + ) : ( + "" + )} + </button> + + {/* Supervisor badge or status icon */} + {isSupervisorTask ? ( + <span className="px-1.5 py-0.5 font-mono text-[9px] bg-[#0f3c78] text-[#75aafc] border border-[#3f6fb3] uppercase"> + Supervisor + </span> + ) : ( + <span + className={`font-mono text-xs ${getStatusColor(task.status)}`} + title={task.status} + > + {getStatusIcon(task.status)} + </span> + )} + + {/* Task name - clickable */} + <button + onClick={() => onSelect(task.id)} + className={`flex-1 text-left font-mono text-sm transition-colors truncate ${ + isSupervisorTask + ? "text-[#9bc3ff] hover:text-white font-medium" + : "text-[#dbe7ff] hover:text-white" + }`} + > + {task.name} + </button> + + {/* Status for supervisor */} + {isSupervisorTask && ( + <span + className={`font-mono text-[10px] ${getStatusColor(task.status)}`} + > + {task.status} + </span> + )} + + {/* Subtask count badge */} + {hasSubtasks && !isSupervisorTask && ( + <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]"> + {task.subtaskCount} sub + </span> + )} + + {/* Priority indicator */} + {task.priority > 0 && ( + <span className="font-mono text-[9px] text-orange-400"> + P{task.priority} + </span> + )} + </div> + + {/* Children */} + {expanded && children && children.length > 0 && ( + <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}> + {children.map((child) => ( + <TreeNode + key={child.id} + task={child} + isSupervisorTask={false} + onSelect={onSelect} + depth={depth + 1} + fetchSubtasks={fetchSubtasks} + /> + ))} + </div> + )} + </div> + ); +} + +// Stats summary component +export interface TaskTreeStats { + total: number; + pending: number; + running: number; + paused: number; + blocked: number; + done: number; + failed: number; + merged: number; +} + +export function calculateTreeStats(tasks: TaskSummary[]): TaskTreeStats { + const stats: TaskTreeStats = { + total: tasks.length, + pending: 0, + running: 0, + paused: 0, + blocked: 0, + done: 0, + failed: 0, + merged: 0, + }; + + for (const task of tasks) { + // Skip supervisor task in stats + if (task.isSupervisor) continue; + + switch (task.status) { + case "pending": + stats.pending++; + break; + case "running": + stats.running++; + break; + case "paused": + stats.paused++; + break; + case "blocked": + stats.blocked++; + break; + case "done": + stats.done++; + break; + case "failed": + stats.failed++; + break; + case "merged": + stats.merged++; + break; + } + } + + // Adjust total to exclude supervisor + stats.total = tasks.filter(t => !t.isSupervisor).length; + + return stats; +} + +// Progress bar for task tree +export function TaskTreeProgressBar({ stats }: { stats: TaskTreeStats }) { + if (stats.total === 0) return null; + + const completedPercent = ((stats.done + stats.merged) / stats.total) * 100; + const runningPercent = (stats.running / stats.total) * 100; + const failedPercent = (stats.failed / stats.total) * 100; + + return ( + <div className="space-y-2"> + {/* Progress bar */} + <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden flex"> + <div + className="bg-emerald-400 transition-all" + style={{ width: `${completedPercent}%` }} + title={`Completed: ${stats.done + stats.merged}`} + /> + <div + className="bg-green-400 transition-all" + style={{ width: `${runningPercent}%` }} + title={`Running: ${stats.running}`} + /> + <div + className="bg-red-400 transition-all" + style={{ width: `${failedPercent}%` }} + title={`Failed: ${stats.failed}`} + /> + </div> + + {/* Summary */} + <div className="flex items-center justify-between font-mono text-[10px]"> + <span className="text-[#555]"> + {stats.done + stats.merged} / {stats.total} completed + </span> + {stats.running > 0 && ( + <span className="text-green-400">{stats.running} running</span> + )} + {stats.failed > 0 && ( + <span className="text-red-400">{stats.failed} failed</span> + )} + </div> + </div> + ); +} + +export function TaskTree({ + tasks, + supervisorTaskId, + onSelect, + onStartSupervisor, + loading = false, + fetchSubtasks, +}: TaskTreeProps) { + if (loading) { + return ( + <div className="p-4 text-center font-mono text-xs text-[#555]"> + Loading tasks... + </div> + ); + } + + // Separate supervisor from other tasks + const supervisorTask = tasks.find(t => t.id === supervisorTaskId || t.isSupervisor); + const workerTasks = tasks.filter(t => t.id !== supervisorTaskId && !t.isSupervisor && !t.parentTaskId); + + // Calculate stats for worker tasks + const stats = calculateTreeStats(tasks); + + return ( + <div className="space-y-4"> + {/* Supervisor Section */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Contract Supervisor + </h3> + {supervisorTask && supervisorTask.status === "pending" && onStartSupervisor && ( + <button + onClick={onStartSupervisor} + className="px-2 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + Start Supervisor + </button> + )} + </div> + + {supervisorTask ? ( + <TreeNode + task={supervisorTask} + isSupervisorTask={true} + onSelect={onSelect} + depth={0} + fetchSubtasks={fetchSubtasks} + /> + ) : ( + <div className="p-3 border border-dashed border-[rgba(117,170,252,0.2)] text-center"> + <p className="font-mono text-xs text-[#555]"> + No supervisor task found + </p> + </div> + )} + </div> + + {/* Progress Section */} + {stats.total > 0 && ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Task Progress + </h3> + <TaskTreeProgressBar stats={stats} /> + </div> + )} + + {/* Worker Tasks Section */} + {workerTasks.length > 0 && ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Worker Tasks ({workerTasks.length}) + </h3> + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {workerTasks.map((task) => ( + <TreeNode + key={task.id} + task={task} + isSupervisorTask={false} + onSelect={onSelect} + depth={0} + fetchSubtasks={fetchSubtasks} + /> + ))} + </div> + </div> + )} + + {/* Empty State */} + {workerTasks.length === 0 && !supervisorTask && ( + <div className="p-4 text-center font-mono text-xs text-[#555]"> + No tasks in this contract + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx new file mode 100644 index 0000000..ddea85f --- /dev/null +++ b/makima/frontend/src/components/workflow/PhaseColumn.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import type { ContractSummary, ContractPhase } from "../../lib/api"; +import { WorkflowContractCard } from "./WorkflowContractCard"; + +interface PhaseColumnProps { + phase: ContractPhase; + contracts: ContractSummary[]; + onContractClick: (contractId: string) => void; + onDrop: (contractId: string, phase: ContractPhase) => void; +} + +const phaseConfig: Record< + ContractPhase, + { label: string; color: string; bgColor: string; borderColor: string } +> = { + research: { + label: "Research", + color: "text-purple-400", + bgColor: "bg-purple-400/10", + borderColor: "border-purple-400/30", + }, + specify: { + label: "Specify", + color: "text-blue-400", + bgColor: "bg-blue-400/10", + borderColor: "border-blue-400/30", + }, + plan: { + label: "Plan", + color: "text-cyan-400", + bgColor: "bg-cyan-400/10", + borderColor: "border-cyan-400/30", + }, + execute: { + label: "Execute", + color: "text-yellow-400", + bgColor: "bg-yellow-400/10", + borderColor: "border-yellow-400/30", + }, + review: { + label: "Review", + color: "text-green-400", + bgColor: "bg-green-400/10", + borderColor: "border-green-400/30", + }, +}; + +export function PhaseColumn({ + phase, + contracts, + onContractClick, + onDrop, +}: PhaseColumnProps) { + const [isDragOver, setIsDragOver] = useState(false); + const config = phaseConfig[phase]; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = () => { + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const contractId = e.dataTransfer.getData("contractId"); + if (contractId) { + onDrop(contractId, phase); + } + }; + + return ( + <div + className={` + flex flex-col min-w-[220px] flex-1 border border-[rgba(117,170,252,0.15)] + ${isDragOver ? "bg-[rgba(117,170,252,0.05)]" : "bg-transparent"} + transition-colors + `} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + {/* Column header */} + <div + className={` + p-3 border-b ${config.borderColor} ${config.bgColor} + flex items-center justify-between + `} + > + <span className={`font-mono text-xs uppercase tracking-wider ${config.color}`}> + {config.label} + </span> + <span className="font-mono text-[10px] text-[#555]"> + ({contracts.length}) + </span> + </div> + + {/* Cards container */} + <div className="flex-1 overflow-y-auto p-2 space-y-2"> + {contracts.length === 0 ? ( + <div className="p-4 text-center font-mono text-[10px] text-[#555]"> + No contracts + </div> + ) : ( + contracts.map((contract) => ( + <WorkflowContractCard + key={contract.id} + contract={contract} + onClick={() => onContractClick(contract.id)} + onDragStart={(e) => { + e.dataTransfer.setData("contractId", contract.id); + e.dataTransfer.effectAllowed = "move"; + }} + /> + )) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx new file mode 100644 index 0000000..af4aec7 --- /dev/null +++ b/makima/frontend/src/components/workflow/WorkflowBoard.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import type { ContractSummary, ContractPhase } from "../../lib/api"; +import { PhaseColumn } from "./PhaseColumn"; + +interface WorkflowBoardProps { + contracts: ContractSummary[]; + onContractClick: (contractId: string) => void; + onPhaseChange: (contractId: string, newPhase: ContractPhase) => void; +} + +const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; + +export function WorkflowBoard({ + contracts, + onContractClick, + onPhaseChange, +}: WorkflowBoardProps) { + // Group contracts by phase + const contractsByPhase = useMemo(() => { + const grouped: Record<ContractPhase, ContractSummary[]> = { + research: [], + specify: [], + plan: [], + execute: [], + review: [], + }; + + for (const contract of contracts) { + const phase = contract.phase as ContractPhase; + if (grouped[phase]) { + grouped[phase].push(contract); + } else { + // Default to research if unknown phase + grouped.research.push(contract); + } + } + + return grouped; + }, [contracts]); + + return ( + <div className="flex gap-2 h-full overflow-x-auto"> + {phases.map((phase) => ( + <PhaseColumn + key={phase} + phase={phase} + contracts={contractsByPhase[phase]} + onContractClick={onContractClick} + onDrop={onPhaseChange} + /> + ))} + </div> + ); +} diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx new file mode 100644 index 0000000..e6c8a1c --- /dev/null +++ b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx @@ -0,0 +1,53 @@ +import type { ContractSummary, ContractStatus } from "../../lib/api"; + +interface WorkflowContractCardProps { + contract: ContractSummary; + onClick: () => void; + onDragStart: (e: React.DragEvent) => void; +} + +const statusConfig: Record<ContractStatus, { label: string; color: string }> = { + active: { label: "Active", color: "text-green-400" }, + completed: { label: "Done", color: "text-blue-400" }, + archived: { label: "Archived", color: "text-[#555]" }, +}; + +export function WorkflowContractCard({ + contract, + onClick, + onDragStart, +}: WorkflowContractCardProps) { + const status = statusConfig[contract.status] || statusConfig.active; + + return ( + <div + draggable + onDragStart={onDragStart} + onClick={onClick} + className="p-3 bg-[rgba(9,13,20,0.8)] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] cursor-pointer transition-colors select-none" + > + {/* Name */} + <div className="font-mono text-sm text-[#dbe7ff] truncate mb-1"> + {contract.name} + </div> + + {/* Status and counts row */} + <div className="flex items-center justify-between"> + <span className={`font-mono text-[10px] uppercase ${status.color}`}> + {status.label} + </span> + <div className="flex items-center gap-2 font-mono text-[10px] text-[#555]"> + <span title="Files">{contract.fileCount} files</span> + <span title="Tasks">{contract.taskCount} tasks</span> + </div> + </div> + + {/* Description preview if exists */} + {contract.description && ( + <div className="mt-1 font-mono text-[10px] text-[#555] truncate"> + {contract.description} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/hooks/useContracts.ts b/makima/frontend/src/hooks/useContracts.ts new file mode 100644 index 0000000..f803527 --- /dev/null +++ b/makima/frontend/src/hooks/useContracts.ts @@ -0,0 +1,308 @@ +import { useState, useCallback, useEffect } from "react"; +import { + listContracts, + getContract, + createContract, + updateContract, + deleteContract, + changeContractPhase, + getContractEvents, + addRemoteRepository, + addLocalRepository, + createManagedRepository, + deleteContractRepository, + setRepositoryPrimary, + addTaskToContract, + removeTaskFromContract, + VersionConflictError, + type ContractSummary, + type ContractWithRelations, + type ContractEvent, + type ContractRepository, + type ContractPhase, + type CreateContractRequest, + type UpdateContractRequest, + type AddRemoteRepositoryRequest, + type AddLocalRepositoryRequest, + type CreateManagedRepositoryRequest, +} from "../lib/api"; + +export interface ConflictState { + hasConflict: boolean; + expectedVersion: number; + actualVersion: number; +} + +export function useContracts() { + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [conflict, setConflict] = useState<ConflictState | null>(null); + + const fetchContracts = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await listContracts(); + setContracts(response.contracts); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch contracts"); + } finally { + setLoading(false); + } + }, []); + + const fetchContract = useCallback( + async (id: string): Promise<ContractWithRelations | null> => { + setError(null); + try { + return await getContract(id); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch contract"); + return null; + } + }, + [] + ); + + const saveContract = useCallback( + async (data: CreateContractRequest): Promise<ContractSummary | null> => { + setError(null); + try { + const contract = await createContract(data); + await fetchContracts(); // Refresh list + return contract; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save contract"); + return null; + } + }, + [fetchContracts] + ); + + const editContract = useCallback( + async ( + id: string, + data: UpdateContractRequest + ): Promise<ContractSummary | null> => { + setError(null); + setConflict(null); + try { + const contract = await updateContract(id, data); + await fetchContracts(); // Refresh list + return contract; + } catch (e) { + if (e instanceof VersionConflictError) { + setConflict({ + hasConflict: true, + expectedVersion: e.expectedVersion, + actualVersion: e.actualVersion, + }); + return null; + } + setError(e instanceof Error ? e.message : "Failed to update contract"); + return null; + } + }, + [fetchContracts] + ); + + const clearConflict = useCallback(() => { + setConflict(null); + }, []); + + const removeContract = useCallback( + async (id: string): Promise<boolean> => { + setError(null); + try { + await deleteContract(id); + await fetchContracts(); // Refresh list + return true; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete contract"); + return false; + } + }, + [fetchContracts] + ); + + const changePhase = useCallback( + async ( + id: string, + phase: ContractPhase + ): Promise<ContractSummary | null> => { + setError(null); + try { + const contract = await changeContractPhase(id, phase); + await fetchContracts(); // Refresh list + return contract; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to change phase"); + return null; + } + }, + [fetchContracts] + ); + + const fetchEvents = useCallback( + async (id: string): Promise<ContractEvent[]> => { + setError(null); + try { + return await getContractEvents(id); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch events"); + return []; + } + }, + [] + ); + + // Repository management + const addRemoteRepo = useCallback( + async ( + contractId: string, + data: AddRemoteRepositoryRequest + ): Promise<ContractRepository | null> => { + setError(null); + try { + return await addRemoteRepository(contractId, data); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to add remote repository" + ); + return null; + } + }, + [] + ); + + const addLocalRepo = useCallback( + async ( + contractId: string, + data: AddLocalRepositoryRequest + ): Promise<ContractRepository | null> => { + setError(null); + try { + return await addLocalRepository(contractId, data); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to add local repository" + ); + return null; + } + }, + [] + ); + + const createManagedRepo = useCallback( + async ( + contractId: string, + data: CreateManagedRepositoryRequest + ): Promise<ContractRepository | null> => { + setError(null); + try { + return await createManagedRepository(contractId, data); + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to create managed repository" + ); + return null; + } + }, + [] + ); + + const removeRepo = useCallback( + async (contractId: string, repoId: string): Promise<boolean> => { + setError(null); + try { + await deleteContractRepository(contractId, repoId); + return true; + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to delete repository" + ); + return false; + } + }, + [] + ); + + const setRepoPrimary = useCallback( + async (contractId: string, repoId: string): Promise<boolean> => { + setError(null); + try { + await setRepositoryPrimary(contractId, repoId); + return true; + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to set repository as primary" + ); + return false; + } + }, + [] + ); + + // Task association + const addTask = useCallback( + async (contractId: string, taskId: string): Promise<boolean> => { + setError(null); + try { + await addTaskToContract(contractId, taskId); + return true; + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to add task to contract" + ); + return false; + } + }, + [] + ); + + const removeTask = useCallback( + async (contractId: string, taskId: string): Promise<boolean> => { + setError(null); + try { + await removeTaskFromContract(contractId, taskId); + return true; + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to remove task from contract" + ); + return false; + } + }, + [] + ); + + // Initial fetch + useEffect(() => { + fetchContracts(); + }, [fetchContracts]); + + return { + contracts, + loading, + error, + conflict, + clearConflict, + fetchContracts, + fetchContract, + saveContract, + editContract, + removeContract, + changePhase, + fetchEvents, + // Repository management + addRemoteRepo, + addLocalRepo, + createManagedRepo, + removeRepo, + setRepoPrimary, + // Task association + addTask, + removeTask, + }; +} diff --git a/makima/frontend/src/hooks/useWebSocket.ts b/makima/frontend/src/hooks/useWebSocket.ts index 961951f..c593621 100644 --- a/makima/frontend/src/hooks/useWebSocket.ts +++ b/makima/frontend/src/hooks/useWebSocket.ts @@ -214,12 +214,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { }, []); const startSession = useCallback( - (sampleRate: number, channels: number = 1) => { + (sampleRate: number, channels: number = 1, contractId?: string | null, authToken?: string | null) => { sendMessage({ type: "start", sampleRate, channels, encoding: "pcm32f", + ...(contractId && authToken ? { contractId, authToken } : {}), }); }, [sendMessage] diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index a11f15e..d77c85c 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -132,7 +132,8 @@ export type BodyElement = data: Record<string, unknown>[]; config?: Record<string, unknown>; } - | { type: "image"; src: string; alt?: string; caption?: string }; + | { type: "image"; src: string; alt?: string; caption?: string } + | { type: "markdown"; content: string }; export interface FileSummary { id: string; @@ -141,8 +142,16 @@ export interface FileSummary { transcriptCount: number; duration: number | null; version: number; + /** Path to linked repository file (e.g., "README.md") */ + repoFilePath: string | null; + /** Sync status: 'none', 'synced', 'modified', 'conflict' */ + repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null; createdAt: string; updatedAt: string; + // Contract info (joined from contracts table) + contractId: string | null; + contractName: string | null; + contractPhase: ContractPhase | null; } export interface FileDetail { @@ -155,6 +164,12 @@ export interface FileDetail { summary: string | null; body: BodyElement[]; version: number; + /** Path to linked repository file (e.g., "README.md") */ + repoFilePath: string | null; + /** When file was last synced from repository */ + repoSyncedAt: string | null; + /** Sync status: 'none', 'synced', 'modified', 'conflict' */ + repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null; createdAt: string; updatedAt: string; } @@ -165,10 +180,14 @@ export interface FileListResponse { } export interface CreateFileRequest { + /** Contract this file belongs to (required - files must belong to a contract) */ + contractId: string; name?: string; description?: string; - transcript: TranscriptEntry[]; + transcript?: TranscriptEntry[]; location?: string; + /** Initial body elements (e.g., from a template) */ + body?: BodyElement[]; } export interface UpdateFileRequest { @@ -400,6 +419,23 @@ export async function restoreFileVersion( return res.json(); } +/** + * Sync a file from its linked repository file. + * Triggers an async operation - the file will be updated when the daemon responds. + * Returns 202 Accepted if the sync started successfully. + */ +export async function syncFileFromRepo(fileId: string): Promise<{ message: string; fileId: string }> { + const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/sync-from-repo`, { + method: "POST", + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || `Failed to sync file: ${res.statusText}`); + } + return res.json(); +} + // ============================================================================= // LLM Tool Definitions for Version History // ============================================================================= @@ -490,6 +526,12 @@ export type DaemonStatus = "connected" | "disconnected" | "unhealthy"; export interface TaskSummary { id: string; + /** Contract this task belongs to */ + contractId: string | null; + /** Contract name (joined from contracts table) */ + contractName: string | null; + /** Contract phase (joined from contracts table) */ + contractPhase: ContractPhase | null; parentTaskId: string | null; depth: number; name: string; @@ -497,6 +539,8 @@ export interface TaskSummary { priority: number; progressSummary: string | null; subtaskCount: number; + /** Whether this is a supervisor task (contract orchestrator) */ + isSupervisor: boolean; version: number; createdAt: string; updatedAt: string; @@ -505,6 +549,8 @@ export interface TaskSummary { export interface Task { id: string; ownerId: string; + /** Contract this task belongs to */ + contractId: string | null; parentTaskId: string | null; depth: number; name: string; @@ -556,6 +602,8 @@ export interface TaskListResponse { } export interface CreateTaskRequest { + /** Contract this task belongs to (required) */ + contractId: string; name: string; description?: string; plan: string; @@ -1289,3 +1337,493 @@ export async function deleteAccount( } return res.json(); } + +// ============================================================================= +// Contract Types for Workflow Management +// ============================================================================= + +export type ContractPhase = "research" | "specify" | "plan" | "execute" | "review"; +export type ContractStatus = "active" | "completed" | "archived"; +export type RepositorySourceType = "remote" | "local" | "managed"; +export type RepositoryStatus = "ready" | "pending" | "creating" | "failed"; + +export interface ContractRepository { + id: string; + contractId: string; + name: string; + repositoryUrl: string | null; + localPath: string | null; + sourceType: RepositorySourceType; + status: RepositoryStatus; + isPrimary: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ContractSummary { + id: string; + name: string; + description: string | null; + phase: ContractPhase; + status: ContractStatus; + fileCount: number; + taskCount: number; + repositoryCount: number; + version: number; + createdAt: string; +} + +export interface Contract { + id: string; + ownerId: string; + name: string; + description: string | null; + phase: ContractPhase; + status: ContractStatus; + /** Supervisor task ID for contract orchestration */ + supervisorTaskId: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +export interface ContractWithRelations extends Contract { + repositories: ContractRepository[]; + files: FileSummary[]; + tasks: TaskSummary[]; +} + +export interface ContractEvent { + id: string; + contractId: string; + eventType: string; + previousPhase: string | null; + newPhase: string | null; + eventData: Record<string, unknown> | null; + createdAt: string; +} + +export interface ContractListResponse { + contracts: ContractSummary[]; + total: number; +} + +export interface CreateContractRequest { + name: string; + description?: string; + /** Initial phase to start in (defaults to "research") */ + initialPhase?: ContractPhase; +} + +export interface UpdateContractRequest { + name?: string; + description?: string; + phase?: ContractPhase; + status?: ContractStatus; + version?: number; +} + +export interface AddRemoteRepositoryRequest { + name: string; + repositoryUrl: string; + isPrimary?: boolean; +} + +export interface AddLocalRepositoryRequest { + name: string; + localPath: string; + isPrimary?: boolean; +} + +export interface CreateManagedRepositoryRequest { + name: string; + isPrimary?: boolean; +} + +export interface ChangePhaseRequest { + phase: ContractPhase; +} + +// ============================================================================= +// Contract API Functions +// ============================================================================= + +/** + * List all contracts. + */ +export async function listContracts(): Promise<ContractListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contracts`); + if (!res.ok) { + throw new Error(`Failed to list contracts: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get a contract with all its relations. + */ +export async function getContract(id: string): Promise<ContractWithRelations> { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`); + if (!res.ok) { + throw new Error(`Failed to get contract: ${res.statusText}`); + } + return res.json(); +} + +/** + * Create a new contract. + */ +export async function createContract( + data: CreateContractRequest +): Promise<ContractSummary> { + const res = await authFetch(`${API_BASE}/api/v1/contracts`, { + method: "POST", + body: JSON.stringify(data), + }); + if (!res.ok) { + throw new Error(`Failed to create contract: ${res.statusText}`); + } + return res.json(); +} + +/** + * Update a contract. + */ +export async function updateContract( + id: string, + data: UpdateContractRequest +): Promise<ContractSummary> { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + + if (res.status === 409) { + const conflict = (await res.json()) as ConflictErrorResponse; + throw new VersionConflictError(conflict); + } + + if (!res.ok) { + throw new Error(`Failed to update contract: ${res.statusText}`); + } + return res.json(); +} + +/** + * Delete a contract. + */ +export async function deleteContract(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to delete contract: ${res.statusText}`); + } +} + +/** + * Change contract phase. + */ +export async function changeContractPhase( + id: string, + phase: ContractPhase +): Promise<ContractSummary> { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/phase`, { + method: "POST", + body: JSON.stringify({ phase }), + }); + if (!res.ok) { + throw new Error(`Failed to change phase: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get contract event history. + */ +export async function getContractEvents( + id: string +): Promise<ContractEvent[]> { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/events`); + if (!res.ok) { + throw new Error(`Failed to get events: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Contract Repository Management +// ============================================================================= + +/** + * Add a remote repository to a contract. + */ +export async function addRemoteRepository( + contractId: string, + data: AddRemoteRepositoryRequest +): Promise<ContractRepository> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/repositories/remote`, + { + method: "POST", + body: JSON.stringify(data), + } + ); + if (!res.ok) { + throw new Error(`Failed to add remote repository: ${res.statusText}`); + } + return res.json(); +} + +/** + * Add a local repository to a contract. + */ +export async function addLocalRepository( + contractId: string, + data: AddLocalRepositoryRequest +): Promise<ContractRepository> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/repositories/local`, + { + method: "POST", + body: JSON.stringify(data), + } + ); + if (!res.ok) { + throw new Error(`Failed to add local repository: ${res.statusText}`); + } + return res.json(); +} + +/** + * Create a managed repository (daemon will create it). + */ +export async function createManagedRepository( + contractId: string, + data: CreateManagedRepositoryRequest +): Promise<ContractRepository> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/repositories/managed`, + { + method: "POST", + body: JSON.stringify(data), + } + ); + if (!res.ok) { + throw new Error(`Failed to create managed repository: ${res.statusText}`); + } + return res.json(); +} + +/** + * Delete a repository from a contract. + */ +export async function deleteContractRepository( + contractId: string, + repoId: string +): Promise<void> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}`, + { + method: "DELETE", + } + ); + if (!res.ok) { + throw new Error(`Failed to delete repository: ${res.statusText}`); + } +} + +/** + * Set a repository as primary. + */ +export async function setRepositoryPrimary( + contractId: string, + repoId: string +): Promise<void> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}/primary`, + { + method: "PUT", + } + ); + if (!res.ok) { + throw new Error(`Failed to set repository as primary: ${res.statusText}`); + } +} + +// ============================================================================= +// Contract Task Association +// ============================================================================= + +/** + * Add a task to a contract. + */ +export async function addTaskToContract( + contractId: string, + taskId: string +): Promise<void> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`, + { + method: "POST", + } + ); + if (!res.ok) { + throw new Error(`Failed to add task to contract: ${res.statusText}`); + } +} + +/** + * Remove a task from a contract. + */ +export async function removeTaskFromContract( + contractId: string, + taskId: string +): Promise<void> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`, + { + method: "DELETE", + } + ); + if (!res.ok) { + throw new Error(`Failed to remove task from contract: ${res.statusText}`); + } +} + +// ============================================================================= +// Contract Chat Types and API +// ============================================================================= + +export interface ContractChatRequest { + message: string; + model?: LlmModel; + history?: ChatMessage[]; +} + +export interface ContractToolCallInfo { + name: string; + result: { + success: boolean; + message: string; + }; +} + +export interface ContractChatResponse { + response: string; + toolCalls: ContractToolCallInfo[]; + pendingQuestions?: UserQuestion[]; +} + +/** + * Chat with a contract using LLM-powered management tools. + */ +export async function chatWithContract( + contractId: string, + message: string, + model?: LlmModel, + history?: ChatMessage[] +): Promise<ContractChatResponse> { + const body: ContractChatRequest = { message }; + if (model) { + body.model = model; + } + if (history && history.length > 0) { + body.history = history; + } + const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/chat`, { + method: "POST", + body: JSON.stringify(body), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Contract chat failed: ${errorText || res.statusText}`); + } + return res.json(); +} + +// Contract chat history types +export interface ContractChatMessage { + id: string; + conversationId: string; + role: "user" | "assistant" | "error"; + content: string; + toolCalls?: unknown; + pendingQuestions?: unknown; + createdAt: string; +} + +export interface ContractChatHistoryResponse { + contractId: string; + conversationId: string; + messages: ContractChatMessage[]; +} + +/** Get contract chat history */ +export async function getContractChatHistory( + contractId: string +): Promise<ContractChatHistoryResponse> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/chat/history` + ); + if (!res.ok) { + throw new Error(`Failed to fetch contract chat history: ${res.statusText}`); + } + return res.json(); +} + +/** Clear contract chat history (starts a new conversation) */ +export async function clearContractChatHistory( + contractId: string +): Promise<void> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/chat/history`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error(`Failed to clear contract chat history: ${res.statusText}`); + } +} + +// ============================================================================= +// Template Types and API +// ============================================================================= + +export interface TemplateSummary { + id: string; + name: string; + phase: ContractPhase; + description: string; + elementCount: number; +} + +export interface FileTemplate { + id: string; + name: string; + phase: ContractPhase; + description: string; + suggestedBody: BodyElement[]; +} + +export interface ListTemplatesResponse { + templates: TemplateSummary[]; +} + +export async function listTemplates( + phase?: ContractPhase +): Promise<ListTemplatesResponse> { + const params = phase ? `?phase=${phase}` : ""; + const res = await authFetch(`${API_BASE}/api/v1/templates${params}`); + if (!res.ok) { + throw new Error(`Failed to list templates: ${res.statusText}`); + } + return res.json(); +} + +export async function getTemplate(id: string): Promise<FileTemplate> { + const res = await authFetch(`${API_BASE}/api/v1/templates/${id}`); + if (!res.ok) { + throw new Error(`Failed to get template: ${res.statusText}`); + } + return res.json(); +} diff --git a/makima/frontend/src/lib/markdown.ts b/makima/frontend/src/lib/markdown.ts new file mode 100644 index 0000000..b6e860a --- /dev/null +++ b/makima/frontend/src/lib/markdown.ts @@ -0,0 +1,228 @@ +/** + * Markdown conversion utilities for BodyElement arrays. + * + * Provides bidirectional conversion between structured BodyElement[] and markdown strings. + */ + +import { BodyElement } from "./api"; + +/** + * Convert an array of BodyElements to a markdown string. + * + * Handles: + * - Headings: # through ###### based on level + * - Paragraphs: plain text with blank lines between + * - Code blocks: ```language\ncontent\n``` + * - Lists: ordered (1. 2. 3.) and unordered (- - -) + * - Charts: rendered as fenced JSON + * - Images: rendered as markdown image syntax + */ +export function bodyToMarkdown(elements: BodyElement[]): string { + return elements + .map((elem) => { + switch (elem.type) { + case "heading": { + const hashes = "#".repeat(Math.min(elem.level, 6)); + return `${hashes} ${elem.text}`; + } + case "paragraph": + return elem.text; + case "code": { + const lang = elem.language || ""; + return `\`\`\`${lang}\n${elem.content}\n\`\`\``; + } + case "list": { + return elem.items + .map((item, i) => (elem.ordered ? `${i + 1}. ${item}` : `- ${item}`)) + .join("\n"); + } + case "chart": { + const titleStr = elem.title ? ` - ${elem.title}` : ""; + const dataStr = JSON.stringify(elem.data, null, 2); + return `\`\`\`chart:${elem.chartType}${titleStr}\n${dataStr}\n\`\`\``; + } + case "image": { + const alt = elem.alt || "image"; + const caption = elem.caption ? `\n*${elem.caption}*` : ""; + return `${caption}`; + } + case "markdown": + // Markdown elements output their content directly + return elem.content; + default: + return ""; + } + }) + .filter((s) => s !== "") + .join("\n\n"); +} + +/** + * Parse a markdown string into an array of BodyElements. + * + * Handles: + * - Headings: lines starting with # through ###### + * - Code blocks: ```language ... ``` + * - Ordered lists: lines starting with 1. 2. etc. + * - Unordered lists: lines starting with - or * or + + * - Paragraphs: all other non-empty lines + */ +export function markdownToBody(markdown: string): BodyElement[] { + const elements: BodyElement[] = []; + const lines = markdown.split("\n"); + let currentParagraph: string[] = []; + let inCodeBlock = false; + let codeBlockLanguage: string | undefined; + let codeBlockContent: string[] = []; + let currentList: { ordered: boolean; items: string[] } | null = null; + + const flushParagraph = () => { + if (currentParagraph.length > 0) { + const text = currentParagraph.join("\n").trim(); + if (text) { + elements.push({ type: "paragraph", text }); + } + currentParagraph = []; + } + }; + + const flushCodeBlock = () => { + if (codeBlockContent.length > 0 || inCodeBlock) { + elements.push({ + type: "code", + language: codeBlockLanguage || undefined, + content: codeBlockContent.join("\n"), + }); + codeBlockContent = []; + codeBlockLanguage = undefined; + } + }; + + const flushList = () => { + if (currentList && currentList.items.length > 0) { + elements.push({ + type: "list", + ordered: currentList.ordered, + items: currentList.items, + }); + currentList = null; + } + }; + + // Convert image syntax  to link syntax [alt](url) or [image](url) + const convertImagesToLinks = (text: string): string => { + return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { + const linkText = alt || "image"; + return `[${linkText}](${url})`; + }); + }; + + for (const rawLine of lines) { + // Check for code block fence (``` or ~~~) + const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); + if (codeFenceMatch) { + if (!inCodeBlock) { + // Starting a code block + flushParagraph(); + flushList(); + inCodeBlock = true; + codeBlockLanguage = codeFenceMatch[2] || undefined; + codeBlockContent = []; + } else { + // Ending a code block + flushCodeBlock(); + inCodeBlock = false; + } + continue; + } + + // If inside a code block, add line as-is + if (inCodeBlock) { + codeBlockContent.push(rawLine); + continue; + } + + // Convert images to links in all lines + const line = convertImagesToLinks(rawLine); + + // Check for headings + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + flushList(); + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + elements.push({ type: "heading", level, text }); + continue; + } + + // Check for unordered list items (-, *, +) + const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); + if (unorderedMatch) { + flushParagraph(); + const itemText = unorderedMatch[1].trim(); + if (currentList && currentList.ordered) { + // Switch from ordered to unordered + flushList(); + } + if (!currentList) { + currentList = { ordered: false, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Check for ordered list items (1. 2. etc) + const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); + if (orderedMatch) { + flushParagraph(); + const itemText = orderedMatch[1].trim(); + if (currentList && !currentList.ordered) { + // Switch from unordered to ordered + flushList(); + } + if (!currentList) { + currentList = { ordered: true, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Empty line - flush everything + if (line.trim() === "") { + flushParagraph(); + flushList(); + continue; + } + + // Regular text - flush list first, then add to paragraph + flushList(); + currentParagraph.push(line); + } + + // Flush any remaining content + if (inCodeBlock) { + flushCodeBlock(); + } + flushParagraph(); + flushList(); + + return elements; +} + +/** + * Copy markdown to clipboard. + * Returns true if successful, false otherwise. + */ +export async function copyMarkdownToClipboard( + elements: BodyElement[] +): Promise<boolean> { + try { + const markdown = bodyToMarkdown(elements); + await navigator.clipboard.writeText(markdown); + return true; + } catch (error) { + console.error("Failed to copy markdown to clipboard:", error); + return false; + } +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index d4ca13a..496a569 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -8,6 +8,8 @@ import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; +import ContractsPage from "./routes/contracts"; +import WorkflowPage from "./routes/workflow"; import MeshPage from "./routes/mesh"; import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; @@ -45,6 +47,30 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/contracts" + element={ + <ProtectedRoute> + <ContractsPage /> + </ProtectedRoute> + } + /> + <Route + path="/contracts/:id" + element={ + <ProtectedRoute> + <ContractsPage /> + </ProtectedRoute> + } + /> + <Route + path="/workflow" + element={ + <ProtectedRoute> + <WorkflowPage /> + </ProtectedRoute> + } + /> + <Route path="/mesh" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx index 7084c2e..ecdd7f2 100644 --- a/makima/frontend/src/routes/_index.tsx +++ b/makima/frontend/src/routes/_index.tsx @@ -1,5 +1,6 @@ import { Masthead } from "../components/Masthead"; import { Logo } from "../components/Logo"; +import { JapaneseHoverText } from "../components/JapaneseHoverText"; export default function HomePage() { return ( @@ -13,7 +14,10 @@ export default function HomePage() { </div> <span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3"> - Control System + <JapaneseHoverText + japanese="支配する" + english="Control System" + /> </span> <h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide"> Mesh Orchestration Platform diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx new file mode 100644 index 0000000..8c90804 --- /dev/null +++ b/makima/frontend/src/routes/contracts.tsx @@ -0,0 +1,614 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { ContractList } from "../components/contracts/ContractList"; +import { ContractDetail } from "../components/contracts/ContractDetail"; +import { DirectoryInput } from "../components/mesh/DirectoryInput"; +import { useContracts } from "../hooks/useContracts"; +import { useAuth } from "../contexts/AuthContext"; +import { createTask, getDaemonDirectories } from "../lib/api"; +import type { + ContractWithRelations, + ContractPhase, + ContractStatus, + CreateContractRequest, + RepositorySourceType, + DaemonDirectory, +} from "../lib/api"; + +export default function ContractsPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <ContractsPageContent />; +} + +function ContractsPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + contracts, + loading, + error, + fetchContract, + saveContract, + editContract, + removeContract, + changePhase, + addRemoteRepo, + addLocalRepo, + createManagedRepo, + removeRepo, + setRepoPrimary, + } = useContracts(); + + const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [newContractName, setNewContractName] = useState(""); + const [newContractDescription, setNewContractDescription] = useState(""); + const [initialPhase, setInitialPhase] = useState<ContractPhase>("research"); + const [repoType, setRepoType] = useState<RepositorySourceType>("remote"); + const [repoName, setRepoName] = useState(""); + const [repoUrl, setRepoUrl] = useState(""); + const [repoPath, setRepoPath] = useState(""); + const [createError, setCreateError] = useState<string | null>(null); + const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + + // Fetch daemon directories when "local" repo type is selected + useEffect(() => { + if (repoType === "local" && isCreating) { + getDaemonDirectories() + .then((res) => setSuggestedDirectories(res.directories)) + .catch(() => setSuggestedDirectories([])); + } + }, [repoType, isCreating]); + + // Load contract detail when ID changes + useEffect(() => { + if (id) { + setDetailLoading(true); + fetchContract(id).then((contract) => { + setContractDetail(contract); + setDetailLoading(false); + }); + } else { + setContractDetail(null); + } + }, [id, fetchContract]); + + const handleSelect = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/contracts"); + }, [navigate]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + }, []); + + // Validate repository configuration + const isRepoValid = useCallback(() => { + if (!repoName.trim()) return false; + if (repoType === "remote" && !repoUrl.trim()) return false; + if (repoType === "local" && !repoPath.trim()) return false; + return true; + }, [repoType, repoName, repoUrl, repoPath]); + + const handleCreateSubmit = useCallback(async () => { + if (!newContractName.trim()) return; + if (!isRepoValid()) { + setCreateError("Repository configuration is required"); + return; + } + + setCreateError(null); + + const data: CreateContractRequest = { + name: newContractName.trim(), + description: newContractDescription.trim() || undefined, + initialPhase: initialPhase !== "research" ? initialPhase : undefined, + }; + + try { + const contract = await saveContract(data); + if (contract) { + // Add the repository after contract creation + try { + if (repoType === "remote") { + await addRemoteRepo(contract.id, { + name: repoName.trim(), + repositoryUrl: repoUrl.trim(), + isPrimary: true, + }); + } else if (repoType === "local") { + await addLocalRepo(contract.id, { + name: repoName.trim(), + localPath: repoPath.trim(), + isPrimary: true, + }); + } else if (repoType === "managed") { + await createManagedRepo(contract.id, { + name: repoName.trim(), + isPrimary: true, + }); + } + } catch (repoError) { + console.error("Failed to add repository:", repoError); + // Still navigate to the contract - repo can be added later + } + + // Clear form state + setIsCreating(false); + setNewContractName(""); + setNewContractDescription(""); + setInitialPhase("research"); + setRepoType("remote"); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + navigate(`/contracts/${contract.id}`); + } + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Failed to create contract"); + } + }, [ + newContractName, + newContractDescription, + repoType, + repoName, + repoUrl, + repoPath, + isRepoValid, + saveContract, + addRemoteRepo, + addLocalRepo, + createManagedRepo, + navigate, + ]); + + const handleCreateCancel = useCallback(() => { + setIsCreating(false); + setNewContractName(""); + setNewContractDescription(""); + setInitialPhase("research"); + setRepoType("remote"); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + setCreateError(null); + }, []); + + const handleUpdate = useCallback( + async (name: string, description: string) => { + if (contractDetail) { + const updated = await editContract(contractDetail.id, { + name, + description: description || undefined, + version: contractDetail.version, + }); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, editContract, fetchContract] + ); + + const handleDelete = useCallback(async () => { + if (contractDetail && confirm("Are you sure you want to delete this contract?")) { + const success = await removeContract(contractDetail.id); + if (success) { + navigate("/contracts"); + } + } + }, [contractDetail, removeContract, navigate]); + + const handlePhaseChange = useCallback( + async (phase: ContractPhase) => { + if (contractDetail) { + const updated = await changePhase(contractDetail.id, phase); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, changePhase, fetchContract] + ); + + const handleStatusChange = useCallback( + async (status: ContractStatus) => { + if (contractDetail) { + const updated = await editContract(contractDetail.id, { + status, + version: contractDetail.version, + }); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, editContract, fetchContract] + ); + + // Repository handlers + const handleAddRemoteRepo = useCallback( + async (name: string, url: string, isPrimary: boolean) => { + if (contractDetail) { + await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, addRemoteRepo, fetchContract] + ); + + const handleAddLocalRepo = useCallback( + async (name: string, path: string, isPrimary: boolean) => { + if (contractDetail) { + await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, addLocalRepo, fetchContract] + ); + + const handleCreateManagedRepo = useCallback( + async (name: string, isPrimary: boolean) => { + if (contractDetail) { + await createManagedRepo(contractDetail.id, { name, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, createManagedRepo, fetchContract] + ); + + const handleDeleteRepo = useCallback( + async (repoId: string) => { + if (contractDetail) { + await removeRepo(contractDetail.id, repoId); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, removeRepo, fetchContract] + ); + + const handleSetRepoPrimary = useCallback( + async (repoId: string) => { + if (contractDetail) { + await setRepoPrimary(contractDetail.id, repoId); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, setRepoPrimary, fetchContract] + ); + + // Refresh contract detail (used after file/task operations) + const handleRefresh = useCallback(async () => { + if (contractDetail) { + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, [contractDetail, fetchContract]); + + // File/task navigation handlers + const handleFileSelect = useCallback( + (fileId: string) => { + navigate(`/files/${fileId}`); + }, + [navigate] + ); + + const handleTaskSelect = useCallback( + (taskId: string) => { + navigate(`/mesh/${taskId}`); + }, + [navigate] + ); + + // Create task within contract context + const handleTaskCreate = useCallback( + async (name: string, plan: string, repositoryUrl?: string) => { + if (!contractDetail) return; + try { + // Create the task with contract_id (task is automatically associated) + const task = await createTask({ + contractId: contractDetail.id, + name, + plan, + repositoryUrl, + }); + // Refresh contract detail to show new task + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + // Navigate to the new task + navigate(`/mesh/${task.id}`); + } catch (e) { + console.error("Failed to create task:", e); + alert(e instanceof Error ? e.message : "Failed to create task"); + } + }, + [contractDetail, fetchContract, navigate] + ); + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Create contract modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Contract + </h3> + + {createError && ( + <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> + {createError} + </div> + )} + + <div className="space-y-4"> + {/* Contract name */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Contract Name + </label> + <input + type="text" + value={newContractName} + onChange={(e) => setNewContractName(e.target.value)} + placeholder="Contract name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Description */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Description (optional) + </label> + <textarea + value={newContractDescription} + onChange={(e) => setNewContractDescription(e.target.value)} + placeholder="Describe what this contract is for..." + rows={2} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + {/* Starting Phase */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Starting Phase + </label> + <select + value={initialPhase} + onChange={(e) => setInitialPhase(e.target.value as ContractPhase)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="research">Research</option> + <option value="specify">Specify</option> + <option value="plan">Plan</option> + <option value="execute">Execute</option> + <option value="review">Review</option> + </select> + <p className="mt-1 font-mono text-xs text-[#8b949e]"> + Skip earlier phases if you already have requirements defined + </p> + </div> + + {/* Repository Configuration */} + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3"> + Repository Configuration (Required) + </label> + + {/* Repository type selector */} + <div className="flex gap-2 mb-3"> + <button + type="button" + onClick={() => setRepoType("remote")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "remote" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Remote + </button> + <button + type="button" + onClick={() => setRepoType("local")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "local" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Local + </button> + <button + type="button" + onClick={() => setRepoType("managed")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "managed" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Managed + </button> + </div> + + {/* Repository name */} + <div className="mb-3"> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository Name + </label> + <input + type="text" + value={repoName} + onChange={(e) => setRepoName(e.target.value)} + placeholder="e.g., my-project" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + + {/* Repository URL (for remote) */} + {repoType === "remote" && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository URL + </label> + <input + type="text" + value={repoUrl} + onChange={(e) => setRepoUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + )} + + {/* Repository path (for local) */} + {repoType === "local" && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Local Path + </label> + <DirectoryInput + value={repoPath} + onChange={setRepoPath} + suggestions={suggestedDirectories} + placeholder="/path/to/repository" + /> + </div> + )} + + {/* Managed description */} + {repoType === "managed" && ( + <p className="font-mono text-xs text-[#8b949e]"> + A managed repository will be created automatically by the daemon. + </p> + )} + </div> + + {/* Actions */} + <div className="flex gap-2 justify-end pt-2"> + <button + onClick={handleCreateCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateSubmit} + disabled={!newContractName.trim() || !isRepoValid()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + + <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> + {/* Contract list */} + <ContractList + contracts={contracts} + loading={loading} + onSelect={handleSelect} + onCreate={handleCreate} + selectedId={id} + /> + + {/* Contract detail or empty state */} + {contractDetail ? ( + <ContractDetail + contract={contractDetail} + loading={detailLoading} + onBack={handleBack} + onUpdate={handleUpdate} + onDelete={handleDelete} + onPhaseChange={handlePhaseChange} + onStatusChange={handleStatusChange} + onFileSelect={handleFileSelect} + onTaskSelect={handleTaskSelect} + onTaskCreate={handleTaskCreate} + onRefresh={handleRefresh} + onAddRemoteRepo={handleAddRemoteRepo} + onAddLocalRepo={handleAddLocalRepo} + onCreateManagedRepo={handleCreateManagedRepo} + onDeleteRepo={handleDeleteRepo} + onSetRepoPrimary={handleSetRepoPrimary} + /> + ) : ( + <div className="panel h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + Select a contract or create a new one + </p> + <button + onClick={handleCreate} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New Contract + </button> + </div> + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 3ba2d52..6cfb3ca 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -12,8 +12,8 @@ import { useFileSubscription, type FileUpdateEvent, } from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api"; -import { createTask } from "../lib/api"; +import type { FileDetail as FileDetailType, BodyElement, Task, ContractSummary } from "../lib/api"; +import { createTask, listContracts } from "../lib/api"; import { useAuth } from "../contexts/AuthContext"; export default function FilesPage() { @@ -59,6 +59,14 @@ function FilesPageContent() { const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); const [createdTask, setCreatedTask] = useState<Task | null>(null); + // Contract selection modal state for task creation + const [showContractModal, setShowContractModal] = useState(false); + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [contractsLoading, setContractsLoading] = useState(false); + const [pendingTaskData, setPendingTaskData] = useState<{ name: string; plan: string } | null>(null); + // Contract selection modal state for file creation + const [showFileContractModal, setShowFileContractModal] = useState(false); + const [pendingFileData, setPendingFileData] = useState<{ name: string; body?: BodyElement[] } | null>(null); const pendingUpdateRef = useRef(false); // Track the last version we sent to detect our own updates const lastSentVersionRef = useRef<number | null>(null); @@ -548,10 +556,10 @@ function FilesPageContent() { [fileDetail] ); - // Create a mesh task from an element + // Create a mesh task from an element - shows contract selection modal const handleCreateTaskFromElement = useCallback( async (index: number, selectedText?: string) => { - if (!fileDetail) return; + if (!fileDetail || contractsLoading) return; const element = fileDetail.body[index]; if (!element) return; @@ -578,57 +586,98 @@ function FilesPageContent() { // Create a task name from the content const name = content.slice(0, 60) + (content.length > 60 ? "..." : ""); + // Store pending task data and show contract selection modal + setPendingTaskData({ name, plan: content }); + setContractsLoading(true); + try { + const response = await listContracts(); + setContracts(response.contracts); + setShowContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, + [fileDetail, contractsLoading] + ); + + // Create task with selected contract + const handleCreateTaskWithContract = useCallback( + async (contractId: string) => { + if (!pendingTaskData || !fileDetail) return; + setShowContractModal(false); try { const task = await createTask({ - name, - plan: content, + contractId, + name: pendingTaskData.name, + plan: pendingTaskData.plan, description: `Created from ${fileDetail.name}`, }); setCreatedTask(task); + setPendingTaskData(null); } catch (err) { console.error("Failed to create task:", err); } }, - [fileDetail] + [pendingTaskData, fileDetail] ); + // Open contract selection modal for file creation const handleCreate = useCallback(async () => { - if (creating) return; + if (creating || contractsLoading) return; + setContractsLoading(true); + try { + const response = await listContracts(); + setContracts(response.contracts); + setPendingFileData({ name: `Untitled ${new Date().toLocaleDateString()}` }); + setShowFileContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, [creating, contractsLoading]); + + // Create file with selected contract + const handleCreateFileWithContract = useCallback(async (contractId: string) => { + if (creating || !pendingFileData) return; + setShowFileContractModal(false); setCreating(true); try { const newFile = await saveFile({ - name: `Untitled ${new Date().toLocaleDateString()}`, + contractId, + name: pendingFileData.name, + body: pendingFileData.body, transcript: [], }); if (newFile) { + // If there's body content, update it + if (pendingFileData.body && pendingFileData.body.length > 0) { + await editFile(newFile.id, { body: pendingFileData.body, version: newFile.version }); + } navigate(`/files/${newFile.id}`); } } finally { setCreating(false); + setPendingFileData(null); } - }, [creating, saveFile, navigate]); + }, [creating, pendingFileData, saveFile, editFile, navigate]); const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => { - if (creating) return; - setCreating(true); + if (creating || contractsLoading) return; + setContractsLoading(true); try { - const newFile = await saveFile({ - name, - transcript: [], - }); - if (newFile) { - // Update with the parsed body - const updated = await editFile(newFile.id, { body, version: newFile.version }); - if (updated) { - navigate(`/files/${updated.id}`); - } else { - navigate(`/files/${newFile.id}`); - } - } + const response = await listContracts(); + setContracts(response.contracts); + setPendingFileData({ name, body }); + setShowFileContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); } finally { - setCreating(false); + setContractsLoading(false); } - }, [creating, saveFile, editFile, navigate]); + }, [creating, contractsLoading]); // Conflict resolution handlers const handleConflictReload = useCallback(async () => { @@ -808,6 +857,124 @@ function FilesPageContent() { </div> </div> )} + + {/* Contract Selection Modal for Task Creation */} + {showContractModal && ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> + <div className="bg-[#0d1117] border border-[#30363d] rounded-lg max-w-md w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[#30363d] flex justify-between items-center"> + <h2 className="text-lg font-semibold text-white">Select Contract for Task</h2> + <button + onClick={() => { + setShowContractModal(false); + setPendingTaskData(null); + }} + className="text-[#8b949e] hover:text-white" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#8b949e] mb-4">No contracts found. Create a contract first.</p> + <button + onClick={() => { + setShowContractModal(false); + setPendingTaskData(null); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#238636] hover:bg-[#2ea043] text-white rounded-md text-sm" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleCreateTaskWithContract(contract.id)} + className="w-full text-left p-3 rounded-md border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-white font-medium">{contract.name}</span> + <span className="text-xs px-2 py-0.5 rounded bg-[#21262d] text-[#8b949e]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-sm text-[#8b949e] mt-1 line-clamp-2">{contract.description}</p> + )} + </button> + ))} + </div> + )} + </div> + </div> + </div> + )} + + {/* Contract Selection Modal for File Creation */} + {showFileContractModal && ( + <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-md w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center"> + <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Select Contract for File</h2> + <button + onClick={() => { + setShowFileContractModal(false); + setPendingFileData(null); + }} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found. Create a contract first.</p> + <button + onClick={() => { + setShowFileContractModal(false); + setPendingFileData(null); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleCreateFileWithContract(contract.id)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span> + <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p> + )} + </button> + ))} + </div> + )} + </div> + </div> + </div> + )} </div> ); } diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx index aaba90c..36c468b 100644 --- a/makima/frontend/src/routes/listen.tsx +++ b/makima/frontend/src/routes/listen.tsx @@ -2,9 +2,11 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Masthead } from "../components/Masthead"; import { SpeakerPanel } from "../components/listen/SpeakerPanel"; import { TranscriptPanel } from "../components/listen/TranscriptPanel"; -import { ControlPanel } from "../components/listen/ControlPanel"; +import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel"; import { useMicrophone } from "../hooks/useMicrophone"; import { useWebSocket } from "../hooks/useWebSocket"; +import { listContracts } from "../lib/api"; +import { useAuth } from "../contexts/AuthContext"; export default function ListenPage() { const [isListening, setIsListening] = useState(false); @@ -12,6 +14,37 @@ export default function ListenPage() { const [permissionRequested, setPermissionRequested] = useState(false); const isListeningRef = useRef(false); + // Contract selection state + const [contracts, setContracts] = useState<ContractOption[]>([]); + const [selectedContractId, setSelectedContractId] = useState<string | null>(null); + const [contractsLoading, setContractsLoading] = useState(true); + const { session, isAuthenticated } = useAuth(); + + // Fetch contracts on mount + useEffect(() => { + if (!isAuthenticated) { + setContractsLoading(false); + return; + } + + async function fetchContracts() { + try { + const response = await listContracts(); + setContracts( + response.contracts.map((c) => ({ + id: c.id, + name: c.name, + })) + ); + } catch (err) { + console.error("Failed to fetch contracts:", err); + } finally { + setContractsLoading(false); + } + } + fetchContracts(); + }, [isAuthenticated]); + // Keep ref in sync with state for use in callbacks useEffect(() => { isListeningRef.current = isListening; @@ -108,9 +141,11 @@ export default function ListenPage() { } // Both microphone and WebSocket are ready - start the session - ws.startSession(mic.sampleRate, mic.channels); + // Pass contract_id and auth token if available + const authToken = session?.access_token || null; + ws.startSession(mic.sampleRate, mic.channels, selectedContractId, authToken); setIsListening(true); - }, [isListening, mic, ws]); + }, [isListening, mic, ws, selectedContractId, session]); const handleNew = useCallback(() => { // Stop current session - backend auto-saves transcript on disconnect @@ -152,6 +187,10 @@ export default function ListenPage() { onToggle={handleToggle} onNew={handleNew} error={error} + contracts={contracts} + selectedContractId={selectedContractId} + onContractChange={setSelectedContractId} + contractsLoading={contractsLoading} /> </div> </main> diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 7ecf96d..d067865 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -7,8 +7,9 @@ import { TaskOutput } from "../components/mesh/TaskOutput"; import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; -import type { TaskWithSubtasks, MeshChatContext } from "../lib/api"; -import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api"; +import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories } from "../lib/api"; +import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; // View modes for the task detail page @@ -91,6 +92,17 @@ export default function MeshPage() { const [creating, setCreating] = useState(false); const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]); const [isStreaming, setIsStreaming] = useState(false); + // Contract selection modal state + const [showContractModal, setShowContractModal] = useState(false); + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [contractsLoading, setContractsLoading] = useState(false); + // Task creation modal (step 2) + const [modalStep, setModalStep] = useState<1 | 2>(1); + const [selectedContract, setSelectedContract] = useState<ContractWithRelations | null>(null); + const [daemonDirectories, setDaemonDirectories] = useState<DaemonDirectory[]>([]); + const [newTaskName, setNewTaskName] = useState(""); + const [newTaskRepoUrl, setNewTaskRepoUrl] = useState<string | null>(null); + const [newTaskTargetPath, setNewTaskTargetPath] = useState(""); // Track which subtask's output we're viewing (null = parent task) const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null); const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null); @@ -139,6 +151,14 @@ export default function MeshPage() { // Only process output for the task we're currently viewing if (event.taskId === activeOutputTaskId) { setTaskOutputEntries((prev) => { + // For auth_required, only allow one per task (replace existing) + if (event.messageType === "auth_required") { + const hasExisting = prev.some(e => e.messageType === "auth_required"); + if (hasExisting) { + return prev; // Skip duplicate auth_required + } + } + // Deduplicate by checking if last entry is identical // This prevents duplicates from React StrictMode or WebSocket reconnects const lastEntry = prev[prev.length - 1]; @@ -383,13 +403,63 @@ export default function MeshPage() { [editTask, taskDetail] ); + // Open contract selection modal const handleCreate = useCallback(async () => { - if (creating) return; + if (creating || contractsLoading) return; + setContractsLoading(true); + try { + const [contractsResponse, directoriesResponse] = await Promise.all([ + listContracts(), + getDaemonDirectories().catch(() => ({ directories: [] })), + ]); + setContracts(contractsResponse.contracts); + setDaemonDirectories(directoriesResponse.directories); + setModalStep(1); + setSelectedContract(null); + setNewTaskName(""); + setNewTaskRepoUrl(null); + setNewTaskTargetPath(""); + setShowContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, [creating, contractsLoading]); + + // Handle contract selection and move to step 2 + const handleSelectContract = useCallback(async (contractSummary: ContractSummary) => { + try { + const contract = await getContract(contractSummary.id); + setSelectedContract(contract); + setNewTaskName(`Task for ${contract.name}`); + // Pre-select primary repository if available + const primaryRepo = contract.repositories.find((r) => r.isPrimary && r.status === "ready"); + if (primaryRepo) { + setNewTaskRepoUrl(primaryRepo.repositoryUrl); + } else { + // Otherwise select first ready repository + const firstReady = contract.repositories.find((r) => r.status === "ready"); + setNewTaskRepoUrl(firstReady?.repositoryUrl || null); + } + setModalStep(2); + } catch (e) { + console.error("Failed to load contract details:", e); + } + }, []); + + // Create task with configured options + const handleCreateTask = useCallback(async () => { + if (creating || !selectedContract) return; + setShowContractModal(false); setCreating(true); try { const newTask = await saveTask({ - name: `Task ${new Date().toLocaleDateString()}`, + contractId: selectedContract.id, + name: newTaskName || `Task for ${selectedContract.name}`, plan: "# Plan\n\nDescribe what this task should accomplish...", + repositoryUrl: newTaskRepoUrl || undefined, + targetRepoPath: newTaskTargetPath || undefined, }); if (newTask) { navigate(`/mesh/${newTask.id}`); @@ -397,13 +467,29 @@ export default function MeshPage() { } finally { setCreating(false); } - }, [creating, saveTask, navigate]); + }, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath]); + + // Close modal and reset state + const handleCloseModal = useCallback(() => { + setShowContractModal(false); + setModalStep(1); + setSelectedContract(null); + setNewTaskName(""); + setNewTaskRepoUrl(null); + setNewTaskTargetPath(""); + }, []); const handleCreateSubtask = useCallback(async () => { if (!taskDetail || creating) return; + // Subtasks inherit contract_id from parent + if (!taskDetail.contractId) { + console.error("Parent task has no contract_id"); + return; + } setCreating(true); try { const newTask = await saveTask({ + contractId: taskDetail.contractId, name: `Subtask of ${taskDetail.name}`, plan: "# Plan\n\nDescribe what this subtask should accomplish...", parentTaskId: taskDetail.id, @@ -597,6 +683,7 @@ export default function MeshPage() { onCreateSubtask={handleCreateSubtask} onToggleSubtaskOutput={handleToggleSubtaskOutput} viewingSubtaskId={viewingSubtaskId} + onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} /> </div> )} @@ -662,6 +749,159 @@ export default function MeshPage() { </div> </div> </main> + + {/* Task Creation Modal (Two Steps) */} + {showContractModal && ( + <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center"> + <div className="flex items-center gap-2"> + {modalStep === 2 && ( + <button + onClick={() => setModalStep(1)} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + title="Back to contract selection" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> + </svg> + </button> + )} + <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]"> + {modalStep === 1 ? "Select Contract" : "Configure Task"} + </h2> + </div> + <button + onClick={handleCloseModal} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {modalStep === 1 ? ( + // Step 1: Select Contract + contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found.</p> + <button + onClick={() => { + handleCloseModal(); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleSelectContract(contract)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span> + <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p> + )} + <div className="flex gap-3 mt-2 text-[10px] font-mono text-[#556677]"> + <span>{contract.taskCount} tasks</span> + <span>{contract.repositoryCount} repos</span> + </div> + </button> + ))} + </div> + ) + ) : ( + // Step 2: Configure Task + selectedContract && ( + <div className="space-y-4"> + {/* Contract badge */} + <div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]"> + <span>Contract:</span> + <span className="text-[#9bc3ff]">{selectedContract.name}</span> + </div> + + {/* Task name */} + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Task Name</label> + <input + type="text" + value={newTaskName} + onChange={(e) => setNewTaskName(e.target.value)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + placeholder="Task name" + /> + </div> + + {/* Repository selection */} + {selectedContract.repositories.length > 0 && ( + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository</label> + <select + value={newTaskRepoUrl || ""} + onChange={(e) => setNewTaskRepoUrl(e.target.value || null)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + > + <option value="">No repository</option> + {selectedContract.repositories + .filter((r) => r.status === "ready") + .map((repo) => ( + <option key={repo.id} value={repo.repositoryUrl || repo.localPath || ""}> + {repo.name} + {repo.isPrimary && " (primary)"} + </option> + ))} + </select> + <p className="text-[10px] font-mono text-[#556677]"> + The repository this task will work on. + </p> + </div> + )} + + {/* Target repo path with DirectoryInput */} + {newTaskRepoUrl && ( + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Target Repository Path</label> + <DirectoryInput + value={newTaskTargetPath} + onChange={setNewTaskTargetPath} + suggestions={daemonDirectories} + placeholder="/path/to/your/local/repo" + repoUrl={newTaskRepoUrl} + /> + <p className="text-[10px] font-mono text-[#556677]"> + Path where the task will push/merge changes. Leave empty to configure later. + </p> + </div> + )} + + {/* Create button */} + <div className="pt-2"> + <button + onClick={handleCreateTask} + disabled={creating} + className="w-full px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] disabled:opacity-50 transition-colors" + > + {creating ? "Creating..." : "Create Task"} + </button> + </div> + </div> + ) + )} + </div> + </div> + </div> + )} </div> ); } diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx index 6d56e67..7ca40ba 100644 --- a/makima/frontend/src/routes/settings.tsx +++ b/makima/frontend/src/routes/settings.tsx @@ -10,8 +10,10 @@ import { changePassword, changeEmail, deleteAccount, + listDaemons, type ApiKeyInfo, type CreateApiKeyResponse, + type Daemon, } from "../lib/api"; // ============================================================================= @@ -297,8 +299,22 @@ export default function SettingsPage() { const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState<string | null>(null); + // Daemon state + const [daemons, setDaemons] = useState<Daemon[]>([]); + const [daemonsLoading, setDaemonsLoading] = useState(true); + const [daemonsError, setDaemonsError] = useState<string | null>(null); + useEffect(() => { loadApiKey(); + loadDaemons(); + }, []); + + // Auto-refresh daemons every 30 seconds + useEffect(() => { + const interval = setInterval(() => { + loadDaemons(); + }, 30000); + return () => clearInterval(interval); }, []); const loadApiKey = async () => { @@ -314,6 +330,18 @@ export default function SettingsPage() { } }; + const loadDaemons = async () => { + try { + setDaemonsError(null); + const response = await listDaemons(); + setDaemons(response.daemons); + } catch (err) { + setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons"); + } finally { + setDaemonsLoading(false); + } + }; + const handleCreate = async () => { try { setActionLoading(true); @@ -579,6 +607,91 @@ export default function SettingsPage() { Then run: <code className="text-green-400">makima-daemon</code> </p> </section> + + {/* Connected Daemons */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center gap-2"> + <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]"> + Daemons + </h2> + {daemons.length > 0 && ( + <span className="text-[10px] font-mono text-[#556677]"> + ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total) + </span> + )} + </div> + <button + onClick={loadDaemons} + disabled={daemonsLoading} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50" + title="Refresh" + > + {daemonsLoading ? "..." : "↻"} + </button> + </div> + + {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>} + + {daemonsLoading && daemons.length === 0 ? ( + <p className="text-[#7788aa] font-mono text-xs">Loading...</p> + ) : daemons.length === 0 ? ( + <div className="text-center py-4"> + <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p> + <p className="text-[#556677] font-mono text-[10px]"> + Start a daemon to enable task execution + </p> + </div> + ) : ( + <div className="space-y-2"> + {daemons.map((daemon) => ( + <div + key={daemon.id} + className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3" + > + <div className="flex items-center justify-between mb-2"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {daemon.hostname || "Unknown Host"} + </span> + <span + className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${ + daemon.status === "connected" + ? "text-green-400 border-green-700/50 bg-green-900/20" + : daemon.status === "unhealthy" + ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20" + : "text-[#8899aa] border-[rgba(117,170,252,0.25)]" + }`} + > + {daemon.status} + </span> + </div> + <div className="font-mono text-[10px] text-[#7788aa] space-y-1"> + <div className="flex justify-between"> + <span>Tasks</span> + <span className="text-[#9bc3ff]"> + {daemon.currentTaskCount} / {daemon.maxConcurrentTasks} + </span> + </div> + <div className="flex justify-between"> + <span>Connected</span> + <span className="text-[#75aafc]"> + {new Date(daemon.connectedAt).toLocaleString()} + </span> + </div> + {daemon.machineId && ( + <div className="flex justify-between"> + <span>Machine</span> + <span className="text-[#556677] truncate ml-2" title={daemon.machineId}> + {daemon.machineId.substring(0, 16)}... + </span> + </div> + )} + </div> + </div> + ))} + </div> + )} + </section> </div> {/* Right Column */} diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx new file mode 100644 index 0000000..cb72e9e --- /dev/null +++ b/makima/frontend/src/routes/workflow.tsx @@ -0,0 +1,205 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { WorkflowBoard } from "../components/workflow/WorkflowBoard"; +import { useContracts } from "../hooks/useContracts"; +import { useAuth } from "../contexts/AuthContext"; +import type { ContractPhase, ContractStatus } from "../lib/api"; + +type StatusFilter = "all" | ContractStatus; + +export default function WorkflowPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <WorkflowPageContent />; +} + +function WorkflowPageContent() { + const navigate = useNavigate(); + const { contracts, loading, error, changePhase, saveContract } = useContracts(); + const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); + const [isCreating, setIsCreating] = useState(false); + const [newContractName, setNewContractName] = useState(""); + + // Filter contracts by status + const filteredContracts = useMemo(() => { + if (statusFilter === "all") { + return contracts; + } + return contracts.filter((c) => c.status === statusFilter); + }, [contracts, statusFilter]); + + const handleContractClick = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + const handlePhaseChange = useCallback( + async (contractId: string, newPhase: ContractPhase) => { + await changePhase(contractId, newPhase); + }, + [changePhase] + ); + + const handleCreateContract = useCallback(async () => { + if (!newContractName.trim()) return; + const contract = await saveContract({ + name: newContractName.trim(), + }); + if (contract) { + setNewContractName(""); + setIsCreating(false); + navigate(`/contracts/${contract.id}`); + } + }, [newContractName, saveContract, navigate]); + + const handleCancelCreate = useCallback(() => { + setNewContractName(""); + setIsCreating(false); + }, []); + + return ( + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0"> + {error} + </div> + )} + + {/* Header with filter and create button */} + <div className="flex items-center justify-between shrink-0"> + <div className="flex items-center gap-4"> + <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> + Board + </h1> + {/* Status filter */} + <div className="flex items-center gap-1"> + {(["all", "active", "completed", "archived"] as StatusFilter[]).map( + (status) => ( + <button + key={status} + onClick={() => setStatusFilter(status)} + className={` + px-2 py-1 font-mono text-[10px] uppercase transition-colors + ${ + statusFilter === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] border border-transparent hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ) + )} + </div> + </div> + <button + onClick={() => setIsCreating(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + New Contract + </button> + </div> + + {/* Create contract modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Contract + </h3> + <div className="space-y-4"> + <input + type="text" + value={newContractName} + onChange={(e) => setNewContractName(e.target.value)} + placeholder="Contract name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateContract(); + if (e.key === "Escape") handleCancelCreate(); + }} + /> + <div className="flex gap-2 justify-end"> + <button + onClick={handleCancelCreate} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateContract} + disabled={!newContractName.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + + {/* Board */} + <div className="flex-1 min-h-0 overflow-hidden"> + {loading ? ( + <div className="h-full flex items-center justify-center"> + <p className="font-mono text-sm text-[#555]">Loading...</p> + </div> + ) : filteredContracts.length === 0 && statusFilter === "all" ? ( + <div className="h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + No contracts yet + </p> + <button + onClick={() => setIsCreating(true)} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + Create First Contract + </button> + </div> + </div> + ) : ( + <WorkflowBoard + contracts={filteredContracts} + onContractClick={handleContractClick} + onPhaseChange={handlePhaseChange} + /> + )} + </div> + </main> + </div> + ); +} |
