diff options
Diffstat (limited to 'makima/frontend/src/components')
25 files changed, 4826 insertions, 211 deletions
diff --git a/makima/frontend/src/components/JapaneseHoverText.tsx b/makima/frontend/src/components/JapaneseHoverText.tsx new file mode 100644 index 0000000..3e60ee2 --- /dev/null +++ b/makima/frontend/src/components/JapaneseHoverText.tsx @@ -0,0 +1,77 @@ +import { useState, useCallback, useRef } from "react"; + +const GLYPHS = "▒▓░█#@*+:-/[]{}<>_"; + +interface JapaneseHoverTextProps { + japanese: string; + english: string; + className?: string; +} + +/** + * Displays Japanese text, transitions to English on hover with scramble effect + */ +export function JapaneseHoverText({ + japanese, + english, + className = "", +}: JapaneseHoverTextProps) { + const [isHovered, setIsHovered] = useState(false); + const [displayText, setDisplayText] = useState(english); + const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); + const iterationRef = useRef(0); + + const scrambleToEnglish = useCallback(() => { + setIsHovered(true); + + // Clear any existing animation + if (timerRef.current) { + clearInterval(timerRef.current); + } + + iterationRef.current = 0; + + timerRef.current = setInterval(() => { + const text = english; + const iteration = iterationRef.current; + + const display = text + .split("") + .map((char, index) => { + if (index < iteration) return char; + return GLYPHS.charAt(Math.floor(Math.random() * GLYPHS.length)); + }) + .join(""); + + setDisplayText(display); + iterationRef.current += 1; + + if (iteration > text.length + 2) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setDisplayText(english); + } + }, 26); + }, [english]); + + const resetToJapanese = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsHovered(false); + setDisplayText(english); + }, [english]); + + return ( + <span + className={`cursor-default ${className}`} + onMouseEnter={scrambleToEnglish} + onMouseLeave={resetToJapanese} + > + {isHovered ? displayText : japanese} + </span> + ); +} diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx index afe385e..bc184bd 100644 --- a/makima/frontend/src/components/Masthead.tsx +++ b/makima/frontend/src/components/Masthead.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router"; import { LogoMark } from "./Logo"; import { NavStrip } from "./NavStrip"; +import { JapaneseHoverText } from "./JapaneseHoverText"; interface MastheadProps { showTicker?: boolean; @@ -18,7 +19,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps) makima.jp </h1> <small className="block text-[#dbe7ff] text-xs tracking-wide"> - Control System + <JapaneseHoverText + japanese="支配する" + english="Control System" + /> </small> </div> </Link> diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 642e9a3..48abe09 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -11,6 +11,8 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Files", href: "/files", requiresAuth: true }, + { label: "Contracts", href: "/contracts", requiresAuth: true }, + { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx new file mode 100644 index 0000000..821d03c --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractCliInput.tsx @@ -0,0 +1,974 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { + getContractChatHistory, + clearContractChatHistory, + startTask, + sendTaskMessage, + type UserQuestion, + type ContractWithRelations, + type TaskStatus, +} from "../../lib/api"; +import { SimpleMarkdown } from "../SimpleMarkdown"; +import { + QuickActionButtons, + type QuickAction, +} from "./QuickActionButtons"; +import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview"; +import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription"; + +interface ContractCliInputProps { + contractId: string; + contract: ContractWithRelations; + onUpdate: () => void; +} + +interface Message { + id: string; + type: "user" | "assistant" | "error" | "question"; + content: string; + toolCalls?: { name: string; success: boolean; message: string }[]; + questions?: UserQuestion[]; + quickActions?: QuickAction[]; +} + +export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) { + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [historyLoading, setHistoryLoading] = useState(true); + const [messages, setMessages] = useState<Message[]>([]); + const [expanded, setExpanded] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null); + const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map()); + const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map()); + + // Task derivation state + const [parsedTasks, setParsedTasks] = useState<ParsedTask[] | null>(null); + const [parsedTaskGroups, setParsedTaskGroups] = useState<string[]>([]); + const [parsedTasksFileName, setParsedTasksFileName] = useState<string>(""); + const [creatingTasks, setCreatingTasks] = useState(false); + + // Supervisor state + const [supervisorStarting, setSupervisorStarting] = useState(false); + const [supervisorOutput, setSupervisorOutput] = useState<TaskOutputEvent[]>([]); + const [supervisorQuestion, setSupervisorQuestion] = useState<{ + id: string; + question: string; + options: string[]; + allowMultiple?: boolean; + allowCustom?: boolean; + } | null>(null); + + const inputRef = useRef<HTMLInputElement>(null); + const messagesRef = useRef<HTMLDivElement>(null); + + // Find the supervisor task for this contract + // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag + const supervisorTask = useMemo(() => { + // Use contract.supervisorTaskId if available (most reliable) + if (contract.supervisorTaskId) { + const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId); + if (taskById) return taskById; + } + // Fallback to finding by isSupervisor flag + return contract.tasks.find((t) => t.isSupervisor); + }, [contract.tasks, contract.supervisorTaskId]); + + // Log for debugging + useEffect(() => { + console.log("Supervisor lookup:", { + contractId: contract.id, + supervisorTaskId: contract.supervisorTaskId, + tasksCount: contract.tasks.length, + foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null, + allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor })) + }); + }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]); + + const supervisorTaskId = supervisorTask?.id ?? null; + const supervisorStatus = supervisorTask?.status as TaskStatus | undefined; + const isSupervisorRunning = supervisorStatus === "running"; + const isSupervisorPending = supervisorStatus === "pending"; + + // Subscribe to supervisor output when it's running + const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => { + // Check for question pattern in output + // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}} + if (!event.isPartial && event.content) { + const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/); + if (questionMatch) { + try { + const questionData = JSON.parse(questionMatch[1]); + if (questionData.id && questionData.question && questionData.options) { + setSupervisorQuestion({ + id: questionData.id, + question: questionData.question, + options: questionData.options, + allowMultiple: questionData.allowMultiple ?? false, + allowCustom: questionData.allowCustom ?? true, + }); + // Don't add this to output since it's a control message + return; + } + } catch { + // Not valid JSON, continue as normal output + } + } + } + + setSupervisorOutput((prev) => { + // If it's a partial message, update the last message + if (event.isPartial && prev.length > 0) { + const lastEvent = prev[prev.length - 1]; + if (lastEvent.messageType === event.messageType && lastEvent.isPartial) { + return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }]; + } + } + return [...prev, event]; + }); + }, []); + + useTaskSubscription({ + taskId: supervisorTaskId, + subscribeOutput: isSupervisorRunning, + onOutput: handleSupervisorOutput, + }); + + // Auto-start supervisor function - starts and waits for it to be running + const ensureSupervisorStarted = useCallback(async (): Promise<boolean> => { + if (!supervisorTask) { + console.warn("No supervisor task found for contract"); + return false; + } + + if (isSupervisorRunning) { + return true; // Already running + } + + if (isSupervisorPending) { + try { + setSupervisorStarting(true); + await startTask(supervisorTask.id); + + // Poll for the task to be running (up to 10 seconds) + for (let i = 0; i < 20; i++) { + await new Promise(resolve => setTimeout(resolve, 500)); + onUpdate(); // Refresh contract to get updated task status + // Note: We can't check the new status here directly since state updates are async + // The UI will update when onUpdate triggers a re-render + } + + // Return true - the caller should check if supervisor is running after this + return true; + } catch (err) { + console.error("Failed to start supervisor:", err); + return false; + } finally { + setSupervisorStarting(false); + } + } + + // Supervisor exists but is in some other state (paused, done, failed, etc.) + // Can still send messages to paused tasks + return supervisorStatus === "paused"; + }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]); + + // Handle answering supervisor questions + const [supervisorAnswers, setSupervisorAnswers] = useState<string[]>([]); + const [supervisorCustomInput, setSupervisorCustomInput] = useState(""); + + const handleSupervisorOptionToggle = useCallback((option: string) => { + setSupervisorAnswers((prev) => { + if (supervisorQuestion?.allowMultiple) { + if (prev.includes(option)) { + return prev.filter((a) => a !== option); + } + return [...prev, option]; + } + return [option]; + }); + }, [supervisorQuestion?.allowMultiple]); + + const handleSubmitSupervisorAnswer = useCallback(async () => { + if (!supervisorQuestion || !supervisorTask) return; + + const customAnswer = supervisorCustomInput.trim(); + const allAnswers = customAnswer + ? [...supervisorAnswers, customAnswer] + : supervisorAnswers; + + if (allAnswers.length === 0) return; + + // Format answer message for supervisor + const answerMessage = `__supervisor_answer__ ${JSON.stringify({ + id: supervisorQuestion.id, + answers: allAnswers, + })}`; + + try { + await sendTaskMessage(supervisorTask.id, answerMessage); + + // Add user message to chat + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { + id: userMsgId, + type: "user", + content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`, + }, + ]); + } catch (err) { + console.error("Failed to send supervisor answer:", err); + } finally { + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + } + }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]); + + const handleCancelSupervisorQuestion = useCallback(() => { + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + }, []); + + // Load chat history on mount + useEffect(() => { + let mounted = true; + + async function loadHistory() { + try { + const history = await getContractChatHistory(contractId); + if (!mounted) return; + + // Convert saved messages to display messages + const displayMessages: Message[] = history.messages.map((msg) => ({ + id: msg.id, + type: msg.role as "user" | "assistant" | "error", + content: msg.content, + toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined, + })); + + setMessages(displayMessages); + + // Auto-expand if there's history + if (displayMessages.length > 0) { + setExpanded(true); + } + } catch (err) { + console.error("Failed to load contract chat history:", err); + } finally { + if (mounted) { + setHistoryLoading(false); + } + } + } + + loadHistory(); + + return () => { + mounted = false; + }; + }, [contractId]); + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + } + }, [messages]); + + // Auto-start supervisor when component mounts if it's pending + useEffect(() => { + if (supervisorTask && isSupervisorPending && !supervisorStarting) { + console.log("Auto-starting supervisor task on mount..."); + ensureSupervisorStarted().then((started) => { + if (started) { + console.log("Supervisor started successfully"); + } + }); + } + }, [supervisorTask?.id]); // Only run when task ID changes, not on every render + + // Convert supervisor output events to messages + useEffect(() => { + if (supervisorOutput.length === 0) return; + + // Get the latest event + const latestEvent = supervisorOutput[supervisorOutput.length - 1]; + + // Only add complete messages (not partials) to the message history + if (!latestEvent.isPartial && latestEvent.content.trim()) { + const msgId = `supervisor-${Date.now()}`; + let msgType: "assistant" | "error" = "assistant"; + let content = latestEvent.content; + + // Format based on message type + switch (latestEvent.messageType) { + case "assistant": + content = latestEvent.content; + break; + case "tool_use": + content = `_Using tool: ${latestEvent.toolName}_`; + break; + case "tool_result": + content = latestEvent.isError + ? `Tool error: ${latestEvent.content}` + : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`; + msgType = latestEvent.isError ? "error" : "assistant"; + break; + case "error": + msgType = "error"; + break; + case "result": + // Final result - show cost info if available + if (latestEvent.costUsd) { + content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`; + } + break; + default: + // system, raw, etc. + break; + } + + setMessages((prev) => { + // Don't add duplicate messages + if (prev.some((m) => m.content === content)) return prev; + return [ + ...prev, + { id: msgId, type: msgType, content }, + ]; + }); + } + }, [supervisorOutput]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || loading) return; + + const userMessage = input.trim(); + setInput(""); + setExpanded(true); + + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: userMessage }, + ]); + + setLoading(true); + + try { + // Supervisor is the ONLY way to interact with contracts + if (!supervisorTask) { + throw new Error("No supervisor task found. Please create a contract with a supervisor."); + } + + // Ensure supervisor is started (this will start it if pending) + await ensureSupervisorStarted(); + + // Send message to supervisor task stdin + await sendTaskMessage(supervisorTask.id, userMessage); + + // Response will come through WebSocket subscription + // No need for a placeholder message - output will stream in + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + inputRef.current?.focus(); + } + }, + [input, loading, supervisorTask, ensureSupervisorStarted] + ); + + const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { + setUserAnswers((prev) => { + const newMap = new Map(prev); + const currentAnswers = newMap.get(questionId) || []; + + if (allowMultiple) { + if (currentAnswers.includes(option)) { + newMap.set(questionId, currentAnswers.filter((a) => a !== option)); + } else { + newMap.set(questionId, [...currentAnswers, option]); + } + } else { + newMap.set(questionId, [option]); + } + + return newMap; + }); + }, []); + + const handleCustomInputChange = useCallback((questionId: string, value: string) => { + setCustomInputs((prev) => { + const newMap = new Map(prev); + newMap.set(questionId, value); + return newMap; + }); + }, []); + + const handleSubmitAnswers = useCallback(async () => { + if (!pendingQuestions || loading) return; + + const answers = pendingQuestions.map((q) => { + const selectedOptions = userAnswers.get(q.id) || []; + const customInput = customInputs.get(q.id)?.trim(); + const finalAnswers = customInput + ? [...selectedOptions, customInput] + : selectedOptions; + + return { + id: q.id, + answers: finalAnswers, + }; + }); + + const answerText = answers + .map((a) => { + const question = pendingQuestions.find((q) => q.id === a.id); + return `${question?.question || a.id}: ${a.answers.join(", ")}`; + }) + .join("\n"); + + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, + ]); + + setLoading(true); + + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, answerText); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]); + + const handleCancelQuestions = useCallback(() => { + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + setSupervisorOutput([]); + setSupervisorQuestion(null); + setSupervisorAnswers([]); + setSupervisorCustomInput(""); + }, []); + + // Handle creating tasks from the preview + const handleCreateDerivedTasks = useCallback( + async (selectedTasks: ParsedTask[]) => { + if (selectedTasks.length === 0) { + setParsedTasks(null); + return; + } + + setCreatingTasks(true); + + // Build a message asking the supervisor to create these tasks + const taskList = selectedTasks + .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`) + .join("\n"); + + const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`; + + // Add user message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: message }, + ]); + + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, message); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setCreatingTasks(false); + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + } + }, + [supervisorTask, ensureSupervisorStarted] + ); + + const handleCancelTaskDerivation = useCallback(() => { + setParsedTasks(null); + setParsedTaskGroups([]); + setParsedTasksFileName(""); + }, []); + + const handleQuickAction = useCallback( + async (action: QuickAction) => { + // Convert the action into a chat message that triggers the appropriate behavior + let message = ""; + switch (action.type) { + case "create_file": + message = "Create the suggested file from the template."; + break; + case "create_task": + message = "Yes, create the tasks."; + break; + case "derive_tasks": + message = "Show me the tasks to review and create them."; + break; + case "run_task": + message = "Run the next task."; + break; + case "advance_phase": + if (action.data?.phase) { + message = `Advance to the ${action.data.phase} phase.`; + } else { + message = "Advance to the next phase."; + } + break; + case "update_file": + message = "Update the file with the task output."; + break; + default: + return; + } + + setExpanded(true); + + // Submit the message + const userMsgId = Date.now().toString(); + setMessages((prev) => [ + ...prev, + { id: userMsgId, type: "user", content: message }, + ]); + + setLoading(true); + try { + if (!supervisorTask) { + throw new Error("No supervisor task found"); + } + await ensureSupervisorStarted(); + await sendTaskMessage(supervisorTask.id, message); + // Response will come through WebSocket + } catch (err) { + const errorMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: errorMsgId, + type: "error", + content: err instanceof Error ? err.message : "An error occurred", + }, + ]); + } finally { + setLoading(false); + } + }, + [supervisorTask, ensureSupervisorStarted] + ); + + return ( + <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {/* Header bar with supervisor status and toggle */} + <div className="px-3 py-2 flex items-center justify-between border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center gap-3"> + <span className="font-mono text-[10px] text-[#555] uppercase tracking-wide"> + Supervisor + </span> + {supervisorTask && ( + <span className={`font-mono text-[10px] px-2 py-0.5 border ${ + isSupervisorRunning + ? "text-green-400 border-green-400/30 bg-green-400/10" + : isSupervisorPending || supervisorStarting + ? "text-yellow-400 border-yellow-400/30 bg-yellow-400/10" + : "text-[#555] border-[rgba(117,170,252,0.2)]" + }`}> + {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"} + </span> + )} + {!supervisorTask && ( + <span className="font-mono text-[10px] text-red-400"> + No supervisor + </span> + )} + </div> + {messages.length > 0 && ( + <button + type="button" + onClick={() => setExpanded(!expanded)} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" + > + {expanded ? "Hide Messages" : `Show Messages (${messages.length})`} + </button> + )} + </div> + + {/* History loading indicator */} + {historyLoading && ( + <div className="px-3 py-2 text-[10px] font-mono text-[#555] flex items-center gap-2 border-b border-[rgba(117,170,252,0.2)]"> + <span className="animate-pulse">Loading history...</span> + </div> + )} + + {/* Messages Panel (expandable) */} + {expanded && messages.length > 0 && !historyLoading && ( + <div className="relative border-b border-[rgba(117,170,252,0.2)]"> + {/* Expand/Collapse button */} + <div className="absolute top-2 right-2 z-10 flex gap-1"> + <button + type="button" + onClick={() => setFullscreen(!fullscreen)} + className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + title={fullscreen ? "Collapse" : "Expand"} + > + {fullscreen ? "▼ Collapse" : "▲ Expand"} + </button> + </div> + <div + ref={messagesRef} + className={`overflow-y-auto p-3 pr-24 space-y-2 transition-all duration-200 ${ + fullscreen ? "max-h-[60vh]" : "max-h-48" + }`} + > + {messages.map((msg) => ( + <div key={msg.id} className="font-mono text-xs"> + {msg.type === "user" && ( + <div className="flex gap-2"> + <span className="text-[#9bc3ff]">></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> + ); +} |
