diff options
Diffstat (limited to 'makima/frontend')
18 files changed, 16 insertions, 5422 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 6fe4ba9..7c5dad1 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -20,12 +20,9 @@ const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Orders", href: "/orders", requiresAuth: true }, - { - label: "Contracts", - href: "/contracts", - requiresAuth: true, - hideInDocumentMode: true, - }, + // /contracts has been removed in Phase 5; the legacy nav entry is gone. + // /exec is still reachable for the standalone task page but hidden when + // document mode is on (the unified surface routes through /directives). { label: "Exec", href: "/exec", diff --git a/makima/frontend/src/components/contracts/CommandModePanel.tsx b/makima/frontend/src/components/contracts/CommandModePanel.tsx deleted file mode 100644 index b39b309..0000000 --- a/makima/frontend/src/components/contracts/CommandModePanel.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useState, useCallback } from "react"; -import { useNavigate } from "react-router"; -import type { ContractWithRelations } from "../../lib/api"; -import { - getSupervisorStatus, - startSupervisor, - stopSupervisor, - resumeSupervisor, - updateContract, - type SupervisorStatus, -} from "../../lib/api"; - -interface CommandModePanelProps { - contract: ContractWithRelations; - onUpdate: () => void; -} - -const statusConfig: Record< - SupervisorStatus["status"], - { label: string; color: string; bgColor: string } -> = { - not_configured: { - label: "Not Configured", - color: "text-[#555]", - bgColor: "bg-[#555]/10", - }, - pending: { - label: "Ready", - color: "text-yellow-400", - bgColor: "bg-yellow-400/10", - }, - starting: { - label: "Starting...", - color: "text-blue-400", - bgColor: "bg-blue-400/10", - }, - running: { - label: "Running", - color: "text-green-400", - bgColor: "bg-green-400/10", - }, - paused: { - label: "Paused", - color: "text-orange-400", - bgColor: "bg-orange-400/10", - }, - done: { - label: "Completed", - color: "text-blue-400", - bgColor: "bg-blue-400/10", - }, - failed: { - label: "Failed", - color: "text-red-400", - bgColor: "bg-red-400/10", - }, -}; - -export function CommandModePanel({ contract, onUpdate }: CommandModePanelProps) { - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - - const supervisorStatus = getSupervisorStatus(contract); - - const handleGoToSupervisor = useCallback(() => { - if (supervisorStatus.supervisorTaskId) { - navigate(`/exec/${supervisorStatus.supervisorTaskId}`); - } - }, [supervisorStatus.supervisorTaskId, navigate]); - const config = statusConfig[supervisorStatus.status]; - - const handleStart = useCallback(async () => { - if (!supervisorStatus.supervisorTaskId) return; - - setLoading(true); - setError(null); - - try { - await startSupervisor(supervisorStatus.supervisorTaskId); - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to start command mode"); - } finally { - setLoading(false); - } - }, [supervisorStatus.supervisorTaskId, onUpdate]); - - const handleStop = useCallback(async () => { - if (!supervisorStatus.supervisorTaskId) return; - - setLoading(true); - setError(null); - - try { - await stopSupervisor(supervisorStatus.supervisorTaskId); - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to stop command mode"); - } finally { - setLoading(false); - } - }, [supervisorStatus.supervisorTaskId, onUpdate]); - - const handleResume = useCallback(async () => { - setLoading(true); - setError(null); - - try { - await resumeSupervisor(contract.id, { resumeMode: "continue" }); - // After resuming, we need to start the task - if (supervisorStatus.supervisorTaskId) { - await startSupervisor(supervisorStatus.supervisorTaskId); - } - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to resume command mode"); - } finally { - setLoading(false); - } - }, [contract.id, supervisorStatus.supervisorTaskId, onUpdate]); - - const handlePhaseGuardChange = useCallback(async (enabled: boolean) => { - setLoading(true); - setError(null); - - try { - await updateContract(contract.id, { - phaseGuard: enabled, - version: contract.version, - }); - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to update phase guard setting"); - } finally { - setLoading(false); - } - }, [contract.id, contract.version, onUpdate]); - - return ( - <div className="space-y-3"> - <div className="flex items-center justify-between"> - <h3 className="font-mono text-xs text-[#75aafc] uppercase"> - Command Mode - </h3> - <div className="flex items-center gap-2"> - {supervisorStatus.supervisorTaskId && ( - <button - onClick={handleGoToSupervisor} - className="px-2 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors flex items-center gap-1" - > - <span className="text-[#75aafc]">▶</span> - Supervisor - </button> - )} - <div - className={`px-2 py-1 rounded font-mono text-xs ${config.color} ${config.bgColor}`} - > - {config.label} - </div> - </div> - </div> - - <p className="font-mono text-xs text-[#555]"> - {supervisorStatus.status === "not_configured" ? ( - "This contract does not have a Command Mode supervisor configured." - ) : supervisorStatus.status === "running" ? ( - "Command Mode is actively working on this contract, spawning tasks and managing progress." - ) : supervisorStatus.status === "pending" ? ( - "Command Mode is ready to start. Click 'Enable' to begin autonomous work." - ) : supervisorStatus.status === "paused" ? ( - "Command Mode is paused. Click 'Resume' to continue work." - ) : supervisorStatus.status === "failed" ? ( - "Command Mode encountered an error. You can resume to retry." - ) : supervisorStatus.status === "done" ? ( - "Command Mode has completed its work on this contract." - ) : ( - "Command Mode is initializing..." - )} - </p> - - {error && ( - <div className="px-3 py-2 bg-red-500/10 border border-red-400/30 font-mono text-xs text-red-400"> - {error} - </div> - )} - - <div className="flex gap-2"> - {supervisorStatus.canStart && ( - <button - onClick={handleStart} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-green-600/20 border border-green-400/50 hover:bg-green-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Starting..." : "Enable Command Mode"} - </button> - )} - - {supervisorStatus.canResume && ( - <button - onClick={handleResume} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-blue-600/20 border border-blue-400/50 hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Resuming..." : "Resume Command Mode"} - </button> - )} - - {supervisorStatus.canStop && ( - <button - onClick={handleStop} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-orange-600/20 border border-orange-400/50 hover:bg-orange-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Stopping..." : "Pause Command Mode"} - </button> - )} - </div> - - {/* Phase Guard Toggle */} - <div className="pt-3 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <label className="flex items-start gap-3 cursor-pointer group"> - <div className="relative mt-0.5"> - <input - type="checkbox" - checked={contract.phaseGuard ?? false} - onChange={(e) => handlePhaseGuardChange(e.target.checked)} - disabled={loading} - className="sr-only peer" - /> - <div className="w-9 h-5 bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] rounded-full peer-checked:bg-[rgba(117,170,252,0.3)] transition-colors peer-disabled:opacity-50" /> - <div className="absolute left-0.5 top-0.5 w-4 h-4 bg-[#555] rounded-full transition-transform peer-checked:translate-x-4 peer-checked:bg-[#75aafc] peer-disabled:opacity-50" /> - </div> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <span className="font-mono text-sm text-[#dbe7ff] group-hover:text-white transition-colors"> - Phase Guard - </span> - {contract.phaseGuard && ( - <span className="px-1.5 py-0.5 text-[9px] font-mono uppercase bg-yellow-500/20 text-yellow-400 border border-yellow-400/30 rounded"> - active - </span> - )} - </div> - <div className="font-mono text-xs text-[#555] mt-0.5"> - Ask for confirmation before advancing to the next phase - </div> - </div> - </label> - </div> - - {/* Show running indicator when active */} - {supervisorStatus.status === "running" && ( - <div className="flex items-center gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" /> - <span className="font-mono text-xs text-green-400"> - Command Mode is actively working - </span> - </div> - )} - - {supervisorStatus.status === "starting" && ( - <div className="flex items-center gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" /> - <span className="font-mono text-xs text-blue-400"> - Initializing command mode... - </span> - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx deleted file mode 100644 index 54d9f3a..0000000 --- a/makima/frontend/src/components/contracts/ContractCliInput.tsx +++ /dev/null @@ -1,974 +0,0 @@ -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 (but not for completed contracts) - useEffect(() => { - if (supervisorTask && isSupervisorPending && !supervisorStarting && contract.status !== 'completed') { - console.log("Auto-starting supervisor task on mount..."); - ensureSupervisorStarted().then((started) => { - if (started) { - console.log("Supervisor started successfully"); - } - }); - } - }, [supervisorTask?.id, contract.status]); // Only run when task ID or contract status changes - - // 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/ContractContextMenu.tsx b/makima/frontend/src/components/contracts/ContractContextMenu.tsx deleted file mode 100644 index f31beb5..0000000 --- a/makima/frontend/src/components/contracts/ContractContextMenu.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { ContractSummary } from "../../lib/api"; - -interface ContractContextMenuProps { - x: number; - y: number; - contract: ContractSummary; - onClose: () => void; - onMarkComplete: () => void; - onMarkActive: () => void; - onArchive: () => void; - onDelete: () => void; - onGoToSupervisor: () => void; -} - -export function ContractContextMenu({ - x, - y, - contract, - onClose, - onMarkComplete, - onMarkActive, - onArchive, - onDelete, - onGoToSupervisor, -}: ContractContextMenuProps) { - const menuRef = useRef<HTMLDivElement>(null); - - // Close on click outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [onClose]); - - // Adjust position if menu would overflow viewport - useEffect(() => { - if (menuRef.current) { - const rect = menuRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (rect.right > viewportWidth) { - menuRef.current.style.left = `${x - rect.width}px`; - } - if (rect.bottom > viewportHeight) { - menuRef.current.style.top = `${y - rect.height}px`; - } - } - }, [x, y]); - - const menuItemClass = - "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; - const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; - - const showMarkComplete = contract.status !== "completed"; - const showMarkActive = contract.status !== "active"; - const showArchive = contract.status !== "archived"; - const showGoToSupervisor = !!contract.supervisorTaskId; - - return ( - <div - ref={menuRef} - className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" - style={{ left: x, top: y }} - > - {/* Header showing contract name */} - <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[200px]"> - {contract.name} - </div> - - {/* Status actions */} - {showMarkComplete && ( - <button - className={menuItemClass} - onClick={() => { - onMarkComplete(); - onClose(); - }} - > - <span className="text-[#75aafc]">✓</span> - Mark as Complete - </button> - )} - - {showMarkActive && ( - <button - className={menuItemClass} - onClick={() => { - onMarkActive(); - onClose(); - }} - > - <span className="text-[#75aafc]">●</span> - Mark as Active - </button> - )} - - {showArchive && ( - <button - className={menuItemClass} - onClick={() => { - onArchive(); - onClose(); - }} - > - <span className="text-[#75aafc]">▣</span> - Archive - </button> - )} - - {/* Supervisor link */} - {showGoToSupervisor && ( - <> - <div className={dividerClass} /> - <button - className={menuItemClass} - onClick={() => { - onGoToSupervisor(); - onClose(); - }} - > - <span className="text-[#75aafc]">▶</span> - Go to Supervisor Task - </button> - </> - )} - - <div className={dividerClass} /> - - {/* Delete action */} - <button - className={`${menuItemClass} text-red-400 hover:bg-red-400/10`} - onClick={() => { - onDelete(); - onClose(); - }} - > - <span className="text-red-400">✕</span> - Delete - </button> - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx deleted file mode 100644 index 02c129e..0000000 --- a/makima/frontend/src/components/contracts/ContractDetail.tsx +++ /dev/null @@ -1,810 +0,0 @@ -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 { CommandModePanel } from "./CommandModePanel"; -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 min-h-0"> - {/* 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> - {contract.localOnly && ( - <span className="px-2 py-0.5 font-mono text-[10px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10"> - Local-Only - </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} - contractType={contract.contractType} - 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 min-h-0"> - {activeTab === "overview" && ( - <OverviewTab - contract={contract} - onStatusChange={onStatusChange} - onPhaseChange={onPhaseChange} - onCreateFile={onCreateFileFromTemplate} - onRefresh={onRefresh} - /> - )} - - {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} - contractType={contract.contractType} - onSelect={onTaskSelect} - onCreate={onTaskCreate} - /> - )} - </div> - - {/* Chat Input */} - <ContractCliInput - contractId={contract.id} - contract={contract} - onUpdate={onRefresh} - /> - </div> - ); -} - -// Overview tab -function OverviewTab({ - contract, - onStatusChange, - onPhaseChange, - onCreateFile, - onRefresh, -}: { - contract: ContractWithRelations; - onStatusChange: (status: ContractStatus) => void; - onPhaseChange: (phase: ContractPhase) => void; - onCreateFile?: (templateId: string, suggestedName: string) => void; - onRefresh: () => void; -}) { - return ( - <div className="space-y-6"> - {/* Command Mode controls */} - <CommandModePanel contract={contract} onUpdate={onRefresh} /> - - {/* 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, - contractType, - onSelect, - onCreate, -}: { - tasks: TaskSummary[]; - repositories: ContractRepository[]; - supervisorTaskId: string | null; - contractType: string; - 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 - show for task-type contracts or contracts without supervisors */} - {(contractType === "task" || !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 deleted file mode 100644 index 1eee6a3..0000000 --- a/makima/frontend/src/components/contracts/ContractList.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useState } from "react"; -import type { ContractSummary, ContractStatus } from "../../lib/api"; -import { PhaseBadge } from "./PhaseBadge"; -import { PhaseProgressBarCompact } from "./PhaseProgressBar"; -import { ContractContextMenu } from "./ContractContextMenu"; - -interface ContractListProps { - contracts: ContractSummary[]; - loading: boolean; - onSelect: (id: string) => void; - onCreate: () => void; - selectedId?: string; - onMarkComplete?: (contract: ContractSummary) => void; - onMarkActive?: (contract: ContractSummary) => void; - onArchive?: (contract: ContractSummary) => void; - onDelete?: (contract: ContractSummary) => void; - onGoToSupervisor?: (contract: ContractSummary) => void; -} - -const statusColors: Record<ContractStatus, string> = { - active: "text-green-400", - completed: "text-blue-400", - archived: "text-[#555]", -}; - -export function ContractList({ - contracts, - loading, - onSelect, - onCreate, - selectedId, - onMarkComplete, - onMarkActive, - onArchive, - onDelete, - onGoToSupervisor, -}: ContractListProps) { - const [filter, setFilter] = useState<ContractStatus | "all">("all"); - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [contextMenuContract, setContextMenuContract] = useState<ContractSummary | null>(null); - - const handleContextMenu = (e: React.MouseEvent, contract: ContractSummary) => { - e.preventDefault(); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuContract(contract); - }; - - const closeContextMenu = () => { - setContextMenuPosition(null); - setContextMenuContract(null); - }; - - 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 min-h-0"> - {/* 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 min-h-0 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)} - onContextMenu={(e) => handleContextMenu(e, contract)} - 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"> - <div className="flex items-center gap-2 min-w-0"> - <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> - {contract.name} - </h3> - {contract.localOnly && ( - <span className="px-1.5 py-0.5 font-mono text-[9px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10 shrink-0"> - Local - </span> - )} - </div> - <span - className={`text-[10px] font-mono uppercase shrink-0 ${ - 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} contractType={contract.contractType} /> - <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> - - {/* Context Menu */} - {contextMenuPosition && contextMenuContract && ( - <ContractContextMenu - x={contextMenuPosition.x} - y={contextMenuPosition.y} - contract={contextMenuContract} - onClose={closeContextMenu} - onMarkComplete={() => onMarkComplete?.(contextMenuContract)} - onMarkActive={() => onMarkActive?.(contextMenuContract)} - onArchive={() => onArchive?.(contextMenuContract)} - onDelete={() => onDelete?.(contextMenuContract)} - onGoToSupervisor={() => onGoToSupervisor?.(contextMenuContract)} - /> - )} - </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 deleted file mode 100644 index 0f46b9b..0000000 --- a/makima/frontend/src/components/contracts/PhaseBadge.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index b2c2e58..0000000 --- a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { useMemo } from "react"; -import type { ContractWithRelations, ContractPhase, ContractType } from "../../lib/api"; - -// Phase deliverables configuration (mirrors backend phase_guidance.rs) -// IDs must match backend phase_guidance.rs exactly for mark_deliverable_complete -interface PhaseDeliverable { - id: string; // Must match backend deliverable ID - name: string; - priority: "required" | "recommended" | "optional"; - description: string; -} - -interface PhaseConfig { - deliverables: PhaseDeliverable[]; - requiresRepository: boolean; - requiresTasks: boolean; - guidance: string; -} - -// Contract type specific deliverables (must match backend phase_guidance.rs) -type ContractTypeDeliverables = Partial<Record<ContractPhase, PhaseConfig>>; - -const CONTRACT_TYPE_DELIVERABLES: Record<ContractType, ContractTypeDeliverables> = { - simple: { - plan: { - deliverables: [ - { id: "plan-document", name: "Plan", priority: "required", description: "Implementation plan detailing the approach and tasks" }, - ], - requiresRepository: true, - requiresTasks: false, - guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.", - }, - execute: { - deliverables: [ - { id: "pull-request", name: "Pull Request", priority: "required", description: "Pull request with the implemented changes" }, - ], - requiresRepository: true, - requiresTasks: true, - guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks to finish the contract.", - }, - }, - specification: { - research: { - deliverables: [ - { id: "research-notes", name: "Research Notes", priority: "required", description: "Document findings and insights during research" }, - ], - requiresRepository: false, - requiresTasks: false, - guidance: "Focus on understanding the problem space and document your findings in the Research Notes before moving to Specify phase.", - }, - specify: { - deliverables: [ - { id: "requirements-document", name: "Requirements Document", priority: "required", description: "Define functional and non-functional requirements" }, - ], - requiresRepository: false, - requiresTasks: false, - guidance: "Define what needs to be built with clear requirements in the Requirements Document. Ensure specifications are detailed enough for planning.", - }, - plan: { - deliverables: [ - { id: "plan-document", name: "Plan", priority: "required", description: "Implementation plan detailing the approach and tasks" }, - ], - requiresRepository: true, - requiresTasks: false, - guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.", - }, - execute: { - deliverables: [ - { id: "pull-request", name: "Pull Request", priority: "required", description: "Pull request with the implemented changes" }, - ], - requiresRepository: true, - requiresTasks: true, - guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks before moving to Review phase.", - }, - review: { - deliverables: [ - { id: "release-notes", name: "Release Notes", priority: "required", description: "Document changes for release communication" }, - ], - requiresRepository: false, - requiresTasks: false, - guidance: "Review completed work and document the release in the Release Notes. The contract can be completed after review.", - }, - }, - execute: { - execute: { - deliverables: [], // No deliverables for execute-only contract type - requiresRepository: true, - requiresTasks: true, - guidance: "Execute the tasks directly. No deliverable documents are required for this contract type.", - }, - }, -}; - -// Get phase config for a specific contract type and phase -function getPhaseConfig(contractType: ContractType, phase: ContractPhase): PhaseConfig { - const typeConfig = CONTRACT_TYPE_DELIVERABLES[contractType]; - const phaseConfig = typeConfig?.[phase]; - - if (phaseConfig) { - return phaseConfig; - } - - // Fallback for unknown phase/type combinations - return { - deliverables: [], - requiresRepository: false, - requiresTasks: false, - guidance: `Unknown phase "${phase}" for contract type "${contractType}"`, - }; -} - -interface DeliverableStatus { - id: 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) { - // Get phase config based on contract type AND phase - const phaseConfig = useMemo( - () => getPhaseConfig(contract.contractType, contract.phase), - [contract.contractType, contract.phase] - ); - - // Calculate deliverable status - const deliverableStatuses = useMemo((): DeliverableStatus[] => { - return phaseConfig.deliverables.map((deliverable) => { - // Find matching file by name similarity - const matchedFile = contract.files.find((f) => { - const nameLower = f.name.toLowerCase(); - const deliverableLower = deliverable.name.toLowerCase(); - return ( - f.contractPhase === contract.phase && - (nameLower.includes(deliverableLower) || deliverableLower.includes(nameLower) || nameLower.includes(deliverable.id.replace("-", " "))) - ); - }); - - return { - ...deliverable, - completed: !!matchedFile, - fileId: matchedFile?.id, - actualName: matchedFile?.name, - }; - }); - }, [contract.files, contract.phase, phaseConfig.deliverables]); - - // 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 deliverables - deliverableStatuses.forEach((s) => { - if (s.priority !== "optional") { - total++; - if (s.completed) completed++; - } - }); - - // Count repository if required - if (phaseConfig.requiresRepository) { - total++; - if (hasRepository) completed++; - } - - // Count tasks if required - if (phaseConfig.requiresTasks && taskStats.total > 0) { - total++; - if (taskStats.done === taskStats.total) completed++; - } - - return total > 0 ? Math.round((completed / total) * 100) : 100; - }, [deliverableStatuses, hasRepository, phaseConfig, 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">{phaseConfig.guidance}</p> - - {/* Deliverables checklist */} - <div className="space-y-2"> - {deliverableStatuses.map((status) => ( - <div - key={status.id} - 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.id, 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 */} - {phaseConfig.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 */} - {phaseConfig.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 deleted file mode 100644 index 95573ed..0000000 --- a/makima/frontend/src/components/contracts/PhaseHint.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 9589db9..0000000 --- a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import type { ContractPhase, ContractType } from "../../lib/api"; -import { getValidPhases } from "../../lib/api"; - -interface PhaseProgressBarProps { - currentPhase: ContractPhase; - contractType?: ContractType; - 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, - contractType, - onPhaseClick, - readonly = false, -}: PhaseProgressBarProps) { - const visiblePhases = contractType ? getValidPhases(contractType) : phases; - const currentIndex = visiblePhases.indexOf(currentPhase); - - return ( - <div className="flex items-center gap-1"> - {visiblePhases.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 < visiblePhases.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, - contractType, -}: { - currentPhase: ContractPhase; - contractType?: ContractType; -}) { - const visiblePhases = contractType ? getValidPhases(contractType) : phases; - const currentIndex = visiblePhases.indexOf(currentPhase); - - return ( - <div className="flex items-center gap-0.5"> - {visiblePhases.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 deleted file mode 100644 index 4dbb90c..0000000 --- a/makima/frontend/src/components/contracts/QuickActionButtons.tsx +++ /dev/null @@ -1,217 +0,0 @@ -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 deleted file mode 100644 index 15741a8..0000000 --- a/makima/frontend/src/components/contracts/RepositoryPanel.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { useState, useEffect } from "react"; -import type { - ContractRepository, - RepositorySourceType, - RepositoryStatus, - DaemonDirectory, - RepositoryHistoryEntry, -} from "../../lib/api"; -import { getDaemonDirectories, getRepositorySuggestions } 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[]>([]); - // Repository history suggestions - const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showSuggestions, setShowSuggestions] = useState(false); - - // Fetch daemon directories when "local" mode is selected - useEffect(() => { - if (addMode === "local") { - getDaemonDirectories() - .then((res) => setSuggestedDirectories(res.directories)) - .catch(() => setSuggestedDirectories([])); - } - }, [addMode]); - - // Fetch repository suggestions when mode changes to remote or local - useEffect(() => { - if (addMode === "remote" || addMode === "local") { - getRepositorySuggestions(addMode, undefined, 10) - .then((res) => { - setRepoSuggestions(res.entries); - setShowSuggestions(res.entries.length > 0); - }) - .catch(() => { - setRepoSuggestions([]); - setShowSuggestions(false); - }); - } else { - setRepoSuggestions([]); - setShowSuggestions(false); - } - }, [addMode]); - - // Apply a suggestion to the form - const applySuggestion = (suggestion: RepositoryHistoryEntry) => { - setName(suggestion.name); - if (suggestion.repositoryUrl) { - setUrl(suggestion.repositoryUrl); - } - if (suggestion.localPath) { - setPath(suggestion.localPath); - } - setShowSuggestions(false); - }; - - 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 justify-between mb-2"> - <span className="font-mono text-xs text-[#75aafc] uppercase"> - Add {sourceTypeLabels[addMode]} Repository - </span> - {repoSuggestions.length > 0 && ( - <button - onClick={() => setShowSuggestions(!showSuggestions)} - className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" - > - {showSuggestions ? "Hide suggestions" : `${repoSuggestions.length} suggestions`} - </button> - )} - </div> - - {/* Suggestions dropdown */} - {showSuggestions && repoSuggestions.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> - {repoSuggestions.map((suggestion) => ( - <button - key={suggestion.id} - onClick={() => applySuggestion(suggestion)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] transition-colors border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> - <span className="text-[10px] text-[#556677]"> - {suggestion.useCount}× - </span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {addMode === "local" ? suggestion.localPath : suggestion.repositoryUrl} - </div> - </button> - ))} - </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 deleted file mode 100644 index 07421ef..0000000 --- a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx +++ /dev/null @@ -1,221 +0,0 @@ -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/main.tsx b/makima/frontend/src/main.tsx index bbb72f3..c1c6c35 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,14 +12,12 @@ import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; -import ContractsPage from "./routes/contracts"; import OrdersPage from "./routes/orders"; import MeshPage from "./routes/mesh"; import DaemonsPage from "./routes/daemons"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; -import ContractFilePage from "./routes/contract-file"; import SpeakPage from "./routes/speak"; import DirectivesPage from "./routes/directives"; import ExecRedirect from "./routes/exec-redirect"; @@ -62,30 +60,6 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/contracts" - element={ - <ProtectedRoute> - <ContractsPage /> - </ProtectedRoute> - } - /> - <Route - path="/contracts/:id" - element={ - <ProtectedRoute> - <ContractsPage /> - </ProtectedRoute> - } - /> - <Route - path="/contracts/:id/files/:fileId" - element={ - <ProtectedRoute> - <ContractFilePage /> - </ProtectedRoute> - } - /> - <Route path="/orders" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/contract-file.tsx b/makima/frontend/src/routes/contract-file.tsx deleted file mode 100644 index 9ed25ed..0000000 --- a/makima/frontend/src/routes/contract-file.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { useParams, useNavigate } from "react-router"; -import { useAuth } from "../contexts/AuthContext"; -import { Masthead } from "../components/Masthead"; -import { FileDetail, type FocusedElement } from "../components/files/FileDetail"; -import { CliInput } from "../components/files/CliInput"; -import { ConflictNotification } from "../components/files/ConflictNotification"; -import { UpdateNotification } from "../components/files/UpdateNotification"; -import { useFiles } from "../hooks/useFiles"; -import { useVersionHistory } from "../hooks/useVersionHistory"; -import { - useFileSubscription, - type FileUpdateEvent, -} from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; - -/** - * ContractFilePage - Wrapper for viewing files within a contract context - * - * This component handles the /contracts/:contractId/files/:fileId route, - * providing navigation back to the contract and rendering the file detail view. - */ -export default function ContractFilePage() { - const { id: contractId, fileId } = useParams<{ id: string; fileId: string }>(); - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - // Render the file page with contract context - return <ContractAwareFilesPage contractId={contractId} fileId={fileId} />; -} - -// A version of the files page aware of contract context -function ContractAwareFilesPage({ - contractId, - fileId, -}: { - contractId?: string; - fileId?: string; -}) { - const navigate = useNavigate(); - const { error, conflict, clearConflict, fetchFile, editFile, removeFile } = useFiles(); - const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); - const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); - const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); - const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); - const pendingUpdateRef = useRef(false); - const lastSentVersionRef = useRef<number | null>(null); - const lastSavedVersionRef = useRef<number | null>(null); - const hasLocalChangesRef = useRef(false); - const isActivelyEditingRef = useRef(false); - const currentVersionRef = useRef<number | null>(null); - - // Handle back navigation - go to contract detail instead of /files - const handleBack = useCallback(() => { - if (contractId) { - navigate(`/contracts/${contractId}`); - } else { - navigate("/contracts"); - } - }, [contractId, navigate]); - - const updateHasLocalChanges = useCallback((value: boolean) => { - hasLocalChangesRef.current = value; - }, []); - - const updateIsActivelyEditing = useCallback((value: boolean) => { - isActivelyEditingRef.current = value; - }, []); - - // Version history - const { - versions, - loading: versionsLoading, - selectedVersion, - loadingVersion, - restoring, - fetchVersion, - restoreToVersion, - clearSelectedVersion, - fetchVersions, - } = useVersionHistory({ - fileId: fileId || null, - currentVersion: fileDetail?.version || 0, - }); - - const handleRestoreVersion = useCallback( - async (targetVersion: number) => { - const result = await restoreToVersion(targetVersion); - if (result) { - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - fetchVersions(); - } - }, - [restoreToVersion, fetchVersions, updateHasLocalChanges] - ); - - // Load file detail when fileId is provided - useEffect(() => { - if (fileId) { - setDetailLoading(true); - updateHasLocalChanges(false); - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - lastSavedVersionRef.current = null; - currentVersionRef.current = null; - setRemoteUpdate(null); - setRemoteFileData(null); - setFocusedElement(null); - fetchFile(fileId).then((detail) => { - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - setDetailLoading(false); - }); - } else { - setFileDetail(null); - currentVersionRef.current = null; - updateHasLocalChanges(false); - } - }, [fileId, fetchFile, updateHasLocalChanges]); - - // Handle file update events from WebSocket - const handleFileUpdate = useCallback( - async (event: FileUpdateEvent) => { - if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) { - lastSavedVersionRef.current = null; - return; - } - - if (pendingUpdateRef.current) { - if (lastSentVersionRef.current !== null) { - const expectedNewVersion = lastSentVersionRef.current + 1; - if (event.version === expectedNewVersion) { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - return; - } - } - return; - } - - if (currentVersionRef.current !== null && event.version === currentVersionRef.current) { - return; - } - - if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) { - const detail = await fetchFile(event.fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - } else { - const remoteData = await fetchFile(event.fileId); - setRemoteFileData(remoteData); - setRemoteUpdate(event); - } - }, - [fetchFile] - ); - - useFileSubscription({ - fileId: fileId || null, - onUpdate: handleFileUpdate, - }); - - const handleDelete = useCallback( - async (id: string) => { - if (confirm("Are you sure you want to delete this file?")) { - const success = await removeFile(id); - if (success && fileId === id) { - handleBack(); - } - } - }, - [removeFile, fileId, handleBack] - ); - - const handleSave = useCallback( - async (id: string, name: string, description: string) => { - if (!fileDetail) return; - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(id, { name, description, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - }, - [editFile, fileDetail, updateHasLocalChanges] - ); - - const handleBodyUpdate = useCallback( - (body: BodyElement[], summary: string | null) => { - if (fileDetail) { - setFileDetail({ - ...fileDetail, - body, - summary, - }); - } - }, - [fileDetail] - ); - - const handleBodyElementUpdate = useCallback( - async (index: number, element: BodyElement) => { - if (fileDetail && fileId) { - const newBody = [...fileDetail.body]; - newBody[index] = element; - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges] - ); - - const handleBodyReorder = useCallback( - async (fromIndex: number, toIndex: number) => { - if (fileDetail && fileId) { - const newBody = [...fileDetail.body]; - const [movedElement] = newBody.splice(fromIndex, 1); - newBody.splice(toIndex, 0, movedElement); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges] - ); - - const handleBodyElementDelete = useCallback( - async (index: number) => { - if (fileDetail && fileId) { - const newBody = fileDetail.body.filter((_, i) => i !== index); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - if (focusedElement?.index === index) { - setFocusedElement(null); - } else if (focusedElement && focusedElement.index > index) { - setFocusedElement({ - ...focusedElement, - index: focusedElement.index - 1, - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleBodyElementDuplicate = useCallback( - async (index: number) => { - if (fileDetail && fileId) { - const elementToDuplicate = fileDetail.body[index]; - if (!elementToDuplicate) return; - - const newBody = [...fileDetail.body]; - newBody.splice(index + 1, 0, { ...elementToDuplicate }); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - if (focusedElement && focusedElement.index > index) { - setFocusedElement({ - ...focusedElement, - index: focusedElement.index + 1, - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleFocusElement = useCallback((element: FocusedElement | null) => { - setFocusedElement(element); - }, []); - - const handleClearFocus = useCallback(() => { - setFocusedElement(null); - }, []); - - const handleConvertElement = useCallback( - async (index: number, toType: string) => { - if (!fileDetail || !fileId) return; - - const element = fileDetail.body[index]; - if (!element) return; - - let textContent = ""; - switch (element.type) { - case "heading": - case "paragraph": - textContent = element.text; - break; - case "code": - textContent = element.content; - break; - case "list": - textContent = element.items.join("\n"); - break; - default: - return; - } - - let newElement: BodyElement; - if (toType === "paragraph") { - newElement = { type: "paragraph", text: textContent }; - } else if (toType === "list_unordered") { - const items = textContent.split("\n").filter(line => line.trim()); - newElement = { type: "list", ordered: false, items }; - } else if (toType === "list_ordered") { - const items = textContent.split("\n").filter(line => line.trim()); - newElement = { type: "list", ordered: true, items }; - } else if (toType === "code") { - newElement = { type: "code", content: textContent }; - } else if (toType.startsWith("heading_")) { - const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6; - newElement = { type: "heading", level, text: textContent }; - } else { - return; - } - - const newBody = [...fileDetail.body]; - newBody[index] = newElement; - - setFileDetail({ ...fileDetail, body: newBody }); - updateHasLocalChanges(true); - - if (focusedElement?.index === index) { - setFocusedElement({ - index, - type: newElement.type, - preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""), - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleGenerateFromElement = useCallback( - (index: number, action: string) => { - if (!fileDetail) return; - - const element = fileDetail.body[index]; - if (!element) return; - - let preview = ""; - switch (element.type) { - case "heading": - case "paragraph": - preview = element.text.slice(0, 50); - break; - case "code": - preview = element.content.slice(0, 50); - break; - case "list": - preview = element.items[0]?.slice(0, 40) || ""; - break; - default: - preview = "Element"; - } - - setFocusedElement({ - index, - type: element.type, - preview: preview + (preview.length >= 50 ? "..." : ""), - }); - - let prompt = ""; - switch (action) { - case "elaborate": - prompt = "Elaborate and expand on this content"; - break; - case "summarize": - prompt = "Summarize this content"; - break; - case "extract_actions": - prompt = "Extract action items from this content"; - break; - } - setSuggestedPrompt(prompt); - }, - [fileDetail] - ); - - // Conflict resolution handlers - const handleConflictReload = useCallback(async () => { - if (fileId) { - clearConflict(); - const detail = await fetchFile(fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - updateHasLocalChanges(false); - } - }, [fileId, clearConflict, fetchFile, updateHasLocalChanges]); - - const handleConflictForceOverwrite = useCallback(async () => { - if (fileId && fileDetail) { - clearConflict(); - const latest = await fetchFile(fileId); - if (latest) { - pendingUpdateRef.current = true; - lastSentVersionRef.current = latest.version; - try { - const result = await editFile(fileId, { body: fileDetail.body, version: latest.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - } - }, [fileId, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]); - - const handleRemoteUpdateRefresh = useCallback(async () => { - if (fileId) { - const detail = await fetchFile(fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - setRemoteUpdate(null); - setRemoteFileData(null); - updateHasLocalChanges(false); - } - }, [fileId, fetchFile, updateHasLocalChanges]); - - const handleRemoteUpdateDismiss = useCallback(() => { - setRemoteUpdate(null); - setRemoteFileData(null); - }, []); - - return ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden"> - <Masthead showTicker={false} showNav /> - - <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col"> - {error && ( - <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0"> - {error} - </div> - )} - - {fileId && fileDetail ? ( - <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> - <div className="flex-1 min-h-0 overflow-hidden"> - <FileDetail - file={fileDetail} - loading={detailLoading} - onBack={handleBack} - onSave={handleSave} - onDelete={handleDelete} - onBodyElementUpdate={handleBodyElementUpdate} - onBodyReorder={handleBodyReorder} - onBodyElementDelete={handleBodyElementDelete} - onBodyElementDuplicate={handleBodyElementDuplicate} - onConvertElement={handleConvertElement} - onGenerateFromElement={handleGenerateFromElement} - onEditingChange={updateIsActivelyEditing} - hasPendingRemoteUpdate={!!remoteUpdate} - onOverwrite={handleRemoteUpdateDismiss} - focusedElement={focusedElement} - onFocusElement={handleFocusElement} - versions={versions} - versionsLoading={versionsLoading} - selectedVersion={selectedVersion} - loadingVersion={loadingVersion} - restoring={restoring} - onSelectVersion={fetchVersion} - onRestoreVersion={handleRestoreVersion} - onClearVersionSelection={clearSelectedVersion} - /> - </div> - <div className="shrink-0"> - <CliInput - fileId={fileId} - onUpdate={handleBodyUpdate} - focusedElement={focusedElement} - onClearFocus={handleClearFocus} - suggestedPrompt={suggestedPrompt} - onClearSuggestedPrompt={() => setSuggestedPrompt(null)} - /> - </div> - </div> - ) : fileId && detailLoading ? ( - <div className="panel h-full flex items-center justify-center"> - <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> - </div> - ) : ( - <div className="panel h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - File not found - </p> - <button - onClick={handleBack} - className="px-4 py-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" - > - ← Back to Contract - </button> - </div> - </div> - )} - </main> - - {/* Conflict notification */} - {conflict?.hasConflict && ( - <ConflictNotification - onReload={handleConflictReload} - onForceOverwrite={handleConflictForceOverwrite} - onDismiss={clearConflict} - /> - )} - - {/* Remote update notification */} - {remoteUpdate && ( - <UpdateNotification - updatedBy={remoteUpdate.updatedBy} - localBody={fileDetail?.body || []} - remoteBody={remoteFileData?.body || []} - onRefresh={handleRemoteUpdateRefresh} - onDismiss={handleRemoteUpdateDismiss} - /> - )} - </div> - ); -} diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx deleted file mode 100644 index ce9ceca..0000000 --- a/makima/frontend/src/routes/contracts.tsx +++ /dev/null @@ -1,885 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { useParams, useNavigate } from "react-router"; -import { Masthead } from "../components/Masthead"; -import { ContractList } from "../components/contracts/ContractList"; -import { ContractDetail } from "../components/contracts/ContractDetail"; -import { DirectoryInput } from "../components/mesh/DirectoryInput"; -import { useContracts } from "../hooks/useContracts"; -import { useAuth } from "../contexts/AuthContext"; -import { - createTask, - getDaemonDirectories, - getRepositorySuggestions, - listContractTypes, -} from "../lib/api"; -import type { - ContractWithRelations, - ContractSummary, - ContractPhase, - ContractStatus, - ContractType, - CreateContractRequest, - RepositorySourceType, - DaemonDirectory, - RepositoryHistoryEntry, - ContractTypeTemplate, -} from "../lib/api"; - -export default function ContractsPage() { - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - return <ContractsPageContent />; -} - -function ContractsPageContent() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const { - contracts, - loading, - error, - fetchContract, - saveContract, - editContract, - removeContract, - changePhase, - addRemoteRepo, - addLocalRepo, - createManagedRepo, - removeRepo, - setRepoPrimary, - } = useContracts(); - - const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [isCreating, setIsCreating] = useState(false); - const [newContractName, setNewContractName] = useState(""); - const [newContractDescription, setNewContractDescription] = useState(""); - const [contractType, setContractType] = useState<ContractType>("simple"); - const [initialPhase, setInitialPhase] = useState<ContractPhase>("plan"); - const [repoType, setRepoType] = useState<RepositorySourceType>("remote"); - const [repoName, setRepoName] = useState(""); - const [repoUrl, setRepoUrl] = useState(""); - const [repoPath, setRepoPath] = useState(""); - const [createError, setCreateError] = useState<string | null>(null); - const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); - const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); - const [contractTypes, setContractTypes] = useState<ContractTypeTemplate[]>([]); - const [contractTypesLoading, setContractTypesLoading] = useState(false); - const [localOnly, setLocalOnly] = useState(false); - - // Fetch contract types when modal opens - API returns both built-in and custom templates - useEffect(() => { - if (isCreating) { - setContractTypesLoading(true); - - listContractTypes() - .then((res) => { - setContractTypes(res.contractTypes); - setContractTypesLoading(false); - }) - .catch((err) => { - console.error("Failed to fetch contract types:", err); - // Fall back to built-in types - const builtinTypes: ContractTypeTemplate[] = [ - { - id: "simple", - name: "Simple", - description: "Plan \u2192 Execute: Simple workflow with a plan document", - phases: ["plan", "execute"], - defaultPhase: "plan", - isBuiltin: true, - }, - { - id: "specification", - name: "Specification", - description: "Research \u2192 Specify \u2192 Plan \u2192 Execute \u2192 Review: Full specification-driven development with TDD", - phases: ["research", "specify", "plan", "execute", "review"], - defaultPhase: "research", - isBuiltin: true, - }, - { - id: "execute", - name: "Execute", - description: "Execute only: Minimal workflow for immediate task execution", - phases: ["execute"], - defaultPhase: "execute", - isBuiltin: true, - }, - ]; - setContractTypes(builtinTypes); - setContractTypesLoading(false); - }); - } - }, [isCreating]); - - // Fetch repository suggestions when modal opens and repo type changes - useEffect(() => { - if (isCreating && (repoType === "remote" || repoType === "local")) { - getRepositorySuggestions(repoType, undefined, 10) - .then((res) => { - setRepoSuggestions(res.entries); - setShowRepoSuggestions(res.entries.length > 0); - }) - .catch(() => { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - }); - } else { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - } - }, [isCreating, repoType]); - - // Apply a repository suggestion - const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => { - setRepoName(suggestion.name); - if (suggestion.repositoryUrl) { - setRepoUrl(suggestion.repositoryUrl); - } - if (suggestion.localPath) { - setRepoPath(suggestion.localPath); - } - setShowRepoSuggestions(false); - }, []); - - // Fetch daemon directories when "local" repo type is selected - useEffect(() => { - if (repoType === "local" && isCreating) { - getDaemonDirectories() - .then((res) => setSuggestedDirectories(res.directories)) - .catch(() => setSuggestedDirectories([])); - } - }, [repoType, isCreating]); - - // Load contract detail when ID changes - useEffect(() => { - if (id) { - setDetailLoading(true); - fetchContract(id).then((contract) => { - setContractDetail(contract); - setDetailLoading(false); - }); - } else { - setContractDetail(null); - } - }, [id, fetchContract]); - - const handleSelect = useCallback( - (contractId: string) => { - navigate(`/contracts/${contractId}`); - }, - [navigate] - ); - - const handleBack = useCallback(() => { - navigate("/contracts"); - }, [navigate]); - - const handleCreate = useCallback(() => { - setIsCreating(true); - }, []); - - // Validate repository configuration - const isRepoValid = useCallback(() => { - if (!repoName.trim()) return false; - if (repoType === "remote" && !repoUrl.trim()) return false; - if (repoType === "local" && !repoPath.trim()) return false; - return true; - }, [repoType, repoName, repoUrl, repoPath]); - - const handleCreateSubmit = useCallback(async () => { - if (!newContractName.trim()) return; - if (!isRepoValid()) { - setCreateError("Repository configuration is required"); - return; - } - - setCreateError(null); - - // Get default phase from contract types or fall back to static function - const selectedType = contractTypes.find((t) => t.id === contractType); - const defaultPhaseForType = selectedType?.defaultPhase || (contractType === "simple" ? "plan" : "research"); - const isCustomTemplate = selectedType && !selectedType.isBuiltin; - - const data: CreateContractRequest = { - name: newContractName.trim(), - description: newContractDescription.trim() || undefined, - // For custom templates, send templateId instead of contractType - contractType: isCustomTemplate ? undefined : contractType, - templateId: isCustomTemplate ? contractType : undefined, - initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined, - localOnly: localOnly || undefined, - }; - - try { - const contract = await saveContract(data); - if (contract) { - // Add the repository after contract creation - try { - if (repoType === "remote") { - await addRemoteRepo(contract.id, { - name: repoName.trim(), - repositoryUrl: repoUrl.trim(), - isPrimary: true, - }); - } else if (repoType === "local") { - await addLocalRepo(contract.id, { - name: repoName.trim(), - localPath: repoPath.trim(), - isPrimary: true, - }); - } else if (repoType === "managed") { - await createManagedRepo(contract.id, { - name: repoName.trim(), - isPrimary: true, - }); - } - } catch (repoError) { - console.error("Failed to add repository:", repoError); - // Still navigate to the contract - repo can be added later - } - - // Clear form state - setIsCreating(false); - setNewContractName(""); - setNewContractDescription(""); - setContractType("simple"); - setInitialPhase("plan"); - setRepoType("remote"); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - setLocalOnly(false); - navigate(`/contracts/${contract.id}`); - } - } catch (err) { - setCreateError(err instanceof Error ? err.message : "Failed to create contract"); - } - }, [ - newContractName, - newContractDescription, - contractType, - contractTypes, - initialPhase, - repoType, - repoName, - repoUrl, - repoPath, - isRepoValid, - saveContract, - addRemoteRepo, - addLocalRepo, - createManagedRepo, - navigate, - ]); - - const handleCreateCancel = useCallback(() => { - setIsCreating(false); - setNewContractName(""); - setNewContractDescription(""); - setContractType("simple"); - setInitialPhase("plan"); - setRepoType("remote"); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - setLocalOnly(false); - setCreateError(null); - }, []); - - const handleUpdate = useCallback( - async (name: string, description: string) => { - if (contractDetail) { - const updated = await editContract(contractDetail.id, { - name, - description: description || undefined, - version: contractDetail.version, - }); - if (updated) { - // Refresh detail - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - } - }, - [contractDetail, editContract, fetchContract] - ); - - const handleDelete = useCallback(async () => { - if (contractDetail && confirm("Are you sure you want to delete this contract?")) { - const success = await removeContract(contractDetail.id); - if (success) { - navigate("/contracts"); - } - } - }, [contractDetail, removeContract, navigate]); - - const handlePhaseChange = useCallback( - async (phase: ContractPhase) => { - if (contractDetail) { - const updated = await changePhase(contractDetail.id, phase); - if (updated) { - // Refresh detail - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - } - }, - [contractDetail, changePhase, fetchContract] - ); - - const handleStatusChange = useCallback( - async (status: ContractStatus) => { - if (contractDetail) { - const updated = await editContract(contractDetail.id, { - status, - version: contractDetail.version, - }); - if (updated) { - // Refresh detail - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - } - }, - [contractDetail, editContract, fetchContract] - ); - - // Repository handlers - const handleAddRemoteRepo = useCallback( - async (name: string, url: string, isPrimary: boolean) => { - if (contractDetail) { - await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary }); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, addRemoteRepo, fetchContract] - ); - - const handleAddLocalRepo = useCallback( - async (name: string, path: string, isPrimary: boolean) => { - if (contractDetail) { - await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary }); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, addLocalRepo, fetchContract] - ); - - const handleCreateManagedRepo = useCallback( - async (name: string, isPrimary: boolean) => { - if (contractDetail) { - await createManagedRepo(contractDetail.id, { name, isPrimary }); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, createManagedRepo, fetchContract] - ); - - const handleDeleteRepo = useCallback( - async (repoId: string) => { - if (contractDetail) { - await removeRepo(contractDetail.id, repoId); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, removeRepo, fetchContract] - ); - - const handleSetRepoPrimary = useCallback( - async (repoId: string) => { - if (contractDetail) { - await setRepoPrimary(contractDetail.id, repoId); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, setRepoPrimary, fetchContract] - ); - - // Refresh contract detail (used after file/task operations) - const handleRefresh = useCallback(async () => { - if (contractDetail) { - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, [contractDetail, fetchContract]); - - // File/task navigation handlers - const handleFileSelect = useCallback( - (fileId: string) => { - if (contractDetail) { - navigate(`/contracts/${contractDetail.id}/files/${fileId}`); - } - }, - [navigate, contractDetail] - ); - - const handleTaskSelect = useCallback( - (taskId: string) => { - navigate(`/exec/${taskId}`); - }, - [navigate] - ); - - // Create task within contract context - const handleTaskCreate = useCallback( - async (name: string, plan: string, repositoryUrl?: string) => { - if (!contractDetail) return; - try { - // Create the task with contract_id (task is automatically associated) - const task = await createTask({ - contractId: contractDetail.id, - name, - plan, - repositoryUrl, - }); - // Refresh contract detail to show new task - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - // Navigate to the new task - navigate(`/exec/${task.id}`); - } catch (e) { - console.error("Failed to create task:", e); - alert(e instanceof Error ? e.message : "Failed to create task"); - } - }, - [contractDetail, fetchContract, navigate] - ); - - // Context menu handlers for ContractList - const handleContextMarkComplete = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "completed", version: contract.version }); - }, - [editContract] - ); - - const handleContextMarkActive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "active", version: contract.version }); - }, - [editContract] - ); - - const handleContextArchive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "archived", version: contract.version }); - }, - [editContract] - ); - - const handleContextDelete = useCallback( - async (contract: ContractSummary) => { - if (confirm(`Are you sure you want to delete "${contract.name}"?`)) { - const success = await removeContract(contract.id); - if (success && contract.id === id) { - navigate("/contracts"); - } - } - }, - [removeContract, id, navigate] - ); - - const handleContextGoToSupervisor = useCallback( - (contract: ContractSummary) => { - if (contract.supervisorTaskId) { - navigate(`/exec/${contract.supervisorTaskId}`); - } - }, - [navigate] - ); - - return ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> - {/* Left: Contract list */} - <div className="w-[350px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> - <ContractList - contracts={contracts} - loading={loading} - onSelect={handleSelect} - onCreate={handleCreate} - selectedId={id} - onMarkComplete={handleContextMarkComplete} - onMarkActive={handleContextMarkActive} - onArchive={handleContextArchive} - onDelete={handleContextDelete} - onGoToSupervisor={handleContextGoToSupervisor} - /> - </div> - - {/* Right: Detail or Create */} - <div className="flex-1 overflow-hidden flex flex-col min-h-0"> - {error && ( - <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> - {error} - </div> - )} - - {/* Contract detail, creation form, or empty state */} - <div className="flex-1 min-h-0 overflow-hidden"> - {isCreating ? ( - <div className="p-4 max-w-lg overflow-y-auto h-full bg-[#0a1628]"> - <h3 className="font-mono text-[10px] text-[#9bc3ff] uppercase tracking-wide mb-4"> - Create Contract - </h3> - - {createError && ( - <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> - {createError} - </div> - )} - - <div className="space-y-4"> - {/* Contract name */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Contract Name - </label> - <input - type="text" - value={newContractName} - onChange={(e) => setNewContractName(e.target.value)} - placeholder="Contract name" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - autoFocus - /> - </div> - - {/* Description */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Description (optional) - </label> - <textarea - value={newContractDescription} - onChange={(e) => setNewContractDescription(e.target.value)} - placeholder="Describe what this contract is for..." - rows={2} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc] resize-none" - /> - </div> - - {/* Contract Type */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Contract Type - </label> - {contractTypesLoading ? ( - <div className="flex items-center justify-center py-4"> - <span className="font-mono text-xs text-[#8b949e]">Loading contract types...</span> - </div> - ) : ( - <> - <div className="flex gap-2"> - {contractTypes.map((type) => ( - <button - key={type.id} - type="button" - onClick={() => { - setContractType(type.id as ContractType); - setInitialPhase(type.defaultPhase as ContractPhase); - }} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - contractType === type.id - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - {type.name} - </button> - ))} - </div> - <p className="mt-1 font-mono text-xs text-[#8b949e]"> - {contractTypes.find((t) => t.id === contractType)?.description || - "Select a contract type"} - </p> - </> - )} - </div> - - {/* Starting Phase */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Starting Phase - </label> - <select - value={initialPhase} - onChange={(e) => setInitialPhase(e.target.value as ContractPhase)} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - > - {(() => { - const template = contractTypes.find((t) => t.id === contractType); - return (template?.phases || []).map((phase) => { - const displayName = template?.phaseNames?.[phase] || (phase.charAt(0).toUpperCase() + phase.slice(1)); - return ( - <option key={phase} value={phase}> - {displayName} - </option> - ); - }); - })()} - </select> - <p className="mt-1 font-mono text-xs text-[#8b949e]"> - {contractType === "simple" - ? "Start in Plan to define what to build, or Execute if already planned" - : "Skip earlier phases if you already have requirements defined"} - </p> - </div> - - {/* Local-Only Mode */} - <div className="space-y-2"> - <div className="flex items-center space-x-3"> - <button - type="button" - onClick={() => setLocalOnly(!localOnly)} - className={`w-5 h-5 flex items-center justify-center border transition-colors ${ - localOnly - ? "bg-[#0f3c78] border-[#75aafc] text-[#dbe7ff]" - : "bg-[#0d1b2d] border-[rgba(117,170,252,0.2)] text-transparent" - }`} - > - {localOnly && ( - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="3" - strokeLinecap="round" - strokeLinejoin="round" - className="w-3 h-3" - > - <polyline points="20 6 9 17 4 12" /> - </svg> - )} - </button> - <label - className="font-mono text-sm text-[#dbe7ff] cursor-pointer select-none" - onClick={() => setLocalOnly(!localOnly)} - > - Local-Only Mode - </label> - </div> - <p className="font-mono text-xs text-[#8b949e] pl-8"> - When enabled, tasks won't automatically push to remote or create PRs. - Use patch files to export changes. - </p> - </div> - - {/* Repository Configuration */} - <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-3"> - Repository Configuration (Required) - </label> - - {/* Repository type selector */} - <div className="flex gap-2 mb-3"> - <button - type="button" - onClick={() => setRepoType("remote")} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - repoType === "remote" - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Remote - </button> - <button - type="button" - onClick={() => setRepoType("local")} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - repoType === "local" - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Local - </button> - <button - type="button" - onClick={() => setRepoType("managed")} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - repoType === "managed" - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Managed - </button> - </div> - - {/* Repository suggestions */} - {showRepoSuggestions && repoSuggestions.length > 0 && ( - <div className="mb-3"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Recent Repositories - </label> - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> - {repoSuggestions.map((suggestion) => ( - <button - key={suggestion.id} - type="button" - onClick={() => applyRepoSuggestion(suggestion)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> - <span className="text-[10px] text-[#556677] ml-2"> - {suggestion.useCount}× - </span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {repoType === "local" ? suggestion.localPath : suggestion.repositoryUrl} - </div> - </button> - ))} - </div> - </div> - )} - - {/* Repository name */} - <div className="mb-3"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Repository Name - </label> - <input - type="text" - value={repoName} - onChange={(e) => setRepoName(e.target.value)} - placeholder="e.g., my-project" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - /> - </div> - - {/* Repository URL (for remote) */} - {repoType === "remote" && ( - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Repository URL - </label> - <input - type="text" - value={repoUrl} - onChange={(e) => setRepoUrl(e.target.value)} - placeholder="https://github.com/user/repo.git" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - /> - </div> - )} - - {/* Repository path (for local) */} - {repoType === "local" && ( - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Local Path - </label> - <DirectoryInput - value={repoPath} - onChange={setRepoPath} - suggestions={suggestedDirectories} - placeholder="/path/to/repository" - /> - </div> - )} - - {/* Managed description */} - {repoType === "managed" && ( - <p className="font-mono text-xs text-[#8b949e]"> - A managed repository will be created automatically by the daemon. - </p> - )} - </div> - - {/* Actions */} - <div className="flex gap-2 justify-end pt-2"> - <button - onClick={handleCreateCancel} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleCreateSubmit} - disabled={!newContractName.trim() || !isRepoValid()} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[rgba(117,170,252,0.2)] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - ) : contractDetail ? ( - <ContractDetail - contract={contractDetail} - loading={detailLoading} - onBack={handleBack} - onUpdate={handleUpdate} - onDelete={handleDelete} - onPhaseChange={handlePhaseChange} - onStatusChange={handleStatusChange} - onFileSelect={handleFileSelect} - onTaskSelect={handleTaskSelect} - onTaskCreate={handleTaskCreate} - onRefresh={handleRefresh} - onAddRemoteRepo={handleAddRemoteRepo} - onAddLocalRepo={handleAddLocalRepo} - onCreateManagedRepo={handleCreateManagedRepo} - onDeleteRepo={handleDeleteRepo} - onSetRepoPrimary={handleSetRepoPrimary} - /> - ) : ( - <div className="panel h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - Select a contract or create a new one - </p> - <button - onClick={handleCreate} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + New Contract - </button> - </div> - </div> - )} - </div> - </div> - </main> - </div> - ); -} diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 7b0a89b..a3ea969 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1530,13 +1530,17 @@ export default function DocumentDirectivesPage() { : null; return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + // h-screen + overflow-hidden so the page itself never scrolls; the + // sidebar and editor pane each manage their own scroll via flex-1 + // children with overflow-y-auto. Previously we set + // height: calc(100vh - 80px) on <main>, which assumed an 80px masthead + // and quietly clipped content when the masthead was taller (or pushed + // the page below the viewport on shorter screens, which made the + // whole page scroll instead of the sidebar/editor independently). + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> - <main - className="flex-1 flex overflow-hidden" - style={{ height: "calc(100vh - 80px)" }} - > - {/* Left: file-tree sidebar */} + <main className="flex-1 flex min-h-0 overflow-hidden"> + {/* Left: file-tree sidebar — independent scroll. */} <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> <DocumentSidebar directives={directives} diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx index 69f13a2..c0c7365 100644 --- a/makima/frontend/src/routes/tmp.tsx +++ b/makima/frontend/src/routes/tmp.tsx @@ -53,7 +53,7 @@ export default function TmpTaskPage() { if (authLoading) { return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> <main className="flex-1 flex items-center justify-center"> <p className="text-[#7788aa] font-mono text-sm">Loading...</p> @@ -63,12 +63,9 @@ export default function TmpTaskPage() { } return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> - <main - className="flex-1 flex flex-col overflow-hidden" - style={{ height: "calc(100vh - 80px)" }} - > + <main className="flex-1 flex flex-col min-h-0 overflow-hidden"> {/* Breadcrumb echoing the document-mode header style. */} <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> |
