diff options
Diffstat (limited to 'makima/frontend/src')
5 files changed, 255 insertions, 2 deletions
diff --git a/makima/frontend/src/components/contracts/ContractContextMenu.tsx b/makima/frontend/src/components/contracts/ContractContextMenu.tsx new file mode 100644 index 0000000..6e21641 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractContextMenu.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef } from "react"; +import type { ContractSummary, ContractStatus } from "../../lib/api"; + +interface ContractContextMenuProps { + x: number; + y: number; + contract: ContractSummary; + onClose: () => void; + onMarkComplete: (contractId: string) => void; + onMarkActive: (contractId: string) => void; + onArchive: (contractId: string) => void; + onGoToSupervisor: (supervisorTaskId: string) => void; + onDelete: (contractId: 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 ContractContextMenu({ + x, + y, + contract, + onClose, + onMarkComplete, + onMarkActive, + onArchive, + onGoToSupervisor, + onDelete, +}: 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 status = statusConfig[contract.status] || statusConfig.active; + + 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"; + + return ( + <div + ref={menuRef} + className="fixed z-50 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" + style={{ left: x, top: y }} + > + {/* Header showing contract info */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.2)]"> + <div className="text-[10px] font-mono text-[#555] uppercase truncate"> + {contract.name} + </div> + <div className={`text-[10px] font-mono ${status.color}`}> + {status.label} | {contract.phase} + </div> + </div> + + {/* Status actions */} + {contract.status !== "completed" && ( + <button + className={menuItemClass} + onClick={() => { + onMarkComplete(contract.id); + onClose(); + }} + > + <span className="text-blue-400">✓</span> + Mark as Complete + </button> + )} + + {contract.status !== "active" && ( + <button + className={menuItemClass} + onClick={() => { + onMarkActive(contract.id); + onClose(); + }} + > + <span className="text-green-400">▶</span> + Mark as Active + </button> + )} + + {contract.status !== "archived" && ( + <button + className={menuItemClass} + onClick={() => { + onArchive(contract.id); + onClose(); + }} + > + <span className="text-[#555]">🗃</span> + Archive + </button> + )} + + {/* Supervisor task link */} + {contract.supervisorTaskId && ( + <> + <div className={dividerClass} /> + <button + className={menuItemClass} + onClick={() => { + onGoToSupervisor(contract.supervisorTaskId!); + 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(contract.id); + onClose(); + }} + > + <span className="text-red-400">x</span> + Delete + </button> + </div> + ); +} diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx index ddea85f..fb15ae3 100644 --- a/makima/frontend/src/components/workflow/PhaseColumn.tsx +++ b/makima/frontend/src/components/workflow/PhaseColumn.tsx @@ -7,6 +7,7 @@ interface PhaseColumnProps { contracts: ContractSummary[]; onContractClick: (contractId: string) => void; onDrop: (contractId: string, phase: ContractPhase) => void; + onContextMenu?: (e: React.MouseEvent, contract: ContractSummary) => void; } const phaseConfig: Record< @@ -50,6 +51,7 @@ export function PhaseColumn({ contracts, onContractClick, onDrop, + onContextMenu, }: PhaseColumnProps) { const [isDragOver, setIsDragOver] = useState(false); const config = phaseConfig[phase]; @@ -114,6 +116,7 @@ export function PhaseColumn({ e.dataTransfer.setData("contractId", contract.id); e.dataTransfer.effectAllowed = "move"; }} + onContextMenu={onContextMenu} /> )) )} diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx index af4aec7..ee2ff75 100644 --- a/makima/frontend/src/components/workflow/WorkflowBoard.tsx +++ b/makima/frontend/src/components/workflow/WorkflowBoard.tsx @@ -6,6 +6,7 @@ interface WorkflowBoardProps { contracts: ContractSummary[]; onContractClick: (contractId: string) => void; onPhaseChange: (contractId: string, newPhase: ContractPhase) => void; + onContextMenu?: (e: React.MouseEvent, contract: ContractSummary) => void; } const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; @@ -14,6 +15,7 @@ export function WorkflowBoard({ contracts, onContractClick, onPhaseChange, + onContextMenu, }: WorkflowBoardProps) { // Group contracts by phase const contractsByPhase = useMemo(() => { @@ -47,6 +49,7 @@ export function WorkflowBoard({ contracts={contractsByPhase[phase]} onContractClick={onContractClick} onDrop={onPhaseChange} + onContextMenu={onContextMenu} /> ))} </div> diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx index 61e6d17..d62f4b0 100644 --- a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx +++ b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx @@ -5,6 +5,7 @@ interface WorkflowContractCardProps { contract: ContractSummary; onClick: () => void; onDragStart: (e: React.DragEvent) => void; + onContextMenu?: (e: React.MouseEvent, contract: ContractSummary) => void; } const statusConfig: Record<ContractStatus, { label: string; color: string }> = { @@ -17,6 +18,7 @@ export function WorkflowContractCard({ contract, onClick, onDragStart, + onContextMenu, }: WorkflowContractCardProps) { const navigate = useNavigate(); const status = statusConfig[contract.status] || statusConfig.active; @@ -28,11 +30,20 @@ export function WorkflowContractCard({ } }; + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (onContextMenu) { + onContextMenu(e, contract); + } + }; + return ( <div draggable onDragStart={onDragStart} onClick={onClick} + onContextMenu={handleContextMenu} className="p-3 bg-[rgba(9,13,20,0.8)] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] cursor-pointer transition-colors select-none" > {/* Header row with name and supervisor button */} diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx index cb72e9e..79148e2 100644 --- a/makima/frontend/src/routes/workflow.tsx +++ b/makima/frontend/src/routes/workflow.tsx @@ -2,12 +2,19 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { WorkflowBoard } from "../components/workflow/WorkflowBoard"; +import { ContractContextMenu } from "../components/contracts/ContractContextMenu"; import { useContracts } from "../hooks/useContracts"; import { useAuth } from "../contexts/AuthContext"; -import type { ContractPhase, ContractStatus } from "../lib/api"; +import type { ContractPhase, ContractStatus, ContractSummary } from "../lib/api"; type StatusFilter = "all" | ContractStatus; +interface ContextMenuState { + x: number; + y: number; + contract: ContractSummary; +} + export default function WorkflowPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); @@ -41,10 +48,11 @@ export default function WorkflowPage() { function WorkflowPageContent() { const navigate = useNavigate(); - const { contracts, loading, error, changePhase, saveContract } = useContracts(); + const { contracts, loading, error, changePhase, saveContract, editContract, removeContract } = useContracts(); const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); const [isCreating, setIsCreating] = useState(false); const [newContractName, setNewContractName] = useState(""); + const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); // Filter contracts by status const filteredContracts = useMemo(() => { @@ -85,6 +93,50 @@ function WorkflowPageContent() { setIsCreating(false); }, []); + // Context menu handlers + const handleContextMenu = useCallback((e: React.MouseEvent, contract: ContractSummary) => { + setContextMenu({ + x: e.clientX, + y: e.clientY, + contract, + }); + }, []); + + const handleCloseContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + const handleMarkComplete = useCallback(async (contractId: string) => { + const contract = contracts.find(c => c.id === contractId); + if (contract) { + await editContract(contractId, { status: "completed", version: contract.version }); + } + }, [contracts, editContract]); + + const handleMarkActive = useCallback(async (contractId: string) => { + const contract = contracts.find(c => c.id === contractId); + if (contract) { + await editContract(contractId, { status: "active", version: contract.version }); + } + }, [contracts, editContract]); + + const handleArchive = useCallback(async (contractId: string) => { + const contract = contracts.find(c => c.id === contractId); + if (contract) { + await editContract(contractId, { status: "archived", version: contract.version }); + } + }, [contracts, editContract]); + + const handleGoToSupervisor = useCallback((supervisorTaskId: string) => { + navigate(`/mesh/${supervisorTaskId}`); + }, [navigate]); + + const handleDelete = useCallback(async (contractId: string) => { + if (confirm("Are you sure you want to delete this contract?")) { + await removeContract(contractId); + } + }, [removeContract]); + return ( <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]"> <Masthead showNav /> @@ -196,10 +248,26 @@ function WorkflowPageContent() { contracts={filteredContracts} onContractClick={handleContractClick} onPhaseChange={handlePhaseChange} + onContextMenu={handleContextMenu} /> )} </div> </main> + + {/* Context Menu */} + {contextMenu && ( + <ContractContextMenu + x={contextMenu.x} + y={contextMenu.y} + contract={contextMenu.contract} + onClose={handleCloseContextMenu} + onMarkComplete={handleMarkComplete} + onMarkActive={handleMarkActive} + onArchive={handleArchive} + onGoToSupervisor={handleGoToSupervisor} + onDelete={handleDelete} + /> + )} </div> ); } |
