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 { DocumentEditor } from "../components/directives/DocumentEditor";
import {
type DirectiveSummary,
type DirectiveStatus,
type DirectiveDocument,
type DirectiveDocumentStatus,
type DirectiveStep,
type Task,
type DocumentTasksResponse,
listDirectiveDocuments,
createDirectiveDocument,
getDirectiveDocument,
updateDirectiveDocument,
listDirectiveDocumentTasks,
createDirectiveTask,
startDirective,
pauseDirective,
updateDirective,
deleteDirective,
createDirectivePR,
advanceDirective,
cleanupDirective,
pickUpOrders,
} from "../lib/api";
import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
// 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-document status palette. Active/draft documents use the same bright
// green-ish accent as a running directive; shipped/archived use a muted blue.
const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
draft: "bg-[#556677]",
active: "bg-green-400",
shipped: "bg-[#75aafc]",
archived: "bg-[#3a4a6a]",
};
// =============================================================================
// Sidebar grouping — group directives by lifecycle stage so the file tree
// reads like a folder per status. We collapse the noisy ones (Archived) by
// default and keep Active / Idle expanded.
// =============================================================================
// Sidebar is now a flat list ordered by status precedence — see
// `sortedDirectives` in DocumentSidebar. Status is shown as a colored dot
// on the right of each row, no per-status grouping.
// Slugify a document title for the displayed `.md` filename, falling back to
// the directive title and finally the document id slice when the title is
// empty. Mirrors the file-naming fix from step 1 (use the user-readable label
// rather than just an id slice). Accepts either a DirectiveSummary or a full
// DirectiveWithSteps — only `title` is read.
function fileLabel(
doc: DirectiveDocument,
directive: { title: string },
): string {
const docTitle = doc.title.trim();
if (docTitle.length > 0) return docTitle;
const dirTitle = directive.title.trim();
if (dirTitle.length > 0) return dirTitle;
return doc.id.slice(0, 8);
}
// =============================================================================
// 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>
);
}
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>
);
}
// =============================================================================
// SidebarSelection — exactly one of taskId/documentId is non-null. taskId is
// reserved for a future "task selection" feature (we expose it in the URL
// already so the param shape is stable). documentId picks one of the
// directive's documents.
// =============================================================================
interface SidebarSelection {
directiveId: string;
taskId: string | null;
documentId: string | null;
}
// =============================================================================
// Per-directive folder — renders as a collapsible folder containing the
// directive's documents. Loads documents lazily on first open (mirroring the
// pattern from step 1's DirectiveFolder, which fetched the full directive
// only when expanded).
// =============================================================================
interface DirectiveFolderProps {
directive: DirectiveSummary;
open: boolean;
onToggle: () => void;
/** Called when the user clicks the folder header itself (after toggle). */
onHeaderClick: () => void;
selection: SidebarSelection | null;
onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
/** Open the inline "+ New ephemeral task" form for this directive. */
onCreateEphemeralTask: (directive: DirectiveSummary) => void;
/** Right-click handler — opens DirectiveContextMenu with start/pause/PR/etc. */
onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
/**
* Document refresh trigger — bumped externally so the folder refetches its
* document list after a create/update happens elsewhere. Primarily used so
* a freshly-created document shows up immediately.
*/
refreshNonce: number;
}
function DirectiveFolder({
directive,
open,
onToggle,
onHeaderClick,
selection,
onSelectDocument,
onCreateDocument,
onCreateEphemeralTask,
onContextMenu,
refreshNonce,
}: DirectiveFolderProps) {
const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
const orchestratorRunning = !!directive.orchestratorTaskId;
// Documents fetched lazily on open. We deliberately scope the fetch to the
// open-state so closed folders don't pay the network cost on initial render.
const [docs, setDocs] = useState<DirectiveDocument[] | null>(null);
const [docsLoading, setDocsLoading] = useState(false);
const [docsError, setDocsError] = useState<string | null>(null);
// shipped/ subfolder open state — independent of the directive folder.
const [shippedOpen, setShippedOpen] = useState(false);
// Whether a "+ New document" call is in flight (disables the button).
const [creating, setCreating] = useState(false);
const refresh = useCallback(async () => {
setDocsLoading(true);
setDocsError(null);
try {
const list = await listDirectiveDocuments(directive.id);
setDocs(list);
} catch (e) {
setDocsError(e instanceof Error ? e.message : "Failed to load documents");
} finally {
setDocsLoading(false);
}
}, [directive.id]);
// Fetch on open; refetch when refreshNonce bumps and the folder is open.
useEffect(() => {
if (!open) return;
void refresh();
}, [open, refresh, refreshNonce]);
// Split the documents into the two visual groups. Memoised so we don't
// recompute on every render.
const { activeDocs, shippedDocs } = useMemo(() => {
const active: DirectiveDocument[] = [];
const shipped: DirectiveDocument[] = [];
for (const d of docs ?? []) {
if (d.status === "shipped" || d.status === "archived") {
shipped.push(d);
} else {
active.push(d);
}
}
// Stable order: by createdAt ascending so the first row is the oldest doc.
active.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
shipped.sort((a, b) => (b.shippedAt ?? "").localeCompare(a.shippedAt ?? ""));
return { activeDocs: active, shippedDocs: shipped };
}, [docs]);
const handleCreate = useCallback(async () => {
if (creating) return;
setCreating(true);
try {
await onCreateDocument(directive);
// Refresh after creating so the new doc appears in the list.
await refresh();
} finally {
setCreating(false);
}
}, [creating, onCreateDocument, directive, refresh]);
// Selection helpers — used to highlight the currently-selected doc row.
const selectedDocumentId =
selection && selection.directiveId === directive.id
? selection.documentId
: null;
return (
<div className="select-none">
{/* Directive folder header. Status is shown as a colored dot on the
RIGHT (per the user's spec — flat list, no per-status grouping).
Right-click opens the context menu (start / pause / archive /
delete / create-PR / update-PR / etc.). */}
<button
type="button"
onClick={() => {
onToggle();
onHeaderClick();
}}
onContextMenu={(e) => onContextMenu(e, directive)}
title={`${directive.title} — ${directive.status}`}
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.06)]"
>
<Caret open={open} />
<FolderIcon open={open} />
<span className="truncate flex-1 text-left">
{directive.title.trim().length > 0
? directive.title
: directive.id.slice(0, 8)}
/
</span>
{orchestratorRunning && (
<span
className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
title="Orchestrator running"
aria-label="Orchestrator running"
/>
)}
{/* Status indicator on the RIGHT side of the row. */}
<span
className={`inline-block w-2 h-2 rounded-full shrink-0 ${dotColor}`}
aria-label={`status: ${directive.status}`}
title={`status: ${directive.status}`}
/>
</button>
{/* Folder body — rendered only when open */}
{open && (
<div className="py-0.5">
{docsLoading && !docs && (
<div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
Loading documents…
</div>
)}
{docsError && (
<div className="pl-14 pr-3 py-1 font-mono text-[10px] text-red-400">
{docsError}
</div>
)}
{/* Active group */}
{docs && (
<>
{/* + New document affordance — sits at the top of the active list
so the user can always reach it without scrolling past
existing docs. */}
<button
type="button"
onClick={handleCreate}
disabled={creating}
className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-emerald-400 hover:bg-[rgba(74,222,128,0.06)] disabled:opacity-50"
title="Create a new document under this directive"
>
<span className="text-[12px] leading-none">+</span>
<span>New document</span>
</button>
{/* + New ephemeral task — sibling affordance for spawning a
one-off task under this directive that's NOT part of the
DAG. Useful for sidebar scratch work, debugging, etc. */}
<button
type="button"
onClick={() => onCreateEphemeralTask(directive)}
className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#c084fc] hover:bg-[rgba(192,132,252,0.06)]"
title="Spawn a one-off ephemeral task under this directive"
>
<span className="text-[12px] leading-none">+</span>
<span>New ephemeral task</span>
</button>
{activeDocs.length === 0 && !docsLoading && (
<div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic">
no active documents
</div>
)}
{activeDocs.map((doc) => (
// Each active document gets its own tasks/ subfolder
// immediately below it. Active docs default-open the
// folder so the user sees their live work without an
// extra click.
<div key={doc.id}>
<DocumentRow
doc={doc}
directive={directive}
selected={doc.id === selectedDocumentId}
onSelect={() => onSelectDocument(directive.id, doc)}
/>
<DocumentTasksFolder
documentId={doc.id}
depth="normal"
defaultOpen={doc.status === "active"}
refreshNonce={refreshNonce}
/>
</div>
))}
{/* shipped/ subfolder — only rendered when at least one shipped
or archived doc exists. Hidden entirely otherwise so empty
directives stay tidy. */}
{shippedDocs.length > 0 && (
<div>
<button
type="button"
onClick={() => setShippedOpen((v) => !v)}
className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]"
>
<Caret open={shippedOpen} />
<FolderIcon open={shippedOpen} />
<span>shipped/</span>
<span className="ml-auto text-[10px] text-[#556677]">
{shippedDocs.length}
</span>
</button>
{shippedOpen &&
shippedDocs.map((doc) => (
// Shipped docs render the doc row + its frozen
// tasks/ subfolder. The tasks/ folder defaults
// closed (history) so it doesn't dominate the
// sidebar; users can click to inspect what work
// produced this shipped contract.
<div key={doc.id}>
<DocumentRow
doc={doc}
directive={directive}
selected={doc.id === selectedDocumentId}
onSelect={() => onSelectDocument(directive.id, doc)}
indent="deep"
/>
<DocumentTasksFolder
documentId={doc.id}
depth="deep"
defaultOpen={false}
refreshNonce={refreshNonce}
/>
</div>
))}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
// =============================================================================
// DocumentRow — one row inside a directive folder. The indent depth differs
// between active rows (one level deep) and shipped rows (two levels deep).
// =============================================================================
interface DocumentRowProps {
doc: DirectiveDocument;
directive: DirectiveSummary;
selected: boolean;
onSelect: () => void;
indent?: "normal" | "deep";
}
function DocumentRow({
doc,
directive,
selected,
onSelect,
indent = "normal",
}: DocumentRowProps) {
const dot = DOC_STATUS_DOT[doc.status] ?? DOC_STATUS_DOT.draft;
const padLeft = indent === "deep" ? "pl-[88px]" : "pl-14";
const name = `${fileLabel(doc, directive)}.md`;
return (
<button
type="button"
onClick={onSelect}
title={name}
className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
selected
? "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={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
aria-hidden
title={doc.status}
/>
<span className="truncate flex-1">{name}</span>
{/* Status chip — only shown for non-active states so the row stays
uncluttered for the common case. */}
{doc.status !== "active" && (
<span className="text-[9px] uppercase tracking-wide text-[#556677]">
{doc.status}
</span>
)}
{/* PR badge for shipped docs. The link short-circuits the row's
onClick so clicking the PR doesn't also re-select the doc. */}
{doc.prUrl && (doc.status === "shipped" || doc.status === "archived") && (
<a
href={doc.prUrl}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
className="text-[9px] text-[#75aafc] hover:text-white border border-[#2a3a5a] rounded px-1"
title={doc.prUrl}
>
PR
</a>
)}
</button>
);
}
// =============================================================================
// Per-document tasks/ subfolder — fetches the steps + ephemeral tasks for a
// single document and renders a collapsible `tasks/` row beneath the
// document. Lazy: fetch only fires once the user opens the folder, and we
// also keep the folder closed by default for shipped docs (where it's
// historical) and open by default for active docs (where it's live work).
// =============================================================================
interface DocumentTasksFolderProps {
documentId: string;
/** Visual indent depth — mirrors the parent DocumentRow's indent so the
* tasks/ row sits one level deeper than its parent doc. */
depth: "normal" | "deep";
/** Whether to fetch+open by default. Active docs default to open so the
* user sees their live tasks immediately; shipped docs default to closed
* (historical), and the user can click to expand. */
defaultOpen: boolean;
/** Bumped externally so the folder refetches its task list after a save
* or status change elsewhere. Same nonce used for the directive folder. */
refreshNonce: number;
}
function DocumentTasksFolder({
documentId,
depth,
defaultOpen,
refreshNonce,
}: DocumentTasksFolderProps) {
const [open, setOpen] = useState(defaultOpen);
const [data, setData] = useState<DocumentTasksResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Inner row indent is one level deeper than the folder header. Folder
// header uses pl-[88px] (deep) or pl-14 (normal); tasks rows go one
// step beyond that.
const headerPadLeft = depth === "deep" ? "pl-[88px]" : "pl-14";
const rowPadLeft = depth === "deep" ? "pl-[112px]" : "pl-[72px]";
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await listDirectiveDocumentTasks(documentId);
setData(res);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load tasks");
} finally {
setLoading(false);
}
}, [documentId]);
// Fetch when the folder is open (initial open or refresh). We don't
// pre-fetch on closed folders so we don't waste bandwidth on the long
// tail of historical shipped docs the user never expands.
useEffect(() => {
if (!open) return;
void refresh();
}, [open, refresh, refreshNonce]);
const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0);
// Don't render the folder at all if we've fetched and the document has
// no tasks. This is the cleanest visual: a draft document just shows up
// as a single row with no children. The empty-folder check is gated on
// a successful fetch so we don't flash "no tasks/" rows during loading.
if (data && total === 0 && !loading && !error) {
return null;
}
return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center gap-1.5 ${headerPadLeft} pr-3 py-1 font-mono text-[11px] text-[#7788aa] hover:bg-[rgba(117,170,252,0.06)]`}
>
<Caret open={open} />
<FolderIcon open={open} />
<span>tasks/</span>
{total > 0 && (
<span className="ml-auto text-[10px] text-[#556677]">{total}</span>
)}
</button>
{open && (
<div className="py-0.5">
{loading && !data && (
<div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-[#556677]`}>
Loading tasks…
</div>
)}
{error && (
<div className={`${rowPadLeft} pr-3 py-1 font-mono text-[10px] text-red-400`}>
{error}
</div>
)}
{data?.steps.map((step) => (
<StepRow key={`step-${step.id}`} step={step} padLeft={rowPadLeft} />
))}
{data?.tasks.map((task) => (
<TaskRow key={`task-${task.id}`} task={task} padLeft={rowPadLeft} />
))}
</div>
)}
</div>
);
}
// Step status → coloured dot, mirroring directive status palette so the
// sidebar reads consistently.
const STEP_STATUS_DOT: Record<string, string> = {
pending: "bg-[#556677]",
ready: "bg-[#9bc3ff]",
running: "bg-yellow-400",
completed: "bg-green-400",
failed: "bg-red-400",
skipped: "bg-[#3a4a6a]",
};
// Task status → coloured dot. Statuses come from the Task model; the small
// set we expect in directive context is enough — anything else falls back
// to the muted "draft" colour.
const TASK_STATUS_DOT: Record<string, string> = {
pending: "bg-[#556677]",
starting: "bg-yellow-400",
running: "bg-yellow-400",
completed: "bg-green-400",
failed: "bg-red-400",
cancelled: "bg-[#3a4a6a]",
interrupted: "bg-orange-400",
};
interface StepRowProps {
step: DirectiveStep;
padLeft: string;
}
function StepRow({ step, padLeft }: StepRowProps) {
const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
return (
<div
title={`${step.name} (${step.status})`}
className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
>
<FileIcon />
<span
className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
aria-hidden
title={step.status}
/>
<span className="truncate flex-1">{step.name}</span>
<span className="text-[9px] uppercase tracking-wide text-[#556677]">
step
</span>
</div>
);
}
interface TaskRowProps {
task: Task;
padLeft: string;
}
function TaskRow({ task, padLeft }: TaskRowProps) {
const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]";
// Supervisor tasks get a small "sup" tag so the user can spot
// contract orchestrators in the list.
const isSup = task.isSupervisor;
return (
<div
title={`${task.name} (${task.status})`}
className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] text-[#9bc3ff]`}
>
<FileIcon />
<span
className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
aria-hidden
title={task.status}
/>
<span className="truncate flex-1">{task.name}</span>
<span className="text-[9px] uppercase tracking-wide text-[#556677]">
{isSup ? "sup" : "task"}
</span>
</div>
);
}
// =============================================================================
// Sidebar
// =============================================================================
interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
onSelectDirective: (directiveId: string) => void;
onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
onCreateContract: () => void;
onCreateEphemeralTask: (directive: DirectiveSummary) => void;
onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
refreshNonce: number;
}
function DocumentSidebar({
directives,
loading,
selection,
onSelectDocument,
onSelectDirective,
onCreateDocument,
onCreateContract,
onCreateEphemeralTask,
onContextMenu,
refreshNonce,
}: SidebarProps) {
// Flat sort: active first, then idle, paused, draft, inactive, archived.
// Status is surfaced as a colored dot on the RIGHT of each contract row
// (see DirectiveFolder header) — the user explicitly asked NOT to nest
// contracts inside per-status folders.
const sortedDirectives: DirectiveSummary[] = 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]);
// Per-directive open state. We auto-open the directive containing the
// current selection so the user can see what they're editing.
const [openDirectives, setOpenDirectives] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!selection) return;
setOpenDirectives((prev) =>
prev[selection.directiveId] ? prev : { ...prev, [selection.directiveId]: true },
);
}, [selection?.directiveId]);
const toggleDirective = (id: string) =>
setOpenDirectives((prev) => ({ ...prev, [id]: !prev[id] }));
return (
<div className="flex flex-col h-full">
{/* Sidebar header */}
<div className="flex items-center justify-between gap-2 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>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-[#556677]">
{directives.length}
</span>
<button
type="button"
onClick={onCreateContract}
className="text-[11px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 hover:border-emerald-400 rounded px-1.5 py-0.5 leading-none"
title="Create a new contract (directive)"
>
+ New
</button>
</div>
</div>
{/* Top-level "directives/" folder */}
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
<FolderIcon open />
<span>directives/</span>
</div>
{/* Body — flat list, status is a colored dot on the right of each row. */}
<div className="flex-1 overflow-y-auto pb-4">
{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>
) : (
<div className="py-0.5">
{sortedDirectives.map((d) => (
<DirectiveFolder
key={d.id}
directive={d}
open={!!openDirectives[d.id]}
onToggle={() => toggleDirective(d.id)}
onHeaderClick={() => onSelectDirective(d.id)}
selection={selection}
onSelectDocument={onSelectDocument}
onCreateDocument={onCreateDocument}
onCreateEphemeralTask={onCreateEphemeralTask}
onContextMenu={onContextMenu}
refreshNonce={refreshNonce}
/>
))}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
// 1) documentId selected → fetch the DirectiveDocument and edit doc.body via
// updateDirectiveDocument (the call that auto-reactivates a shipped doc).
// 2) no documentId (legacy fallback, kept for the "select a directive but
// not a document" transitional case) → edit directive.goal as before.
// =============================================================================
interface EditorShellProps {
selection: SidebarSelection | null;
hasDirectives: boolean;
listLoading: boolean;
/** Bumped after a successful document save so the sidebar refetches. */
onDocumentChanged: () => void;
}
function EditorShell({
selection,
hasDirectives,
listLoading,
onDocumentChanged,
}: EditorShellProps) {
const directiveId = selection?.directiveId;
const documentId = selection?.documentId ?? null;
// We deliberately don't pull `updateGoal` here — in the multi-document
// world, edits flow through updateDirectiveDocument (which auto-reactivates
// a shipped doc when its body changes). The legacy directive.goal is
// unused on this surface.
const {
directive,
loading: directiveLoading,
cleanup,
createPR,
pickUpOrders,
} = useDirective(directiveId);
// Document fetch — only when documentId is selected. Refetched whenever the
// id changes; not polled (the document stream is too low-traffic to warrant
// background refresh in this iteration).
const [doc, setDoc] = useState<DirectiveDocument | null>(null);
const [docLoading, setDocLoading] = useState(false);
const [docError, setDocError] = useState<string | null>(null);
useEffect(() => {
if (!documentId) {
setDoc(null);
setDocLoading(false);
setDocError(null);
return;
}
let cancelled = false;
setDocLoading(true);
setDocError(null);
getDirectiveDocument(documentId)
.then((d) => {
if (cancelled) return;
setDoc(d);
})
.catch((e) => {
if (cancelled) return;
setDocError(e instanceof Error ? e.message : "Failed to load document");
})
.finally(() => {
if (cancelled) return;
setDocLoading(false);
});
return () => {
cancelled = true;
};
}, [documentId]);
// Save callback for the document path. The backend re-stamps a shipped doc
// back to active when its body changes, so we just optimistically update
// local state with the server's response.
const onUpdateDocumentBody = useCallback(
async (body: string) => {
if (!documentId) return;
const updated = await updateDirectiveDocument(documentId, { body });
setDoc(updated);
// Tell the sidebar to refetch the directive's document list so the
// status chip flips from `shipped` back to `active` (and any title
// changes propagate). Cheap — folders only refetch when open.
onDocumentChanged();
},
[documentId, onDocumentChanged],
);
// ---- Empty / error / loading states ------------------------------------
if (!directiveId) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">
{listLoading
? "Loading documents..."
: hasDirectives
? "Select a document from the sidebar"
: "No directives yet — create one from the legacy UI"}
</p>
</div>
);
}
if (directiveLoading && !directive) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">Loading directive...</p>
</div>
);
}
if (!directive) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#7788aa] font-mono text-[12px]">Directive not found</p>
</div>
);
}
// --- Document path: documentId selected --------------------------------
if (documentId) {
if (docLoading && !doc) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">Loading document...</p>
</div>
);
}
if (docError) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-red-400 font-mono text-[12px]">{docError}</p>
</div>
);
}
if (!doc) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#7788aa] font-mono text-[12px]">Document not found</p>
</div>
);
}
// Synthesise a directive-shaped object whose `goal` is the document body.
// DocumentEditor was originally written against DirectiveWithSteps, so we
// can keep its shape by overriding `goal` with `doc.body` and `title`
// with the document's filename label. The steps panel still draws from
// the real directive (passed through StepsBlockContextProvider).
const docTitle = `${fileLabel(doc, directive)}.md`;
const directiveAsDocument = {
...directive,
goal: doc.body,
title: docTitle,
};
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>
<DocumentEditor
// Keying by document id ensures the Lexical editor remounts cleanly
// when the user switches documents, so the previous doc's body
// doesn't bleed into the new one.
key={doc.id}
directive={directiveAsDocument}
onUpdateGoal={onUpdateDocumentBody}
onCleanup={async () => {
await cleanup();
}}
onCreatePR={async () => {
await createPR();
}}
onPickUpOrders={async () => {
await pickUpOrders();
}}
/>
</div>
);
}
// --- Legacy fallback: directive selected but no document chosen --------
// We only ever land here transiently while the page resolves the default
// document selection, so we render a thin "loading" placeholder rather
// than the full goal editor (which would be confusing alongside the new
// multi-document model).
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<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>
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<p className="text-[#556677] font-mono text-[12px]">
Select a document, or click "+ New document" to create one.
</p>
</div>
</div>
);
}
// =============================================================================
// Page
// =============================================================================
export default function DocumentDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: routeDirectiveId } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const { directives, loading: listLoading } = useDirectives();
// refreshNonce — bumped to tell open directive folders to refetch their
// document lists (after a create or save).
const [refreshNonce, setRefreshNonce] = useState(0);
const bumpRefresh = useCallback(() => setRefreshNonce((n) => n + 1), []);
// Derive the SidebarSelection from the URL. The route param is the
// directive id; ?document=:id and ?task=:id pick a specific child. Exactly
// one of taskId/documentId can be set; if both happen to be present in the
// URL (which shouldn't happen via our nav code) we prefer ?task= since
// task selection is the more disruptive action.
const selection: SidebarSelection | null = useMemo(() => {
if (!routeDirectiveId) return null;
const taskId = searchParams.get("task");
const documentId = searchParams.get("document");
if (taskId) return { directiveId: routeDirectiveId, taskId, documentId: null };
if (documentId) return { directiveId: routeDirectiveId, taskId: null, documentId };
return { directiveId: routeDirectiveId, taskId: null, documentId: null };
}, [routeDirectiveId, searchParams]);
useEffect(() => {
if (!authLoading && isAuthConfigured && !isAuthenticated) {
navigate("/login");
}
}, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
// ------------------------------------------------------------------
// Default-selection: when the user clicks a directive's folder header (or
// lands on /directives/:id without ?document=) we pick the first active or
// draft document and update the URL to point at it. This avoids the
// "directive selected, but nothing in the editor" intermediate state.
// ------------------------------------------------------------------
const lastResolvedRef = useRef<string | null>(null);
useEffect(() => {
if (!routeDirectiveId) {
lastResolvedRef.current = null;
return;
}
// Only auto-resolve when no document/task has been picked yet, AND we
// haven't already resolved this directive in a prior tick (otherwise
// navigating away from the doc would instantly re-pick the same one).
if (selection?.documentId || selection?.taskId) {
lastResolvedRef.current = routeDirectiveId;
return;
}
if (lastResolvedRef.current === routeDirectiveId) return;
lastResolvedRef.current = routeDirectiveId;
let cancelled = false;
listDirectiveDocuments(routeDirectiveId)
.then((list) => {
if (cancelled) return;
// Prefer the first 'active' doc; fall back to the first 'draft'.
const firstActive = list.find((d) => d.status === "active");
const firstDraft = list.find((d) => d.status === "draft");
const pick = firstActive ?? firstDraft;
if (pick) {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("document", pick.id);
next.delete("task");
return next;
},
{ replace: true },
);
}
})
.catch(() => {
// Swallow — the editor pane will show "Document not found" and the
// user can click "+ New document" to recover.
});
return () => {
cancelled = true;
};
}, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]);
const handleSelectDocument = useCallback(
(directiveId: string, doc: DirectiveDocument) => {
navigate(`/directives/${directiveId}?document=${doc.id}`);
},
[navigate],
);
// When the user clicks a directive folder header (not a document row), we
// jump to /directives/:id without ?document= — the default-selection
// effect above will then pick the first active doc.
const handleSelectDirective = useCallback(
(directiveId: string) => {
if (routeDirectiveId === directiveId) return;
navigate(`/directives/${directiveId}`);
},
[navigate, routeDirectiveId],
);
const handleCreateDocument = useCallback(
async (directive: DirectiveSummary) => {
const created = await createDirectiveDocument(directive.id, {
title: "",
body: "",
});
bumpRefresh();
// Navigate to the new doc so it's selected immediately.
navigate(`/directives/${directive.id}?document=${created.id}`);
},
[bumpRefresh, navigate],
);
// Modal state for the two new creation surfaces in the sidebar:
// * + New contract → opens NewContractModal, calls useDirectives.create
// * + New ephemeral task (per directive) → opens NewEphemeralTaskModal
const { create: createDirective } = useDirectives();
const [showNewContract, setShowNewContract] = useState(false);
const [newEphemeralFor, setNewEphemeralFor] = useState<DirectiveSummary | null>(null);
const handleSubmitNewContract = useCallback(
async (title: string, goal: string, repositoryUrl: string) => {
const d = await createDirective({
title,
goal,
repositoryUrl: repositoryUrl.length > 0 ? repositoryUrl : undefined,
});
setShowNewContract(false);
navigate(`/directives/${d.id}`);
},
[createDirective, navigate],
);
const handleSubmitNewEphemeral = useCallback(
async (name: string, plan: string) => {
if (!newEphemeralFor) return;
const task = await createDirectiveTask(newEphemeralFor.id, { name, plan });
const target = newEphemeralFor.id;
setNewEphemeralFor(null);
bumpRefresh();
navigate(`/directives/${target}?task=${task.id}`);
},
[newEphemeralFor, bumpRefresh, navigate],
);
// Right-click context menu state. Right-clicking any directive header
// opens the menu; menu actions (start/pause/archive/delete/PR/etc.) hit
// the directives API and trigger a sidebar refresh on success.
const { refresh: refreshDirectiveList } = useDirectives();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
directive: DirectiveSummary;
} | null>(null);
const handleContextMenu = useCallback(
(e: React.MouseEvent, directive: DirectiveSummary) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, directive });
},
[],
);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
const runAction = useCallback(
async (action: () => Promise<unknown>, errMsg: string) => {
try {
await action();
await refreshDirectiveList();
bumpRefresh();
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[makima] ${errMsg}`, err);
alert(
err instanceof Error ? `${errMsg}: ${err.message}` : errMsg,
);
}
},
[refreshDirectiveList, bumpRefresh],
);
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>
);
}
return (
// h-screen + overflow-hidden so the page itself never scrolls; the
// sidebar and editor pane each manage their own scroll via flex-1
// children with overflow-y-auto. Previously we set
// height: calc(100vh - 80px) on <main>, which assumed an 80px masthead
// and quietly clipped content when the masthead was taller (or pushed
// the page below the viewport on shorter screens, which made the
// whole page scroll instead of the sidebar/editor independently).
<div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden">
<Masthead showNav />
<main className="flex-1 flex min-h-0 overflow-hidden">
{/* Left: file-tree sidebar — independent scroll. */}
<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}
onSelectDocument={handleSelectDocument}
onSelectDirective={handleSelectDirective}
onCreateDocument={handleCreateDocument}
onCreateContract={() => setShowNewContract(true)}
onCreateEphemeralTask={(d) => setNewEphemeralFor(d)}
onContextMenu={handleContextMenu}
refreshNonce={refreshNonce}
/>
</div>
{/* Right: Lexical editor */}
<EditorShell
selection={selection}
hasDirectives={directives.length > 0}
listLoading={listLoading}
onDocumentChanged={bumpRefresh}
/>
</main>
{showNewContract && (
<NewContractModal
onClose={() => setShowNewContract(false)}
onSubmit={handleSubmitNewContract}
/>
)}
{newEphemeralFor && (
<NewEphemeralTaskModal
directive={newEphemeralFor}
onClose={() => setNewEphemeralFor(null)}
onSubmit={handleSubmitNewEphemeral}
/>
)}
{contextMenu && (
<DirectiveContextMenu
x={contextMenu.x}
y={contextMenu.y}
directive={contextMenu.directive}
onClose={closeContextMenu}
onStart={() =>
runAction(
() => startDirective(contextMenu.directive.id),
"Failed to start contract",
)
}
onPause={() =>
runAction(
() => pauseDirective(contextMenu.directive.id),
"Failed to pause contract",
)
}
onArchive={() =>
runAction(
() =>
updateDirective(contextMenu.directive.id, {
status: "archived",
}),
"Failed to archive contract",
)
}
onDelete={async () => {
if (
!window.confirm(
`Delete "${contextMenu.directive.title}"? This cannot be undone.`,
)
) {
return;
}
await runAction(
() => deleteDirective(contextMenu.directive.id),
"Failed to delete contract",
);
// If the deleted contract was selected, clear the URL.
if (selection?.directiveId === contextMenu.directive.id) {
navigate("/directives");
}
}}
onGoToPR={() => {
if (contextMenu.directive.prUrl) {
window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
}
}}
onCreatePR={() =>
runAction(
() => createDirectivePR(contextMenu.directive.id),
contextMenu.directive.prUrl
? "Failed to update PR"
: "Failed to create PR",
)
}
onAdvance={() =>
runAction(
() => advanceDirective(contextMenu.directive.id),
"Failed to advance DAG",
)
}
onCleanup={() =>
runAction(
() => cleanupDirective(contextMenu.directive.id),
"Failed to clean up contract",
)
}
onPickUpOrders={() =>
runAction(
() => pickUpOrders(contextMenu.directive.id),
"Failed to pick up orders",
)
}
/>
)}
</div>
);
}
/**
* Modal for creating a new directive (= "contract" in the doc-mode UI).
* Title + goal are required; repository_url is optional. On submit calls
* useDirectives.create and navigates the user into the new directive.
*/
function NewContractModal({
onClose,
onSubmit,
}: {
onClose: () => void;
onSubmit: (title: string, goal: string, repositoryUrl: string) => Promise<void>;
}) {
const [title, setTitle] = useState("");
const [goal, setGoal] = useState("");
const [repo, setRepo] = useState("");
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.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 t = title.trim();
const g = goal.trim();
if (!t || !g || submitting) return;
setSubmitting(true);
setErr(null);
try {
await onSubmit(t, g, repo.trim());
} catch (caught) {
setErr(caught instanceof Error ? caught.message : String(caught));
} 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-[520px] 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 contract
</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">
Title
</label>
<input
ref={titleRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Migrate auth to Supabase"
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">
Goal
</label>
<textarea
value={goal}
onChange={(e) => setGoal(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
void submit(e as unknown as React.FormEvent);
}
}}
rows={4}
placeholder="Describe what the contract should achieve"
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 className="space-y-1">
<label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide">
Repository URL (optional)
</label>
<input
type="text"
value={repo}
onChange={(e) => setRepo(e.target.value)}
placeholder="e.g. https://github.com/owner/repo"
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>
{err && (
<p className="text-[11px] font-mono text-red-400">{err}</p>
)}
</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={!title.trim() || !goal.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…" : "Create contract"}
</button>
</div>
</form>
</div>
);
}
/**
* Modal for spawning an ephemeral task under a directive. Mirrors the
* existing right-click "+ New task" flow.
*/
function NewEphemeralTaskModal({
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 [err, setErr] = useState<string | null>(null);
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 n = name.trim();
const p = plan.trim();
if (!n || !p || submitting) return;
setSubmitting(true);
setErr(null);
try {
await onSubmit(n, p);
} catch (caught) {
setErr(caught instanceof Error ? caught.message : String(caught));
} 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 ephemeral 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"
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>
{err && (
<p className="text-[11px] font-mono text-red-400">{err}</p>
)}
</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-[#c084fc] border border-[#c084fc]/40 hover:border-[#c084fc] disabled:opacity-40 disabled:cursor-not-allowed"
>
{submitting ? "Spawning…" : "Spawn task"}
</button>
</div>
</form>
</div>
);
}