summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-19 17:55:22 +0000
committerGitHub <noreply@github.com>2026-01-19 17:55:22 +0000
commit52d121269195f0e799d0ab4241e4facc3c7c0596 (patch)
tree13d3dcdd743cf15f31d6d87097bf51ebfd01a305
parent164941cbd591b46f69a034bb9b86521fd7700ddb (diff)
downloadsoryu-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>
-rw-r--r--makima/frontend/src/components/contracts/ContractContextMenu.tsx160
-rw-r--r--makima/frontend/src/components/contracts/ContractList.tsx40
-rw-r--r--makima/frontend/src/components/workflow/PhaseColumn.tsx3
-rw-r--r--makima/frontend/src/components/workflow/WorkflowBoard.tsx66
-rw-r--r--makima/frontend/src/components/workflow/WorkflowContractCard.tsx3
-rw-r--r--makima/frontend/src/routes/contracts.tsx51
-rw-r--r--makima/frontend/src/routes/workflow.tsx49
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>