summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx270
1 files changed, 239 insertions, 31 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index d589dac..044c8af 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -12,11 +12,16 @@ import {
type DirectiveStep,
type Task,
type DirectiveContractTasksResponse as ContractTasksResponse,
+ type DirectiveContractMergeMode as ContractMergeMode,
listDirectiveContracts as listContracts,
createDirectiveContract as createContract,
getDirectiveContract as getContract,
updateDirectiveContract as updateContract,
listDirectiveContractTasks as listContractTasks,
+ startDirectiveContract,
+ pauseDirectiveContract,
+ completeDirectiveContract,
+ unlockDirectiveContract,
createDirectiveTask,
startDirective,
pauseDirective,
@@ -41,10 +46,13 @@ const STATUS_DOT: Record<DirectiveStatus, string> = {
archived: "bg-[#3a4a6a]",
};
-// Per-document status palette. Active/draft documents use the same bright
-// green-ish accent as a running directive; shipped/archived use a muted blue.
+// Per-contract status palette. Active = bright green (currently driving
+// daemons); queued = amber (locked, waiting for the active slot); draft
+// = grey (editable spec); shipped = muted blue (work done); archived =
+// faint navy.
const DOC_STATUS_DOT: Record<ContractStatus, string> = {
draft: "bg-[#556677]",
+ queued: "bg-amber-400",
active: "bg-green-400",
shipped: "bg-[#75aafc]",
archived: "bg-[#3a4a6a]",
@@ -892,6 +900,226 @@ function DocumentSidebar({
}
// =============================================================================
+// Contract header — breadcrumb + status badge + lifecycle action buttons +
+// merge mode radio. Renders above the spec editor in the document path.
+//
+// Action visibility is status-driven:
+// * draft → Lock & Start
+// * queued → Unlock (back to draft); shows "queued" pill
+// * active → Pause, Complete, Unlock; shows "active" + pulsing dot
+// * shipped → reopen via spec edit (no buttons here; backend reactivates)
+// * archived → no buttons
+//
+// Merge mode (shared / own_pr) is editable while the contract is in
+// `draft` or `queued` — once active, the queue scheduler has already
+// claimed the slot, so flipping the toggle would silently change a
+// running flow's branch target. Locked rows show the value as readonly.
+// =============================================================================
+
+interface ContractHeaderProps {
+ directive: { id: string; title: string; orchestratorTaskId: string | null };
+ doc: Contract;
+ docTitle: string;
+ /** Called with the server's response after any status / merge-mode
+ * transition so the parent can refresh the editor + sidebar. */
+ onContractChanged: (updated: Contract) => void;
+}
+
+function ContractHeader({
+ directive,
+ doc,
+ docTitle,
+ onContractChanged,
+}: ContractHeaderProps) {
+ const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">(
+ null,
+ );
+ const [error, setError] = useState<string | null>(null);
+
+ const wrap = useCallback(
+ async (tag: typeof busy, op: () => Promise<Contract>) => {
+ try {
+ setBusy(tag);
+ setError(null);
+ const updated = await op();
+ onContractChanged(updated);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Unknown error");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [onContractChanged],
+ );
+
+ const onStart = useCallback(
+ () => wrap("start", () => startDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onPause = useCallback(
+ () => wrap("pause", () => pauseDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onComplete = useCallback(
+ () => wrap("complete", () => completeDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onUnlock = useCallback(
+ () => wrap("unlock", () => unlockDirectiveContract(doc.id)),
+ [doc.id, wrap],
+ );
+ const onMergeMode = useCallback(
+ (mode: ContractMergeMode) =>
+ wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })),
+ [doc.id, wrap],
+ );
+
+ const editableMergeMode = doc.status === "draft" || doc.status === "queued";
+
+ return (
+ <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)] flex flex-col gap-2">
+ {/* Row 1: breadcrumb + status pill + orchestrator indicator */}
+ <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+ <FileIcon />
+ <span>directives /</span>
+ <span className="text-[#9bc3ff]">
+ {directive.title.trim().length > 0
+ ? directive.title
+ : directive.id.slice(0, 8)}
+ </span>
+ <span>/</span>
+ <span className="text-white">{docTitle}</span>
+ <ContractStatusPill status={doc.status} />
+ {!!directive.orchestratorTaskId && (
+ <span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
+ <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
+ orchestrator running
+ </span>
+ )}
+ </div>
+
+ {/* Row 2: action buttons (status-driven) + merge mode + error */}
+ <div className="flex items-center gap-2 text-[11px] font-mono">
+ {doc.status === "draft" && (
+ <ContractActionButton onClick={onStart} disabled={busy !== null} variant="primary">
+ {busy === "start" ? "Starting…" : "Lock & Start"}
+ </ContractActionButton>
+ )}
+ {doc.status === "queued" && (
+ <ContractActionButton onClick={onUnlock} disabled={busy !== null}>
+ {busy === "unlock" ? "Unlocking…" : "Unlock"}
+ </ContractActionButton>
+ )}
+ {doc.status === "active" && (
+ <>
+ <ContractActionButton onClick={onPause} disabled={busy !== null}>
+ {busy === "pause" ? "Pausing…" : "Pause"}
+ </ContractActionButton>
+ <ContractActionButton onClick={onComplete} disabled={busy !== null} variant="primary">
+ {busy === "complete" ? "Completing…" : "Mark complete"}
+ </ContractActionButton>
+ <ContractActionButton onClick={onUnlock} disabled={busy !== null}>
+ {busy === "unlock" ? "Unlocking…" : "Unlock"}
+ </ContractActionButton>
+ </>
+ )}
+
+ {/* Merge mode radios — visible always, editable only in draft/queued */}
+ <div className="ml-auto flex items-center gap-2 text-[#7788aa]">
+ <span className="uppercase tracking-wide">merge:</span>
+ <MergeModeRadio
+ value={doc.mergeMode}
+ onChange={onMergeMode}
+ disabled={!editableMergeMode || busy !== null}
+ />
+ </div>
+ </div>
+
+ {error && (
+ <div className="text-[10px] font-mono text-red-400">{error}</div>
+ )}
+ </div>
+ );
+}
+
+function ContractStatusPill({ status }: { status: ContractStatus }) {
+ const styles: Record<ContractStatus, { label: string; cls: string }> = {
+ draft: { label: "draft", cls: "text-[#556677]" },
+ queued: { label: "queued", cls: "text-amber-400" },
+ active: { label: "active", cls: "text-green-400" },
+ shipped: { label: "shipped", cls: "text-[#75aafc]" },
+ archived: { label: "archived", cls: "text-[#7788aa]" },
+ };
+ const s = styles[status];
+ return <span className={`ml-2 normal-case ${s.cls}`}>{s.label}</span>;
+}
+
+function ContractActionButton({
+ children,
+ onClick,
+ disabled,
+ variant,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ variant?: "primary";
+}) {
+ const base =
+ "px-2 py-1 border border-[rgba(117,170,252,0.3)] rounded text-[10px] uppercase tracking-wide transition-colors";
+ const colors =
+ variant === "primary"
+ ? "text-green-300 hover:bg-[rgba(120,200,140,0.1)]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]";
+ const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className={`${base} ${colors} ${dim}`}
+ >
+ {children}
+ </button>
+ );
+}
+
+function MergeModeRadio({
+ value,
+ onChange,
+ disabled,
+}: {
+ value: ContractMergeMode;
+ onChange: (mode: ContractMergeMode) => void;
+ disabled?: boolean;
+}) {
+ const opt = (mode: ContractMergeMode, label: string) => {
+ const selected = value === mode;
+ const cls = selected
+ ? "text-white border-[rgba(117,170,252,0.6)] bg-[rgba(117,170,252,0.1)]"
+ : "text-[#7788aa] border-transparent hover:text-[#9bc3ff]";
+ const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
+ return (
+ <button
+ key={mode}
+ type="button"
+ onClick={() => !disabled && !selected && onChange(mode)}
+ disabled={disabled}
+ className={`px-2 py-0.5 rounded border ${cls} ${dim} text-[10px] uppercase tracking-wide`}
+ >
+ {label}
+ </button>
+ );
+ };
+ return (
+ <div className="flex items-center gap-1">
+ {opt("shared", "shared")}
+ {opt("own_pr", "own pr")}
+ </div>
+ );
+}
+
+// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
// 1) documentId selected → fetch the Contract and edit doc.body via
@@ -1093,35 +1321,15 @@ function EditorShell({
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
- {/* Breadcrumb — directives / <directive title> / <document title>.md */}
- <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
- <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
- <FileIcon />
- <span>directives /</span>
- <span className="text-[#9bc3ff]">
- {directive.title.trim().length > 0
- ? directive.title
- : directive.id.slice(0, 8)}
- </span>
- <span>/</span>
- <span className="text-white">{docTitle}</span>
- {doc.status === "shipped" && (
- <span className="ml-2 text-[#75aafc] normal-case">shipped</span>
- )}
- {doc.status === "archived" && (
- <span className="ml-2 text-[#7788aa] normal-case">archived</span>
- )}
- {doc.status === "draft" && (
- <span className="ml-2 text-[#556677] normal-case">draft</span>
- )}
- {!!directive.orchestratorTaskId && (
- <span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
- <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
- orchestrator running
- </span>
- )}
- </div>
- </div>
+ <ContractHeader
+ directive={directive}
+ doc={doc}
+ docTitle={docTitle}
+ onContractChanged={(updated) => {
+ setDoc(updated);
+ onDocumentChanged();
+ }}
+ />
<DocumentEditor
// Keying by document id ensures the Lexical editor remounts cleanly