diff options
| author | soryu <soryu@soryu.co> | 2026-02-14 21:29:26 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-14 21:29:26 +0000 |
| commit | 9aadbc7958d39d181c0dd0600e2b7c30bb6c391a (patch) | |
| tree | ef8bed9718c39041191b58a284ee31f5d8d32521 /makima/frontend/src | |
| parent | c1e55ce4fec79f9909b957f86bd7fa8b76939746 (diff) | |
| download | soryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.tar.gz soryu-9aadbc7958d39d181c0dd0600e2b7c30bb6c391a.zip | |
Makima system improvements: Orders, directive questions, PR creation fix, bug fixes (#62)
* feat: soryu-co/soryu - makima: Fix directive goal update bug - stale closure issue
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Create Orders database schema and backend API
* feat: soryu-co/soryu - makima: Fix task Claude instance not receiving user inputs from input box
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Build Orders frontend page replacing the Board page
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Fix directive PR creation system
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 139 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 5 | ||||
| -rw-r--r-- | makima/frontend/src/components/orders/OrderDetail.tsx | 530 | ||||
| -rw-r--r-- | makima/frontend/src/components/orders/OrderList.tsx | 188 | ||||
| -rw-r--r-- | makima/frontend/src/components/workflow/PhaseColumn.tsx | 126 | ||||
| -rw-r--r-- | makima/frontend/src/components/workflow/WorkflowBoard.tsx | 98 | ||||
| -rw-r--r-- | makima/frontend/src/components/workflow/WorkflowContractCard.tsx | 76 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useOrders.ts | 123 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 145 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 14 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 3 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 2 | ||||
| -rw-r--r-- | makima/frontend/src/routes/orders.tsx | 238 | ||||
| -rw-r--r-- | makima/frontend/src/routes/workflow.tsx | 250 |
15 files changed, 1380 insertions, 559 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 9bb7777..5aba6a3 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -12,7 +12,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, - { label: "Board", href: "/workflow", requiresAuth: true }, + { label: "Orders", href: "/orders", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx index b73463d..e278939 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -1,8 +1,9 @@ import { useState, useMemo, useEffect, useRef } from "react"; -import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest } from "../../lib/api"; import { DirectiveDAG } from "./DirectiveDAG"; import { DirectiveLogStream } from "./DirectiveLogStream"; import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription"; +import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, @@ -21,6 +22,7 @@ interface DirectiveDetailProps { onFailStep: (stepId: string) => void; onSkipStep: (stepId: string) => void; onUpdateGoal: (goal: string) => void; + onUpdate: (req: UpdateDirectiveRequest) => void; onDelete: () => void; onRefresh: () => void; onCleanupTasks: () => void; @@ -35,6 +37,7 @@ export function DirectiveDetail({ onFailStep, onSkipStep, onUpdateGoal, + onUpdate, onDelete, onRefresh, onCleanupTasks, @@ -42,6 +45,12 @@ export function DirectiveDetail({ const [editingGoal, setEditingGoal] = useState(false); const [goalText, setGoalText] = useState(directive.goal); const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null); + + // Sync goalText and reset editing state when directive changes + useEffect(() => { + setGoalText(directive.goal); + setEditingGoal(false); + }, [directive.id, directive.goal]); const [searchQuery, setSearchQuery] = useState(""); const [isLogCollapsed, setIsLogCollapsed] = useState(true); const prevHadRunningRef = useRef(false); @@ -53,6 +62,24 @@ export function DirectiveDetail({ const terminalStatuses = new Set(["completed", "failed", "skipped"]); const hasTerminalTasks = directive.steps.some((s) => s.taskId && terminalStatuses.has(s.status)); + // Get pending questions for this directive's tasks + const { pendingQuestions, submitAnswer } = useSupervisorQuestions(); + const directiveTaskIds = useMemo(() => { + const ids = new Set<string>(); + if (directive.orchestratorTaskId) ids.add(directive.orchestratorTaskId); + for (const step of directive.steps) { + if (step.taskId) ids.add(step.taskId); + } + return ids; + }, [directive.orchestratorTaskId, directive.steps]); + + const directiveQuestions = useMemo( + () => pendingQuestions.filter((q) => + q.directiveId === directive.id || directiveTaskIds.has(q.taskId) + ), + [pendingQuestions, directive.id, directiveTaskIds] + ); + // Build task map from directive steps and orchestrator // Derive a stable key from the actual task IDs to avoid recreating the map on every poll const taskMapKey = useMemo(() => { @@ -149,6 +176,26 @@ export function DirectiveDetail({ </div> )} + {/* Reconcile mode toggle */} + <div className="flex items-center gap-2 mb-2"> + <button + type="button" + onClick={() => onUpdate({ reconcileMode: !directive.reconcileMode })} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + directive.reconcileMode + ? "text-amber-400 border-amber-800 bg-amber-900/20" + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {directive.reconcileMode ? "Reconcile: ON" : "Reconcile: OFF"} + </button> + <span className="text-[9px] font-mono text-[#445566]"> + {directive.reconcileMode + ? "Questions pause execution" + : "Questions timeout after 30s"} + </span> + </div> + {/* Orchestrator planning indicator */} {directive.orchestratorTaskId && ( <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a30] border border-[rgba(117,170,252,0.2)] rounded"> @@ -199,6 +246,20 @@ export function DirectiveDetail({ </div> )} + {/* Pending Questions */} + {directiveQuestions.length > 0 && ( + <div className="mb-2 space-y-2"> + {directiveQuestions.map((q) => ( + <DirectiveQuestionCard + key={q.questionId} + question={q} + taskName={taskMap.get(q.taskId) || "Task"} + onAnswer={(response) => submitAnswer(q.questionId, response)} + /> + ))} + </div> + )} + {/* Controls */} <div className="flex flex-wrap gap-2"> {(directive.status === "draft" || directive.status === "paused") && ( @@ -235,7 +296,7 @@ export function DirectiveDetail({ </span> <button type="button" - onClick={() => setEditingGoal(true)} + onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }} className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1" > Update Goal @@ -342,3 +403,77 @@ export function DirectiveDetail({ </div> ); } + +/** Inline question card for directive pending questions */ +function DirectiveQuestionCard({ + question, + taskName, + onAnswer, +}: { + question: { questionId: string; question: string; choices: string[]; context: string | null }; + taskName: string; + onAnswer: (response: string) => void; +}) { + const [customResponse, setCustomResponse] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (response: string) => { + setSubmitting(true); + await onAnswer(response); + setSubmitting(false); + }; + + return ( + <div className="px-2 py-2 bg-[#1a1020] border border-purple-900/50 rounded"> + <div className="flex items-center gap-1.5 mb-1"> + <span className="inline-block w-2 h-2 rounded-full bg-purple-400 animate-pulse" /> + <span className="text-[9px] font-mono text-purple-400 uppercase"> + Question from {taskName} + </span> + </div> + <p className="text-[11px] font-mono text-white mb-1.5">{question.question}</p> + {question.context && ( + <p className="text-[9px] font-mono text-[#556677] mb-1.5">{question.context}</p> + )} + {question.choices.length > 0 ? ( + <div className="flex flex-wrap gap-1"> + {question.choices.map((choice) => ( + <button + key={choice} + type="button" + disabled={submitting} + onClick={() => handleSubmit(choice)} + className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 hover:border-purple-600 rounded px-2 py-0.5 disabled:opacity-50" + > + {choice} + </button> + ))} + </div> + ) : ( + <div className="flex gap-1"> + <input + type="text" + value={customResponse} + onChange={(e) => setCustomResponse(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && customResponse.trim()) { + handleSubmit(customResponse.trim()); + } + }} + placeholder="Type your answer..." + className="flex-1 bg-[#0a0618] border border-purple-900/50 rounded px-2 py-0.5 text-[10px] font-mono text-white placeholder:text-[#445566]" + disabled={submitting} + /> + <button + type="button" + disabled={submitting || !customResponse.trim()} + onClick={() => handleSubmit(customResponse.trim())} + className="text-[10px] font-mono text-purple-300 hover:text-white border border-purple-800 rounded px-2 py-0.5 disabled:opacity-50" + > + Send + </button> + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index f49c366..2db4250 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -77,7 +77,10 @@ export function TaskOutput({ setInputValue(""); inputRef.current?.focus(); } catch (err) { - setInputError(err instanceof Error ? err.message : "Failed to send input"); + const errorMsg = err instanceof Error ? err.message : "Failed to send input"; + setInputError(errorMsg); + // Auto-dismiss error after 5 seconds + setTimeout(() => setInputError(null), 5000); } finally { setSendingInput(false); } diff --git a/makima/frontend/src/components/orders/OrderDetail.tsx b/makima/frontend/src/components/orders/OrderDetail.tsx new file mode 100644 index 0000000..7f8a95d --- /dev/null +++ b/makima/frontend/src/components/orders/OrderDetail.tsx @@ -0,0 +1,530 @@ +import { useState } from "react"; +import type { + Order, + OrderStatus, + OrderPriority, + OrderType, + UpdateOrderRequest, + DirectiveSummary, +} from "../../lib/api"; + +const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { + open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" }, + in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" }, + done: { color: "text-emerald-400 border-emerald-800", label: "DONE" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +const PRIORITY_OPTIONS: { value: OrderPriority; color: string; label: string }[] = [ + { value: "critical", color: "text-red-400 border-red-800", label: "Critical" }, + { value: "high", color: "text-orange-400 border-orange-800", label: "High" }, + { value: "medium", color: "text-yellow-400 border-yellow-800", label: "Medium" }, + { value: "low", color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "Low" }, + { value: "none", color: "text-[#556677] border-[#2a3a5a]", label: "None" }, +]; + +const TYPE_OPTIONS: { value: OrderType; color: string; label: string }[] = [ + { value: "feature", color: "text-[#75aafc]", label: "Feature" }, + { value: "bug", color: "text-red-400", label: "Bug" }, + { value: "spike", color: "text-yellow-400", label: "Spike" }, + { value: "chore", color: "text-[#7788aa]", label: "Chore" }, + { value: "improvement", color: "text-emerald-400", label: "Improvement" }, +]; + +const STATUS_OPTIONS: OrderStatus[] = ["open", "in_progress", "done", "archived"]; + +interface OrderDetailProps { + order: Order; + directives: DirectiveSummary[]; + onUpdate: (req: UpdateOrderRequest) => Promise<void>; + onDelete: () => void; + onLinkDirective: (directiveId: string) => Promise<void>; + onLinkContract: (contractId: string) => Promise<void>; + onConvertToStep: (directiveId: string) => Promise<void>; + onRefresh: () => void; +} + +export function OrderDetail({ + order, + directives, + onUpdate, + onDelete, + onLinkDirective, + onLinkContract, + onConvertToStep, + onRefresh, +}: OrderDetailProps) { + const [editingTitle, setEditingTitle] = useState(false); + const [titleText, setTitleText] = useState(order.title); + const [editingDesc, setEditingDesc] = useState(false); + const [descText, setDescText] = useState(order.description || ""); + const [editingLabels, setEditingLabels] = useState(false); + const [labelsText, setLabelsText] = useState(order.labels.join(", ")); + const [showLinkDirective, setShowLinkDirective] = useState(false); + const [showLinkContract, setShowLinkContract] = useState(false); + const [contractIdInput, setContractIdInput] = useState(""); + const [showConvertToStep, setShowConvertToStep] = useState(false); + + const badge = STATUS_BADGE[order.status] || STATUS_BADGE.open; + const currentPriority = PRIORITY_OPTIONS.find((p) => p.value === order.priority) || PRIORITY_OPTIONS[4]; + const currentType = TYPE_OPTIONS.find((t) => t.value === order.orderType) || TYPE_OPTIONS[0]; + + const handleTitleSave = async () => { + if (titleText.trim() && titleText !== order.title) { + await onUpdate({ title: titleText.trim() }); + } + setEditingTitle(false); + }; + + const handleDescSave = async () => { + const newDesc = descText.trim() || null; + if (newDesc !== order.description) { + await onUpdate({ description: newDesc }); + } + setEditingDesc(false); + }; + + const handleLabelsSave = async () => { + const newLabels = labelsText + .split(",") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + await onUpdate({ labels: newLabels }); + setEditingLabels(false); + }; + + const handleStatusChange = async (status: OrderStatus) => { + await onUpdate({ status }); + }; + + const handlePriorityChange = async (priority: OrderPriority) => { + await onUpdate({ priority }); + }; + + const handleTypeChange = async (orderType: OrderType) => { + await onUpdate({ orderType }); + }; + + const handleLinkDirective = async (directiveId: string) => { + await onLinkDirective(directiveId); + setShowLinkDirective(false); + }; + + const handleLinkContract = async () => { + if (!contractIdInput.trim()) return; + await onLinkContract(contractIdInput.trim()); + setContractIdInput(""); + setShowLinkContract(false); + }; + + const handleConvertToStep = async (directiveId: string) => { + await onConvertToStep(directiveId); + setShowConvertToStep(false); + }; + + return ( + <div className="flex flex-col h-full overflow-y-auto"> + {/* Header */} + <div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-2"> + {editingTitle ? ( + <div className="flex-1 flex items-center gap-2 pr-2"> + <input + value={titleText} + onChange={(e) => setTitleText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleTitleSave(); + if (e.key === "Escape") setEditingTitle(false); + }} + autoFocus + className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[14px] font-mono text-white" + /> + <button + type="button" + onClick={handleTitleSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300" + > + [save] + </button> + <button + type="button" + onClick={() => setEditingTitle(false)} + className="text-[10px] font-mono text-[#556677] hover:text-white" + > + [cancel] + </button> + </div> + ) : ( + <h2 + className="text-[14px] font-mono text-white font-medium truncate pr-2 cursor-pointer hover:text-[#9bc3ff]" + onClick={() => { + setTitleText(order.title); + setEditingTitle(true); + }} + > + {order.title} + </h2> + )} + <div className="flex items-center gap-2 shrink-0"> + <span + className={`text-[10px] font-mono ${badge.color} border rounded px-2 py-0.5`} + > + {badge.label} + </span> + <button + type="button" + onClick={onRefresh} + className="text-[10px] font-mono text-[#7788aa] hover:text-white" + title="Refresh" + > + [refresh] + </button> + </div> + </div> + + {/* Type + Priority inline */} + <div className="flex items-center gap-3 mb-2"> + <span className={`text-[10px] font-mono ${currentType.color}`}> + {currentType.label} + </span> + <span className="text-[10px] font-mono text-[#2a3a5a]">/</span> + <span className={`text-[10px] font-mono ${currentPriority.color} border rounded px-1.5 py-0.5`}> + {currentPriority.label} + </span> + </div> + + {/* Linked entities */} + {order.directiveId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Directive: <a href={`/directives/${order.directiveId}`} className="text-[#75aafc] hover:text-white underline">{order.directiveId.slice(0, 8)}...</a> + </div> + )} + {order.contractId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Contract: <a href={`/contracts/${order.contractId}`} className="text-[#75aafc] hover:text-white underline">{order.contractId.slice(0, 8)}...</a> + </div> + )} + {order.directiveStepId && ( + <div className="text-[10px] font-mono text-[#556677] mb-1 truncate"> + Step: <span className="text-[#7788aa]">{order.directiveStepId.slice(0, 8)}...</span> + </div> + )} + + {/* Controls */} + <div className="flex flex-wrap gap-2 mt-2"> + <button + type="button" + onClick={onDelete} + className="text-[10px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-2 py-1 ml-auto" + > + Delete + </button> + </div> + </div> + + {/* Status selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Status + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {STATUS_OPTIONS.map((s) => { + const sBadge = STATUS_BADGE[s]; + return ( + <button + key={s} + type="button" + onClick={() => handleStatusChange(s)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + s === order.status + ? `${sBadge.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {sBadge.label} + </button> + ); + })} + </div> + </div> + + {/* Priority selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Priority + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {PRIORITY_OPTIONS.map((p) => ( + <button + key={p.value} + type="button" + onClick={() => handlePriorityChange(p.value)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + p.value === order.priority + ? `${p.color} bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {p.label} + </button> + ))} + </div> + </div> + + {/* Type selector */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1.5"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Type + </span> + </div> + <div className="flex gap-1.5 flex-wrap"> + {TYPE_OPTIONS.map((t) => ( + <button + key={t.value} + type="button" + onClick={() => handleTypeChange(t.value)} + className={`text-[10px] font-mono border rounded px-2 py-0.5 transition-colors ${ + t.value === order.orderType + ? `${t.color} border-current bg-[rgba(117,170,252,0.1)]` + : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]" + }`} + > + {t.label} + </button> + ))} + </div> + </div> + + {/* Description */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Description + </span> + {!editingDesc && ( + <button + type="button" + onClick={() => { + setDescText(order.description || ""); + setEditingDesc(true); + }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingDesc ? ( + <div className="flex flex-col gap-1.5"> + <textarea + value={descText} + onChange={(e) => setDescText(e.target.value)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white resize-y min-h-[80px]" + rows={4} + autoFocus + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleDescSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingDesc(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <p className="text-[11px] font-mono text-[#c0d0e0] whitespace-pre-wrap"> + {order.description || <span className="text-[#556677] italic">No description</span>} + </p> + )} + </div> + + {/* Labels */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Labels + </span> + {!editingLabels && ( + <button + type="button" + onClick={() => { + setLabelsText(order.labels.join(", ")); + setEditingLabels(true); + }} + className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" + > + [edit] + </button> + )} + </div> + {editingLabels ? ( + <div className="flex flex-col gap-1.5"> + <input + value={labelsText} + onChange={(e) => setLabelsText(e.target.value)} + placeholder="label1, label2, ..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[11px] font-mono text-white" + autoFocus + /> + <div className="flex gap-1.5"> + <button + type="button" + onClick={handleLabelsSave} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5" + > + Save + </button> + <button + type="button" + onClick={() => setEditingLabels(false)} + className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="flex gap-1 flex-wrap"> + {order.labels.length > 0 ? ( + order.labels.map((l) => ( + <span + key={l} + className="text-[10px] font-mono text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5" + > + {l} + </span> + )) + ) : ( + <span className="text-[10px] font-mono text-[#556677] italic">No labels</span> + )} + </div> + )} + </div> + + {/* Actions */} + <div className="px-4 py-3 flex-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> + Actions + </span> + + <div className="flex flex-col gap-2"> + {/* Link to Directive */} + <div> + <button + type="button" + onClick={() => setShowLinkDirective(!showLinkDirective)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left" + > + Link to Directive + </button> + {showLinkDirective && ( + <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded"> + {directives.length === 0 ? ( + <div className="px-3 py-2 text-[10px] font-mono text-[#556677]"> + No directives available + </div> + ) : ( + directives.map((d) => ( + <button + key={d.id} + type="button" + onClick={() => handleLinkDirective(d.id)} + className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + {d.title} + </button> + )) + )} + </div> + )} + </div> + + {/* Link to Contract */} + <div> + <button + type="button" + onClick={() => setShowLinkContract(!showLinkContract)} + className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1 w-full text-left" + > + Link to Contract + </button> + {showLinkContract && ( + <div className="mt-1 flex gap-1.5"> + <input + value={contractIdInput} + onChange={(e) => setContractIdInput(e.target.value)} + placeholder="Contract ID..." + className="flex-1 bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white" + autoFocus + /> + <button + type="button" + onClick={handleLinkContract} + disabled={!contractIdInput.trim()} + className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50" + > + Link + </button> + </div> + )} + </div> + + {/* Convert to Directive Step */} + {!order.directiveStepId && ( + <div> + <button + type="button" + onClick={() => setShowConvertToStep(!showConvertToStep)} + className="text-[10px] font-mono text-yellow-400 hover:text-yellow-300 border border-yellow-800 rounded px-2 py-1 w-full text-left" + > + Convert to Directive Step + </button> + {showConvertToStep && ( + <div className="mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto rounded"> + {directives.length === 0 ? ( + <div className="px-3 py-2 text-[10px] font-mono text-[#556677]"> + No directives available + </div> + ) : ( + directives.map((d) => ( + <button + key={d.id} + type="button" + onClick={() => handleConvertToStep(d.id)} + className="w-full text-left px-3 py-1.5 text-[10px] font-mono text-yellow-400 hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + {d.title} + </button> + )) + )} + </div> + )} + </div> + )} + </div> + </div> + + {/* Metadata */} + <div className="px-4 py-2 border-t border-[rgba(117,170,252,0.1)]"> + <div className="flex items-center justify-between text-[9px] font-mono text-[#556677]"> + <span>Created {new Date(order.createdAt).toLocaleDateString()}</span> + <span>Updated {new Date(order.updatedAt).toLocaleDateString()}</span> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/orders/OrderList.tsx b/makima/frontend/src/components/orders/OrderList.tsx new file mode 100644 index 0000000..76ac7a7 --- /dev/null +++ b/makima/frontend/src/components/orders/OrderList.tsx @@ -0,0 +1,188 @@ +import { useState, useMemo } from "react"; +import type { Order, OrderStatus, OrderPriority, OrderType } from "../../lib/api"; + +const STATUS_BADGE: Record<OrderStatus, { color: string; label: string }> = { + open: { color: "text-[#75aafc] border-[rgba(117,170,252,0.4)]", label: "OPEN" }, + in_progress: { color: "text-yellow-400 border-yellow-800", label: "IN PROGRESS" }, + done: { color: "text-emerald-400 border-emerald-800", label: "DONE" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +const PRIORITY_COLOR: Record<OrderPriority, string> = { + critical: "bg-red-400", + high: "bg-orange-400", + medium: "bg-yellow-400", + low: "bg-[#75aafc]", + none: "bg-[#556677]", +}; + +const TYPE_BADGE: Record<OrderType, { color: string; label: string }> = { + feature: { color: "text-[#75aafc] border-[rgba(117,170,252,0.3)]", label: "FEAT" }, + bug: { color: "text-red-400 border-red-800", label: "BUG" }, + spike: { color: "text-yellow-400 border-yellow-800", label: "SPIKE" }, + chore: { color: "text-[#7788aa] border-[#2a3a5a]", label: "CHORE" }, + improvement: { color: "text-emerald-400 border-emerald-800", label: "IMPROVE" }, +}; + +interface OrderListProps { + orders: Order[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; + statusFilter: OrderStatus | undefined; + onStatusFilter: (s: OrderStatus | undefined) => void; + typeFilter: OrderType | undefined; + onTypeFilter: (t: OrderType | undefined) => void; +} + +const STATUS_OPTIONS: (OrderStatus | "all")[] = ["all", "open", "in_progress", "done", "archived"]; +const TYPE_OPTIONS: (OrderType | "all")[] = ["all", "feature", "bug", "spike", "chore", "improvement"]; + +export function OrderList({ + orders, + selectedId, + onSelect, + onCreate, + statusFilter, + onStatusFilter, + typeFilter, + onTypeFilter, +}: OrderListProps) { + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search.trim()) return orders; + const q = search.toLowerCase(); + return orders.filter( + (o) => + o.title.toLowerCase().includes(q) || + (o.description && o.description.toLowerCase().includes(q)) || + o.labels.some((l) => l.toLowerCase().includes(q)), + ); + }, [orders, search]); + + return ( + <div className="flex flex-col h-full"> + {/* Header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]"> + <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide"> + Orders + </span> + <button + type="button" + onClick={onCreate} + className="text-[11px] font-mono text-[#75aafc] hover:text-white bg-transparent border border-[rgba(117,170,252,0.3)] rounded px-2 py-0.5 hover:border-[rgba(117,170,252,0.6)] transition-colors" + > + + New + </button> + </div> + + {/* Search */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)]"> + <input + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search orders..." + className="w-full bg-transparent border-none outline-none text-[11px] font-mono text-white placeholder:text-[#556677]" + /> + </div> + + {/* Filters */} + <div className="px-3 py-1.5 border-b border-[rgba(117,170,252,0.1)] flex flex-col gap-1"> + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0"> + Status + </span> + {STATUS_OPTIONS.map((s) => ( + <button + key={s} + type="button" + onClick={() => onStatusFilter(s === "all" ? undefined : s)} + className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${ + (s === "all" && !statusFilter) || s === statusFilter + ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]" + : "text-[#556677] hover:text-[#7788aa] border border-transparent" + }`} + > + {s === "all" ? "ALL" : s === "in_progress" ? "WIP" : s.toUpperCase()} + </button> + ))} + </div> + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-mono text-[#556677] uppercase w-[38px] shrink-0"> + Type + </span> + {TYPE_OPTIONS.map((t) => ( + <button + key={t} + type="button" + onClick={() => onTypeFilter(t === "all" ? undefined : t)} + className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${ + (t === "all" && !typeFilter) || t === typeFilter + ? "text-white bg-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.4)]" + : "text-[#556677] hover:text-[#7788aa] border border-transparent" + }`} + > + {t === "all" ? "ALL" : t.toUpperCase()} + </button> + ))} + </div> + </div> + + {/* List */} + <div className="flex-1 overflow-y-auto"> + {filtered.length === 0 ? ( + <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> + No orders found + </div> + ) : ( + filtered.map((o) => { + const statusBadge = STATUS_BADGE[o.status] || STATUS_BADGE.open; + const typeBadge = TYPE_BADGE[o.orderType] || TYPE_BADGE.feature; + const priorityColor = PRIORITY_COLOR[o.priority] || PRIORITY_COLOR.none; + + return ( + <button + key={o.id} + type="button" + onClick={() => onSelect(o.id)} + className={`w-full text-left px-3 py-2.5 border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] transition-colors ${ + selectedId === o.id ? "bg-[rgba(117,170,252,0.1)]" : "" + }`} + > + <div className="flex items-start gap-2 mb-1"> + {/* Priority dot */} + <span + className={`w-2 h-2 rounded-full ${priorityColor} shrink-0 mt-[3px]`} + title={o.priority} + /> + <span className="text-[12px] font-mono text-white truncate flex-1"> + {o.title} + </span> + </div> + <div className="flex items-center gap-1.5 pl-4"> + <span + className={`text-[9px] font-mono ${statusBadge.color} border rounded px-1.5 py-0.5`} + > + {statusBadge.label} + </span> + <span + className={`text-[9px] font-mono ${typeBadge.color} border rounded px-1.5 py-0.5`} + > + {typeBadge.label} + </span> + {o.labels.length > 0 && ( + <span className="text-[9px] font-mono text-[#556677] truncate"> + {o.labels.slice(0, 2).join(", ")} + {o.labels.length > 2 && ` +${o.labels.length - 2}`} + </span> + )} + </div> + </button> + ); + }) + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx deleted file mode 100644 index 277b04c..0000000 --- a/makima/frontend/src/components/workflow/PhaseColumn.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState } from "react"; -import type { ContractSummary, ContractPhase } from "../../lib/api"; -import { WorkflowContractCard } from "./WorkflowContractCard"; - -interface PhaseColumnProps { - phase: ContractPhase; - contracts: ContractSummary[]; - onContractClick: (contractId: string) => void; - onDrop: (contractId: string, phase: ContractPhase) => void; - onContextMenu?: (e: React.MouseEvent, contract: ContractSummary) => void; -} - -const phaseConfig: Record< - ContractPhase, - { label: string; color: string; bgColor: string; borderColor: string } -> = { - research: { - label: "Research", - color: "text-purple-400", - bgColor: "bg-purple-400/10", - borderColor: "border-purple-400/30", - }, - specify: { - label: "Specify", - color: "text-blue-400", - bgColor: "bg-blue-400/10", - borderColor: "border-blue-400/30", - }, - plan: { - label: "Plan", - color: "text-cyan-400", - bgColor: "bg-cyan-400/10", - borderColor: "border-cyan-400/30", - }, - execute: { - label: "Execute", - color: "text-yellow-400", - bgColor: "bg-yellow-400/10", - borderColor: "border-yellow-400/30", - }, - review: { - label: "Review", - color: "text-green-400", - bgColor: "bg-green-400/10", - borderColor: "border-green-400/30", - }, -}; - -export function PhaseColumn({ - phase, - contracts, - onContractClick, - onDrop, - onContextMenu, -}: PhaseColumnProps) { - const [isDragOver, setIsDragOver] = useState(false); - const config = phaseConfig[phase]; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const contractId = e.dataTransfer.getData("contractId"); - if (contractId) { - onDrop(contractId, phase); - } - }; - - return ( - <div - className={` - flex flex-col min-w-[220px] flex-1 border border-[rgba(117,170,252,0.15)] - ${isDragOver ? "bg-[rgba(117,170,252,0.05)]" : "bg-transparent"} - transition-colors - `} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - {/* Column header */} - <div - className={` - p-3 border-b ${config.borderColor} ${config.bgColor} - flex items-center justify-between - `} - > - <span className={`font-mono text-xs uppercase tracking-wider ${config.color}`}> - {config.label} - </span> - <span className="font-mono text-[10px] text-[#555]"> - ({contracts.length}) - </span> - </div> - - {/* Cards container */} - <div className="flex-1 overflow-y-auto p-2 space-y-2"> - {contracts.length === 0 ? ( - <div className="p-4 text-center font-mono text-[10px] text-[#555]"> - No contracts - </div> - ) : ( - contracts.map((contract) => ( - <WorkflowContractCard - key={contract.id} - contract={contract} - onClick={() => onContractClick(contract.id)} - onDragStart={(e) => { - e.dataTransfer.setData("contractId", contract.id); - e.dataTransfer.effectAllowed = "move"; - }} - onContextMenu={onContextMenu ? (e) => onContextMenu(e, contract) : undefined} - /> - )) - )} - </div> - </div> - ); -} diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx deleted file mode 100644 index e36ca21..0000000 --- a/makima/frontend/src/components/workflow/WorkflowBoard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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"]; - -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[]> = { - research: [], - specify: [], - plan: [], - execute: [], - review: [], - }; - - for (const contract of contracts) { - const phase = contract.phase as ContractPhase; - if (grouped[phase]) { - grouped[phase].push(contract); - } else { - // Default to research if unknown phase - grouped.research.push(contract); - } - } - - return grouped; - }, [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} - 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)} - /> - )} - </> - ); -} diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx deleted file mode 100644 index 86fcd13..0000000 --- a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useNavigate } from "react-router"; -import type { ContractSummary, ContractStatus } from "../../lib/api"; - -interface WorkflowContractCardProps { - contract: ContractSummary; - onClick: () => void; - onDragStart: (e: React.DragEvent) => void; - onContextMenu?: (e: React.MouseEvent) => void; -} - -const statusConfig: Record<ContractStatus, { label: string; color: string }> = { - active: { label: "Active", color: "text-green-400" }, - completed: { label: "Done", color: "text-blue-400" }, - archived: { label: "Archived", color: "text-[#555]" }, -}; - -export function WorkflowContractCard({ - contract, - onClick, - onDragStart, - onContextMenu, -}: WorkflowContractCardProps) { - const navigate = useNavigate(); - const status = statusConfig[contract.status] || statusConfig.active; - - const handleSupervisorClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (contract.supervisorTaskId) { - navigate(`/mesh/${contract.supervisorTaskId}`); - } - }; - - return ( - <div - 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 */} - <div className="flex items-center justify-between gap-2 mb-1"> - <div className="font-mono text-sm text-[#dbe7ff] truncate flex-1"> - {contract.name} - </div> - {contract.supervisorTaskId && ( - <button - onClick={handleSupervisorClick} - title="Open Supervisor Task" - className="flex-shrink-0 px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.1)] transition-colors" - > - ▶ - </button> - )} - </div> - - {/* Status and counts row */} - <div className="flex items-center justify-between"> - <span className={`font-mono text-[10px] uppercase ${status.color}`}> - {status.label} - </span> - <div className="flex items-center gap-2 font-mono text-[10px] text-[#555]"> - <span title="Files">{contract.fileCount} files</span> - <span title="Tasks">{contract.taskCount} tasks</span> - </div> - </div> - - {/* Description preview if exists */} - {contract.description && ( - <div className="mt-1 font-mono text-[10px] text-[#555] truncate"> - {contract.description} - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/hooks/useOrders.ts b/makima/frontend/src/hooks/useOrders.ts new file mode 100644 index 0000000..2dd20bb --- /dev/null +++ b/makima/frontend/src/hooks/useOrders.ts @@ -0,0 +1,123 @@ +import { useState, useEffect, useCallback } from "react"; +import { + type Order, + type OrderStatus, + type OrderType, + type OrderPriority, + type CreateOrderRequest, + type UpdateOrderRequest, + listOrders, + createOrder, + getOrder, + updateOrder, + deleteOrder, + linkOrderToDirective, + linkOrderToContract, + convertOrderToStep, +} from "../lib/api"; + +export function useOrders( + statusFilter?: OrderStatus, + typeFilter?: OrderType, + priorityFilter?: OrderPriority, +) { + const [orders, setOrders] = useState<Order[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await listOrders(statusFilter, typeFilter, priorityFilter); + setOrders(res.orders); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load orders"); + } finally { + setLoading(false); + } + }, [statusFilter, typeFilter, priorityFilter]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = useCallback(async (req: CreateOrderRequest) => { + const o = await createOrder(req); + await refresh(); + return o; + }, [refresh]); + + const remove = useCallback(async (id: string) => { + await deleteOrder(id); + await refresh(); + }, [refresh]); + + return { orders, loading, error, refresh, create, remove }; +} + +export function useOrder(id: string | undefined) { + const [order, setOrder] = useState<Order | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const refresh = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const o = await getOrder(id); + setOrder(o); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load order"); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + setOrder(null); + setError(null); + setLoading(true); + refresh(); + }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + + const update = useCallback(async (req: UpdateOrderRequest) => { + if (!id) return; + const o = await updateOrder(id, req); + setOrder(o); + return o; + }, [id]); + + const remove = useCallback(async () => { + if (!id) return; + await deleteOrder(id); + }, [id]); + + const linkDirective = useCallback(async (directiveId: string) => { + if (!id) return; + const o = await linkOrderToDirective(id, directiveId); + setOrder(o); + return o; + }, [id]); + + const linkContract = useCallback(async (contractId: string) => { + if (!id) return; + const o = await linkOrderToContract(id, contractId); + setOrder(o); + return o; + }, [id]); + + const convertToStep = useCallback(async (directiveId: string) => { + if (!id) return; + const step = await convertOrderToStep(id, directiveId); + await refresh(); + return step; + }, [id, refresh]); + + return { + order, loading, error, refresh, + update, remove, + linkDirective, linkContract, convertToStep, + }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 480041c..f88176b 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2241,6 +2241,8 @@ export interface PendingQuestion { questionId: string; taskId: string; contractId: string; + /** Directive this question relates to (if from a directive task) */ + directiveId?: string | null; question: string; choices: string[]; context: string | null; @@ -3025,6 +3027,8 @@ export interface Directive { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode: boolean; goalUpdatedAt: string; startedAt: string | null; version: number; @@ -3064,6 +3068,8 @@ export interface DirectiveSummary { completionTaskId: string | null; /** Whether the memory system is enabled for this directive */ memoryEnabled: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode: boolean; version: number; createdAt: string; updatedAt: string; @@ -3086,6 +3092,8 @@ export interface CreateDirectiveRequest { baseBranch?: string; /** Enable the memory system for this directive (default: false) */ memoryEnabled?: boolean; + /** Whether questions pause execution indefinitely until answered (default: false) */ + reconcileMode?: boolean; } export interface UpdateDirectiveRequest { @@ -3098,6 +3106,8 @@ export interface UpdateDirectiveRequest { orchestratorTaskId?: string; /** Enable or disable the memory system for this directive */ memoryEnabled?: boolean; + /** Whether questions pause execution indefinitely until answered */ + reconcileMode?: boolean; version?: number; } @@ -3246,3 +3256,138 @@ export async function cleanupDirectiveTasks(id: string): Promise<{ deleted: numb return res.json(); } +// ============================================================================= +// Orders API +// ============================================================================= + +export type OrderPriority = "critical" | "high" | "medium" | "low" | "none"; +export type OrderStatus = "open" | "in_progress" | "done" | "archived"; +export type OrderType = "feature" | "bug" | "spike" | "chore" | "improvement"; + +export interface Order { + id: string; + ownerId: string; + title: string; + description: string | null; + priority: OrderPriority; + status: OrderStatus; + orderType: OrderType; + labels: string[]; + directiveId: string | null; + directiveStepId: string | null; + contractId: string | null; + repositoryUrl: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OrderListResponse { + orders: Order[]; + total: number; +} + +export interface CreateOrderRequest { + title: string; + description?: string | null; + priority?: OrderPriority; + status?: OrderStatus; + orderType?: OrderType; + labels?: string[]; + directiveId?: string | null; + contractId?: string | null; + repositoryUrl?: string | null; +} + +export interface UpdateOrderRequest { + title?: string; + description?: string | null; + priority?: OrderPriority; + status?: OrderStatus; + orderType?: OrderType; + labels?: string[]; + directiveId?: string | null; + directiveStepId?: string | null; + contractId?: string | null; + repositoryUrl?: string | null; +} + +export async function listOrders( + status?: OrderStatus, + type?: OrderType, + priority?: OrderPriority, + directiveId?: string, + contractId?: string, +): Promise<OrderListResponse> { + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (type) params.set("type", type); + if (priority) params.set("priority", priority); + if (directiveId) params.set("directiveId", directiveId); + if (contractId) params.set("contractId", contractId); + const qs = params.toString(); + const res = await authFetch(`${API_BASE}/api/v1/orders${qs ? `?${qs}` : ""}`); + if (!res.ok) throw new Error(`Failed to list orders: ${res.statusText}`); + return res.json(); +} + +export async function createOrder(req: CreateOrderRequest): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create order: ${res.statusText}`); + return res.json(); +} + +export async function getOrder(id: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`); + if (!res.ok) throw new Error(`Failed to get order: ${res.statusText}`); + return res.json(); +} + +export async function updateOrder(id: string, req: UpdateOrderRequest): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to update order: ${res.statusText}`); + return res.json(); +} + +export async function deleteOrder(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete order: ${res.statusText}`); +} + +export async function linkOrderToDirective(orderId: string, directiveId: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-directive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directiveId }), + }); + if (!res.ok) throw new Error(`Failed to link order to directive: ${res.statusText}`); + return res.json(); +} + +export async function linkOrderToContract(orderId: string, contractId: string): Promise<Order> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/link-contract`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contractId }), + }); + if (!res.ok) throw new Error(`Failed to link order to contract: ${res.statusText}`); + return res.json(); +} + +export async function convertOrderToStep(orderId: string, directiveId: string): Promise<DirectiveStep> { + const res = await authFetch(`${API_BASE}/api/v1/orders/${orderId}/convert-to-step`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ directiveId }), + }); + if (!res.ok) throw new Error(`Failed to convert order to step: ${res.statusText}`); + return res.json(); +} + diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 3dc68f5..acc9afc 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,7 +12,7 @@ import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; -import WorkflowPage from "./routes/workflow"; +import OrdersPage from "./routes/orders"; import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; @@ -81,10 +81,18 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/workflow" + path="/orders" element={ <ProtectedRoute> - <WorkflowPage /> + <OrdersPage /> + </ProtectedRoute> + } + /> + <Route + path="/orders/:id" + element={ + <ProtectedRoute> + <OrdersPage /> </ProtectedRoute> } /> diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index ca4437c..643cfee 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -12,7 +12,7 @@ export default function DirectivesPage() { const navigate = useNavigate(); const { id: selectedId } = useParams<{ id: string }>(); const { directives, loading: listLoading, create, remove } = useDirectives(); - const { directive, refresh: refreshDetail, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); + const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanupTasks } = useDirective(selectedId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); @@ -207,6 +207,7 @@ export default function DirectivesPage() { onFailStep={failStep} onSkipStep={skipStep} onUpdateGoal={updateGoal} + onUpdate={update} onDelete={handleDelete} onRefresh={refreshDetail} onCleanupTasks={cleanupTasks} diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index cb4a77c..1d1db84 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -852,7 +852,7 @@ export default function MeshPage() { <div className="flex-1 min-h-0 overflow-hidden"> <TaskOutput entries={taskOutputEntries} - isStreaming={isStreaming || taskDetail.status === "running"} + isStreaming={isStreaming || taskDetail.status === "running" || taskDetail.status === "starting"} viewingSubtaskName={viewingSubtaskName} onClearSubtaskView={viewingSubtaskId ? () => { setViewingSubtaskId(null); diff --git a/makima/frontend/src/routes/orders.tsx b/makima/frontend/src/routes/orders.tsx new file mode 100644 index 0000000..735c557 --- /dev/null +++ b/makima/frontend/src/routes/orders.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { OrderList } from "../components/orders/OrderList"; +import { OrderDetail } from "../components/orders/OrderDetail"; +import { useOrders, useOrder } from "../hooks/useOrders"; +import { useDirectives } from "../hooks/useDirectives"; +import { useAuth } from "../contexts/AuthContext"; +import type { OrderStatus, OrderType, OrderPriority } from "../lib/api"; + +export default function OrdersPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { id: selectedId } = useParams<{ id: string }>(); + + const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined); + const [typeFilter, setTypeFilter] = useState<OrderType | undefined>(undefined); + const { orders, loading: listLoading, create, refresh: refreshList } = useOrders(statusFilter, typeFilter); + const { order, refresh: refreshDetail, update, remove: removeOrder, linkDirective, linkContract, convertToStep } = useOrder(selectedId); + const { directives } = useDirectives(); + + const [showCreate, setShowCreate] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [newPriority, setNewPriority] = useState<OrderPriority>("medium"); + const [newType, setNewType] = useState<OrderType>("feature"); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + const handleCreate = async () => { + if (!newTitle.trim()) return; + try { + const o = await create({ + title: newTitle.trim(), + description: newDesc.trim() || undefined, + priority: newPriority, + orderType: newType, + }); + setShowCreate(false); + setNewTitle(""); + setNewDesc(""); + setNewPriority("medium"); + setNewType("feature"); + navigate(`/orders/${o.id}`); + } catch (e) { + console.error("Failed to create order:", e); + } + }; + + const handleDelete = async () => { + if (!selectedId) return; + if (!window.confirm("Delete this order?")) return; + try { + await removeOrder(); + await refreshList(); + navigate("/orders"); + } catch (e) { + console.error("Failed to delete:", e); + } + }; + + const handleUpdate = async (req: Parameters<typeof update>[0]) => { + await update(req); + await refreshList(); + }; + + const handleLinkDirective = async (directiveId: string) => { + await linkDirective(directiveId); + await refreshList(); + }; + + const handleLinkContract = async (contractId: string) => { + await linkContract(contractId); + await refreshList(); + }; + + const handleConvertToStep = async (directiveId: string) => { + await convertToStep(directiveId); + await refreshList(); + }; + + const priorityOptions: { value: OrderPriority; label: string }[] = [ + { value: "critical", label: "Critical" }, + { value: "high", label: "High" }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + { value: "none", label: "None" }, + ]; + + const typeOptions: { value: OrderType; label: string }[] = [ + { value: "feature", label: "Feature" }, + { value: "bug", label: "Bug" }, + { value: "spike", label: "Spike" }, + { value: "chore", label: "Chore" }, + { value: "improvement", label: "Improvement" }, + ]; + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> + {/* Left: List */} + <div className="w-[280px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> + <OrderList + orders={orders} + selectedId={selectedId ?? null} + onSelect={(id) => navigate(`/orders/${id}`)} + onCreate={() => setShowCreate(true)} + statusFilter={statusFilter} + onStatusFilter={setStatusFilter} + typeFilter={typeFilter} + onTypeFilter={setTypeFilter} + /> + </div> + + {/* Right: Detail or Create */} + <div className="flex-1 overflow-hidden"> + {showCreate ? ( + <div className="p-4 max-w-lg"> + <h2 className="text-[14px] font-mono text-white font-medium mb-4"> + New Order + </h2> + <div className="flex flex-col gap-3"> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Title + </label> + <input + value={newTitle} + onChange={(e) => setNewTitle(e.target.value)} + placeholder="Order title..." + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + onKeyDown={(e) => { + if (e.key === "Enter" && newTitle.trim()) handleCreate(); + }} + /> + </div> + <div> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Description (optional) + </label> + <textarea + value={newDesc} + onChange={(e) => setNewDesc(e.target.value)} + placeholder="Describe the order..." + rows={4} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white resize-y" + /> + </div> + <div className="flex gap-4"> + <div className="flex-1"> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Priority + </label> + <select + value={newPriority} + onChange={(e) => setNewPriority(e.target.value as OrderPriority)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + > + {priorityOptions.map((p) => ( + <option key={p.value} value={p.value}>{p.label}</option> + ))} + </select> + </div> + <div className="flex-1"> + <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> + Type + </label> + <select + value={newType} + onChange={(e) => setNewType(e.target.value as OrderType)} + className="w-full bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[12px] font-mono text-white" + > + {typeOptions.map((t) => ( + <option key={t.value} value={t.value}>{t.label}</option> + ))} + </select> + </div> + </div> + <div className="flex gap-2"> + <button + type="button" + onClick={handleCreate} + disabled={!newTitle.trim()} + className="text-[11px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-3 py-1 disabled:opacity-50" + > + Create + </button> + <button + type="button" + onClick={() => setShowCreate(false)} + className="text-[11px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-3 py-1" + > + Cancel + </button> + </div> + </div> + </div> + ) : selectedId && order ? ( + <OrderDetail + order={order} + directives={directives} + onUpdate={handleUpdate} + onDelete={handleDelete} + onLinkDirective={handleLinkDirective} + onLinkContract={handleLinkContract} + onConvertToStep={handleConvertToStep} + onRefresh={refreshDetail} + /> + ) : ( + <div className="flex-1 flex items-center justify-center h-full"> + <p className="text-[#556677] font-mono text-[12px]"> + {listLoading + ? "Loading..." + : "Select an order or create a new one"} + </p> + </div> + )} + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx deleted file mode 100644 index e122092..0000000 --- a/makima/frontend/src/routes/workflow.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; -import { useNavigate } from "react-router"; -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, ContractSummary } from "../lib/api"; - -type StatusFilter = "all" | ContractStatus; - -export default function WorkflowPage() { - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - return <WorkflowPageContent />; -} - -function WorkflowPageContent() { - const navigate = useNavigate(); - const { contracts, loading, error, changePhase, saveContract, editContract, removeContract } = useContracts(); - const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); - const [isCreating, setIsCreating] = useState(false); - const [newContractName, setNewContractName] = useState(""); - - // Filter contracts by status - const filteredContracts = useMemo(() => { - if (statusFilter === "all") { - return contracts; - } - return contracts.filter((c) => c.status === statusFilter); - }, [contracts, statusFilter]); - - const handleContractClick = useCallback( - (contractId: string) => { - navigate(`/contracts/${contractId}`); - }, - [navigate] - ); - - const handlePhaseChange = useCallback( - async (contractId: string, newPhase: ContractPhase) => { - await changePhase(contractId, newPhase); - }, - [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({ - name: newContractName.trim(), - }); - if (contract) { - setNewContractName(""); - setIsCreating(false); - navigate(`/contracts/${contract.id}`); - } - }, [newContractName, saveContract, navigate]); - - const handleCancelCreate = useCallback(() => { - setNewContractName(""); - setIsCreating(false); - }, []); - - return ( - <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> - {error && ( - <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0"> - {error} - </div> - )} - - {/* Header with filter and create button */} - <div className="flex items-center justify-between shrink-0"> - <div className="flex items-center gap-4"> - <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> - Board - </h1> - {/* Status filter */} - <div className="flex items-center gap-1"> - {(["all", "active", "completed", "archived"] as StatusFilter[]).map( - (status) => ( - <button - key={status} - onClick={() => setStatusFilter(status)} - className={` - px-2 py-1 font-mono text-[10px] uppercase transition-colors - ${ - statusFilter === status - ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" - : "text-[#555] border border-transparent hover:text-[#75aafc]" - } - `} - > - {status} - </button> - ) - )} - </div> - </div> - <button - onClick={() => setIsCreating(true)} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + New Contract - </button> - </div> - - {/* Create contract modal */} - {isCreating && ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> - <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> - Create Contract - </h3> - <div className="space-y-4"> - <input - type="text" - value={newContractName} - onChange={(e) => setNewContractName(e.target.value)} - placeholder="Contract name" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") handleCreateContract(); - if (e.key === "Escape") handleCancelCreate(); - }} - /> - <div className="flex gap-2 justify-end"> - <button - onClick={handleCancelCreate} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleCreateContract} - disabled={!newContractName.trim()} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - </div> - )} - - {/* Board */} - <div className="flex-1 min-h-0 overflow-hidden"> - {loading ? ( - <div className="h-full flex items-center justify-center"> - <p className="font-mono text-sm text-[#555]">Loading...</p> - </div> - ) : filteredContracts.length === 0 && statusFilter === "all" ? ( - <div className="h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - No contracts yet - </p> - <button - onClick={() => setIsCreating(true)} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + Create First Contract - </button> - </div> - </div> - ) : ( - <WorkflowBoard - contracts={filteredContracts} - onContractClick={handleContractClick} - onPhaseChange={handlePhaseChange} - onMarkComplete={handleContextMarkComplete} - onMarkActive={handleContextMarkActive} - onArchive={handleContextArchive} - onDelete={handleContextDelete} - onGoToSupervisor={handleContextGoToSupervisor} - /> - )} - </div> - </main> - </div> - ); -} |
