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/components | |
| 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/components')
8 files changed, 860 insertions, 304 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> - ); -} |
