diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 603 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveList.tsx | 140 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 9 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 19 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 324 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 4 |
6 files changed, 19 insertions, 1080 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx deleted file mode 100644 index 4931afa..0000000 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ /dev/null @@ -1,603 +0,0 @@ -import { useState, useMemo, useEffect, useRef } from "react"; -import type { DirectiveWithSteps, DirectiveStatus, UpdateDirectiveRequest, DirectiveOrderGroup, CreateDOGRequest, UpdateDOGRequest } from "../../lib/api"; -import { DirectiveDAG } from "./DirectiveDAG"; -import type { SpecializedStep } from "./DirectiveDAG"; -import { DirectiveLogStream } from "./DirectiveLogStream"; -import { TaskSlideOutPanel } from "./TaskSlideOutPanel"; -import { DOGList } from "./DOGList"; -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" }, - active: { color: "text-green-400 border-green-800", label: "ACTIVE" }, - idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" }, - paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" }, - inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" }, - archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, -}; - -interface DirectiveDetailProps { - directive: DirectiveWithSteps; - onStart: () => void; - onPause: () => void; - onAdvance: () => void; - onCompleteStep: (stepId: string) => void; - onFailStep: (stepId: string) => void; - onSkipStep: (stepId: string) => void; - onUpdateGoal: (goal: string) => void; - onUpdate: (req: UpdateDirectiveRequest) => void; - onDelete: () => void; - onRefresh: () => void; - onCleanup: () => void; - onPickUpOrders: () => Promise<{ message: string; orderCount: number; taskId: string | null } | null>; - onCreatePR: () => Promise<void>; - dogs: DirectiveOrderGroup[]; - dogsLoading: boolean; - onCreateDog: (req: CreateDOGRequest) => Promise<DirectiveOrderGroup | null>; - onUpdateDog: (dogId: string, req: UpdateDOGRequest) => Promise<void>; - onDeleteDog: (dogId: string) => Promise<void>; - onPickUpDogOrders: (dogId: string) => Promise<any>; -} - -export function DirectiveDetail({ - directive, - onStart, - onPause, - onAdvance, - onCompleteStep, - onFailStep, - onSkipStep, - onUpdateGoal, - onUpdate, - onDelete, - onRefresh, - onCleanup, - onPickUpOrders, - onCreatePR, - dogs, - dogsLoading, - onCreateDog, - onUpdateDog, - onDeleteDog, - onPickUpDogOrders, -}: DirectiveDetailProps) { - const [activeTab, setActiveTab] = useState<"steps" | "dogs">("steps"); - const [editingGoal, setEditingGoal] = useState(false); - const [goalText, setGoalText] = useState(directive.goal); - const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null); - const [pickingUpOrders, setPickingUpOrders] = useState(false); - const [pickUpResult, setPickUpResult] = useState<string | null>(null); - const [creatingPR, setCreatingPR] = useState(false); - const [slideOutTaskId, setSlideOutTaskId] = useState<string | null>(null); - - const handleViewTask = (taskId: string) => { - setSlideOutTaskId(taskId); - }; - - // 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); - const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft; - - const completedSteps = directive.steps.filter((s) => s.status === "completed").length; - const totalSteps = directive.steps.length; - const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; - // 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(() => { - const parts: string[] = []; - if (directive.orchestratorTaskId) parts.push(`o:${directive.orchestratorTaskId}`); - for (const step of directive.steps) { - if (step.taskId) parts.push(`${step.id}:${step.taskId}`); - } - return parts.join(","); - }, [directive.orchestratorTaskId, directive.steps]); - - const taskMap = useMemo(() => { - const map = new Map<string, string>(); - if (directive.orchestratorTaskId) { - map.set(directive.orchestratorTaskId, "Orchestrator"); - } - for (const step of directive.steps) { - if (step.taskId) { - map.set(step.taskId, step.name); - } - } - return map; - }, [taskMapKey]); // eslint-disable-line react-hooks/exhaustive-deps - - // Build specialized steps for DAG visualization - const specializedSteps = useMemo(() => { - const steps: SpecializedStep[] = []; - - if (directive.orchestratorTaskId) { - steps.push({ - id: `orchestrator-${directive.orchestratorTaskId}`, - name: taskMap.get(directive.orchestratorTaskId) || "Planning", - type: "orchestrator", - taskId: directive.orchestratorTaskId, - status: "running", - }); - } - - if (directive.completionTaskId) { - steps.push({ - id: `completion-${directive.completionTaskId}`, - name: directive.prUrl ? "Updating PR" : "Creating PR", - type: "completion", - taskId: directive.completionTaskId, - status: "running", - }); - } - - return steps; - }, [directive.orchestratorTaskId, directive.completionTaskId, directive.prUrl, taskMap]); - - // Subscribe to all task outputs - const { connected, entries, clearEntries } = useMultiTaskSubscription({ - taskMap, - enabled: taskMap.size > 0, - }); - - // Auto-expand log panel when tasks start running - const hasRunningTasks = directive.steps.some((s) => s.status === "running") || - !!directive.orchestratorTaskId; - - useEffect(() => { - if (hasRunningTasks && !prevHadRunningRef.current) { - setIsLogCollapsed(false); - } - prevHadRunningRef.current = hasRunningTasks; - }, [hasRunningTasks]); - - const handlePickUpOrders = async () => { - setPickingUpOrders(true); - setPickUpResult(null); - try { - const result = await onPickUpOrders(); - if (result) { - setPickUpResult(result.message); - setTimeout(() => setPickUpResult(null), 5000); - } - } catch (e) { - setPickUpResult(e instanceof Error ? e.message : "Failed to pick up orders"); - setTimeout(() => setPickUpResult(null), 5000); - } finally { - setPickingUpOrders(false); - } - }; - - const handleGoalSave = () => { - if (goalText.trim() && goalText !== directive.goal) { - onUpdateGoal(goalText.trim()); - } - setEditingGoal(false); - }; - - - // Find the task name for the slide-out panel - const slideOutTaskName = slideOutTaskId - ? (directive.steps.find((s) => s.taskId === slideOutTaskId)?.name ?? - taskMap.get(slideOutTaskId) ?? - undefined) - : undefined; - - 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"> - <h2 className="text-[14px] font-mono text-white font-medium truncate pr-2"> - {directive.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> - - {/* Progress bar */} - {totalSteps > 0 && ( - <div className="flex items-center gap-2 mb-2"> - <div className="flex-1 h-1.5 bg-[#1a2540] rounded overflow-hidden"> - <div - className="h-full bg-emerald-600 rounded transition-all" - style={{ width: `${progress}%` }} - /> - </div> - <span className="text-[10px] font-mono text-[#7788aa] shrink-0"> - {completedSteps}/{totalSteps} steps - </span> - </div> - )} - - {/* Repo info */} - {(directive.repositoryUrl || directive.localPath) && ( - <div className="text-[10px] font-mono text-[#556677] mb-2 truncate"> - {directive.repositoryUrl || directive.localPath} - {directive.baseBranch && ` @ ${directive.baseBranch}`} - </div> - )} - - {/* Reconcile mode toggle */} - <div className="flex items-center gap-2 mb-2"> - <div className="flex items-center border border-[#2a3a5a] rounded overflow-hidden"> - {(["auto", "semi-auto", "manual"] as const).map((mode) => { - const isActive = directive.reconcileMode === mode; - const modeStyles: Record<string, string> = { - auto: isActive ? "text-[#9bc3ff] bg-[#1a2540]" : "text-[#445566] hover:text-[#7788aa]", - "semi-auto": isActive ? "text-amber-400 bg-amber-900/20" : "text-[#445566] hover:text-[#7788aa]", - manual: isActive ? "text-orange-400 bg-orange-900/20" : "text-[#445566] hover:text-[#7788aa]", - }; - const labels: Record<string, string> = { auto: "Auto", "semi-auto": "Semi", manual: "Manual" }; - return ( - <button - key={mode} - type="button" - onClick={() => onUpdate({ reconcileMode: mode })} - className={`text-[10px] font-mono px-2 py-0.5 transition-colors border-r border-[#2a3a5a] last:border-r-0 ${modeStyles[mode]}`} - > - {labels[mode]} - </button> - ); - })} - </div> - <span className="text-[9px] font-mono text-[#445566]"> - {directive.reconcileMode === "auto" && "Questions timeout after 30s"} - {directive.reconcileMode === "semi-auto" && "Questions pause execution"} - {directive.reconcileMode === "manual" && "Tasks ask clarifying questions"} - </span> - </div> - - {/* PR link */} - {directive.prUrl && ( - <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#0a1a10] border border-emerald-900 rounded"> - <span className="inline-block w-2 h-2 rounded-full bg-emerald-400" /> - <span className="text-[10px] font-mono text-emerald-400"> - PR created - </span> - <a - href={directive.prUrl} - target="_blank" - rel="noopener noreferrer" - className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 underline ml-auto truncate max-w-[200px]" - > - {directive.prUrl} - </a> - </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") && ( - <button - type="button" - onClick={onStart} - className="text-[10px] font-mono text-green-400 hover:text-green-300 border border-green-800 rounded px-2 py-1" - > - Start - </button> - )} - {directive.status === "active" && ( - <> - <button - type="button" - onClick={onPause} - className="text-[10px] font-mono text-orange-400 hover:text-orange-300 border border-orange-800 rounded px-2 py-1" - > - Pause - </button> - <button - type="button" - onClick={onAdvance} - className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.3)] rounded px-2 py-1" - > - Advance - </button> - </> - )} - {directive.status === "idle" && ( - <div className="flex items-center gap-2"> - <span className="text-[10px] font-mono text-yellow-400"> - All steps done. Update goal to add new work. - </span> - <button - type="button" - 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 - </button> - <button - type="button" - onClick={onCleanup} - className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-1" - > - Clean up - </button> - </div> - )} - {completedSteps > 0 && !directive.completionTaskId && ( - <button - type="button" - onClick={async () => { - setCreatingPR(true); - try { await onCreatePR(); } catch (e) { console.error("Failed to create PR:", e); } finally { setCreatingPR(false); } - }} - disabled={creatingPR} - className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-1 disabled:opacity-50" - > - {creatingPR ? "Creating..." : directive.prUrl ? "Update PR" : "Create PR"} - </button> - )} - <button - type="button" - onClick={handlePickUpOrders} - disabled={pickingUpOrders} - className="text-[10px] font-mono text-[#c084fc] hover:text-[#d8b4fe] border border-[rgba(192,132,252,0.3)] rounded px-2 py-1 disabled:opacity-50" - > - {pickingUpOrders ? "Planning..." : "Plan Orders"} - </button> - <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> - - {pickUpResult && ( - <div className="mt-2 px-2 py-1.5 bg-[#1a1030] border border-[rgba(192,132,252,0.2)] rounded"> - <span className="text-[10px] font-mono text-[#c084fc]">{pickUpResult}</span> - </div> - )} - </div> - - {/* Goal */} - <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"> - Goal - </span> - {!editingGoal && ( - <button - type="button" - onClick={() => { setGoalText(directive.goal); setEditingGoal(true); }} - className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]" - > - [edit] - </button> - )} - </div> - {editingGoal ? ( - <div className="flex flex-col gap-1.5"> - <textarea - value={goalText} - onChange={(e) => setGoalText(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-[60px]" - rows={3} - /> - <div className="flex gap-1.5"> - <button - type="button" - onClick={handleGoalSave} - 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={() => setEditingGoal(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"> - {directive.goal} - </p> - )} - </div> - - {/* Tab bar */} - <div className="flex items-center gap-0 border-b border-[rgba(117,170,252,0.1)] px-4"> - <button - type="button" - onClick={() => setActiveTab("steps")} - className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors - ${activeTab === "steps" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"} - `} - > - Steps ({totalSteps}) - </button> - <button - type="button" - onClick={() => setActiveTab("dogs")} - className={`px-3 py-2 text-[10px] font-mono uppercase tracking-wide transition-colors - ${activeTab === "dogs" ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" : "text-[#556677] hover:text-[#9bc3ff]"} - `} - > - DOGs ({dogs.length}) - </button> - </div> - - {/* Tab content */} - {activeTab === "steps" ? ( - <div className="px-4 py-3 flex-1"> - <DirectiveDAG - steps={directive.steps} - specializedSteps={specializedSteps} - onComplete={onCompleteStep} - onFail={onFailStep} - onSkip={onSkipStep} - onViewTask={handleViewTask} - /> - </div> - ) : ( - <div className="px-4 py-3 flex-1"> - <DOGList - dogs={dogs} - loading={dogsLoading} - onCreateDog={onCreateDog} - onUpdateDog={onUpdateDog} - onDeleteDog={onDeleteDog} - onPickUpOrders={onPickUpDogOrders} - /> - </div> - )} - - {/* Log Stream */} - {taskMap.size > 0 && ( - <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]"> - <DirectiveLogStream - entries={entries} - taskMap={taskMap} - connected={connected} - visibleTaskIds={visibleTaskIds} - searchQuery={searchQuery} - isCollapsed={isLogCollapsed} - onToggleCollapse={() => setIsLogCollapsed((prev) => !prev)} - onSetVisibleTaskIds={setVisibleTaskIds} - onSetSearchQuery={setSearchQuery} - onClear={clearEntries} - /> - </div> - )} - </div> - - <TaskSlideOutPanel - taskId={slideOutTaskId || ""} - taskName={slideOutTaskName} - isOpen={!!slideOutTaskId} - onClose={() => setSlideOutTaskId(null)} - /> - </> - ); -} - -/** 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/directives/DirectiveList.tsx b/makima/frontend/src/components/directives/DirectiveList.tsx deleted file mode 100644 index a35c8b1..0000000 --- a/makima/frontend/src/components/directives/DirectiveList.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useState, useMemo } from "react"; -import type { DirectiveSummary, DirectiveStatus } from "../../lib/api"; -import { useSupervisorQuestions } from "../../contexts/SupervisorQuestionsContext"; -import { DirectiveContextMenu } from "./DirectiveContextMenu"; - -const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { - draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, - active: { color: "text-green-400 border-green-800", label: "ACTIVE" }, - idle: { color: "text-yellow-400 border-yellow-800", label: "IDLE" }, - paused: { color: "text-orange-400 border-orange-800", label: "PAUSED" }, - inactive: { color: "text-[#9bc3ff] border-[#3f6fb3]", label: "INACTIVE" }, - archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, -}; - -interface DirectiveListProps { - directives: DirectiveSummary[]; - selectedId: string | null; - onSelect: (id: string) => void; - onCreate: () => void; - onStart?: (directive: DirectiveSummary) => void; - onPause?: (directive: DirectiveSummary) => void; - onArchive?: (directive: DirectiveSummary) => void; - onDelete?: (directive: DirectiveSummary) => void; - onGoToPR?: (directive: DirectiveSummary) => void; -} - -export function DirectiveList({ directives, selectedId, onSelect, onCreate, onStart, onPause, onArchive, onDelete, onGoToPR }: DirectiveListProps) { - const { pendingQuestions } = useSupervisorQuestions(); - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [contextMenuDirective, setContextMenuDirective] = useState<DirectiveSummary | null>(null); - - const handleContextMenu = (e: React.MouseEvent, directive: DirectiveSummary) => { - e.preventDefault(); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuDirective(directive); - }; - - const closeContextMenu = () => { - setContextMenuPosition(null); - setContextMenuDirective(null); - }; - - const questionsPerDirective = useMemo(() => { - const counts = new Map<string, number>(); - for (const q of pendingQuestions) { - if (q.directiveId) { - counts.set(q.directiveId, (counts.get(q.directiveId) || 0) + 1); - } - } - return counts; - }, [pendingQuestions]); - - return ( - <div className="flex flex-col h-full"> - <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"> - Directives - </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> - <div className="flex-1 overflow-y-auto"> - {directives.length === 0 ? ( - <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]"> - No directives yet - </div> - ) : ( - directives.map((d) => { - const badge = STATUS_BADGE[d.status] || STATUS_BADGE.draft; - const progress = d.totalSteps > 0 - ? Math.round((d.completedSteps / d.totalSteps) * 100) - : 0; - - return ( - <button - key={d.id} - type="button" - onClick={() => onSelect(d.id)} - onContextMenu={(e) => handleContextMenu(e, d)} - 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 === d.id ? "bg-[rgba(117,170,252,0.1)]" : "" - }`} - > - <div className="flex items-center justify-between mb-1"> - <span className="text-[12px] font-mono text-white truncate pr-2"> - {d.title} - </span> - {questionsPerDirective.has(d.id) && ( - <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse shrink-0" title={`${questionsPerDirective.get(d.id)} pending question(s)`} /> - )} - <span - className={`text-[9px] font-mono ${badge.color} border rounded px-1.5 py-0.5 shrink-0`} - > - {badge.label} - </span> - </div> - <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1.5"> - {d.goal} - </p> - {d.totalSteps > 0 && ( - <div className="flex items-center gap-2"> - <div className="flex-1 h-1 bg-[#1a2540] rounded overflow-hidden"> - <div - className="h-full bg-emerald-600 rounded transition-all" - style={{ width: `${progress}%` }} - /> - </div> - <span className="text-[9px] font-mono text-[#556677] shrink-0"> - {d.completedSteps}/{d.totalSteps} - </span> - </div> - )} - </button> - ); - }) - )} - </div> - - {/* Context Menu */} - {contextMenuPosition && contextMenuDirective && ( - <DirectiveContextMenu - x={contextMenuPosition.x} - y={contextMenuPosition.y} - directive={contextMenuDirective} - onClose={closeContextMenu} - onStart={() => onStart?.(contextMenuDirective)} - onPause={() => onPause?.(contextMenuDirective)} - onArchive={() => onArchive?.(contextMenuDirective)} - onDelete={() => onDelete?.(contextMenuDirective)} - onGoToPR={() => onGoToPR?.(contextMenuDirective)} - /> - )} - </div> - ); -} diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 8104de0..e7b2b80 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -18,7 +18,6 @@ import { completeDirectiveStep, failDirectiveStep, skipDirectiveStep, - updateDirectiveGoal, cleanupDirective, pickUpOrders as pickUpOrdersApi, createDirectivePR, @@ -293,12 +292,6 @@ export function useDirective(id: string | undefined) { await refresh(); }, [id, refresh]); - const updateGoal = useCallback(async (goal: string) => { - if (!id) return; - await updateDirectiveGoal(id, goal); - await refresh(); - }, [id, refresh]); - const cleanup = useCallback(async () => { if (!id) return; await cleanupDirective(id); @@ -323,7 +316,7 @@ export function useDirective(id: string | undefined) { update, addStep, removeStep, start, pause, advance, completeStep, failStep, skipStep, - updateGoal, cleanup, + cleanup, pickUpOrders: pickUpOrdersFn, createPR, }; diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index f777ba0..a4ec4db 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3207,7 +3207,6 @@ export interface Directive { id: string; ownerId: string; title: string; - goal: string; status: DirectiveStatus; repositoryUrl: string | null; localPath: string | null; @@ -3220,7 +3219,6 @@ export interface Directive { memoryEnabled: boolean; /** Reconcile mode: auto (timeout), semi-auto (pause), or manual (ask questions) */ reconcileMode: string; - goalUpdatedAt: string; startedAt: string | null; version: number; createdAt: string; @@ -3255,7 +3253,6 @@ export interface DirectiveSummary { id: string; ownerId: string; title: string; - goal: string; status: DirectiveStatus; repositoryUrl: string | null; orchestratorTaskId: string | null; @@ -3281,7 +3278,9 @@ export interface DirectiveListResponse { export interface CreateDirectiveRequest { title: string; - goal: string; + /** Optional. When provided, a first contract is auto-created with + * this body so the directive is immediately ready to start. */ + contractBody?: string; repositoryUrl?: string; localPath?: string; baseBranch?: string; @@ -3293,7 +3292,6 @@ export interface CreateDirectiveRequest { export interface UpdateDirectiveRequest { title?: string; - goal?: string; status?: string; repositoryUrl?: string; localPath?: string; @@ -3433,15 +3431,8 @@ export async function skipDirectiveStep(directiveId: string, stepId: string): Pr return res.json(); } -export async function updateDirectiveGoal(id: string, goal: string): Promise<Directive> { - const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/goal`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ goal }), - }); - if (!res.ok) throw new Error(`Failed to update goal: ${res.statusText}`); - return res.json(); -} +// (updateDirectiveGoal removed — spec edits go through the contracts API. +// Use updateDirectiveContract(activeContractId, { body }) instead.) export async function cleanupDirective(id: string): Promise<{ message: string; taskId: string | null }> { const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/cleanup`, { diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 895c86a..f397e54 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -1,317 +1,15 @@ -import { useState, useEffect, useCallback } from "react"; -import { useParams, useNavigate } from "react-router"; -import { Masthead } from "../components/Masthead"; -import { DirectiveList } from "../components/directives/DirectiveList"; -import { DirectiveDetail } from "../components/directives/DirectiveDetail"; -import { useDirectives, useDirective } from "../hooks/useDirectives"; -import { useDogs } from "../hooks/useDogs"; -import { useUserSettings } from "../hooks/useUserSettings"; -import { useAuth } from "../contexts/AuthContext"; +// Top-level /directives route — now always renders the document-mode UI. +// +// The legacy tabular UI (DirectiveList + DirectiveDetail) was retired +// when directives.goal was dropped: contracts own the spec text now, +// and the document-mode page is the only surface that knows how to +// edit them. The user-settings `documentModeEnabled` toggle is no +// longer consulted here (kept around in settings for future flag use). +// +// /directives/:id is delegated to DocumentDirectivesPage which reads +// the param itself. import DocumentDirectivesPage from "./document-directives"; -import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api"; -/** - * Top-level /directives route. Gates between the legacy tabular UI and the - * Document Mode (POC) UI based on the user's settings flag. - * - * Both code paths support /directives/:id deep links — the param is read by - * each branch independently via useParams. - */ export default function DirectivesPage() { - const { settings, loading: settingsLoading } = useUserSettings(); - - // While settings are loading for the very first time, render nothing inside - // a Masthead-wrapped shell so we don't briefly flash the legacy UI just to - // swap to document mode a moment later. - if (settingsLoading && !settings) { - 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> - ); - } - - if (settings?.documentModeEnabled) { - return <DocumentDirectivesPage />; - } - - return <LegacyDirectivesPage />; -} - -function LegacyDirectivesPage() { - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - const { id: selectedId } = useParams<{ id: string }>(); - const { directives, loading: listLoading, create, remove, refresh: refreshList } = useDirectives(); - const { directive, refresh: refreshDetail, update, start, pause, advance, completeStep, failStep, skipStep, updateGoal, cleanup, pickUpOrders, createPR } = useDirective(selectedId); - const { dogs, loading: dogsLoading, create: createDog, update: updateDog, remove: removeDog, pickUpOrders: pickUpDogOrders } = useDogs(selectedId); - - const [showCreate, setShowCreate] = useState(false); - const [newTitle, setNewTitle] = useState(""); - const [newGoal, setNewGoal] = useState(""); - const [newRepoUrl, setNewRepoUrl] = useState(""); - const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); - - // Fetch repository suggestions when create form opens - useEffect(() => { - if (showCreate) { - getRepositorySuggestions("remote", undefined, 10) - .then((res) => { - setRepoSuggestions(res.entries); - setShowRepoSuggestions(res.entries.length > 0); - }) - .catch(() => { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - }); - } else { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - } - }, [showCreate]); - - const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => { - if (suggestion.repositoryUrl) { - setNewRepoUrl(suggestion.repositoryUrl); - } - if (!newTitle.trim() && suggestion.name) { - setNewTitle(suggestion.name); - } - setShowRepoSuggestions(false); - }, [newTitle]); - - 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 handleContextStart = async (directive: DirectiveSummary) => { - try { - await startDirective(directive.id); - await refreshList(); - } catch (e) { - console.error("Failed to start directive:", e); - } - }; - - const handleContextPause = async (directive: DirectiveSummary) => { - try { - await pauseDirective(directive.id); - await refreshList(); - } catch (e) { - console.error("Failed to pause directive:", e); - } - }; - - const handleContextArchive = async (directive: DirectiveSummary) => { - try { - await updateDirective(directive.id, { status: "archived" }); - await refreshList(); - } catch (e) { - console.error("Failed to archive directive:", e); - } - }; - - const handleContextDelete = async (directive: DirectiveSummary) => { - if (!window.confirm("Delete this directive?")) return; - try { - await remove(directive.id); - if (directive.id === selectedId) navigate("/directives"); - } catch (e) { - console.error("Failed to delete:", e); - } - }; - - const handleContextGoToPR = (directive: DirectiveSummary) => { - if (directive.prUrl) window.open(directive.prUrl, "_blank"); - }; - - const handleCreate = async () => { - if (!newTitle.trim() || !newGoal.trim()) return; - try { - const d = await create({ - title: newTitle.trim(), - goal: newGoal.trim(), - repositoryUrl: newRepoUrl.trim() || undefined, - }); - setShowCreate(false); - setNewTitle(""); - setNewGoal(""); - setNewRepoUrl(""); - navigate(`/directives/${d.id}`); - } catch (e) { - console.error("Failed to create directive:", e); - } - }; - - const handleDelete = async () => { - if (!selectedId) return; - if (!window.confirm("Delete this directive?")) return; - try { - await remove(selectedId); - navigate("/directives"); - } catch (e) { - console.error("Failed to delete:", e); - } - }; - - 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"> - <DirectiveList - directives={directives} - selectedId={selectedId ?? null} - onSelect={(id) => navigate(`/directives/${id}`)} - onCreate={() => setShowCreate(true)} - onStart={handleContextStart} - onPause={handleContextPause} - onArchive={handleContextArchive} - onDelete={handleContextDelete} - onGoToPR={handleContextGoToPR} - /> - </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 Directive - </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="Project 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" - /> - </div> - <div> - <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> - Goal - </label> - <textarea - value={newGoal} - onChange={(e) => setNewGoal(e.target.value)} - placeholder="What should this directive accomplish?" - 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> - {showRepoSuggestions && repoSuggestions.length > 0 && ( - <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> - Recent Repositories - </label> - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> - {repoSuggestions.map((suggestion) => ( - <button - key={suggestion.id} - type="button" - onClick={() => applyRepoSuggestion(suggestion)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> - <span className="text-[10px] text-[#556677] ml-2"> - {suggestion.useCount}× - </span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {suggestion.repositoryUrl} - </div> - </button> - ))} - </div> - </div> - )} - <div> - <label className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-1"> - Repository URL (optional) - </label> - <input - value={newRepoUrl} - onChange={(e) => setNewRepoUrl(e.target.value)} - placeholder="https://github.com/..." - 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" - /> - </div> - <div className="flex gap-2"> - <button - type="button" - onClick={handleCreate} - disabled={!newTitle.trim() || !newGoal.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 && directive ? ( - <DirectiveDetail - directive={directive} - onStart={start} - onPause={pause} - onAdvance={advance} - onCompleteStep={completeStep} - onFailStep={failStep} - onSkipStep={skipStep} - onUpdateGoal={updateGoal} - onUpdate={update} - onDelete={handleDelete} - onRefresh={refreshDetail} - onCleanup={cleanup} - onPickUpOrders={pickUpOrders} - onCreatePR={createPR} - dogs={dogs} - dogsLoading={dogsLoading} - onCreateDog={createDog} - onUpdateDog={updateDog} - onDeleteDog={removeDog} - onPickUpDogOrders={pickUpDogOrders} - /> - ) : ( - <div className="flex-1 flex items-center justify-center h-full"> - <p className="text-[#556677] font-mono text-[12px]"> - {listLoading - ? "Loading..." - : "Select a directive or create a new one"} - </p> - </div> - )} - </div> - </main> - </div> - ); + return <DocumentDirectivesPage />; } diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 63d0b96..06e427a 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1595,10 +1595,10 @@ export default function DocumentDirectivesPage() { const [newEphemeralFor, setNewEphemeralFor] = useState<DirectiveSummary | null>(null); const handleSubmitNewContract = useCallback( - async (title: string, goal: string, repositoryUrl: string) => { + async (title: string, body: string, repositoryUrl: string) => { const d = await createDirective({ title, - goal, + contractBody: body, repositoryUrl: repositoryUrl.length > 0 ? repositoryUrl : undefined, }); setShowNewContract(false); |
