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 | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/_index.tsx | 6 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 614 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 221 | ||||
| -rw-r--r-- | makima/frontend/src/routes/listen.tsx | 45 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 250 | ||||
| -rw-r--r-- | makima/frontend/src/routes/settings.tsx | 113 | ||||
| -rw-r--r-- | makima/frontend/src/routes/workflow.tsx | 205 |
7 files changed, 1418 insertions, 36 deletions
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx index 7084c2e..ecdd7f2 100644 --- a/makima/frontend/src/routes/_index.tsx +++ b/makima/frontend/src/routes/_index.tsx @@ -1,5 +1,6 @@ import { Masthead } from "../components/Masthead"; import { Logo } from "../components/Logo"; +import { JapaneseHoverText } from "../components/JapaneseHoverText"; export default function HomePage() { return ( @@ -13,7 +14,10 @@ export default function HomePage() { </div> <span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3"> - Control System + <JapaneseHoverText + japanese="支配する" + english="Control System" + /> </span> <h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide"> Mesh Orchestration Platform diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx new file mode 100644 index 0000000..8c90804 --- /dev/null +++ b/makima/frontend/src/routes/contracts.tsx @@ -0,0 +1,614 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { ContractList } from "../components/contracts/ContractList"; +import { ContractDetail } from "../components/contracts/ContractDetail"; +import { DirectoryInput } from "../components/mesh/DirectoryInput"; +import { useContracts } from "../hooks/useContracts"; +import { useAuth } from "../contexts/AuthContext"; +import { createTask, getDaemonDirectories } from "../lib/api"; +import type { + ContractWithRelations, + ContractPhase, + ContractStatus, + CreateContractRequest, + RepositorySourceType, + DaemonDirectory, +} from "../lib/api"; + +export default function ContractsPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <ContractsPageContent />; +} + +function ContractsPageContent() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { + contracts, + loading, + error, + fetchContract, + saveContract, + editContract, + removeContract, + changePhase, + addRemoteRepo, + addLocalRepo, + createManagedRepo, + removeRepo, + setRepoPrimary, + } = useContracts(); + + const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [newContractName, setNewContractName] = useState(""); + const [newContractDescription, setNewContractDescription] = useState(""); + const [initialPhase, setInitialPhase] = useState<ContractPhase>("research"); + const [repoType, setRepoType] = useState<RepositorySourceType>("remote"); + const [repoName, setRepoName] = useState(""); + const [repoUrl, setRepoUrl] = useState(""); + const [repoPath, setRepoPath] = useState(""); + const [createError, setCreateError] = useState<string | null>(null); + const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + + // Fetch daemon directories when "local" repo type is selected + useEffect(() => { + if (repoType === "local" && isCreating) { + getDaemonDirectories() + .then((res) => setSuggestedDirectories(res.directories)) + .catch(() => setSuggestedDirectories([])); + } + }, [repoType, isCreating]); + + // Load contract detail when ID changes + useEffect(() => { + if (id) { + setDetailLoading(true); + fetchContract(id).then((contract) => { + setContractDetail(contract); + setDetailLoading(false); + }); + } else { + setContractDetail(null); + } + }, [id, fetchContract]); + + const handleSelect = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + const handleBack = useCallback(() => { + navigate("/contracts"); + }, [navigate]); + + const handleCreate = useCallback(() => { + setIsCreating(true); + }, []); + + // Validate repository configuration + const isRepoValid = useCallback(() => { + if (!repoName.trim()) return false; + if (repoType === "remote" && !repoUrl.trim()) return false; + if (repoType === "local" && !repoPath.trim()) return false; + return true; + }, [repoType, repoName, repoUrl, repoPath]); + + const handleCreateSubmit = useCallback(async () => { + if (!newContractName.trim()) return; + if (!isRepoValid()) { + setCreateError("Repository configuration is required"); + return; + } + + setCreateError(null); + + const data: CreateContractRequest = { + name: newContractName.trim(), + description: newContractDescription.trim() || undefined, + initialPhase: initialPhase !== "research" ? initialPhase : undefined, + }; + + try { + const contract = await saveContract(data); + if (contract) { + // Add the repository after contract creation + try { + if (repoType === "remote") { + await addRemoteRepo(contract.id, { + name: repoName.trim(), + repositoryUrl: repoUrl.trim(), + isPrimary: true, + }); + } else if (repoType === "local") { + await addLocalRepo(contract.id, { + name: repoName.trim(), + localPath: repoPath.trim(), + isPrimary: true, + }); + } else if (repoType === "managed") { + await createManagedRepo(contract.id, { + name: repoName.trim(), + isPrimary: true, + }); + } + } catch (repoError) { + console.error("Failed to add repository:", repoError); + // Still navigate to the contract - repo can be added later + } + + // Clear form state + setIsCreating(false); + setNewContractName(""); + setNewContractDescription(""); + setInitialPhase("research"); + setRepoType("remote"); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + navigate(`/contracts/${contract.id}`); + } + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Failed to create contract"); + } + }, [ + newContractName, + newContractDescription, + repoType, + repoName, + repoUrl, + repoPath, + isRepoValid, + saveContract, + addRemoteRepo, + addLocalRepo, + createManagedRepo, + navigate, + ]); + + const handleCreateCancel = useCallback(() => { + setIsCreating(false); + setNewContractName(""); + setNewContractDescription(""); + setInitialPhase("research"); + setRepoType("remote"); + setRepoName(""); + setRepoUrl(""); + setRepoPath(""); + setCreateError(null); + }, []); + + const handleUpdate = useCallback( + async (name: string, description: string) => { + if (contractDetail) { + const updated = await editContract(contractDetail.id, { + name, + description: description || undefined, + version: contractDetail.version, + }); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, editContract, fetchContract] + ); + + const handleDelete = useCallback(async () => { + if (contractDetail && confirm("Are you sure you want to delete this contract?")) { + const success = await removeContract(contractDetail.id); + if (success) { + navigate("/contracts"); + } + } + }, [contractDetail, removeContract, navigate]); + + const handlePhaseChange = useCallback( + async (phase: ContractPhase) => { + if (contractDetail) { + const updated = await changePhase(contractDetail.id, phase); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, changePhase, fetchContract] + ); + + const handleStatusChange = useCallback( + async (status: ContractStatus) => { + if (contractDetail) { + const updated = await editContract(contractDetail.id, { + status, + version: contractDetail.version, + }); + if (updated) { + // Refresh detail + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + } + }, + [contractDetail, editContract, fetchContract] + ); + + // Repository handlers + const handleAddRemoteRepo = useCallback( + async (name: string, url: string, isPrimary: boolean) => { + if (contractDetail) { + await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, addRemoteRepo, fetchContract] + ); + + const handleAddLocalRepo = useCallback( + async (name: string, path: string, isPrimary: boolean) => { + if (contractDetail) { + await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, addLocalRepo, fetchContract] + ); + + const handleCreateManagedRepo = useCallback( + async (name: string, isPrimary: boolean) => { + if (contractDetail) { + await createManagedRepo(contractDetail.id, { name, isPrimary }); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, createManagedRepo, fetchContract] + ); + + const handleDeleteRepo = useCallback( + async (repoId: string) => { + if (contractDetail) { + await removeRepo(contractDetail.id, repoId); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, removeRepo, fetchContract] + ); + + const handleSetRepoPrimary = useCallback( + async (repoId: string) => { + if (contractDetail) { + await setRepoPrimary(contractDetail.id, repoId); + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, + [contractDetail, setRepoPrimary, fetchContract] + ); + + // Refresh contract detail (used after file/task operations) + const handleRefresh = useCallback(async () => { + if (contractDetail) { + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + } + }, [contractDetail, fetchContract]); + + // File/task navigation handlers + const handleFileSelect = useCallback( + (fileId: string) => { + navigate(`/files/${fileId}`); + }, + [navigate] + ); + + const handleTaskSelect = useCallback( + (taskId: string) => { + navigate(`/mesh/${taskId}`); + }, + [navigate] + ); + + // Create task within contract context + const handleTaskCreate = useCallback( + async (name: string, plan: string, repositoryUrl?: string) => { + if (!contractDetail) return; + try { + // Create the task with contract_id (task is automatically associated) + const task = await createTask({ + contractId: contractDetail.id, + name, + plan, + repositoryUrl, + }); + // Refresh contract detail to show new task + const refreshed = await fetchContract(contractDetail.id); + setContractDetail(refreshed); + // Navigate to the new task + navigate(`/mesh/${task.id}`); + } catch (e) { + console.error("Failed to create task:", e); + alert(e instanceof Error ? e.message : "Failed to create task"); + } + }, + [contractDetail, fetchContract, navigate] + ); + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Create contract modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Contract + </h3> + + {createError && ( + <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> + {createError} + </div> + )} + + <div className="space-y-4"> + {/* Contract name */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Contract Name + </label> + <input + type="text" + value={newContractName} + onChange={(e) => setNewContractName(e.target.value)} + placeholder="Contract name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Description */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Description (optional) + </label> + <textarea + value={newContractDescription} + onChange={(e) => setNewContractDescription(e.target.value)} + placeholder="Describe what this contract is for..." + rows={2} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + {/* Starting Phase */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Starting Phase + </label> + <select + value={initialPhase} + onChange={(e) => setInitialPhase(e.target.value as ContractPhase)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="research">Research</option> + <option value="specify">Specify</option> + <option value="plan">Plan</option> + <option value="execute">Execute</option> + <option value="review">Review</option> + </select> + <p className="mt-1 font-mono text-xs text-[#8b949e]"> + Skip earlier phases if you already have requirements defined + </p> + </div> + + {/* Repository Configuration */} + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3"> + Repository Configuration (Required) + </label> + + {/* Repository type selector */} + <div className="flex gap-2 mb-3"> + <button + type="button" + onClick={() => setRepoType("remote")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "remote" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Remote + </button> + <button + type="button" + onClick={() => setRepoType("local")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "local" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Local + </button> + <button + type="button" + onClick={() => setRepoType("managed")} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ + repoType === "managed" + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + Managed + </button> + </div> + + {/* Repository name */} + <div className="mb-3"> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository Name + </label> + <input + type="text" + value={repoName} + onChange={(e) => setRepoName(e.target.value)} + placeholder="e.g., my-project" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + + {/* Repository URL (for remote) */} + {repoType === "remote" && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository URL + </label> + <input + type="text" + value={repoUrl} + onChange={(e) => setRepoUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + </div> + )} + + {/* Repository path (for local) */} + {repoType === "local" && ( + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Local Path + </label> + <DirectoryInput + value={repoPath} + onChange={setRepoPath} + suggestions={suggestedDirectories} + placeholder="/path/to/repository" + /> + </div> + )} + + {/* Managed description */} + {repoType === "managed" && ( + <p className="font-mono text-xs text-[#8b949e]"> + A managed repository will be created automatically by the daemon. + </p> + )} + </div> + + {/* Actions */} + <div className="flex gap-2 justify-end pt-2"> + <button + onClick={handleCreateCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateSubmit} + disabled={!newContractName.trim() || !isRepoValid()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + + <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> + {/* Contract list */} + <ContractList + contracts={contracts} + loading={loading} + onSelect={handleSelect} + onCreate={handleCreate} + selectedId={id} + /> + + {/* Contract detail or empty state */} + {contractDetail ? ( + <ContractDetail + contract={contractDetail} + loading={detailLoading} + onBack={handleBack} + onUpdate={handleUpdate} + onDelete={handleDelete} + onPhaseChange={handlePhaseChange} + onStatusChange={handleStatusChange} + onFileSelect={handleFileSelect} + onTaskSelect={handleTaskSelect} + onTaskCreate={handleTaskCreate} + onRefresh={handleRefresh} + onAddRemoteRepo={handleAddRemoteRepo} + onAddLocalRepo={handleAddLocalRepo} + onCreateManagedRepo={handleCreateManagedRepo} + onDeleteRepo={handleDeleteRepo} + onSetRepoPrimary={handleSetRepoPrimary} + /> + ) : ( + <div className="panel h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + Select a contract or create a new one + </p> + <button + onClick={handleCreate} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New Contract + </button> + </div> + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 3ba2d52..6cfb3ca 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -12,8 +12,8 @@ import { useFileSubscription, type FileUpdateEvent, } from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api"; -import { createTask } from "../lib/api"; +import type { FileDetail as FileDetailType, BodyElement, Task, ContractSummary } from "../lib/api"; +import { createTask, listContracts } from "../lib/api"; import { useAuth } from "../contexts/AuthContext"; export default function FilesPage() { @@ -59,6 +59,14 @@ function FilesPageContent() { const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); const [createdTask, setCreatedTask] = useState<Task | null>(null); + // Contract selection modal state for task creation + const [showContractModal, setShowContractModal] = useState(false); + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [contractsLoading, setContractsLoading] = useState(false); + const [pendingTaskData, setPendingTaskData] = useState<{ name: string; plan: string } | null>(null); + // Contract selection modal state for file creation + const [showFileContractModal, setShowFileContractModal] = useState(false); + const [pendingFileData, setPendingFileData] = useState<{ name: string; body?: BodyElement[] } | null>(null); const pendingUpdateRef = useRef(false); // Track the last version we sent to detect our own updates const lastSentVersionRef = useRef<number | null>(null); @@ -548,10 +556,10 @@ function FilesPageContent() { [fileDetail] ); - // Create a mesh task from an element + // Create a mesh task from an element - shows contract selection modal const handleCreateTaskFromElement = useCallback( async (index: number, selectedText?: string) => { - if (!fileDetail) return; + if (!fileDetail || contractsLoading) return; const element = fileDetail.body[index]; if (!element) return; @@ -578,57 +586,98 @@ function FilesPageContent() { // Create a task name from the content const name = content.slice(0, 60) + (content.length > 60 ? "..." : ""); + // Store pending task data and show contract selection modal + setPendingTaskData({ name, plan: content }); + setContractsLoading(true); + try { + const response = await listContracts(); + setContracts(response.contracts); + setShowContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, + [fileDetail, contractsLoading] + ); + + // Create task with selected contract + const handleCreateTaskWithContract = useCallback( + async (contractId: string) => { + if (!pendingTaskData || !fileDetail) return; + setShowContractModal(false); try { const task = await createTask({ - name, - plan: content, + contractId, + name: pendingTaskData.name, + plan: pendingTaskData.plan, description: `Created from ${fileDetail.name}`, }); setCreatedTask(task); + setPendingTaskData(null); } catch (err) { console.error("Failed to create task:", err); } }, - [fileDetail] + [pendingTaskData, fileDetail] ); + // Open contract selection modal for file creation const handleCreate = useCallback(async () => { - if (creating) return; + if (creating || contractsLoading) return; + setContractsLoading(true); + try { + const response = await listContracts(); + setContracts(response.contracts); + setPendingFileData({ name: `Untitled ${new Date().toLocaleDateString()}` }); + setShowFileContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, [creating, contractsLoading]); + + // Create file with selected contract + const handleCreateFileWithContract = useCallback(async (contractId: string) => { + if (creating || !pendingFileData) return; + setShowFileContractModal(false); setCreating(true); try { const newFile = await saveFile({ - name: `Untitled ${new Date().toLocaleDateString()}`, + contractId, + name: pendingFileData.name, + body: pendingFileData.body, transcript: [], }); if (newFile) { + // If there's body content, update it + if (pendingFileData.body && pendingFileData.body.length > 0) { + await editFile(newFile.id, { body: pendingFileData.body, version: newFile.version }); + } navigate(`/files/${newFile.id}`); } } finally { setCreating(false); + setPendingFileData(null); } - }, [creating, saveFile, navigate]); + }, [creating, pendingFileData, saveFile, editFile, navigate]); const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => { - if (creating) return; - setCreating(true); + if (creating || contractsLoading) return; + setContractsLoading(true); try { - const newFile = await saveFile({ - name, - transcript: [], - }); - if (newFile) { - // Update with the parsed body - const updated = await editFile(newFile.id, { body, version: newFile.version }); - if (updated) { - navigate(`/files/${updated.id}`); - } else { - navigate(`/files/${newFile.id}`); - } - } + const response = await listContracts(); + setContracts(response.contracts); + setPendingFileData({ name, body }); + setShowFileContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); } finally { - setCreating(false); + setContractsLoading(false); } - }, [creating, saveFile, editFile, navigate]); + }, [creating, contractsLoading]); // Conflict resolution handlers const handleConflictReload = useCallback(async () => { @@ -808,6 +857,124 @@ function FilesPageContent() { </div> </div> )} + + {/* Contract Selection Modal for Task Creation */} + {showContractModal && ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> + <div className="bg-[#0d1117] border border-[#30363d] rounded-lg max-w-md w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[#30363d] flex justify-between items-center"> + <h2 className="text-lg font-semibold text-white">Select Contract for Task</h2> + <button + onClick={() => { + setShowContractModal(false); + setPendingTaskData(null); + }} + className="text-[#8b949e] hover:text-white" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#8b949e] mb-4">No contracts found. Create a contract first.</p> + <button + onClick={() => { + setShowContractModal(false); + setPendingTaskData(null); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#238636] hover:bg-[#2ea043] text-white rounded-md text-sm" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleCreateTaskWithContract(contract.id)} + className="w-full text-left p-3 rounded-md border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-white font-medium">{contract.name}</span> + <span className="text-xs px-2 py-0.5 rounded bg-[#21262d] text-[#8b949e]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-sm text-[#8b949e] mt-1 line-clamp-2">{contract.description}</p> + )} + </button> + ))} + </div> + )} + </div> + </div> + </div> + )} + + {/* Contract Selection Modal for File Creation */} + {showFileContractModal && ( + <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-md w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center"> + <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Select Contract for File</h2> + <button + onClick={() => { + setShowFileContractModal(false); + setPendingFileData(null); + }} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found. Create a contract first.</p> + <button + onClick={() => { + setShowFileContractModal(false); + setPendingFileData(null); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleCreateFileWithContract(contract.id)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span> + <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p> + )} + </button> + ))} + </div> + )} + </div> + </div> + </div> + )} </div> ); } diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx index aaba90c..36c468b 100644 --- a/makima/frontend/src/routes/listen.tsx +++ b/makima/frontend/src/routes/listen.tsx @@ -2,9 +2,11 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Masthead } from "../components/Masthead"; import { SpeakerPanel } from "../components/listen/SpeakerPanel"; import { TranscriptPanel } from "../components/listen/TranscriptPanel"; -import { ControlPanel } from "../components/listen/ControlPanel"; +import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel"; import { useMicrophone } from "../hooks/useMicrophone"; import { useWebSocket } from "../hooks/useWebSocket"; +import { listContracts } from "../lib/api"; +import { useAuth } from "../contexts/AuthContext"; export default function ListenPage() { const [isListening, setIsListening] = useState(false); @@ -12,6 +14,37 @@ export default function ListenPage() { const [permissionRequested, setPermissionRequested] = useState(false); const isListeningRef = useRef(false); + // Contract selection state + const [contracts, setContracts] = useState<ContractOption[]>([]); + const [selectedContractId, setSelectedContractId] = useState<string | null>(null); + const [contractsLoading, setContractsLoading] = useState(true); + const { session, isAuthenticated } = useAuth(); + + // Fetch contracts on mount + useEffect(() => { + if (!isAuthenticated) { + setContractsLoading(false); + return; + } + + async function fetchContracts() { + try { + const response = await listContracts(); + setContracts( + response.contracts.map((c) => ({ + id: c.id, + name: c.name, + })) + ); + } catch (err) { + console.error("Failed to fetch contracts:", err); + } finally { + setContractsLoading(false); + } + } + fetchContracts(); + }, [isAuthenticated]); + // Keep ref in sync with state for use in callbacks useEffect(() => { isListeningRef.current = isListening; @@ -108,9 +141,11 @@ export default function ListenPage() { } // Both microphone and WebSocket are ready - start the session - ws.startSession(mic.sampleRate, mic.channels); + // Pass contract_id and auth token if available + const authToken = session?.access_token || null; + ws.startSession(mic.sampleRate, mic.channels, selectedContractId, authToken); setIsListening(true); - }, [isListening, mic, ws]); + }, [isListening, mic, ws, selectedContractId, session]); const handleNew = useCallback(() => { // Stop current session - backend auto-saves transcript on disconnect @@ -152,6 +187,10 @@ export default function ListenPage() { onToggle={handleToggle} onNew={handleNew} error={error} + contracts={contracts} + selectedContractId={selectedContractId} + onContractChange={setSelectedContractId} + contractsLoading={contractsLoading} /> </div> </main> diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 7ecf96d..d067865 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -7,8 +7,9 @@ import { TaskOutput } from "../components/mesh/TaskOutput"; import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; -import type { TaskWithSubtasks, MeshChatContext } from "../lib/api"; -import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api"; +import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories } from "../lib/api"; +import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; // View modes for the task detail page @@ -91,6 +92,17 @@ export default function MeshPage() { const [creating, setCreating] = useState(false); const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]); const [isStreaming, setIsStreaming] = useState(false); + // Contract selection modal state + const [showContractModal, setShowContractModal] = useState(false); + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [contractsLoading, setContractsLoading] = useState(false); + // Task creation modal (step 2) + const [modalStep, setModalStep] = useState<1 | 2>(1); + const [selectedContract, setSelectedContract] = useState<ContractWithRelations | null>(null); + const [daemonDirectories, setDaemonDirectories] = useState<DaemonDirectory[]>([]); + const [newTaskName, setNewTaskName] = useState(""); + const [newTaskRepoUrl, setNewTaskRepoUrl] = useState<string | null>(null); + const [newTaskTargetPath, setNewTaskTargetPath] = useState(""); // Track which subtask's output we're viewing (null = parent task) const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null); const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null); @@ -139,6 +151,14 @@ export default function MeshPage() { // Only process output for the task we're currently viewing if (event.taskId === activeOutputTaskId) { setTaskOutputEntries((prev) => { + // For auth_required, only allow one per task (replace existing) + if (event.messageType === "auth_required") { + const hasExisting = prev.some(e => e.messageType === "auth_required"); + if (hasExisting) { + return prev; // Skip duplicate auth_required + } + } + // Deduplicate by checking if last entry is identical // This prevents duplicates from React StrictMode or WebSocket reconnects const lastEntry = prev[prev.length - 1]; @@ -383,13 +403,63 @@ export default function MeshPage() { [editTask, taskDetail] ); + // Open contract selection modal const handleCreate = useCallback(async () => { - if (creating) return; + if (creating || contractsLoading) return; + setContractsLoading(true); + try { + const [contractsResponse, directoriesResponse] = await Promise.all([ + listContracts(), + getDaemonDirectories().catch(() => ({ directories: [] })), + ]); + setContracts(contractsResponse.contracts); + setDaemonDirectories(directoriesResponse.directories); + setModalStep(1); + setSelectedContract(null); + setNewTaskName(""); + setNewTaskRepoUrl(null); + setNewTaskTargetPath(""); + setShowContractModal(true); + } catch (e) { + console.error("Failed to load contracts:", e); + } finally { + setContractsLoading(false); + } + }, [creating, contractsLoading]); + + // Handle contract selection and move to step 2 + const handleSelectContract = useCallback(async (contractSummary: ContractSummary) => { + try { + const contract = await getContract(contractSummary.id); + setSelectedContract(contract); + setNewTaskName(`Task for ${contract.name}`); + // Pre-select primary repository if available + const primaryRepo = contract.repositories.find((r) => r.isPrimary && r.status === "ready"); + if (primaryRepo) { + setNewTaskRepoUrl(primaryRepo.repositoryUrl); + } else { + // Otherwise select first ready repository + const firstReady = contract.repositories.find((r) => r.status === "ready"); + setNewTaskRepoUrl(firstReady?.repositoryUrl || null); + } + setModalStep(2); + } catch (e) { + console.error("Failed to load contract details:", e); + } + }, []); + + // Create task with configured options + const handleCreateTask = useCallback(async () => { + if (creating || !selectedContract) return; + setShowContractModal(false); setCreating(true); try { const newTask = await saveTask({ - name: `Task ${new Date().toLocaleDateString()}`, + contractId: selectedContract.id, + name: newTaskName || `Task for ${selectedContract.name}`, plan: "# Plan\n\nDescribe what this task should accomplish...", + repositoryUrl: newTaskRepoUrl || undefined, + targetRepoPath: newTaskTargetPath || undefined, }); if (newTask) { navigate(`/mesh/${newTask.id}`); @@ -397,13 +467,29 @@ export default function MeshPage() { } finally { setCreating(false); } - }, [creating, saveTask, navigate]); + }, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath]); + + // Close modal and reset state + const handleCloseModal = useCallback(() => { + setShowContractModal(false); + setModalStep(1); + setSelectedContract(null); + setNewTaskName(""); + setNewTaskRepoUrl(null); + setNewTaskTargetPath(""); + }, []); const handleCreateSubtask = useCallback(async () => { if (!taskDetail || creating) return; + // Subtasks inherit contract_id from parent + if (!taskDetail.contractId) { + console.error("Parent task has no contract_id"); + return; + } setCreating(true); try { const newTask = await saveTask({ + contractId: taskDetail.contractId, name: `Subtask of ${taskDetail.name}`, plan: "# Plan\n\nDescribe what this subtask should accomplish...", parentTaskId: taskDetail.id, @@ -597,6 +683,7 @@ export default function MeshPage() { onCreateSubtask={handleCreateSubtask} onToggleSubtaskOutput={handleToggleSubtaskOutput} viewingSubtaskId={viewingSubtaskId} + onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} /> </div> )} @@ -662,6 +749,159 @@ export default function MeshPage() { </div> </div> </main> + + {/* Task Creation Modal (Two Steps) */} + {showContractModal && ( + <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4 max-h-[80vh] flex flex-col"> + <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center"> + <div className="flex items-center gap-2"> + {modalStep === 2 && ( + <button + onClick={() => setModalStep(1)} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + title="Back to contract selection" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> + </svg> + </button> + )} + <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]"> + {modalStep === 1 ? "Select Contract" : "Configure Task"} + </h2> + </div> + <button + onClick={handleCloseModal} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + <div className="p-4 overflow-y-auto flex-1"> + {modalStep === 1 ? ( + // Step 1: Select Contract + contracts.length === 0 ? ( + <div className="text-center py-8"> + <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found.</p> + <button + onClick={() => { + handleCloseModal(); + navigate("/contracts"); + }} + className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors" + > + Create Contract + </button> + </div> + ) : ( + <div className="space-y-2"> + {contracts.map((contract) => ( + <button + key={contract.id} + onClick={() => handleSelectContract(contract)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span> + <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]"> + {contract.phase} + </span> + </div> + {contract.description && ( + <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p> + )} + <div className="flex gap-3 mt-2 text-[10px] font-mono text-[#556677]"> + <span>{contract.taskCount} tasks</span> + <span>{contract.repositoryCount} repos</span> + </div> + </button> + ))} + </div> + ) + ) : ( + // Step 2: Configure Task + selectedContract && ( + <div className="space-y-4"> + {/* Contract badge */} + <div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]"> + <span>Contract:</span> + <span className="text-[#9bc3ff]">{selectedContract.name}</span> + </div> + + {/* Task name */} + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Task Name</label> + <input + type="text" + value={newTaskName} + onChange={(e) => setNewTaskName(e.target.value)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + placeholder="Task name" + /> + </div> + + {/* Repository selection */} + {selectedContract.repositories.length > 0 && ( + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository</label> + <select + value={newTaskRepoUrl || ""} + onChange={(e) => setNewTaskRepoUrl(e.target.value || null)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + > + <option value="">No repository</option> + {selectedContract.repositories + .filter((r) => r.status === "ready") + .map((repo) => ( + <option key={repo.id} value={repo.repositoryUrl || repo.localPath || ""}> + {repo.name} + {repo.isPrimary && " (primary)"} + </option> + ))} + </select> + <p className="text-[10px] font-mono text-[#556677]"> + The repository this task will work on. + </p> + </div> + )} + + {/* Target repo path with DirectoryInput */} + {newTaskRepoUrl && ( + <div className="space-y-1"> + <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Target Repository Path</label> + <DirectoryInput + value={newTaskTargetPath} + onChange={setNewTaskTargetPath} + suggestions={daemonDirectories} + placeholder="/path/to/your/local/repo" + repoUrl={newTaskRepoUrl} + /> + <p className="text-[10px] font-mono text-[#556677]"> + Path where the task will push/merge changes. Leave empty to configure later. + </p> + </div> + )} + + {/* Create button */} + <div className="pt-2"> + <button + onClick={handleCreateTask} + disabled={creating} + className="w-full px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] disabled:opacity-50 transition-colors" + > + {creating ? "Creating..." : "Create Task"} + </button> + </div> + </div> + ) + )} + </div> + </div> + </div> + )} </div> ); } diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx index 6d56e67..7ca40ba 100644 --- a/makima/frontend/src/routes/settings.tsx +++ b/makima/frontend/src/routes/settings.tsx @@ -10,8 +10,10 @@ import { changePassword, changeEmail, deleteAccount, + listDaemons, type ApiKeyInfo, type CreateApiKeyResponse, + type Daemon, } from "../lib/api"; // ============================================================================= @@ -297,8 +299,22 @@ export default function SettingsPage() { const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState<string | null>(null); + // Daemon state + const [daemons, setDaemons] = useState<Daemon[]>([]); + const [daemonsLoading, setDaemonsLoading] = useState(true); + const [daemonsError, setDaemonsError] = useState<string | null>(null); + useEffect(() => { loadApiKey(); + loadDaemons(); + }, []); + + // Auto-refresh daemons every 30 seconds + useEffect(() => { + const interval = setInterval(() => { + loadDaemons(); + }, 30000); + return () => clearInterval(interval); }, []); const loadApiKey = async () => { @@ -314,6 +330,18 @@ export default function SettingsPage() { } }; + const loadDaemons = async () => { + try { + setDaemonsError(null); + const response = await listDaemons(); + setDaemons(response.daemons); + } catch (err) { + setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons"); + } finally { + setDaemonsLoading(false); + } + }; + const handleCreate = async () => { try { setActionLoading(true); @@ -579,6 +607,91 @@ export default function SettingsPage() { Then run: <code className="text-green-400">makima-daemon</code> </p> </section> + + {/* Connected Daemons */} + <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4"> + <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center gap-2"> + <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]"> + Daemons + </h2> + {daemons.length > 0 && ( + <span className="text-[10px] font-mono text-[#556677]"> + ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total) + </span> + )} + </div> + <button + onClick={loadDaemons} + disabled={daemonsLoading} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50" + title="Refresh" + > + {daemonsLoading ? "..." : "↻"} + </button> + </div> + + {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>} + + {daemonsLoading && daemons.length === 0 ? ( + <p className="text-[#7788aa] font-mono text-xs">Loading...</p> + ) : daemons.length === 0 ? ( + <div className="text-center py-4"> + <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p> + <p className="text-[#556677] font-mono text-[10px]"> + Start a daemon to enable task execution + </p> + </div> + ) : ( + <div className="space-y-2"> + {daemons.map((daemon) => ( + <div + key={daemon.id} + className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3" + > + <div className="flex items-center justify-between mb-2"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {daemon.hostname || "Unknown Host"} + </span> + <span + className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${ + daemon.status === "connected" + ? "text-green-400 border-green-700/50 bg-green-900/20" + : daemon.status === "unhealthy" + ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20" + : "text-[#8899aa] border-[rgba(117,170,252,0.25)]" + }`} + > + {daemon.status} + </span> + </div> + <div className="font-mono text-[10px] text-[#7788aa] space-y-1"> + <div className="flex justify-between"> + <span>Tasks</span> + <span className="text-[#9bc3ff]"> + {daemon.currentTaskCount} / {daemon.maxConcurrentTasks} + </span> + </div> + <div className="flex justify-between"> + <span>Connected</span> + <span className="text-[#75aafc]"> + {new Date(daemon.connectedAt).toLocaleString()} + </span> + </div> + {daemon.machineId && ( + <div className="flex justify-between"> + <span>Machine</span> + <span className="text-[#556677] truncate ml-2" title={daemon.machineId}> + {daemon.machineId.substring(0, 16)}... + </span> + </div> + )} + </div> + </div> + ))} + </div> + )} + </section> </div> {/* Right Column */} diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx new file mode 100644 index 0000000..cb72e9e --- /dev/null +++ b/makima/frontend/src/routes/workflow.tsx @@ -0,0 +1,205 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { WorkflowBoard } from "../components/workflow/WorkflowBoard"; +import { useContracts } from "../hooks/useContracts"; +import { useAuth } from "../contexts/AuthContext"; +import type { ContractPhase, ContractStatus } from "../lib/api"; + +type StatusFilter = "all" | ContractStatus; + +export default function WorkflowPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + + // Redirect to login if not authenticated (when auth is configured) + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Show loading while checking auth + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <WorkflowPageContent />; +} + +function WorkflowPageContent() { + const navigate = useNavigate(); + const { contracts, loading, error, changePhase, saveContract } = useContracts(); + const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); + const [isCreating, setIsCreating] = useState(false); + const [newContractName, setNewContractName] = useState(""); + + // Filter contracts by status + const filteredContracts = useMemo(() => { + if (statusFilter === "all") { + return contracts; + } + return contracts.filter((c) => c.status === statusFilter); + }, [contracts, statusFilter]); + + const handleContractClick = useCallback( + (contractId: string) => { + navigate(`/contracts/${contractId}`); + }, + [navigate] + ); + + const handlePhaseChange = useCallback( + async (contractId: string, newPhase: ContractPhase) => { + await changePhase(contractId, newPhase); + }, + [changePhase] + ); + + const handleCreateContract = useCallback(async () => { + if (!newContractName.trim()) return; + const contract = await saveContract({ + name: newContractName.trim(), + }); + if (contract) { + setNewContractName(""); + setIsCreating(false); + navigate(`/contracts/${contract.id}`); + } + }, [newContractName, saveContract, navigate]); + + const handleCancelCreate = useCallback(() => { + setNewContractName(""); + setIsCreating(false); + }, []); + + return ( + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0"> + {error} + </div> + )} + + {/* Header with filter and create button */} + <div className="flex items-center justify-between shrink-0"> + <div className="flex items-center gap-4"> + <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> + Board + </h1> + {/* Status filter */} + <div className="flex items-center gap-1"> + {(["all", "active", "completed", "archived"] as StatusFilter[]).map( + (status) => ( + <button + key={status} + onClick={() => setStatusFilter(status)} + className={` + px-2 py-1 font-mono text-[10px] uppercase transition-colors + ${ + statusFilter === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] border border-transparent hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ) + )} + </div> + </div> + <button + onClick={() => setIsCreating(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + New Contract + </button> + </div> + + {/* Create contract modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Contract + </h3> + <div className="space-y-4"> + <input + type="text" + value={newContractName} + onChange={(e) => setNewContractName(e.target.value)} + placeholder="Contract name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateContract(); + if (e.key === "Escape") handleCancelCreate(); + }} + /> + <div className="flex gap-2 justify-end"> + <button + onClick={handleCancelCreate} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateContract} + disabled={!newContractName.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + + {/* Board */} + <div className="flex-1 min-h-0 overflow-hidden"> + {loading ? ( + <div className="h-full flex items-center justify-center"> + <p className="font-mono text-sm text-[#555]">Loading...</p> + </div> + ) : filteredContracts.length === 0 && statusFilter === "all" ? ( + <div className="h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + No contracts yet + </p> + <button + onClick={() => setIsCreating(true)} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + Create First Contract + </button> + </div> + </div> + ) : ( + <WorkflowBoard + contracts={filteredContracts} + onContractClick={handleContractClick} + onPhaseChange={handlePhaseChange} + /> + )} + </div> + </main> + </div> + ); +} |
