import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { Masthead } from "../components/Masthead";
import { useDirective, useDirectives } from "../hooks/useDirectives";
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
import {
startDirective,
pauseDirective,
updateDirective,
deleteDirective,
completeDirectiveStep,
failDirectiveStep,
skipDirectiveStep,
stopTask,
listDirectiveRevisions,
newDirectiveDraft,
createDirectivePR,
advanceDirective,
cleanupDirective,
pickUpOrders,
sendTaskMessage,
listDirectiveEphemeralTasks,
createDirectiveTask,
listOrphanTasks,
} from "../lib/api";
import type {
DirectiveStatus,
DirectiveSummary,
DirectiveWithSteps,
DirectiveRevision,
TaskSummary,
} from "../lib/api";
// Status dot color, matching the existing tabular UI's badge palette so the
// document mode feels like a sibling of the existing list, not a foreign UI.
const STATUS_DOT: Record<DirectiveStatus, string> = {
draft: "bg-[#556677]",
active: "bg-green-400",
idle: "bg-yellow-400",
paused: "bg-orange-400",
inactive: "bg-[#75aafc]",
archived: "bg-[#3a4a6a]",
};
// Per-task dot color for the sidebar entries inside a directive folder.
// Matches the StepsBlockNode palette.
const STEP_STATUS_DOT: Record<string, string> = {
pending: "bg-[#556677]",
ready: "bg-[#9bc3ff]",
running: "bg-yellow-400",
done: "bg-green-400",
failed: "bg-red-400",
skipped: "bg-[#3a4a6a]",
};
// =============================================================================
// Sidebar icons (inline SVG, no new deps)
// =============================================================================
function FolderIcon({ open = false }: { open?: boolean }) {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
{open ? (
<path
d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z"
fill="#75aafc"
opacity="0.85"
/>
) : (
<path
d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z"
fill="#75aafc"
opacity="0.65"
/>
)}
</svg>
);
}
function FileIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<path
d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"
fill="none"
stroke="#9bc3ff"
strokeWidth="1"
/>
<path
d="M9.5 1.5v3h3"
fill="none"
stroke="#9bc3ff"
strokeWidth="1"
/>
</svg>
);
}
/** Terminal/prompt icon for orchestrator and step tasks. */
function TaskIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" />
</svg>
);
}
/** Asterisk-on-terminal icon for ephemeral spinoff tasks — visually
distinct from the plain TaskIcon used for step-spawned execution tasks
so users can tell at a glance which tasks are part of the DAG vs which
are user-spun side quests. */
function EphemeralTaskIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#c084fc" strokeWidth="1" />
<path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#c084fc" strokeWidth="1" fill="none" strokeLinecap="round" />
<path d="M11 4l1 1m-1 0l1-1" stroke="#c084fc" strokeWidth="1" fill="none" />
</svg>
);
}
/** PR-bracket icon for the completion task. */
function CompletionIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" />
</svg>
);
}
function PinIcon() {
return (
<svg
viewBox="0 0 16 16"
width={10}
height={10}
className="shrink-0"
aria-hidden
>
<path
d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z"
fill="#75aafc"
opacity="0.7"
/>
</svg>
);
}
/** Tiny chip used for the inline directive-folder hover actions. */
function FolderActionButton({
children,
title,
onClick,
}: {
children: React.ReactNode;
title: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="w-5 h-5 flex items-center justify-center text-[10px] text-[#7788aa] hover:text-white hover:bg-[rgba(117,170,252,0.15)] rounded transition-colors"
>
{children}
</button>
);
}
function Caret({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 8 8"
width={8}
height={8}
className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
aria-hidden
>
<path d="M2 1l4 3-4 3z" fill="#7788aa" />
</svg>
);
}
// =============================================================================
// Sidebar
// =============================================================================
// =============================================================================
// Task row context menu — sits next to DirectiveContextMenu and offers the
// task-level controls (interrupt for orchestrator/completion, complete/fail/
// skip for step tasks).
// =============================================================================
interface TaskContextMenuProps {
x: number;
y: number;
task: FolderTaskRow;
onClose: () => void;
onInterrupt: () => void;
onComplete?: () => void;
onFail?: () => void;
onSkip?: () => void;
/** Send a freeform message to the running task (same wire as the inline comment box). */
onSendMessage?: () => void;
/** Navigate to the standalone task page for full-screen control. */
onOpenInTaskPage?: () => void;
}
function TaskContextMenu({
x,
y,
task,
onClose,
onInterrupt,
onComplete,
onFail,
onSkip,
onSendMessage,
onOpenInTaskPage,
}: TaskContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const click = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", click);
document.addEventListener("keydown", key);
return () => {
document.removeEventListener("mousedown", click);
document.removeEventListener("keydown", key);
};
}, [onClose]);
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (rect.right > window.innerWidth) ref.current.style.left = `${x - rect.width}px`;
if (rect.bottom > window.innerHeight) ref.current.style.top = `${y - rect.height}px`;
}, [x, y]);
const item =
"w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
const divider = "border-t border-[rgba(117,170,252,0.2)] my-1";
// Interrupt is meaningful for live tasks (orchestrator-active or running steps).
const showInterrupt =
task.kind === "orchestrator-active" ||
task.kind === "completion" ||
task.status === "running";
// Step lifecycle controls only apply to step tasks.
const isStep = task.kind === "step";
const showComplete = isStep && task.status !== "done";
const showFail = isStep && task.status !== "failed";
const showSkip = isStep && task.status !== "skipped";
return (
<div
ref={ref}
className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
style={{ left: x, top: y }}
>
<div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[220px]">
{task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label}
</div>
{showInterrupt && (
<button
className={item}
onClick={() => {
onInterrupt();
onClose();
}}
>
<span className="text-amber-300">⏹</span>
Interrupt
</button>
)}
{(showComplete || showFail || showSkip) && <div className={divider} />}
{showComplete && (
<button
className={item}
onClick={() => {
onComplete?.();
onClose();
}}
>
<span className="text-emerald-400">✓</span>
Mark complete
</button>
)}
{showFail && (
<button
className={item}
onClick={() => {
onFail?.();
onClose();
}}
>
<span className="text-red-400">✗</span>
Mark failed
</button>
)}
{showSkip && (
<button
className={item}
onClick={() => {
onSkip?.();
onClose();
}}
>
<span className="text-[#7788aa]">⤳</span>
Skip
</button>
)}
{/* Direct task-page actions: send-message and open-in-task-page mirror
what the standalone /exec/:taskId page exposes. */}
{(onSendMessage || onOpenInTaskPage) && <div className={divider} />}
{onSendMessage && (
<button
className={item}
onClick={() => {
onSendMessage();
onClose();
}}
>
<span className="text-cyan-300">⌨</span>
Send message
</button>
)}
{onOpenInTaskPage && (
<button
className={item}
onClick={() => {
onOpenInTaskPage();
onClose();
}}
>
<span className="text-[#75aafc]">↗</span>
Open in task page
</button>
)}
</div>
);
}
function slugify(title: string, fallback: string): string {
const slug = title
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9._-]/g, "")
.toLowerCase();
return slug.length > 0 ? slug : fallback;
}
interface SidebarSelection {
directiveId: string;
/** null = the directive's document; otherwise a task id (orchestrator/step). */
taskId: string | null;
}
/**
* Per-directive folder. Renders the directive as a collapsible folder whose
* body is the pinned document entry (always first) followed by a `tasks/`
* subfolder containing the orchestrator, completion, and step tasks.
*
* Status dot lives on the right side only (single-side, per the v2 design).
* If a directive or task has a pending user question, its icon glows.
*/
function DirectiveFolder({
directive,
open,
onToggle,
selection,
onSelect,
pendingTaskIds,
hasPendingForDirective,
onDirectiveContextMenu,
onTaskContextMenu,
onCreateTask,
onQuickAction,
}: {
directive: DirectiveSummary;
open: boolean;
onToggle: () => void;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
/** Set of task ids that currently have pending user questions. */
pendingTaskIds: Set<string>;
/** Whether any pending question is associated with this directive. */
hasPendingForDirective: boolean;
onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
/** Open the inline "+ New task" form for this directive. */
onCreateTask: (d: DirectiveSummary) => void;
/** Trigger a quick action (start/pause/PR) on the directive. */
onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
}) {
const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;
// Lazy fetch full directive (with steps) only when folder is open.
const { directive: detailed } = useDirective(open ? directive.id : undefined);
const docSelected =
selection?.directiveId === directive.id && selection.taskId === null;
// Ephemeral tasks attached to this directive (no directive_step_id). Fetched
// lazily when the folder opens; refetched whenever a poll lands on the
// directive's detail (poll-driven freshness).
const [ephemeralTasks, setEphemeralTasks] = useState<TaskSummary[]>([]);
useEffect(() => {
if (!open) return;
let cancelled = false;
listDirectiveEphemeralTasks(directive.id)
.then((res) => {
if (!cancelled) setEphemeralTasks(res.tasks);
})
.catch((err) => {
// eslint-disable-next-line no-console
console.warn("[makima] failed to load ephemeral tasks", err);
});
return () => {
cancelled = true;
};
}, [open, directive.id, directive.updatedAt]);
// Collect the tasks to surface in the folder body.
const tasks = useMemo(
() => collectTasks(detailed, directive, ephemeralTasks),
[detailed, directive, ephemeralTasks],
);
const orchestratorRunning = !!directive.orchestratorTaskId;
// Tasks subfolder open state — independent of the directive folder.
const [tasksOpen, setTasksOpen] = useState<boolean>(true);
// Revisions subfolder — collapsed by default since most contracts have
// no merged history yet.
const [revisionsOpen, setRevisionsOpen] = useState<boolean>(false);
const [revisions, setRevisions] = useState<DirectiveRevision[]>([]);
// Fetch revisions only when the parent folder is open. Re-fetch whenever
// the directive's pr_url changes so a freshly-raised PR appears.
useEffect(() => {
if (!open) return;
let cancelled = false;
listDirectiveRevisions(directive.id)
.then((res) => {
if (!cancelled) setRevisions(res.revisions);
})
.catch((err) => {
// eslint-disable-next-line no-console
console.warn("[makima] failed to load revisions", err);
});
return () => {
cancelled = true;
};
}, [open, directive.id, directive.prUrl]);
// Inline action buttons on the folder header — visible on hover (and when
// the folder is open) so users don't have to right-click to discover the
// primary directive controls. Mirrors a code-editor sidebar's affordance.
const showStart =
directive.status === "draft" ||
directive.status === "paused" ||
directive.status === "idle" ||
directive.status === "inactive";
const showPause = directive.status === "active";
return (
<div className="select-none group/dir">
<div
className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
>
<button
type="button"
onClick={onToggle}
title={directive.title}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
>
<Caret open={open} />
<FolderIcon open={open} />
<span className="truncate flex-1">{directive.title}</span>
</button>
{/* Hover/open-only action chips — discoverable replacement for the
right-click menu. Right-click still works as a power-user fallback. */}
<div
className={`flex items-center gap-0.5 transition-opacity ${
open
? "opacity-100"
: "opacity-0 group-hover/dir:opacity-100"
}`}
>
{showStart && (
<FolderActionButton
title="Start"
onClick={() => onQuickAction(directive, "start")}
>
▶
</FolderActionButton>
)}
{showPause && (
<FolderActionButton
title="Pause"
onClick={() => onQuickAction(directive, "pause")}
>
❚❚
</FolderActionButton>
)}
{directive.prUrl && (
<FolderActionButton
title="Open PR"
onClick={() =>
window.open(directive.prUrl ?? "", "_blank", "noreferrer")
}
>
↗
</FolderActionButton>
)}
<FolderActionButton
title="New task"
onClick={() => onCreateTask(directive)}
>
+
</FolderActionButton>
</div>
{/* Status dot — RIGHT side only. Glows when this directive has a
pending user question, or pulses when the orchestrator is live. */}
<StatusDot
color={dotColor}
live={orchestratorRunning}
glow={hasPendingForDirective}
status={directive.status}
/>
</div>
{open && (
<ul className="py-0.5">
{/* Pinned document entry — always at the top of the folder. */}
<li>
<button
type="button"
onClick={() =>
onSelect({ directiveId: directive.id, taskId: null })
}
className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${
docSelected
? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
}`}
>
<PinIcon />
<FileIcon />
<span className="truncate flex-1">{fileName}</span>
</button>
</li>
{/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
<li>
<button
type="button"
onClick={() => setTasksOpen((p) => !p)}
className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
<Caret open={tasksOpen} />
<FolderIcon open={tasksOpen} />
<span className="truncate flex-1 text-left">tasks/</span>
{tasks.length > 0 && (
<span className="text-[10px] text-[#556677]">{tasks.length}</span>
)}
</button>
{tasksOpen && (
<ul className="py-0.5">
{tasks.length === 0 ? (
<li className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
No tasks yet
</li>
) : (
tasks.map((t) => {
const isSelected =
selection?.directiveId === directive.id &&
selection?.taskId === t.taskId;
const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
const live =
t.status === "running" || t.kind === "orchestrator-active";
const glow = pendingTaskIds.has(t.taskId);
const Icon =
t.kind === "completion"
? CompletionIcon
: t.kind === "ephemeral"
? EphemeralTaskIcon
: TaskIcon;
return (
<li key={t.taskId}>
<button
type="button"
onClick={() =>
onSelect({
directiveId: directive.id,
taskId: t.taskId,
})
}
onContextMenu={(e) =>
onTaskContextMenu(e, t, directive.id)
}
title={t.label}
className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
isSelected
? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
}`}
>
<Icon />
<span className="truncate flex-1">{t.label}</span>
<StatusDot
color={tdot}
live={live}
glow={glow}
status={t.status}
/>
</button>
</li>
);
})
)}
</ul>
)}
</li>
{/* revisions/ subfolder — per-PR frozen snapshots of this contract.
Only rendered when there's at least one revision; otherwise the
folder body would be a confusing empty placeholder. */}
{revisions.length > 0 && (
<li>
<button
type="button"
onClick={() => setRevisionsOpen((p) => !p)}
className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
<Caret open={revisionsOpen} />
<FolderIcon open={revisionsOpen} />
<span className="truncate flex-1 text-left">revisions/</span>
<span className="text-[10px] text-[#556677]">
{revisions.length}
</span>
</button>
{revisionsOpen && (
<ul className="py-0.5">
{revisions.map((r) => {
const isSelected =
selection?.directiveId === directive.id &&
selection?.taskId === `revision:${r.id}`;
return (
<li key={r.id}>
<button
type="button"
onClick={() =>
onSelect({
directiveId: directive.id,
taskId: `revision:${r.id}`,
})
}
title={`v${r.version} · ${r.prState} · ${r.prUrl}`}
className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
isSelected
? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
}`}
>
<FileIcon />
<span className="truncate flex-1">
v{r.version}.md
</span>
<RevisionStateBadge prState={r.prState} />
</button>
</li>
);
})}
</ul>
)}
</li>
)}
</ul>
)}
</div>
);
}
/**
* Read-only viewer for a frozen contract revision. We render the markdown as
* plain pre-formatted text — these are immutable historical records, not
* places to edit. A header strip shows the PR state and a deep link.
*/
function RevisionViewer({
directiveId,
revisionId,
}: {
directiveId: string;
revisionId: string;
}) {
const [revision, setRevision] = useState<DirectiveRevision | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
listDirectiveRevisions(directiveId)
.then((res) => {
if (cancelled) return;
const found = res.revisions.find((r) => r.id === revisionId) ?? null;
if (!found) setError("Revision not found");
setRevision(found);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [directiveId, revisionId]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[#556677] font-mono text-[12px]">Loading revision…</p>
</div>
);
}
if (error || !revision) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-red-400 font-mono text-[12px]">
{error ?? "Revision not found"}
</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]">
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-[24px] font-medium text-white tracking-tight">
v{revision.version}
</h1>
<RevisionStateBadge prState={revision.prState} />
</div>
<p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-1">
Frozen {new Date(revision.frozenAt).toLocaleString()}
</p>
<p className="text-[11px] font-mono text-[#7788aa] mb-8">
<a
href={revision.prUrl}
target="_blank"
rel="noreferrer"
className="text-[#75aafc] hover:text-[#9bc3ff] underline"
>
{revision.prUrl}
</a>
</p>
{/* Render the frozen markdown as plain pre-formatted text. We
deliberately do not parse it into rich nodes — the goal is to
show the historical record exactly as it was at PR time. */}
<pre className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-[#e0eaf8]">
{revision.content}
</pre>
</div>
</div>
</div>
);
}
/** Tiny pill showing the PR state of a revision (open / merged / closed). */
function RevisionStateBadge({ prState }: { prState: string }) {
const tone =
prState === "merged"
? "text-emerald-300 border-emerald-700/60"
: prState === "closed"
? "text-[#7788aa] border-[#2a3a5a]"
: "text-amber-300 border-amber-600/40";
return (
<span
className={`text-[9px] font-mono uppercase border rounded px-1 py-0 ${tone}`}
>
{prState}
</span>
);
}
/**
* Right-side status indicator. Composes the colored status dot with optional
* "live" pulse (orchestrator running) and "glow" attention ring (pending user
* question waiting on a response).
*/
function StatusDot({
color,
live,
glow,
status,
}: {
color: string;
live: boolean;
glow: boolean;
status: string;
}) {
// The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it
// doesn't fight the live pulse for attention when both are present.
const ring = glow
? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse"
: "";
const livePulse = live && !glow ? "animate-pulse" : "";
const title = glow
? `${status} — needs response`
: live
? `${status} — running`
: `status: ${status}`;
return (
<span
className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`}
aria-label={title}
title={title}
/>
);
}
interface FolderTaskRow {
taskId: string;
/** Directive step id for step kinds — needed for complete/fail/skip APIs. */
stepId: string | null;
label: string;
status: string;
kind: "orchestrator-active" | "completion" | "step" | "ephemeral";
}
function collectTasks(
detailed: DirectiveWithSteps | null,
summary: DirectiveSummary,
ephemeralTasks: TaskSummary[],
): FolderTaskRow[] {
const rows: FolderTaskRow[] = [];
// Orchestrator (planner) — surfaces only while it's actively running so
// the folder is not flooded with stale orchestrator entries.
const orchestratorId =
detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null;
if (orchestratorId) {
rows.push({
taskId: orchestratorId,
stepId: null,
label: "orchestrator",
status: "running",
kind: "orchestrator-active",
});
}
// Completion (PR creation) task.
const completionId =
detailed?.completionTaskId ?? summary.completionTaskId ?? null;
if (completionId) {
rows.push({
taskId: completionId,
stepId: null,
label: "completion",
status: "running",
kind: "completion",
});
}
// Step tasks — only steps that have actually been started have a taskId.
if (detailed) {
for (const step of detailed.steps) {
if (!step.taskId) continue;
rows.push({
taskId: step.taskId,
stepId: step.id,
label: step.name,
status: step.status,
kind: "step",
});
}
}
// Ephemeral tasks — user-spawned spinoffs not part of the DAG. Surfaced
// alongside step tasks but with a different icon and the "ephemeral" kind
// so context menus and the merge button behave correctly.
for (const t of ephemeralTasks) {
rows.push({
taskId: t.id,
stepId: null,
label: t.name,
status: t.status,
kind: "ephemeral",
});
}
return rows;
}
interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
/** Open the inline "+ New task" form for this directive. */
onCreateTask: (d: DirectiveSummary) => void;
/** Trigger a quick action (start/pause/PR) on the directive. */
onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void;
/** Navigate to an orphan (no-directive) task's standalone view. */
onSelectOrphan: (taskId: string) => void;
}
function DocumentSidebar({
directives,
loading,
selection,
onSelect,
onDirectiveContextMenu,
onTaskContextMenu,
onCreateTask,
onQuickAction,
onSelectOrphan,
}: SidebarProps) {
// Orphan tasks (no directive) — top-level "tmp/" pseudo-folder. Polled
// every 5s so newly-spawned standalone tasks appear without a manual
// refresh.
const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]);
useEffect(() => {
let cancelled = false;
const load = () => {
listOrphanTasks()
.then((res) => {
if (!cancelled) setOrphanTasks(res.tasks);
})
.catch(() => {
/* swallow — tmp/ is a nice-to-have, never blocking */
});
};
load();
const interval = setInterval(load, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
const [tmpOpen, setTmpOpen] = useState<boolean>(true);
// Pending user questions — drives the "glow" attention ring. We split into
// two indices so the directive folder header glows whenever ANY of its
// tasks has a pending question, while individual task rows glow only for
// their own question.
const { pendingQuestions } = useSupervisorQuestions();
const { directivesWithPending, tasksWithPending } = useMemo(() => {
const dirs = new Set<string>();
const tasks = new Set<string>();
for (const q of pendingQuestions) {
if (q.directiveId) dirs.add(q.directiveId);
if (q.taskId) tasks.add(q.taskId);
}
return { directivesWithPending: dirs, tasksWithPending: tasks };
}, [pendingQuestions]);
// Sort active first, then idle, then paused, then drafts, then inactive
// (shipped contracts are quieter), then archived.
const sorted = useMemo(() => {
const order: Record<DirectiveStatus, number> = {
active: 0,
paused: 1,
idle: 2,
draft: 3,
inactive: 4,
archived: 5,
};
return [...directives].sort((a, b) => {
const oa = order[a.status] ?? 99;
const ob = order[b.status] ?? 99;
if (oa !== ob) return oa - ob;
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
});
}, [directives]);
// Track which directive folders are open. The currently selected directive
// is forced open so deep links land on something visible.
const [openIds, setOpenIds] = useState<Set<string>>(new Set());
const lastSelectedRef = useRef<string | null>(null);
useEffect(() => {
if (selection && selection.directiveId !== lastSelectedRef.current) {
lastSelectedRef.current = selection.directiveId;
setOpenIds((prev) => {
if (prev.has(selection.directiveId)) return prev;
const next = new Set(prev);
next.add(selection.directiveId);
return next;
});
}
}, [selection]);
const toggleOpen = useCallback((id: string) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
return (
<div className="flex flex-col h-full">
{/* Sidebar header */}
<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">
Contracts
</span>
<span className="text-[10px] font-mono text-[#556677]">
{directives.length}
</span>
</div>
{/* Top-level "contracts/" folder header (informational, non-interactive). */}
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
<FolderIcon open />
<span>contracts/</span>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto pb-4">
{/* tmp/ pseudo-folder — orphan tasks (directive_id NULL). Always
rendered so users can create scratchpad tasks even when zero
directives exist; collapses to a thin header when empty. */}
<div className="select-none border-b border-dashed border-[rgba(117,170,252,0.1)] pb-1 mb-1">
<button
type="button"
onClick={() => setTmpOpen((p) => !p)}
className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
<Caret open={tmpOpen} />
<FolderIcon open={tmpOpen} />
<span className="truncate flex-1 text-left text-[#7788aa]">tmp/</span>
<span className="text-[10px] text-[#556677]">
{orphanTasks.length}
</span>
</button>
{tmpOpen && (
<ul className="py-0.5">
{orphanTasks.length === 0 ? (
<li className="pl-8 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
No orphan tasks
</li>
) : (
orphanTasks.map((t) => {
const tdot =
STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
const live = t.status === "running";
return (
<li key={t.id}>
<button
type="button"
onClick={() => onSelectOrphan(t.id)}
title={t.name}
className="w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent transition-colors"
>
<TaskIcon />
<span className="truncate flex-1">{t.name}</span>
<StatusDot
color={tdot}
live={live}
glow={false}
status={t.status}
/>
</button>
</li>
);
})
)}
</ul>
)}
</div>
{loading && directives.length === 0 ? (
<div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
Loading...
</div>
) : directives.length === 0 ? (
<div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
No contracts yet
</div>
) : (
sorted.map((d) => (
<DirectiveFolder
key={d.id}
directive={d}
open={openIds.has(d.id)}
onToggle={() => toggleOpen(d.id)}
selection={selection}
onSelect={onSelect}
pendingTaskIds={tasksWithPending}
hasPendingForDirective={directivesWithPending.has(d.id)}
onDirectiveContextMenu={onDirectiveContextMenu}
onTaskContextMenu={onTaskContextMenu}
onCreateTask={onCreateTask}
onQuickAction={onQuickAction}
/>
))
)}
</div>
</div>
);
}
// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states.
// =============================================================================
/**
* Wraps DocumentTaskStream with ephemeral-aware metadata. Determines whether
* the selected task is part of the directive's DAG (orchestrator/completion/
* steps) or an ephemeral spinoff, and looks up its current status from the
* ephemeral list — that decides whether the "Merge to base" affordance
* should appear in the stream's action header.
*/
function EphemeralAwareTaskStream({
taskId,
label,
directive,
}: {
taskId: string;
label: string;
directive: DirectiveWithSteps;
}) {
const isStepBound =
taskId === directive.orchestratorTaskId ||
taskId === directive.completionTaskId ||
directive.steps.some((s) => s.taskId === taskId);
// Status lookup for ephemeral tasks. We poll the ephemeral list lazily —
// this is a lightweight call and only triggers when the user is viewing a
// task in the editor pane.
const [ephemeralStatus, setEphemeralStatus] = useState<string | undefined>();
useEffect(() => {
if (isStepBound) return;
let cancelled = false;
const load = () => {
listDirectiveEphemeralTasks(directive.id)
.then((res) => {
if (cancelled) return;
const match = res.tasks.find((t) => t.id === taskId);
setEphemeralStatus(match?.status);
})
.catch(() => {
/* non-blocking */
});
};
load();
const interval = setInterval(load, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [taskId, directive.id, isStepBound]);
return (
<DocumentTaskStream
taskId={taskId}
label={label}
ephemeral={!isStepBound}
status={ephemeralStatus}
/>
);
}
interface EditorShellProps {
selectedId: string | undefined;
selectedTaskId: string | null;
hasDirectives: boolean;
listLoading: boolean;
onClearTask: () => void;
}
function EditorShell({
selectedId,
selectedTaskId,
hasDirectives,
listLoading,
onClearTask,
}: EditorShellProps) {
const {
directive,
loading,
updateGoal,
cleanup,
createPR,
pickUpOrders,
} = useDirective(selectedId);
if (!selectedId) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">
{listLoading
? "Loading contracts..."
: hasDirectives
? "Select a contract from the sidebar"
: "No contracts yet — create one from the legacy UI"}
</p>
</div>
);
}
if (loading && !directive) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">Loading contract...</p>
</div>
);
}
if (!directive) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#7788aa] font-mono text-[12px]">Contract not found</p>
</div>
);
}
// The "task" param can encode either a real task id, or a revision via the
// `revision:<uuid>` prefix. Split that out so the right pane can switch
// between the live task stream and the read-only revision viewer.
const revisionId =
selectedTaskId && selectedTaskId.startsWith("revision:")
? selectedTaskId.slice("revision:".length)
: null;
const realTaskId = revisionId ? null : selectedTaskId;
// Resolve the label for the breadcrumb when a task is selected.
const taskLabel = realTaskId
? realTaskId === directive.orchestratorTaskId
? "orchestrator"
: realTaskId === directive.completionTaskId
? "completion"
: directive.steps.find((s) => s.taskId === realTaskId)?.name ??
realTaskId.slice(0, 8)
: revisionId
? "revision"
: null;
// "Now executing" strip — surfaces what's live when looking at the
// contract editor, so users don't have to scan the sidebar to find it.
const liveTask = (() => {
if (selectedTaskId) return null; // already viewing a task; strip is redundant
if (directive.orchestratorTaskId) {
return { id: directive.orchestratorTaskId, name: "orchestrator" };
}
if (directive.completionTaskId) {
return { id: directive.completionTaskId, name: "completion" };
}
const runningStep = directive.steps.find((s) => s.status === "running");
if (runningStep && runningStep.taskId) {
return { id: runningStep.taskId, name: runningStep.name };
}
return null;
})();
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */}
<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>contracts /</span>
<span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span>
{selectedTaskId && (
<>
<span>/</span>
<span className="text-[#9bc3ff]">{taskLabel}</span>
<button
type="button"
onClick={onClearTask}
className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case"
>
back to contract
</button>
</>
)}
</div>
</div>
{/* Now-executing strip — only when viewing the contract doc itself.
Click to jump into the live task transcript. */}
{liveTask && (
<button
type="button"
onClick={() =>
// Navigate via the search-param so EditorShell switches to the
// task stream for this live task.
(window.location.search = `?task=${liveTask.id}`)
}
className="shrink-0 flex items-center gap-2 px-6 py-1.5 bg-amber-900/15 border-b border-amber-700/40 text-amber-300 font-mono text-[11px] hover:bg-amber-900/30 transition-colors"
>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
<span className="uppercase tracking-wide text-[10px]">Now executing</span>
<span className="text-[#dbe7ff]">{liveTask.name}</span>
<span className="ml-auto text-[10px] text-amber-200/70">
click to view transcript ↗
</span>
</button>
)}
{revisionId ? (
<RevisionViewer directiveId={directive.id} revisionId={revisionId} />
) : realTaskId ? (
<EphemeralAwareTaskStream
taskId={realTaskId}
label={taskLabel ?? realTaskId.slice(0, 8)}
directive={directive}
/>
) : (
<DocumentEditor
directive={directive}
onUpdateGoal={async (goal) => {
await updateGoal(goal);
}}
onCleanup={async () => {
await cleanup();
}}
onCreatePR={async () => {
await createPR();
}}
onPickUpOrders={async () => {
await pickUpOrders();
}}
/>
)}
</div>
);
}
// =============================================================================
// Page
// =============================================================================
type ContextMenuState =
| { kind: "directive"; x: number; y: number; directive: DirectiveSummary }
| {
kind: "task";
x: number;
y: number;
task: FolderTaskRow;
directiveId: string;
}
| null;
export default function DocumentDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const selectedTaskId = searchParams.get("task");
const { directives, loading: listLoading, refresh: refreshList } = useDirectives();
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
useEffect(() => {
if (!authLoading && isAuthConfigured && !isAuthenticated) {
navigate("/login");
}
}, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
const onSelect = useCallback(
(sel: SidebarSelection) => {
const next = `/directives/${sel.directiveId}${
sel.taskId ? `?task=${sel.taskId}` : ""
}`;
navigate(next);
},
[navigate],
);
const onClearTask = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete("task");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const onDirectiveContextMenu = useCallback(
(e: React.MouseEvent, d: DirectiveSummary) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ kind: "directive", x: e.clientX, y: e.clientY, directive: d });
},
[],
);
const onTaskContextMenu = useCallback(
(e: React.MouseEvent, task: FolderTaskRow, directiveId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ kind: "task", x: e.clientX, y: e.clientY, task, directiveId });
},
[],
);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
// Inline "+ New task" form state. When set, we render a small modal-ish
// overlay anchored to the directive folder; submitting calls the
// ephemeral-task endpoint.
const [newTaskFor, setNewTaskFor] = useState<DirectiveSummary | null>(null);
const onCreateTask = useCallback((d: DirectiveSummary) => {
setNewTaskFor(d);
}, []);
const handleSubmitNewTask = useCallback(
async (name: string, plan: string) => {
if (!newTaskFor) return;
try {
const task = await createDirectiveTask(newTaskFor.id, { name, plan });
// Navigate the user into the freshly-spawned task's transcript.
navigate(`/directives/${newTaskFor.id}?task=${task.id}`);
setNewTaskFor(null);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[makima] failed to create ephemeral task", err);
alert(
err instanceof Error
? `Failed to create task: ${err.message}`
: "Failed to create task",
);
}
},
[newTaskFor, navigate],
);
const onQuickAction = useCallback(
async (d: DirectiveSummary, action: "start" | "pause" | "pr") => {
try {
if (action === "start") {
await startDirective(d.id);
} else if (action === "pause") {
await pauseDirective(d.id);
} else if (action === "pr") {
await createDirectivePR(d.id);
}
await refreshList();
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[makima] quick action ${action} failed`, err);
}
},
[refreshList],
);
const onSelectOrphan = useCallback(
(taskId: string) => {
navigate(`/tmp/${taskId}`);
},
[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 selection: SidebarSelection | null = selectedId
? { directiveId: selectedId, taskId: selectedTaskId }
: null;
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: file-tree sidebar */}
<div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
<DocumentSidebar
directives={directives}
loading={listLoading}
selection={selection}
onSelect={onSelect}
onDirectiveContextMenu={onDirectiveContextMenu}
onTaskContextMenu={onTaskContextMenu}
onCreateTask={onCreateTask}
onQuickAction={onQuickAction}
onSelectOrphan={onSelectOrphan}
/>
</div>
{/* Right: Lexical editor / task stream */}
<EditorShell
selectedId={selectedId}
selectedTaskId={selectedTaskId}
hasDirectives={directives.length > 0}
listLoading={listLoading}
onClearTask={onClearTask}
/>
</main>
{/* Context menus — rendered at page level so they overlay everything. */}
{contextMenu?.kind === "directive" && (
<DirectiveContextMenu
x={contextMenu.x}
y={contextMenu.y}
directive={contextMenu.directive}
onClose={closeContextMenu}
onStart={async () => {
await startDirective(contextMenu.directive.id);
await refreshList();
}}
onPause={async () => {
await pauseDirective(contextMenu.directive.id);
await refreshList();
}}
onArchive={async () => {
await updateDirective(contextMenu.directive.id, {
status: "archived",
});
await refreshList();
}}
onDelete={async () => {
if (
!window.confirm(
`Delete "${contextMenu.directive.title}"? This cannot be undone.`,
)
) {
return;
}
await deleteDirective(contextMenu.directive.id);
await refreshList();
// If the deleted one was selected, clear selection.
if (selectedId === contextMenu.directive.id) {
navigate("/directives");
}
}}
onGoToPR={() => {
if (contextMenu.directive.prUrl) {
window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
}
}}
onNewDraft={async () => {
await newDirectiveDraft(contextMenu.directive.id);
await refreshList();
// Send the user into the freshly-cleared contract so they can
// start typing the next iteration immediately.
navigate(`/directives/${contextMenu.directive.id}`);
}}
onCreatePR={async () => {
await createDirectivePR(contextMenu.directive.id);
await refreshList();
}}
onAdvance={async () => {
await advanceDirective(contextMenu.directive.id);
await refreshList();
}}
onCleanup={async () => {
await cleanupDirective(contextMenu.directive.id);
await refreshList();
}}
onPickUpOrders={async () => {
await pickUpOrders(contextMenu.directive.id);
await refreshList();
}}
/>
)}
{contextMenu?.kind === "task" && (
<TaskContextMenu
x={contextMenu.x}
y={contextMenu.y}
task={contextMenu.task}
onClose={closeContextMenu}
onInterrupt={async () => {
try {
await stopTask(contextMenu.task.taskId);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[makima] failed to interrupt task", err);
}
await refreshList();
}}
onComplete={async () => {
if (!contextMenu.task.stepId) return;
await completeDirectiveStep(
contextMenu.directiveId,
contextMenu.task.stepId,
);
await refreshList();
}}
onFail={async () => {
if (!contextMenu.task.stepId) return;
await failDirectiveStep(
contextMenu.directiveId,
contextMenu.task.stepId,
);
await refreshList();
}}
onSkip={async () => {
if (!contextMenu.task.stepId) return;
await skipDirectiveStep(
contextMenu.directiveId,
contextMenu.task.stepId,
);
await refreshList();
}}
onSendMessage={async () => {
// Browser prompt is the lightest-weight surface that doesn't
// require redesigning a modal. The same comment box is also
// available below the live transcript when the task is selected.
const message = window.prompt("Send message to task:");
if (!message || !message.trim()) return;
try {
await sendTaskMessage(contextMenu.task.taskId, message.trim());
} catch (err) {
// eslint-disable-next-line no-console
console.error("[makima] failed to send task message", err);
}
}}
onOpenInTaskPage={() => {
// The standalone /exec/:taskId page has the full task UI with
// worktree diff viewer, checkpoint controls, etc.
navigate(`/exec/${contextMenu.task.taskId}`);
}}
/>
)}
{newTaskFor && (
<NewTaskModal
directive={newTaskFor}
onClose={() => setNewTaskFor(null)}
onSubmit={handleSubmitNewTask}
/>
)}
</div>
);
}
/**
* Inline "+ New task" form for spawning an ephemeral task under a
* directive. Surfaced as a centered modal, dismissible with Esc / click-out.
*/
function NewTaskModal({
directive,
onClose,
onSubmit,
}: {
directive: DirectiveSummary;
onClose: () => void;
onSubmit: (name: string, plan: string) => Promise<void>;
}) {
const [name, setName] = useState("");
const [plan, setPlan] = useState("");
const [submitting, setSubmitting] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
nameRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
const trimmedPlan = plan.trim();
if (!trimmedName || !trimmedPlan || submitting) return;
setSubmitting(true);
try {
await onSubmit(trimmedName, trimmedPlan);
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onClose}
>
<form
onSubmit={submit}
onClick={(e) => e.stopPropagation()}
className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col"
>
<div className="px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)]">
<p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
New task in
</p>
<p className="text-[12px] font-mono text-white truncate">
{directive.title}
</p>
</div>
<div className="px-4 py-4 space-y-3">
<div className="space-y-1">
<label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
Name
</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Investigate flaky test in auth.test.ts"
className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566]"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
Plan / instructions
</label>
<textarea
value={plan}
onChange={(e) => setPlan(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
void submit(e as unknown as React.FormEvent);
}
}}
rows={5}
placeholder="What should the task do?"
className="w-full bg-transparent border border-[rgba(117,170,252,0.2)] focus:border-[#75aafc] outline-none px-3 py-2 text-[13px] text-[#dbe7ff] placeholder-[#445566] resize-none"
/>
</div>
</div>
<div className="px-4 py-3 border-t border-dashed border-[rgba(117,170,252,0.25)] flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-[#7788aa] border border-[#2a3a5a] hover:text-white"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || !plan.trim() || submitting}
className="px-3 py-1.5 text-[11px] font-mono uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed"
>
{submitting ? "Creating…" : "Spawn task"}
</button>
</div>
</form>
</div>
);
}