summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-19 19:59:50 +0000
committersoryu <soryu@soryu.co>2026-01-19 19:59:50 +0000
commit13345fa1e26c5d004f4fa89c4a9341fb3926a433 (patch)
treed2eba5003bcab5574c6f62b77c10eb9613cedd17
parent0833fb1f30c0c3b920157deb882e0e902c3af02a (diff)
downloadsoryu-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>
-rw-r--r--makima/frontend/src/components/contracts/ContractContextMenu.tsx168
-rw-r--r--makima/frontend/src/components/workflow/PhaseColumn.tsx3
-rw-r--r--makima/frontend/src/components/workflow/WorkflowBoard.tsx3
-rw-r--r--makima/frontend/src/components/workflow/WorkflowContractCard.tsx11
-rw-r--r--makima/frontend/src/routes/workflow.tsx72
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">&#10003;</span>
+ Mark as Complete
+ </button>
+ )}
+
+ {contract.status !== "active" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onMarkActive(contract.id);
+ onClose();
+ }}
+ >
+ <span className="text-green-400">&#9654;</span>
+ Mark as Active
+ </button>
+ )}
+
+ {contract.status !== "archived" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onArchive(contract.id);
+ onClose();
+ }}
+ >
+ <span className="text-[#555]">&#128451;</span>
+ Archive
+ </button>
+ )}
+
+ {/* Supervisor task link */}
+ {contract.supervisorTaskId && (
+ <>
+ <div className={dividerClass} />
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGoToSupervisor(contract.supervisorTaskId!);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">&#9654;</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>
);
}