diff options
| author | soryu <soryu@soryu.co> | 2026-02-09 00:11:51 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-09 00:11:51 +0000 |
| commit | 8c23b3ab6f7fabca01b0468911bae073aa5ced32 (patch) | |
| tree | f50159aee13b13f0b55618ac09e9be1f89a41bb2 /makima/frontend/src/components/directives | |
| parent | 3662b334dfd68cfdf00ed44ae88927c2e1b2aabe (diff) | |
| download | soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.tar.gz soryu-8c23b3ab6f7fabca01b0468911bae073aa5ced32.zip | |
Add new directive mechanism v3
Diffstat (limited to 'makima/frontend/src/components/directives')
4 files changed, 472 insertions, 0 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx new file mode 100644 index 0000000..f288a0d --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx @@ -0,0 +1,87 @@ +import { useMemo } from "react"; +import type { DirectiveStep } from "../../lib/api"; +import { StepNode } from "./StepNode"; + +interface DirectiveDAGProps { + steps: DirectiveStep[]; + onComplete?: (stepId: string) => void; + onFail?: (stepId: string) => void; + onSkip?: (stepId: string) => void; +} + +interface Layer { + steps: DirectiveStep[]; +} + +function topoSort(steps: DirectiveStep[]): Layer[] { + if (steps.length === 0) return []; + + const stepMap = new Map(steps.map((s) => [s.id, s])); + const assigned = new Set<string>(); + const layers: Layer[] = []; + + // Iteratively find steps whose dependencies are all assigned + let remaining = [...steps]; + while (remaining.length > 0) { + const layer: DirectiveStep[] = []; + for (const step of remaining) { + const depsResolved = step.dependsOn.every( + (depId) => assigned.has(depId) || !stepMap.has(depId) + ); + if (depsResolved) { + layer.push(step); + } + } + + if (layer.length === 0) { + // Cycle detected or orphaned — push all remaining + layers.push({ steps: remaining }); + break; + } + + for (const s of layer) { + assigned.add(s.id); + } + layers.push({ steps: layer.sort((a, b) => a.orderIndex - b.orderIndex) }); + remaining = remaining.filter((s) => !assigned.has(s.id)); + } + + return layers; +} + +export function DirectiveDAG({ steps, onComplete, onFail, onSkip }: DirectiveDAGProps) { + const layers = useMemo(() => topoSort(steps), [steps]); + + if (steps.length === 0) { + return ( + <div className="text-center py-8 text-[#7788aa] font-mono text-sm"> + No steps yet. Add steps to build the DAG. + </div> + ); + } + + return ( + <div className="flex flex-col gap-4 items-center py-4"> + {layers.map((layer, layerIdx) => ( + <div key={layerIdx}> + {layerIdx > 0 && ( + <div className="flex justify-center py-1"> + <div className="w-px h-4 bg-[#2a3a5a]" /> + </div> + )} + <div className="flex flex-wrap gap-3 justify-center"> + {layer.steps.map((step) => ( + <StepNode + key={step.id} + step={step} + onComplete={onComplete ? () => onComplete(step.id) : undefined} + onFail={onFail ? () => onFail(step.id) : undefined} + onSkip={onSkip ? () => onSkip(step.id) : undefined} + /> + ))} + </div> + </div> + ))} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx new file mode 100644 index 0000000..abd2c55 --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import { DirectiveDAG } from "./DirectiveDAG"; + +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" }, + 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; + onDelete: () => void; + onRefresh: () => void; +} + +export function DirectiveDetail({ + directive, + onStart, + onPause, + onAdvance, + onCompleteStep, + onFailStep, + onSkipStep, + onUpdateGoal, + onDelete, + onRefresh, +}: DirectiveDetailProps) { + const [editingGoal, setEditingGoal] = useState(false); + const [goalText, setGoalText] = useState(directive.goal); + 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; + + const handleGoalSave = () => { + if (goalText.trim() && goalText !== directive.goal) { + onUpdateGoal(goalText.trim()); + } + setEditingGoal(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"> + <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> + )} + + {/* 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={() => 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> + </div> + )} + <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> + + {/* 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> + + {/* DAG */} + <div className="px-4 py-3 flex-1"> + <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2"> + Steps ({totalSteps}) + </span> + <DirectiveDAG + steps={directive.steps} + onComplete={onCompleteStep} + onFail={onFailStep} + onSkip={onSkipStep} + /> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveList.tsx b/makima/frontend/src/components/directives/DirectiveList.tsx new file mode 100644 index 0000000..6393ea7 --- /dev/null +++ b/makima/frontend/src/components/directives/DirectiveList.tsx @@ -0,0 +1,87 @@ +import type { DirectiveSummary, DirectiveStatus } from "../../lib/api"; + +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" }, + archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" }, +}; + +interface DirectiveListProps { + directives: DirectiveSummary[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: () => void; +} + +export function DirectiveList({ directives, selectedId, onSelect, onCreate }: DirectiveListProps) { + 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)} + 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> + <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> + </div> + ); +} diff --git a/makima/frontend/src/components/directives/StepNode.tsx b/makima/frontend/src/components/directives/StepNode.tsx new file mode 100644 index 0000000..fa91956 --- /dev/null +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -0,0 +1,82 @@ +import type { DirectiveStep, StepStatus } from "../../lib/api"; + +const STATUS_COLORS: Record<StepStatus, { bg: string; border: string; text: string }> = { + pending: { bg: "bg-[#1a2540]", border: "border-[#2a3a5a]", text: "text-[#7788aa]" }, + ready: { bg: "bg-[#2a2a10]", border: "border-[#4a4a20]", text: "text-yellow-400" }, + running: { bg: "bg-[#0a2a1a]", border: "border-[#1a5a3a]", text: "text-green-400" }, + completed: { bg: "bg-[#0a2a2a]", border: "border-[#1a5a5a]", text: "text-emerald-400" }, + failed: { bg: "bg-[#2a1a1a]", border: "border-[#5a2a2a]", text: "text-red-400" }, + skipped: { bg: "bg-[#1a1a2a]", border: "border-[#2a2a4a]", text: "text-[#7788aa]" }, +}; + +const STATUS_LABELS: Record<StepStatus, string> = { + pending: "PENDING", + ready: "READY", + running: "RUNNING", + completed: "DONE", + failed: "FAILED", + skipped: "SKIP", +}; + +interface StepNodeProps { + step: DirectiveStep; + onComplete?: () => void; + onFail?: () => void; + onSkip?: () => void; +} + +export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { + const colors = STATUS_COLORS[step.status] || STATUS_COLORS.pending; + const label = STATUS_LABELS[step.status] || step.status.toUpperCase(); + + return ( + <div + className={`${colors.bg} ${colors.border} border rounded px-3 py-2 min-w-[160px] max-w-[220px]`} + > + <div className="flex items-center justify-between gap-2 mb-1"> + <span className="text-[11px] font-mono text-white truncate font-medium"> + {step.name} + </span> + <span className={`text-[9px] font-mono ${colors.text} uppercase shrink-0`}> + {label} + </span> + </div> + {step.description && ( + <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1"> + {step.description} + </p> + )} + {(step.status === "running" || step.status === "ready") && ( + <div className="flex gap-1 mt-1"> + {onComplete && ( + <button + type="button" + onClick={onComplete} + className="text-[9px] font-mono text-emerald-400 hover:text-emerald-300 bg-transparent border border-emerald-800 rounded px-1.5 py-0.5" + > + Done + </button> + )} + {onFail && ( + <button + type="button" + onClick={onFail} + className="text-[9px] font-mono text-red-400 hover:text-red-300 bg-transparent border border-red-800 rounded px-1.5 py-0.5" + > + Fail + </button> + )} + {onSkip && ( + <button + type="button" + onClick={onSkip} + className="text-[9px] font-mono text-[#7788aa] hover:text-white bg-transparent border border-[#2a3a5a] rounded px-1.5 py-0.5" + > + Skip + </button> + )} + </div> + )} + </div> + ); +} |
