summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/directives/DirectiveDAG.tsx87
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx216
-rw-r--r--makima/frontend/src/components/directives/DirectiveList.tsx87
-rw-r--r--makima/frontend/src/components/directives/StepNode.tsx82
-rw-r--r--makima/frontend/src/hooks/useDirectives.ts150
-rw-r--r--makima/frontend/src/lib/api.ts222
-rw-r--r--makima/frontend/src/main.tsx17
-rw-r--r--makima/frontend/src/routes/directives.tsx168
9 files changed, 1030 insertions, 0 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index fb95c7f..46fef7a 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -13,6 +13,7 @@ const NAV_LINKS: NavLink[] = [
{ label: "Contracts", href: "/contracts", requiresAuth: true },
{ label: "Board", href: "/workflow", requiresAuth: true },
{ label: "Mesh", href: "/mesh", requiresAuth: true },
+ { label: "Directives", href: "/directives", requiresAuth: true },
{ label: "History", href: "/history", requiresAuth: true },
];
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>
+ );
+}
diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts
new file mode 100644
index 0000000..b69275a
--- /dev/null
+++ b/makima/frontend/src/hooks/useDirectives.ts
@@ -0,0 +1,150 @@
+import { useState, useEffect, useCallback } from "react";
+import {
+ type DirectiveSummary,
+ type DirectiveWithSteps,
+ type CreateDirectiveRequest,
+ type UpdateDirectiveRequest,
+ type CreateDirectiveStepRequest,
+ listDirectives,
+ createDirective,
+ getDirective,
+ updateDirective,
+ deleteDirective,
+ createDirectiveStep,
+ deleteDirectiveStep,
+ startDirective,
+ pauseDirective,
+ advanceDirective,
+ completeDirectiveStep,
+ failDirectiveStep,
+ skipDirectiveStep,
+ updateDirectiveGoal,
+} from "../lib/api";
+
+export function useDirectives() {
+ const [directives, setDirectives] = useState<DirectiveSummary[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await listDirectives();
+ setDirectives(res.directives);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load directives");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const create = useCallback(async (req: CreateDirectiveRequest) => {
+ const d = await createDirective(req);
+ await refresh();
+ return d;
+ }, [refresh]);
+
+ const remove = useCallback(async (id: string) => {
+ await deleteDirective(id);
+ await refresh();
+ }, [refresh]);
+
+ return { directives, loading, error, refresh, create, remove };
+}
+
+export function useDirective(id: string | undefined) {
+ const [directive, setDirective] = useState<DirectiveWithSteps | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const refresh = useCallback(async () => {
+ if (!id) return;
+ try {
+ setLoading(true);
+ setError(null);
+ const d = await getDirective(id);
+ setDirective(d);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load directive");
+ } finally {
+ setLoading(false);
+ }
+ }, [id]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const update = useCallback(async (req: UpdateDirectiveRequest) => {
+ if (!id) return;
+ await updateDirective(id, req);
+ await refresh();
+ }, [id, refresh]);
+
+ const addStep = useCallback(async (req: CreateDirectiveStepRequest) => {
+ if (!id) return;
+ await createDirectiveStep(id, req);
+ await refresh();
+ }, [id, refresh]);
+
+ const removeStep = useCallback(async (stepId: string) => {
+ if (!id) return;
+ await deleteDirectiveStep(id, stepId);
+ await refresh();
+ }, [id, refresh]);
+
+ const start = useCallback(async () => {
+ if (!id) return;
+ await startDirective(id);
+ await refresh();
+ }, [id, refresh]);
+
+ const pause = useCallback(async () => {
+ if (!id) return;
+ await pauseDirective(id);
+ await refresh();
+ }, [id, refresh]);
+
+ const advance = useCallback(async () => {
+ if (!id) return;
+ await advanceDirective(id);
+ await refresh();
+ }, [id, refresh]);
+
+ const completeStep = useCallback(async (stepId: string) => {
+ if (!id) return;
+ await completeDirectiveStep(id, stepId);
+ await refresh();
+ }, [id, refresh]);
+
+ const failStep = useCallback(async (stepId: string) => {
+ if (!id) return;
+ await failDirectiveStep(id, stepId);
+ await refresh();
+ }, [id, refresh]);
+
+ const skipStep = useCallback(async (stepId: string) => {
+ if (!id) return;
+ await skipDirectiveStep(id, stepId);
+ await refresh();
+ }, [id, refresh]);
+
+ const updateGoal = useCallback(async (goal: string) => {
+ if (!id) return;
+ await updateDirectiveGoal(id, goal);
+ await refresh();
+ }, [id, refresh]);
+
+ return {
+ directive, loading, error, refresh,
+ update, addStep, removeStep,
+ start, pause, advance,
+ completeStep, failStep, skipStep,
+ updateGoal,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 7732725..b1422df 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3003,4 +3003,226 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi
return res.json();
}
+// =============================================================================
+// Directive Types & API
+// =============================================================================
+
+export type DirectiveStatus = "draft" | "active" | "idle" | "paused" | "archived";
+export type StepStatus = "pending" | "ready" | "running" | "completed" | "failed" | "skipped";
+
+export interface Directive {
+ id: string;
+ ownerId: string;
+ title: string;
+ goal: string;
+ status: DirectiveStatus;
+ repositoryUrl: string | null;
+ localPath: string | null;
+ baseBranch: string | null;
+ orchestratorTaskId: string | null;
+ goalUpdatedAt: string;
+ startedAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DirectiveStep {
+ id: string;
+ directiveId: string;
+ name: string;
+ description: string | null;
+ taskPlan: string | null;
+ dependsOn: string[];
+ status: StepStatus;
+ taskId: string | null;
+ orderIndex: number;
+ generation: number;
+ startedAt: string | null;
+ completedAt: string | null;
+ createdAt: string;
+}
+
+export interface DirectiveWithSteps extends Directive {
+ steps: DirectiveStep[];
+}
+
+export interface DirectiveSummary {
+ id: string;
+ ownerId: string;
+ title: string;
+ goal: string;
+ status: DirectiveStatus;
+ repositoryUrl: string | null;
+ orchestratorTaskId: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+ totalSteps: number;
+ completedSteps: number;
+ runningSteps: number;
+ failedSteps: number;
+}
+
+export interface DirectiveListResponse {
+ directives: DirectiveSummary[];
+ total: number;
+}
+
+export interface CreateDirectiveRequest {
+ title: string;
+ goal: string;
+ repositoryUrl?: string;
+ localPath?: string;
+ baseBranch?: string;
+}
+
+export interface UpdateDirectiveRequest {
+ title?: string;
+ goal?: string;
+ status?: string;
+ repositoryUrl?: string;
+ localPath?: string;
+ baseBranch?: string;
+ orchestratorTaskId?: string;
+ version?: number;
+}
+
+export interface CreateDirectiveStepRequest {
+ name: string;
+ description?: string;
+ taskPlan?: string;
+ dependsOn?: string[];
+ orderIndex?: number;
+ generation?: number;
+}
+
+export interface UpdateDirectiveStepRequest {
+ name?: string;
+ description?: string;
+ taskPlan?: string;
+ dependsOn?: string[];
+ status?: string;
+ taskId?: string;
+ orderIndex?: number;
+}
+
+export async function listDirectives(): Promise<DirectiveListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives`);
+ if (!res.ok) throw new Error(`Failed to list directives: ${res.statusText}`);
+ return res.json();
+}
+
+export async function createDirective(req: CreateDirectiveRequest): Promise<Directive> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to create directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function getDirective(id: string): Promise<DirectiveWithSteps> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`);
+ if (!res.ok) throw new Error(`Failed to get directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function updateDirective(id: string, req: UpdateDirectiveRequest): Promise<Directive> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to update directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function deleteDirective(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(`Failed to delete directive: ${res.statusText}`);
+}
+
+export async function createDirectiveStep(directiveId: string, req: CreateDirectiveStepRequest): Promise<DirectiveStep> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to create step: ${res.statusText}`);
+ return res.json();
+}
+
+export async function batchCreateDirectiveSteps(directiveId: string, steps: CreateDirectiveStepRequest[]): Promise<DirectiveStep[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/batch`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(steps),
+ });
+ if (!res.ok) throw new Error(`Failed to batch create steps: ${res.statusText}`);
+ return res.json();
+}
+
+export async function updateDirectiveStep(directiveId: string, stepId: string, req: UpdateDirectiveStepRequest): Promise<DirectiveStep> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to update step: ${res.statusText}`);
+ return res.json();
+}
+
+export async function deleteDirectiveStep(directiveId: string, stepId: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(`Failed to delete step: ${res.statusText}`);
+}
+
+export async function startDirective(id: string): Promise<DirectiveWithSteps> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/start`, { method: "POST" });
+ if (!res.ok) throw new Error(`Failed to start directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function pauseDirective(id: string): Promise<Directive> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/pause`, { method: "POST" });
+ if (!res.ok) throw new Error(`Failed to pause directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function advanceDirective(id: string): Promise<DirectiveWithSteps> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/advance`, { method: "POST" });
+ if (!res.ok) throw new Error(`Failed to advance directive: ${res.statusText}`);
+ return res.json();
+}
+
+export async function completeDirectiveStep(directiveId: string, stepId: string): Promise<DirectiveStep> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/complete`, { method: "POST" });
+ if (!res.ok) throw new Error(`Failed to complete step: ${res.statusText}`);
+ return res.json();
+}
+
+export async function failDirectiveStep(directiveId: string, stepId: string): Promise<DirectiveStep> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/fail`, { method: "POST" });
+ if (!res.ok) throw new Error(`Failed to fail step: ${res.statusText}`);
+ return res.json();
+}
+
+export async function skipDirectiveStep(directiveId: string, stepId: string): Promise<DirectiveStep> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/skip`, { method: "POST" });
+ if (!res.ok) throw new Error(`Failed to skip step: ${res.statusText}`);
+ 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();
+}
+
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index 50fffe4..3dc68f5 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -19,6 +19,7 @@ import LoginPage from "./routes/login";
import SettingsPage from "./routes/settings";
import ContractFilePage from "./routes/contract-file";
import SpeakPage from "./routes/speak";
+import DirectivesPage from "./routes/directives";
createRoot(document.getElementById("root")!).render(
<StrictMode>
@@ -128,6 +129,22 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
+ path="/directives"
+ element={
+ <ProtectedRoute>
+ <DirectivesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/directives/:id"
+ element={
+ <ProtectedRoute>
+ <DirectivesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
path="/speak"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
new file mode 100644
index 0000000..82e5d48
--- /dev/null
+++ b/makima/frontend/src/routes/directives.tsx
@@ -0,0 +1,168 @@
+import { useState, useEffect } 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 { useAuth } from "../contexts/AuthContext";
+
+export default function DirectivesPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const { id: selectedId } = useParams<{ id: string }>();
+ const { directives, loading: listLoading, create, remove } = useDirectives();
+ const { directive, refresh: refreshDetail, start, pause, advance, completeStep, failStep, skipStep, updateGoal } = useDirective(selectedId);
+
+ const [showCreate, setShowCreate] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newGoal, setNewGoal] = useState("");
+ const [newRepoUrl, setNewRepoUrl] = useState("");
+
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ const handleCreate = async () => {
+ if (!newTitle.trim() || !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)}
+ />
+ </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>
+ <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}
+ onDelete={handleDelete}
+ onRefresh={refreshDetail}
+ />
+ ) : (
+ <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>
+ );
+}