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,
} from "../lib/api";
// Status dot color, matching the existing tabular UI's badge palette so the
// document mode feels like a sibling of the existing list, not a foreign UI.
const STATUS_DOT: Record<DirectiveStatus, string> = {
draft: "bg-[#556677]",
active: "bg-green-400",
idle: "bg-yellow-400",
paused: "bg-orange-400",
inactive: "bg-[#75aafc]",
archived: "bg-[#3a4a6a]",
};
// Per-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.
// =============================================================================
type SidebarGroup = "active" | "idle" | "archived";
const GROUP_LABEL: Record<SidebarGroup, string> = {
active: "active",
idle: "idle",
archived: "archived",
};
function bucketOf(status: DirectiveStatus): SidebarGroup {
if (status === "active" || status === "paused") return "active";
if (status === "archived") return "archived";
// draft + idle land in the idle bucket (i.e. "not currently running").
return "idle";
}
// 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>;
/**
* 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,
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 */}
<button
type="button"
onClick={() => {
onToggle();
onHeaderClick();
}}
title={directive.title}
className="w-full flex items-center gap-1.5 pl-9 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={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
aria-hidden
/>
<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"
/>
)}
</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>
{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>;
refreshNonce: number;
}
function DocumentSidebar({
directives,
loading,
selection,
onSelectDocument,
onSelectDirective,
onCreateDocument,
refreshNonce,
}: SidebarProps) {
const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => {
const out: Record<SidebarGroup, DirectiveSummary[]> = {
active: [],
idle: [],
archived: [],
};
for (const d of directives) {
out[bucketOf(d.status)].push(d);
}
// Sort each group alphabetically so it feels like a stable file tree.
(Object.keys(out) as SidebarGroup[]).forEach((k) => {
out[k].sort((a, b) =>
a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
);
});
return out;
}, [directives]);
// Default-collapsed state per group folder.
const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({
active: true,
idle: true,
archived: false,
});
// 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 toggleGroup = (g: SidebarGroup) =>
setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] }));
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 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">
Documents
</span>
<span className="text-[10px] font-mono text-[#556677]">
{directives.length}
</span>
</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 */}
<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 directives yet
</div>
) : (
(Object.keys(groups) as SidebarGroup[]).map((group) => {
const list = groups[group];
if (list.length === 0) return null;
const open = openGroups[group];
return (
<div key={group} className="select-none">
{/* Group header (sub-folder) */}
<button
type="button"
onClick={() => toggleGroup(group)}
className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
<Caret open={open} />
<FolderIcon open={open} />
<span>{GROUP_LABEL[group]}/</span>
<span className="ml-auto text-[10px] text-[#556677]">
{list.length}
</span>
</button>
{/* Each directive is a folder containing N documents. */}
{open && (
<div className="py-0.5">
{list.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}
refreshNonce={refreshNonce}
/>
))}
</div>
)}
</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],
);
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}
refreshNonce={refreshNonce}
/>
</div>
{/* Right: Lexical editor */}
<EditorShell
selection={selection}
hasDirectives={directives.length > 0}
listLoading={listLoading}
onDocumentChanged={bumpRefresh}
/>
</main>
</div>
);
}