diff options
| author | soryu <soryu@soryu.co> | 2026-01-19 17:55:22 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-19 17:55:22 +0000 |
| commit | 52d121269195f0e799d0ab4241e4facc3c7c0596 (patch) | |
| tree | 13d3dcdd743cf15f31d6d87097bf51ebfd01a305 | |
| parent | 164941cbd591b46f69a034bb9b86521fd7700ddb (diff) | |
| download | soryu-52d121269195f0e799d0ab4241e4facc3c7c0596.tar.gz soryu-52d121269195f0e799d0ab4241e4facc3c7c0596.zip | |
Add right-click context menu for contracts on contracts and board pages (#8)
Implement a reusable ContractContextMenu component that provides:
- Mark as Complete/Active/Archive status actions (conditionally shown)
- Go to Supervisor Task link (when supervisor exists)
- Delete action with confirmation
Integrate context menu into:
- ContractList.tsx on the contracts page
- WorkflowBoard on the workflow/board page via PhaseColumn and WorkflowContractCard
Features match ElementContextMenu patterns:
- Fixed positioning with z-50
- Click outside and Escape key close handlers
- Viewport overflow prevention
- Dark theme colors (#0a1628, #0d1b2d, #75aafc, #9bc3ff)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
7 files changed, 358 insertions, 14 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..f31beb5 --- /dev/null +++ b/makima/frontend/src/components/contracts/ContractContextMenu.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef } from "react"; +import type { ContractSummary } from "../../lib/api"; + +interface ContractContextMenuProps { + x: number; + y: number; + contract: ContractSummary; + onClose: () => void; + onMarkComplete: () => void; + onMarkActive: () => void; + onArchive: () => void; + onDelete: () => void; + onGoToSupervisor: () => void; +} + +export function ContractContextMenu({ + x, + y, + contract, + onClose, + onMarkComplete, + onMarkActive, + onArchive, + onDelete, + onGoToSupervisor, +}: 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 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"; + + const showMarkComplete = contract.status !== "completed"; + const showMarkActive = contract.status !== "active"; + const showArchive = contract.status !== "archived"; + const showGoToSupervisor = !!contract.supervisorTaskId; + + return ( + <div + ref={menuRef} + className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" + style={{ left: x, top: y }} + > + {/* Header showing contract name */} + <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[200px]"> + {contract.name} + </div> + + {/* Status actions */} + {showMarkComplete && ( + <button + className={menuItemClass} + onClick={() => { + onMarkComplete(); + onClose(); + }} + > + <span className="text-[#75aafc]">✓</span> + Mark as Complete + </button> + )} + + {showMarkActive && ( + <button + className={menuItemClass} + onClick={() => { + onMarkActive(); + onClose(); + }} + > + <span className="text-[#75aafc]">●</span> + Mark as Active + </button> + )} + + {showArchive && ( + <button + className={menuItemClass} + onClick={() => { + onArchive(); + onClose(); + }} + > + <span className="text-[#75aafc]">▣</span> + Archive + </button> + )} + + {/* Supervisor link */} + {showGoToSupervisor && ( + <> + <div className={dividerClass} /> + <button + className={menuItemClass} + onClick={() => { + onGoToSupervisor(); + 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(); + onClose(); + }} + > + <span className="text-red-400">✕</span> + Delete + </button> + </div> + ); +} diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx index 3a7b163..ebde497 100644 --- a/makima/frontend/src/components/contracts/ContractList.tsx +++ b/makima/frontend/src/components/contracts/ContractList.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { ContractSummary, ContractStatus } from "../../lib/api"; import { PhaseBadge } from "./PhaseBadge"; import { PhaseProgressBarCompact } from "./PhaseProgressBar"; +import { ContractContextMenu } from "./ContractContextMenu"; interface ContractListProps { contracts: ContractSummary[]; @@ -9,6 +10,11 @@ interface ContractListProps { onSelect: (id: string) => void; onCreate: () => void; selectedId?: string; + onMarkComplete?: (contract: ContractSummary) => void; + onMarkActive?: (contract: ContractSummary) => void; + onArchive?: (contract: ContractSummary) => void; + onDelete?: (contract: ContractSummary) => void; + onGoToSupervisor?: (contract: ContractSummary) => void; } const statusColors: Record<ContractStatus, string> = { @@ -23,8 +29,26 @@ export function ContractList({ onSelect, onCreate, selectedId, + onMarkComplete, + onMarkActive, + onArchive, + onDelete, + onGoToSupervisor, }: ContractListProps) { const [filter, setFilter] = useState<ContractStatus | "all">("all"); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); + const [contextMenuContract, setContextMenuContract] = useState<ContractSummary | null>(null); + + const handleContextMenu = (e: React.MouseEvent, contract: ContractSummary) => { + e.preventDefault(); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuContract(contract); + }; + + const closeContextMenu = () => { + setContextMenuPosition(null); + setContextMenuContract(null); + }; const filteredContracts = filter === "all" @@ -92,6 +116,7 @@ export function ContractList({ <button key={contract.id} onClick={() => onSelect(contract.id)} + onContextMenu={(e) => handleContextMenu(e, contract)} className={` w-full text-left p-4 transition-colors ${ @@ -139,6 +164,21 @@ export function ContractList({ </div> )} </div> + + {/* Context Menu */} + {contextMenuPosition && contextMenuContract && ( + <ContractContextMenu + x={contextMenuPosition.x} + y={contextMenuPosition.y} + contract={contextMenuContract} + onClose={closeContextMenu} + onMarkComplete={() => onMarkComplete?.(contextMenuContract)} + onMarkActive={() => onMarkActive?.(contextMenuContract)} + onArchive={() => onArchive?.(contextMenuContract)} + onDelete={() => onDelete?.(contextMenuContract)} + onGoToSupervisor={() => onGoToSupervisor?.(contextMenuContract)} + /> + )} </div> ); } diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx index ddea85f..277b04c 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 ? (e) => onContextMenu(e, contract) : undefined} /> )) )} diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx index af4aec7..e36ca21 100644 --- a/makima/frontend/src/components/workflow/WorkflowBoard.tsx +++ b/makima/frontend/src/components/workflow/WorkflowBoard.tsx @@ -1,11 +1,17 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { ContractSummary, ContractPhase } from "../../lib/api"; import { PhaseColumn } from "./PhaseColumn"; +import { ContractContextMenu } from "../contracts/ContractContextMenu"; interface WorkflowBoardProps { contracts: ContractSummary[]; onContractClick: (contractId: string) => void; onPhaseChange: (contractId: string, newPhase: ContractPhase) => void; + onMarkComplete?: (contract: ContractSummary) => void; + onMarkActive?: (contract: ContractSummary) => void; + onArchive?: (contract: ContractSummary) => void; + onDelete?: (contract: ContractSummary) => void; + onGoToSupervisor?: (contract: ContractSummary) => void; } const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; @@ -14,7 +20,27 @@ export function WorkflowBoard({ contracts, onContractClick, onPhaseChange, + onMarkComplete, + onMarkActive, + onArchive, + onDelete, + onGoToSupervisor, }: WorkflowBoardProps) { + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); + const [contextMenuContract, setContextMenuContract] = useState<ContractSummary | null>(null); + + const handleContextMenu = (e: React.MouseEvent, contract: ContractSummary) => { + e.preventDefault(); + e.stopPropagation(); // Prevent interference with drag-and-drop + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuContract(contract); + }; + + const closeContextMenu = () => { + setContextMenuPosition(null); + setContextMenuContract(null); + }; + // Group contracts by phase const contractsByPhase = useMemo(() => { const grouped: Record<ContractPhase, ContractSummary[]> = { @@ -39,16 +65,34 @@ export function WorkflowBoard({ }, [contracts]); return ( - <div className="flex gap-2 h-full overflow-x-auto"> - {phases.map((phase) => ( - <PhaseColumn - key={phase} - phase={phase} - contracts={contractsByPhase[phase]} - onContractClick={onContractClick} - onDrop={onPhaseChange} + <> + <div className="flex gap-2 h-full overflow-x-auto"> + {phases.map((phase) => ( + <PhaseColumn + key={phase} + phase={phase} + contracts={contractsByPhase[phase]} + onContractClick={onContractClick} + onDrop={onPhaseChange} + onContextMenu={handleContextMenu} + /> + ))} + </div> + + {/* Context Menu */} + {contextMenuPosition && contextMenuContract && ( + <ContractContextMenu + x={contextMenuPosition.x} + y={contextMenuPosition.y} + contract={contextMenuContract} + onClose={closeContextMenu} + onMarkComplete={() => onMarkComplete?.(contextMenuContract)} + onMarkActive={() => onMarkActive?.(contextMenuContract)} + onArchive={() => onArchive?.(contextMenuContract)} + onDelete={() => onDelete?.(contextMenuContract)} + onGoToSupervisor={() => onGoToSupervisor?.(contextMenuContract)} /> - ))} - </div> + )} + </> ); } diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx index 61e6d17..86fcd13 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) => 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; @@ -33,6 +35,7 @@ export function WorkflowContractCard({ draggable onDragStart={onDragStart} onClick={onClick} + onContextMenu={onContextMenu} 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/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index d2b6b1b..0893ff6 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { ContractList } from "../components/contracts/ContractList"; @@ -9,6 +9,7 @@ import { useAuth } from "../contexts/AuthContext"; import { createTask, getDaemonDirectories, getRepositorySuggestions } from "../lib/api"; import type { ContractWithRelations, + ContractSummary, ContractPhase, ContractStatus, ContractType, @@ -411,6 +412,49 @@ function ContractsPageContent() { [contractDetail, fetchContract, navigate] ); + // Context menu handlers for ContractList + const handleContextMarkComplete = useCallback( + async (contract: ContractSummary) => { + await editContract(contract.id, { status: "completed", version: contract.version }); + }, + [editContract] + ); + + const handleContextMarkActive = useCallback( + async (contract: ContractSummary) => { + await editContract(contract.id, { status: "active", version: contract.version }); + }, + [editContract] + ); + + const handleContextArchive = useCallback( + async (contract: ContractSummary) => { + await editContract(contract.id, { status: "archived", version: contract.version }); + }, + [editContract] + ); + + const handleContextDelete = useCallback( + async (contract: ContractSummary) => { + if (confirm(`Are you sure you want to delete "${contract.name}"?`)) { + const success = await removeContract(contract.id); + if (success && contract.id === id) { + navigate("/contracts"); + } + } + }, + [removeContract, id, navigate] + ); + + const handleContextGoToSupervisor = useCallback( + (contract: ContractSummary) => { + if (contract.supervisorTaskId) { + navigate(`/mesh/${contract.supervisorTaskId}`); + } + }, + [navigate] + ); + return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> <Masthead showNav /> @@ -684,6 +728,11 @@ function ContractsPageContent() { onSelect={handleSelect} onCreate={handleCreate} selectedId={id} + onMarkComplete={handleContextMarkComplete} + onMarkActive={handleContextMarkActive} + onArchive={handleContextArchive} + onDelete={handleContextDelete} + onGoToSupervisor={handleContextGoToSupervisor} /> {/* Contract detail or empty state */} diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx index cb72e9e..e122092 100644 --- a/makima/frontend/src/routes/workflow.tsx +++ b/makima/frontend/src/routes/workflow.tsx @@ -4,7 +4,7 @@ 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"; +import type { ContractPhase, ContractStatus, ContractSummary } from "../lib/api"; type StatusFilter = "all" | ContractStatus; @@ -41,7 +41,7 @@ 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(""); @@ -68,6 +68,46 @@ function WorkflowPageContent() { [changePhase] ); + // Context menu handlers + const handleContextMarkComplete = useCallback( + async (contract: ContractSummary) => { + await editContract(contract.id, { status: "completed", version: contract.version }); + }, + [editContract] + ); + + const handleContextMarkActive = useCallback( + async (contract: ContractSummary) => { + await editContract(contract.id, { status: "active", version: contract.version }); + }, + [editContract] + ); + + const handleContextArchive = useCallback( + async (contract: ContractSummary) => { + await editContract(contract.id, { status: "archived", version: contract.version }); + }, + [editContract] + ); + + const handleContextDelete = useCallback( + async (contract: ContractSummary) => { + if (confirm(`Are you sure you want to delete "${contract.name}"?`)) { + await removeContract(contract.id); + } + }, + [removeContract] + ); + + const handleContextGoToSupervisor = useCallback( + (contract: ContractSummary) => { + if (contract.supervisorTaskId) { + navigate(`/mesh/${contract.supervisorTaskId}`); + } + }, + [navigate] + ); + const handleCreateContract = useCallback(async () => { if (!newContractName.trim()) return; const contract = await saveContract({ @@ -196,6 +236,11 @@ function WorkflowPageContent() { contracts={filteredContracts} onContractClick={handleContractClick} onPhaseChange={handlePhaseChange} + onMarkComplete={handleContextMarkComplete} + onMarkActive={handleContextMarkActive} + onArchive={handleContextArchive} + onDelete={handleContextDelete} + onGoToSupervisor={handleContextGoToSupervisor} /> )} </div> |
