diff options
Diffstat (limited to 'makima/frontend/src/components/contracts/ContractDetail.tsx')
| -rw-r--r-- | makima/frontend/src/components/contracts/ContractDetail.tsx | 794 |
1 files changed, 794 insertions, 0 deletions
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx new file mode 100644 index 0000000..cf5f8f2 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractDetail.tsx @@ -0,0 +1,794 @@ +import { useState, useEffect, useCallback } from "react"; +import type { + ContractWithRelations, + ContractPhase, + ContractStatus, + ContractRepository, + FileSummary, + TaskSummary, + TemplateSummary, +} from "../../lib/api"; +import { + listTemplates, + getTemplate, + createFile, +} from "../../lib/api"; +import { PhaseProgressBar } from "./PhaseProgressBar"; +import { PhaseHint } from "./PhaseHint"; +import { RepositoryPanel } from "./RepositoryPanel"; +import { ContractCliInput } from "./ContractCliInput"; +import { PhaseDeliverablesPanel } from "./PhaseDeliverablesPanel"; +import { TaskTree } from "../mesh/TaskTree"; + +type Tab = "overview" | "repos" | "files" | "tasks"; + +interface ContractDetailProps { + contract: ContractWithRelations; + loading: boolean; + onBack: () => void; + onUpdate: (name: string, description: string) => void; + onDelete: () => void; + onPhaseChange: (phase: ContractPhase) => void; + onStatusChange: (status: ContractStatus) => void; + onFileSelect: (id: string) => void; + onTaskSelect: (id: string) => void; + onTaskCreate: (name: string, plan: string, repositoryUrl?: string) => void; + onRefresh: () => void; + // Repository callbacks + onAddRemoteRepo: (name: string, url: string, isPrimary: boolean) => void; + onAddLocalRepo: (name: string, path: string, isPrimary: boolean) => void; + onCreateManagedRepo: (name: string, isPrimary: boolean) => void; + onDeleteRepo: (repoId: string) => void; + onSetRepoPrimary: (repoId: string) => void; + // File creation callback for phase deliverables + onCreateFileFromTemplate?: (templateId: string, suggestedName: string) => void; +} + +const statusConfig: Record<ContractStatus, { label: string; color: string }> = { + active: { label: "Active", color: "text-green-400" }, + completed: { label: "Completed", color: "text-blue-400" }, + archived: { label: "Archived", color: "text-[#555]" }, +}; + +export function ContractDetail({ + contract, + loading, + onBack, + onUpdate, + onDelete, + onPhaseChange, + onStatusChange, + onFileSelect, + onTaskSelect, + onTaskCreate, + onRefresh, + onAddRemoteRepo, + onAddLocalRepo, + onCreateManagedRepo, + onDeleteRepo, + onSetRepoPrimary, + onCreateFileFromTemplate, +}: ContractDetailProps) { + const [activeTab, setActiveTab] = useState<Tab>("overview"); + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(contract.name); + const [description, setDescription] = useState(contract.description || ""); + + const handleSave = () => { + onUpdate(name, description); + setIsEditing(false); + }; + + const handleCancel = () => { + setName(contract.name); + setDescription(contract.description || ""); + setIsEditing(false); + }; + + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ); + } + + const tabs: { key: Tab; label: string; count?: number }[] = [ + { key: "overview", label: "Overview" }, + { key: "repos", label: "Repositories", count: contract.repositories.length }, + { key: "files", label: "Files", count: contract.files.length }, + { key: "tasks", label: "Tasks", count: contract.tasks.length }, + ]; + + return ( + <div className="panel h-full flex flex-col"> + {/* Header */} + <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="flex items-center justify-between mb-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + ← Back to list + </button> + <div className="flex items-center gap-2"> + {isEditing ? ( + <> + <button + onClick={handleCancel} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Cancel + </button> + <button + onClick={handleSave} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + Save + </button> + </> + ) : ( + <> + <button + onClick={() => setIsEditing(true)} + className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase" + > + Edit + </button> + <button + onClick={onDelete} + className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + </> + )} + </div> + </div> + + {isEditing ? ( + <div className="space-y-3"> + <input + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + placeholder="Contract name" + /> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + rows={2} + placeholder="Description (optional)" + /> + </div> + ) : ( + <> + <div className="flex items-center gap-3 mb-2"> + <h2 className="font-mono text-lg text-[#dbe7ff]"> + {contract.name} + </h2> + <span + className={`font-mono text-xs uppercase ${ + statusConfig[contract.status].color + }`} + > + {statusConfig[contract.status].label} + </span> + </div> + {contract.description && ( + <p className="font-mono text-sm text-[#9bc3ff] mb-3"> + {contract.description} + </p> + )} + </> + )} + + {/* Phase progress */} + <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]"> + <PhaseProgressBar + currentPhase={contract.phase} + onPhaseClick={onPhaseChange} + /> + </div> + </div> + + {/* Tabs */} + <div className="flex border-b border-[rgba(117,170,252,0.2)]"> + {tabs.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={` + px-4 py-2 font-mono text-xs uppercase tracking-wider transition-colors + ${ + activeTab === tab.key + ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" + : "text-[#555] hover:text-[#9bc3ff]" + } + `} + > + {tab.label} + {tab.count !== undefined && tab.count > 0 && ( + <span className="ml-1 text-[10px]">({tab.count})</span> + )} + </button> + ))} + </div> + + {/* Tab content */} + <div className="flex-1 overflow-y-auto p-4"> + {activeTab === "overview" && ( + <OverviewTab + contract={contract} + onStatusChange={onStatusChange} + onPhaseChange={onPhaseChange} + onCreateFile={onCreateFileFromTemplate} + /> + )} + + {activeTab === "repos" && ( + <RepositoryPanel + repositories={contract.repositories} + onAddRemote={onAddRemoteRepo} + onAddLocal={onAddLocalRepo} + onCreateManaged={onCreateManagedRepo} + onDelete={onDeleteRepo} + onSetPrimary={onSetRepoPrimary} + /> + )} + + {activeTab === "files" && ( + <FilesTab + files={contract.files} + contractId={contract.id} + contractPhase={contract.phase} + onSelect={onFileSelect} + onRefresh={onRefresh} + /> + )} + + {activeTab === "tasks" && ( + <TasksTab + tasks={contract.tasks} + repositories={contract.repositories} + supervisorTaskId={contract.supervisorTaskId} + onSelect={onTaskSelect} + onCreate={onTaskCreate} + /> + )} + </div> + + {/* Chat Input */} + <ContractCliInput + contractId={contract.id} + contract={contract} + onUpdate={onRefresh} + /> + </div> + ); +} + +// Overview tab +function OverviewTab({ + contract, + onStatusChange, + onPhaseChange, + onCreateFile, +}: { + contract: ContractWithRelations; + onStatusChange: (status: ContractStatus) => void; + onPhaseChange: (phase: ContractPhase) => void; + onCreateFile?: (templateId: string, suggestedName: string) => void; +}) { + return ( + <div className="space-y-6"> + {/* Phase deliverables checklist */} + <PhaseDeliverablesPanel + contract={contract} + onCreateFile={onCreateFile} + /> + + {/* Phase hint */} + <PhaseHint contract={contract} onAdvancePhase={onPhaseChange} /> + + {/* Task progress summary */} + <TaskStatusSummary tasks={contract.tasks} /> + + {/* Stats */} + <div className="grid grid-cols-3 gap-4"> + <StatCard label="Repositories" value={contract.repositories.length} /> + <StatCard label="Files" value={contract.files.length} /> + <StatCard label="Tasks" value={contract.tasks.length} /> + </div> + + {/* Status change */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Status + </h3> + <div className="flex gap-2"> + {(["active", "completed", "archived"] as ContractStatus[]).map( + (status) => ( + <button + key={status} + onClick={() => onStatusChange(status)} + className={` + px-3 py-1.5 font-mono text-xs uppercase transition-colors + ${ + contract.status === status + ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" + : "text-[#555] border border-transparent hover:text-[#75aafc]" + } + `} + > + {status} + </button> + ) + )} + </div> + </div> + + {/* Metadata */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> + Details + </h3> + <div className="space-y-1 font-mono text-xs text-[#555]"> + <p>Created: {new Date(contract.createdAt).toLocaleString()}</p> + <p>Updated: {new Date(contract.updatedAt).toLocaleString()}</p> + <p>Version: {contract.version}</p> + </div> + </div> + </div> + ); +} + +function StatCard({ label, value }: { label: string; value: number }) { + return ( + <div className="p-3 border border-[rgba(117,170,252,0.2)]"> + <div className="font-mono text-2xl text-[#dbe7ff]">{value}</div> + <div className="font-mono text-[10px] text-[#555] uppercase">{label}</div> + </div> + ); +} + +// Task status summary with progress bar +function TaskStatusSummary({ tasks }: { tasks: TaskSummary[] }) { + if (tasks.length === 0) return null; + + // Count tasks by status + const statusCounts = { + done: 0, + merged: 0, + running: 0, + pending: 0, + failed: 0, + other: 0, + }; + + for (const task of tasks) { + switch (task.status) { + case "done": + statusCounts.done++; + break; + case "merged": + statusCounts.merged++; + break; + case "running": + case "initializing": + case "starting": + statusCounts.running++; + break; + case "pending": + statusCounts.pending++; + break; + case "failed": + statusCounts.failed++; + break; + default: + statusCounts.other++; + } + } + + const completedCount = statusCounts.done + statusCounts.merged; + const progressPercent = (completedCount / tasks.length) * 100; + + // Build summary parts + const parts: string[] = []; + if (completedCount > 0) parts.push(`${completedCount} done`); + if (statusCounts.running > 0) parts.push(`${statusCounts.running} running`); + if (statusCounts.pending > 0) parts.push(`${statusCounts.pending} pending`); + if (statusCounts.failed > 0) parts.push(`${statusCounts.failed} failed`); + + return ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Task Progress + </h3> + + {/* Progress bar */} + <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> + <div + className="h-full bg-green-400 transition-all duration-300" + style={{ width: `${progressPercent}%` }} + /> + </div> + + {/* Summary text */} + <div className="flex items-center justify-between"> + <span className="font-mono text-xs text-[#9bc3ff]"> + {parts.join(", ")} + </span> + <span className="font-mono text-xs text-[#555]"> + {completedCount}/{tasks.length} completed + </span> + </div> + </div> + ); +} + +// Phase color mapping for badges +const phaseColors: Record<ContractPhase, string> = { + research: "bg-purple-500/20 text-purple-400 border-purple-400/30", + specify: "bg-blue-500/20 text-blue-400 border-blue-400/30", + plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30", + execute: "bg-green-500/20 text-green-400 border-green-400/30", + review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30", +}; + +// Files tab with template creation +function FilesTab({ + files, + contractId, + contractPhase, + onSelect, + onRefresh, +}: { + files: FileSummary[]; + contractId: string; + contractPhase: ContractPhase; + onSelect: (id: string) => void; + onRefresh: () => void; +}) { + const [showTemplateModal, setShowTemplateModal] = useState(false); + const [templates, setTemplates] = useState<TemplateSummary[]>([]); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [creating, setCreating] = useState(false); + const [fileName, setFileName] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); + + // Load templates when modal opens + useEffect(() => { + if (showTemplateModal) { + setLoadingTemplates(true); + listTemplates(contractPhase) + .then((res) => setTemplates(res.templates)) + .catch((err) => console.error("Failed to load templates:", err)) + .finally(() => setLoadingTemplates(false)); + } + }, [showTemplateModal, contractPhase]); + + const handleCreateFromTemplate = useCallback(async () => { + if (!fileName.trim() || !selectedTemplateId) return; + + setCreating(true); + try { + // Get the full template with body + const template = await getTemplate(selectedTemplateId); + + // Create the file with contract (files must belong to contracts) + await createFile({ + contractId, + name: fileName.trim(), + description: template.description, + body: template.suggestedBody, + }); + + // Reset and close + setShowTemplateModal(false); + setFileName(""); + setSelectedTemplateId(null); + onRefresh(); + } catch (err) { + console.error("Failed to create file from template:", err); + } finally { + setCreating(false); + } + }, [fileName, selectedTemplateId, contractId, onRefresh]); + + const handleCloseModal = () => { + setShowTemplateModal(false); + setFileName(""); + setSelectedTemplateId(null); + }; + + return ( + <div className="space-y-4"> + {/* Create from template button */} + <button + onClick={() => setShowTemplateModal(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Create from Template + </button> + + {/* Template Selection Modal */} + {showTemplateModal && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase"> + Create File from Template + </h3> + <span className={`px-2 py-0.5 text-[10px] font-mono uppercase border rounded ${phaseColors[contractPhase]}`}> + {contractPhase} phase + </span> + </div> + + <div className="space-y-4 flex-1 overflow-y-auto"> + {/* File name input */} + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + File Name + </label> + <input + type="text" + value={fileName} + onChange={(e) => setFileName(e.target.value)} + placeholder="e.g., Project Requirements" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Template selection */} + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-2"> + Select Template + </label> + {loadingTemplates ? ( + <p className="font-mono text-xs text-[#555]">Loading templates...</p> + ) : templates.length === 0 ? ( + <p className="font-mono text-xs text-[#555]">No templates available for {contractPhase} phase</p> + ) : ( + <div className="space-y-2 max-h-60 overflow-y-auto"> + {templates.map((template) => ( + <button + key={template.id} + onClick={() => setSelectedTemplateId(template.id)} + className={`w-full text-left p-3 border transition-colors ${ + selectedTemplateId === template.id + ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" + : "border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)]" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-sm text-[#dbe7ff]"> + {template.name} + </span> + <span className="font-mono text-[10px] text-[#555]"> + {template.elementCount} elements + </span> + </div> + <p className="font-mono text-xs text-[#555]"> + {template.description} + </p> + </button> + ))} + </div> + )} + </div> + </div> + + <div className="flex gap-2 justify-end mt-4 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <button + onClick={handleCloseModal} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreateFromTemplate} + disabled={!fileName.trim() || !selectedTemplateId || creating} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {creating ? "Creating..." : "Create File"} + </button> + </div> + </div> + </div> + )} + + {/* File list */} + {files.length === 0 ? ( + <p className="font-mono text-xs text-[#555]"> + No files in this contract. Create one from a template above. + </p> + ) : ( + <div className="space-y-2"> + {files.map((file) => ( + <button + key={file.id} + onClick={() => onSelect(file.id)} + className="w-full text-left p-3 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]"> + {file.name} + </span> + {file.contractPhase && ( + <span + className={`px-1.5 py-0.5 text-[9px] font-mono uppercase border rounded ${ + phaseColors[file.contractPhase] + }`} + title={`Added during ${file.contractPhase} phase`} + > + {file.contractPhase} + </span> + )} + </div> + <span className="font-mono text-[10px] text-[#555]"> + v{file.version} + </span> + </div> + {file.description && ( + <p className="font-mono text-xs text-[#555] mt-1 truncate"> + {file.description} + </p> + )} + </button> + ))} + </div> + )} + </div> + ); +} + +// Tasks tab - now using TaskTree for supervisor view +function TasksTab({ + tasks, + repositories, + supervisorTaskId, + onSelect, + onCreate, +}: { + tasks: TaskSummary[]; + repositories: ContractRepository[]; + supervisorTaskId: string | null; + onSelect: (id: string) => void; + onCreate: (name: string, plan: string, repositoryUrl?: string) => void; +}) { + const [isCreating, setIsCreating] = useState(false); + const [taskName, setTaskName] = useState(""); + const [taskPlan, setTaskPlan] = useState("# Plan\n\nDescribe what this task should accomplish..."); + + // Find primary repository or first ready one + const readyRepos = repositories.filter((r) => r.status === "ready"); + const primaryRepo = readyRepos.find((r) => r.isPrimary) || readyRepos[0]; + const [selectedRepoId, setSelectedRepoId] = useState<string>(primaryRepo?.id || ""); + + const handleCreate = () => { + if (!taskName.trim()) return; + const selectedRepo = repositories.find((r) => r.id === selectedRepoId); + // Get the URL - for remote repos it's repositoryUrl, for local it's the local path + const repoUrl = selectedRepo?.repositoryUrl || selectedRepo?.localPath; + onCreate(taskName.trim(), taskPlan, repoUrl || undefined); + setIsCreating(false); + setTaskName(""); + setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); + setSelectedRepoId(primaryRepo?.id || ""); + }; + + const handleCancel = () => { + setIsCreating(false); + setTaskName(""); + setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); + setSelectedRepoId(primaryRepo?.id || ""); + }; + + return ( + <div className="space-y-4"> + {/* TaskTree with supervisor view */} + <TaskTree + tasks={tasks} + supervisorTaskId={supervisorTaskId} + onSelect={onSelect} + /> + + {/* Manual task creation (hidden when supervisor exists - supervisor creates tasks) */} + {!supervisorTaskId && ( + <> + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <button + onClick={() => setIsCreating(true)} + className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + + Create Task Manually + </button> + </div> + + {/* Create Task Modal */} + {isCreating && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Task + </h3> + <div className="space-y-4"> + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Name + </label> + <input + type="text" + value={taskName} + onChange={(e) => setTaskName(e.target.value)} + placeholder="Task name" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> + </div> + + {/* Repository selection */} + {readyRepos.length > 0 && ( + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Repository + </label> + <select + value={selectedRepoId} + onChange={(e) => setSelectedRepoId(e.target.value)} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + > + <option value="">No repository</option> + {readyRepos.map((repo) => ( + <option key={repo.id} value={repo.id}> + {repo.name} + {repo.isPrimary ? " (Primary)" : ""} + {" - "} + {repo.sourceType} + </option> + ))} + </select> + </div> + )} + + <div> + <label className="block font-mono text-xs text-[#555] uppercase mb-1"> + Plan + </label> + <textarea + value={taskPlan} + onChange={(e) => setTaskPlan(e.target.value)} + rows={6} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + /> + </div> + + <div className="flex gap-2 justify-end"> + <button + onClick={handleCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleCreate} + disabled={!taskName.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + )} + </> + )} + </div> + ); +} |
