diff options
| author | soryu <soryu@soryu.co> | 2026-01-19 19:59:50 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-19 19:59:50 +0000 |
| commit | 13345fa1e26c5d004f4fa89c4a9341fb3926a433 (patch) | |
| tree | d2eba5003bcab5574c6f62b77c10eb9613cedd17 | |
| parent | 0833fb1f30c0c3b920157deb882e0e902c3af02a (diff) | |
| download | soryu-13345fa1e26c5d004f4fa89c4a9341fb3926a433.tar.gz soryu-13345fa1e26c5d004f4fa89c4a9341fb3926a433.zip | |
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 <noreply@anthropic.com>
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> ); } |
