diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDAG.tsx | 87 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 216 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveList.tsx | 87 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/StepNode.tsx | 82 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 150 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 222 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 17 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 168 | ||||
| -rw-r--r-- | makima/frontend/tsconfig.tsbuildinfo | 2 |
10 files changed, 1031 insertions, 1 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> + ); +} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index 0fbecaa..f36c337 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/stepnode.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file |
