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 DirectiveContract as Contract,
type DirectiveContractStatus as ContractStatus,
type DirectiveStep,
type Task,
type DirectiveContractTasksResponse as ContractTasksResponse,
type DirectiveContractMergeMode as ContractMergeMode,
listDirectiveContracts as listContracts,
createDirectiveContract as createContract,
getDirectiveContract as getContract,
updateDirectiveContract as updateContract,
listDirectiveContractTasks as listContractTasks,
startDirectiveContract,
pauseDirectiveContract,
completeDirectiveContract,
unlockDirectiveContract,
createDirectiveTask,
startDirective,
pauseDirective,
updateDirective,
deleteDirective,
createDirectivePR,
advanceDirective,
cleanupDirective,
pickUpOrders,
} from "../lib/api";
import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
// 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-contract status palette. Active = bright green (currently driving
// daemons); queued = amber (locked, waiting for the active slot); draft
// = grey (editable spec); shipped = muted blue (work done); archived =
// faint navy.
const DOC_STATUS_DOT: Record<ContractStatus, string> = {
draft: "bg-[#556677]",
queued: "bg-amber-400",
active: "bg-green-400",
shipped: "bg-[#75aafc]",
archived: "bg-[#3a4a6a]",
};
// =============================================================================
// 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: Contract,
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: Contract) => 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;
/** Click handler for task/step rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => 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,
onSelectTask,
refreshNonce,
}: DirectiveFolderProps) {
const selectedTaskIdForFolder =
selection && selection.directiveId === directive.id
? selection.taskId
: null;
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<Contract[] | 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 listContracts(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: Contract[] = [];
const shipped: Contract[] = [];
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}
directiveId={directive.id}
depth="normal"
defaultOpen={doc.status === "active"}
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
/>
</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}
directiveId={directive.id}
depth="deep"
defaultOpen={false}
refreshNonce={refreshNonce}
selectedTaskId={selectedTaskIdForFolder}
onSelectTask={onSelectTask}
/>
</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: Contract;
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;
/** Parent directive id — needed so a clicked task row can navigate to
* /directives/<directiveId>?task=<taskId>. */
directiveId: 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;
/** Currently-selected task id (drives row highlight). */
selectedTaskId: string | null;
/** Click handler for step/task rows — navigates to the live transcript. */
onSelectTask: (directiveId: string, taskId: string) => void;
}
function DocumentTasksFolder({
documentId,
directiveId,
depth,
defaultOpen,
refreshNonce,
selectedTaskId,
onSelectTask,
}: DocumentTasksFolderProps) {
const [open, setOpen] = useState(defaultOpen);
const [data, setData] = useState<ContractTasksResponse | 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 listContractTasks(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}
directiveId={directiveId}
selected={!!selectedTaskId && step.taskId === selectedTaskId}
padLeft={rowPadLeft}
onSelect={onSelectTask}
/>
))}
{data?.tasks.map((task) => (
<TaskRow
key={`task-${task.id}`}
task={task}
directiveId={directiveId}
selected={task.id === selectedTaskId}
padLeft={rowPadLeft}
onSelect={onSelectTask}
/>
))}
</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;
directiveId: string;
selected: boolean;
padLeft: string;
onSelect: (directiveId: string, taskId: string) => void;
}
function StepRow({
step,
directiveId,
selected,
padLeft,
onSelect,
}: StepRowProps) {
const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]";
// Steps without an underlying task can't be opened — the executor
// hasn't started yet so there's no transcript to show. Render them
// disabled so the user can see them in the list but knows they're
// inert. Same for steps stuck in pending/skipped.
const taskId = step.taskId;
const clickable = !!taskId;
return (
<button
type="button"
disabled={!clickable}
onClick={() => clickable && onSelect(directiveId, taskId!)}
title={
clickable
? `${step.name} (${step.status})`
: `${step.name} — no task spawned yet (${step.status})`
}
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]"
: clickable
? "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
: "text-[#556677] border-l-2 border-transparent cursor-not-allowed"
}`}
>
<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>
</button>
);
}
interface TaskRowProps {
task: Task;
directiveId: string;
selected: boolean;
padLeft: string;
onSelect: (directiveId: string, taskId: string) => void;
}
function TaskRow({
task,
directiveId,
selected,
padLeft,
onSelect,
}: 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 (
<button
type="button"
onClick={() => onSelect(directiveId, task.id)}
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] 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={task.status}
/>
<span className="truncate flex-1">{task.name}</span>
<span className="text-[9px] uppercase tracking-wide text-[#556677]">
{isSup ? "sup" : "task"}
</span>
</button>
);
}
// =============================================================================
// Sidebar
// =============================================================================
interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
onSelectDocument: (directiveId: string, doc: Contract) => void;
onSelectDirective: (directiveId: string) => void;
onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
onCreateContract: () => void;
onCreateEphemeralTask: (directive: DirectiveSummary) => void;
onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void;
onSelectTask: (directiveId: string, taskId: string) => void;
refreshNonce: number;
}
function DocumentSidebar({
directives,
loading,
selection,
onSelectDocument,
onSelectDirective,
onCreateDocument,
onCreateContract,
onCreateEphemeralTask,
onContextMenu,
onSelectTask,
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}
onSelectTask={onSelectTask}
refreshNonce={refreshNonce}
/>
))}
</div>
)}
</div>
</div>
);
}
// =============================================================================
// Contract header — breadcrumb + status badge + lifecycle action buttons +
// merge mode radio. Renders above the spec editor in the document path.
//
// Action visibility is status-driven:
// * draft → Lock & Start
// * queued → Unlock (back to draft); shows "queued" pill
// * active → Pause, Complete, Unlock; shows "active" + pulsing dot
// * shipped → reopen via spec edit (no buttons here; backend reactivates)
// * archived → no buttons
//
// Merge mode (shared / own_pr) is editable while the contract is in
// `draft` or `queued` — once active, the queue scheduler has already
// claimed the slot, so flipping the toggle would silently change a
// running flow's branch target. Locked rows show the value as readonly.
// =============================================================================
interface ContractHeaderProps {
directive: { id: string; title: string; orchestratorTaskId: string | null };
doc: Contract;
docTitle: string;
/** Called with the server's response after any status / merge-mode
* transition so the parent can refresh the editor + sidebar. */
onContractChanged: (updated: Contract) => void;
}
function ContractHeader({
directive,
doc,
docTitle,
onContractChanged,
}: ContractHeaderProps) {
const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">(
null,
);
const [error, setError] = useState<string | null>(null);
const wrap = useCallback(
async (tag: typeof busy, op: () => Promise<Contract>) => {
try {
setBusy(tag);
setError(null);
const updated = await op();
onContractChanged(updated);
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
} finally {
setBusy(null);
}
},
[onContractChanged],
);
const onStart = useCallback(
() => wrap("start", () => startDirectiveContract(doc.id)),
[doc.id, wrap],
);
const onPause = useCallback(
() => wrap("pause", () => pauseDirectiveContract(doc.id)),
[doc.id, wrap],
);
const onComplete = useCallback(
() => wrap("complete", () => completeDirectiveContract(doc.id)),
[doc.id, wrap],
);
const onUnlock = useCallback(
() => wrap("unlock", () => unlockDirectiveContract(doc.id)),
[doc.id, wrap],
);
const onMergeMode = useCallback(
(mode: ContractMergeMode) =>
wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })),
[doc.id, wrap],
);
const editableMergeMode = doc.status === "draft" || doc.status === "queued";
return (
<div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)] flex flex-col gap-2">
{/* Row 1: breadcrumb + status pill + orchestrator indicator */}
<div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
<FileIcon />
<span>directives /</span>
<span className="text-[#9bc3ff]">
{directive.title.trim().length > 0
? directive.title
: directive.id.slice(0, 8)}
</span>
<span>/</span>
<span className="text-white">{docTitle}</span>
<ContractStatusPill status={doc.status} />
{!!directive.orchestratorTaskId && (
<span className="ml-auto inline-flex items-center gap-1 text-yellow-400">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
orchestrator running
</span>
)}
</div>
{/* Row 2: action buttons (status-driven) + merge mode + error */}
<div className="flex items-center gap-2 text-[11px] font-mono">
{doc.status === "draft" && (
<ContractActionButton onClick={onStart} disabled={busy !== null} variant="primary">
{busy === "start" ? "Starting…" : "Lock & Start"}
</ContractActionButton>
)}
{doc.status === "queued" && (
<ContractActionButton onClick={onUnlock} disabled={busy !== null}>
{busy === "unlock" ? "Unlocking…" : "Unlock"}
</ContractActionButton>
)}
{doc.status === "active" && (
<>
<ContractActionButton onClick={onPause} disabled={busy !== null}>
{busy === "pause" ? "Pausing…" : "Pause"}
</ContractActionButton>
<ContractActionButton onClick={onComplete} disabled={busy !== null} variant="primary">
{busy === "complete" ? "Completing…" : "Mark complete"}
</ContractActionButton>
<ContractActionButton onClick={onUnlock} disabled={busy !== null}>
{busy === "unlock" ? "Unlocking…" : "Unlock"}
</ContractActionButton>
</>
)}
{/* Merge mode radios — visible always, editable only in draft/queued */}
<div className="ml-auto flex items-center gap-2 text-[#7788aa]">
<span className="uppercase tracking-wide">merge:</span>
<MergeModeRadio
value={doc.mergeMode}
onChange={onMergeMode}
disabled={!editableMergeMode || busy !== null}
/>
</div>
</div>
{error && (
<div className="text-[10px] font-mono text-red-400">{error}</div>
)}
</div>
);
}
function ContractStatusPill({ status }: { status: ContractStatus }) {
const styles: Record<ContractStatus, { label: string; cls: string }> = {
draft: { label: "draft", cls: "text-[#556677]" },
queued: { label: "queued", cls: "text-amber-400" },
active: { label: "active", cls: "text-green-400" },
shipped: { label: "shipped", cls: "text-[#75aafc]" },
archived: { label: "archived", cls: "text-[#7788aa]" },
};
const s = styles[status];
return <span className={`ml-2 normal-case ${s.cls}`}>{s.label}</span>;
}
function ContractActionButton({
children,
onClick,
disabled,
variant,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
variant?: "primary";
}) {
const base =
"px-2 py-1 border border-[rgba(117,170,252,0.3)] rounded text-[10px] uppercase tracking-wide transition-colors";
const colors =
variant === "primary"
? "text-green-300 hover:bg-[rgba(120,200,140,0.1)]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]";
const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`${base} ${colors} ${dim}`}
>
{children}
</button>
);
}
function MergeModeRadio({
value,
onChange,
disabled,
}: {
value: ContractMergeMode;
onChange: (mode: ContractMergeMode) => void;
disabled?: boolean;
}) {
const opt = (mode: ContractMergeMode, label: string) => {
const selected = value === mode;
const cls = selected
? "text-white border-[rgba(117,170,252,0.6)] bg-[rgba(117,170,252,0.1)]"
: "text-[#7788aa] border-transparent hover:text-[#9bc3ff]";
const dim = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer";
return (
<button
key={mode}
type="button"
onClick={() => !disabled && !selected && onChange(mode)}
disabled={disabled}
className={`px-2 py-0.5 rounded border ${cls} ${dim} text-[10px] uppercase tracking-wide`}
>
{label}
</button>
);
};
return (
<div className="flex items-center gap-1">
{opt("shared", "shared")}
{opt("own_pr", "own pr")}
</div>
);
}
// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states. Two modes:
// 1) documentId selected → fetch the Contract and edit doc.body via
// updateContract (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 updateContract (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<Contract | 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);
getContract(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 updateContract(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>
);
}
// --- Task path: task row clicked in the sidebar ------------------------
// Renders the live transcript via DocumentTaskStream. Selection wins over
// the document path when both are somehow present (defensive).
if (selection?.taskId) {
const taskId = selection.taskId;
// Resolve a human label for the task: orchestrator/completion are
// labelled by role; step tasks borrow the step name; everything else
// is an ephemeral and just shows the task id slice. Look-up uses the
// already-fetched directive (with steps).
const stepWithTask = directive.steps.find((s) => s.taskId === taskId);
const label =
taskId === directive.orchestratorTaskId
? "orchestrator"
: taskId === directive.completionTaskId
? "completion"
: stepWithTask?.name ?? taskId.slice(0, 8);
const isStepBound =
taskId === directive.orchestratorTaskId ||
taskId === directive.completionTaskId ||
!!stepWithTask;
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>
<span>/</span>
<span className="text-white">{label}</span>
</div>
</div>
<DocumentTaskStream
taskId={taskId}
label={label}
ephemeral={!isStepBound}
/>
</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">
<ContractHeader
directive={directive}
doc={doc}
docTitle={docTitle}
onContractChanged={(updated) => {
setDoc(updated);
onDocumentChanged();
}}
/>
<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;
listContracts(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: Contract) => {
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 createContract(directive.id, {
title: "",
body: "",
});
bumpRefresh();
// Navigate to the new doc so it's selected immediately.
navigate(`/directives/${directive.id}?document=${created.id}`);
},
[bumpRefresh, navigate],
);
// Click on a task or step row → open the live transcript pane via
// ?task=<id>. EditorShell switches to DocumentTaskStream when this is set.
const handleSelectTask = useCallback(
(directiveId: string, taskId: string) => {
navigate(`/directives/${directiveId}?task=${taskId}`);
},
[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}
onSelectTask={handleSelectTask}
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>
);
}