diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/routes/mesh.tsx | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/frontend/src/routes/mesh.tsx')
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 250 |
1 files changed, 245 insertions, 5 deletions
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 7ecf96d..d067865 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -7,8 +7,9 @@ import { TaskOutput } from "../components/mesh/TaskOutput"; import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; -import type { TaskWithSubtasks, MeshChatContext } from "../lib/api"; -import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api"; +import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories } from "../lib/api"; +import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; // View modes for the task detail page @@ -91,6 +92,17 @@ export default function MeshPage() { const [creating, setCreating] = useState(false); const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]); const [isStreaming, setIsStreaming] = useState(false); + // Contract selection modal state + const [showContractModal, setShowContractModal] = useState(false); + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [contractsLoading, setContractsLoading] = useState(false); + // Task creation modal (step 2) + const [modalStep, setModalStep] = useState<1 | 2>(1); + const [selectedContract, setSelectedContract] = useState<ContractWithRelations | null>(null); + const [daemonDirectories, setDaemonDirectories] = useState<DaemonDirectory[]>([]); + const [newTaskName, setNewTaskName] = useState(""); + const [newTaskRepoUrl, setNewTaskRepoUrl] = useState<string | null>(null); + const [newTaskTargetPath, setNewTaskTargetPath] = useState(""); // Track which subtask's output we're viewing (null = parent task) const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null); const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null); @@ -139,6 +151,14 @@ export default function MeshPage() { // Only process output for the task we're currently viewing if (event.taskId === activeOutputTaskId) { setTaskOutputEntries((prev) => { + // For auth_required, only allow one per task (replace existing) + if (event.messageType === "auth_required") { + const hasExisting = prev.some(e => e.messageType === "auth_required"); + if (hasExisting) { + return prev; // Skip duplicate auth_required + } + } + // Deduplicate by checking if last entry is identical // This prevents duplicates from React StrictMode or WebSocket reconnects const lastEntry = prev[prev.length - 1]; @@ -383,13 +403,63 @@ export default function MeshPage() { [editTask, taskDetail] ); + // Open contract selection modal const handleCreate = useCallback(async () => { - if (creating) return; + if (creating || contractsLoading) return; + setContractsLoading(true); + try { + const [contractsResponse, directoriesResponse] = await Promise.all([ + listContracts(), + getDaemonDirectories().catch(() => ({ directories: [] })), + ]); + setContracts(contractsResponse.contracts); + setDaemonDirectories(directoriesResponse.directories); + setModalStep(1); + setSelectedContract(null); + setNewTaskName(""); + setNewTaskRepoUrl(null); + setNewTaskTargetPath(""); + setShowContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, [creating, contractsLoading]); + + // Handle contract selection and move to step 2 + const handleSelectContract = useCallback(async (contractSummary: ContractSummary) => { + try { + const contract = await getContract(contractSummary.id); + setSelectedContract(contract); + setNewTaskName(`Task for ${contract.name}`); + // Pre-select primary repository if available + const primaryRepo = contract.repositories.find((r) => r.isPrimary && r.status === "ready"); + if (primaryRepo) { + setNewTaskRepoUrl(primaryRepo.repositoryUrl); + } else { + // Otherwise select first ready repository + const firstReady = contract.repositories.find((r) => r.status === "ready"); + setNewTaskRepoUrl(firstReady?.repositoryUrl || null); + } + setModalStep(2); + } catch (e) { + console.error("Failed to load contract details:", e); + } + }, []); + + // Create task with configured options + const handleCreateTask = useCallback(async () => { + if (creating || !selectedContract) return; + setShowContractModal(false); setCreating(true); try { const newTask = await saveTask({ - name: `Task ${new Date().toLocaleDateString()}`, + contractId: selectedContract.id, + name: newTaskName || `Task for ${selectedContract.name}`, plan: "# Plan\n\nDescribe what this task should accomplish...", + repositoryUrl: newTaskRepoUrl || undefined, + targetRepoPath: newTaskTargetPath || undefined, }); if (newTask) { navigate(`/mesh/${newTask.id}`); @@ -397,13 +467,29 @@ export default function MeshPage() { } finally { setCreating(false); } - }, [creating, saveTask, navigate]); + }, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath]); + + // Close modal and reset state + const handleCloseModal = useCallback(() => { + setShowContractModal(false); + setModalStep(1); + setSelectedContract(null); + setNewTaskName(""); + setNewTaskRepoUrl(null); + setNewTaskTargetPath(""); + }, []); const handleCreateSubtask = useCallback(async () => { if (!taskDetail || creating) return; + // Subtasks inherit contract_id from parent + if (!taskDetail.contractId) { + console.error("Parent task has no contract_id"); + return; + } setCreating(true); try { const newTask = await saveTask({ + contractId: taskDetail.contractId, name: `Subtask of ${taskDetail.name}`, plan: "# Plan\n\nDescribe what this subtask should accomplish...", parentTaskId: taskDetail.id, @@ -597,6 +683,7 @@ export default function MeshPage() { onCreateSubtask={handleCreateSubtask} onToggleSubtaskOutput={handleToggleSubtaskOutput} viewingSubtaskId={viewingSubtaskId} + onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} /> </div> )} @@ -662,6 +749,159 @@ export default function MeshPage() { </div> </div> </main> + + {/* Task Creation Modal (Two Steps) */} + {showContractModal && ( + <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center"> + <div className="flex items-center gap-2"> + {modalStep === 2 && ( + <button + onClick={() => setModalStep(1)} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + title="Back to contract selection" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> + </svg> + </button> + )} + <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]"> + {modalStep === 1 ? "Select Contract" : "Configure Task"} + </h2> + </div> + <button + onClick={handleCloseModal} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {modalStep === 1 ? ( + // Step 1: Select Contract + contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found.</p> + <button + onClick={() => { + handleCloseModal(); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleSelectContract(contract)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span> + <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p> + )} + <div className="flex gap-3 mt-2 text-[10px] font-mono text-[#556677]"> + <span>{contract.taskCount} tasks</span> + <span>{contract.repositoryCount} repos</span> + </div> + </button> + ))} + </div> + ) + ) : ( + // Step 2: Configure Task + selectedContract && ( + <div className="space-y-4"> + {/* Contract badge */} + <div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]"> + <span>Contract:</span> + <span className="text-[#9bc3ff]">{selectedContract.name}</span> + </div> + + {/* Task name */} + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Task Name</label> + <input + type="text" + value={newTaskName} + onChange={(e) => setNewTaskName(e.target.value)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + placeholder="Task name" + /> + </div> + + {/* Repository selection */} + {selectedContract.repositories.length > 0 && ( + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository</label> + <select + value={newTaskRepoUrl || ""} + onChange={(e) => setNewTaskRepoUrl(e.target.value || null)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + > + <option value="">No repository</option> + {selectedContract.repositories + .filter((r) => r.status === "ready") + .map((repo) => ( + <option key={repo.id} value={repo.repositoryUrl || repo.localPath || ""}> + {repo.name} + {repo.isPrimary && " (primary)"} + </option> + ))} + </select> + <p className="text-[10px] font-mono text-[#556677]"> + The repository this task will work on. + </p> + </div> + )} + + {/* Target repo path with DirectoryInput */} + {newTaskRepoUrl && ( + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Target Repository Path</label> + <DirectoryInput + value={newTaskTargetPath} + onChange={setNewTaskTargetPath} + suggestions={daemonDirectories} + placeholder="/path/to/your/local/repo" + repoUrl={newTaskRepoUrl} + /> + <p className="text-[10px] font-mono text-[#556677]"> + Path where the task will push/merge changes. Leave empty to configure later. + </p> + </div> + )} + + {/* Create button */} + <div className="pt-2"> + <button + onClick={handleCreateTask} + disabled={creating} + className="w-full px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] disabled:opacity-50 transition-colors" + > + {creating ? "Creating..." : "Create Task"} + </button> + </div> + </div> + ) + )} + </div> + </div> + </div> + )} </div> ); } |
