summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-14 21:29:26 +0000
committerGitHub <noreply@github.com>2026-02-14 21:29:26 +0000
commit9aadbc7958d39d181c0dd0600e2b7c30bb6c391a (patch)
treeef8bed9718c39041191b58a284ee31f5d8d32521 /makima/frontend/src
parentc1e55ce4fec79f9909b957f86bd7fa8b76939746 (diff)
downloadsoryu-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.tsx2
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx139
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx5
-rw-r--r--makima/frontend/src/components/orders/OrderDetail.tsx530
-rw-r--r--makima/frontend/src/components/orders/OrderList.tsx188
-rw-r--r--makima/frontend/src/components/workflow/PhaseColumn.tsx126
-rw-r--r--makima/frontend/src/components/workflow/WorkflowBoard.tsx98
-rw-r--r--makima/frontend/src/components/workflow/WorkflowContractCard.tsx76
-rw-r--r--makima/frontend/src/hooks/useOrders.ts123
-rw-r--r--makima/frontend/src/lib/api.ts145
-rw-r--r--makima/frontend/src/main.tsx14
-rw-r--r--makima/frontend/src/routes/directives.tsx3
-rw-r--r--makima/frontend/src/routes/mesh.tsx2
-rw-r--r--makima/frontend/src/routes/orders.tsx238
-rw-r--r--makima/frontend/src/routes/workflow.tsx250
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>
- );
-}