From 13345fa1e26c5d004f4fa89c4a9341fb3926a433 Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 19 Jan 2026 19:59:50 +0000 Subject: Restore context menu for contract cards on workflow/board page - Create ContractContextMenu component with options: - Mark as Complete - Mark as Active - Archive - Go to Supervisor Task (when available) - Delete - Add onContextMenu handler to WorkflowContractCard with e.preventDefault() to prevent browser default context menu - Pass onContextMenu through PhaseColumn and WorkflowBoard components - Implement context menu state and handlers in workflow.tsx route - Context menu appears at click position and closes on click outside or Escape The key fix is calling e.preventDefault() in the card's onContextMenu handler to prevent the browser's default context menu from appearing. Co-Authored-By: Claude Opus 4.5 --- .../components/contracts/ContractContextMenu.tsx | 168 +++++++++++++++++++++ .../src/components/workflow/PhaseColumn.tsx | 3 + .../src/components/workflow/WorkflowBoard.tsx | 3 + .../components/workflow/WorkflowContractCard.tsx | 11 ++ makima/frontend/src/routes/workflow.tsx | 72 ++++++++- 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 makima/frontend/src/components/contracts/ContractContextMenu.tsx 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 = { + 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(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 ( +
+ {/* Header showing contract info */} +
+
+ {contract.name} +
+
+ {status.label} | {contract.phase} +
+
+ + {/* Status actions */} + {contract.status !== "completed" && ( + + )} + + {contract.status !== "active" && ( + + )} + + {contract.status !== "archived" && ( + + )} + + {/* Supervisor task link */} + {contract.supervisorTaskId && ( + <> +
+ + + )} + +
+ + {/* Delete action */} + +
+ ); +} 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} /> ))}
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 = { @@ -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 (
{/* 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("all"); const [isCreating, setIsCreating] = useState(false); const [newContractName, setNewContractName] = useState(""); + const [contextMenu, setContextMenu] = useState(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 (
@@ -196,10 +248,26 @@ function WorkflowPageContent() { contracts={filteredContracts} onContractClick={handleContractClick} onPhaseChange={handlePhaseChange} + onContextMenu={handleContextMenu} /> )}
+ + {/* Context Menu */} + {contextMenu && ( + + )}
); } -- cgit v1.2.3