import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { Masthead } from "../components/Masthead";
import { useDirective, useDirectives } from "../hooks/useDirectives";
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
import type {
DirectiveStatus,
DirectiveSummary,
DirectiveWithSteps,
} 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",
archived: "bg-[#3a4a6a]",
};
// Per-task dot color for the sidebar entries inside a directive folder.
// Matches the StepsBlockNode palette.
const STEP_STATUS_DOT: Record<string, string> = {
pending: "bg-[#556677]",
ready: "bg-[#9bc3ff]",
running: "bg-yellow-400",
done: "bg-green-400",
failed: "bg-red-400",
skipped: "bg-[#3a4a6a]",
};
// =============================================================================
// Sidebar icons (inline SVG, no new deps)
// =============================================================================
function FolderIcon({ open = false }: { open?: boolean }) {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
{open ? (
<path
d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z"
fill="#75aafc"
opacity="0.85"
/>
) : (
<path
d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z"
fill="#75aafc"
opacity="0.65"
/>
)}
</svg>
);
}
function FileIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<path
d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"
fill="none"
stroke="#9bc3ff"
strokeWidth="1"
/>
<path
d="M9.5 1.5v3h3"
fill="none"
stroke="#9bc3ff"
strokeWidth="1"
/>
</svg>
);
}
/** Terminal/prompt icon for orchestrator and step tasks. */
function TaskIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<rect x="1.5" y="3" width="13" height="10" rx="1" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#9bc3ff" strokeWidth="1" fill="none" strokeLinecap="round" />
</svg>
);
}
/** PR-bracket icon for the completion task. */
function CompletionIcon() {
return (
<svg
viewBox="0 0 16 16"
width={12}
height={12}
className="shrink-0"
aria-hidden
>
<circle cx="4" cy="4" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<circle cx="4" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<circle cx="12" cy="12" r="1.4" fill="none" stroke="#9bc3ff" strokeWidth="1" />
<path d="M4 5.4v5.2 M4 12h6.6 M12 4l0 6.6" stroke="#9bc3ff" strokeWidth="1" fill="none" />
</svg>
);
}
function PinIcon() {
return (
<svg
viewBox="0 0 16 16"
width={10}
height={10}
className="shrink-0"
aria-hidden
>
<path
d="M8 1.5l1.6 3.6 3.9.4-2.95 2.7.85 3.9L8 10.2 4.6 12.1l.85-3.9L2.5 5.5l3.9-.4z"
fill="#75aafc"
opacity="0.7"
/>
</svg>
);
}
function Caret({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 8 8"
width={8}
height={8}
className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
aria-hidden
>
<path d="M2 1l4 3-4 3z" fill="#7788aa" />
</svg>
);
}
// =============================================================================
// Sidebar
// =============================================================================
function slugify(title: string, fallback: string): string {
const slug = title
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9._-]/g, "")
.toLowerCase();
return slug.length > 0 ? slug : fallback;
}
interface SidebarSelection {
directiveId: string;
/** null = the directive's document; otherwise a task id (orchestrator/step). */
taskId: string | null;
}
interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
}
/**
* Per-directive folder. Renders the directive as a collapsible folder whose
* body is the pinned document entry (always first) followed by a `tasks/`
* subfolder containing the orchestrator, completion, and step tasks.
*
* Status dot lives on the right side only (single-side, per the v2 design).
* If a directive or task has a pending user question, its icon glows.
*/
function DirectiveFolder({
directive,
open,
onToggle,
selection,
onSelect,
pendingTaskIds,
hasPendingForDirective,
}: {
directive: DirectiveSummary;
open: boolean;
onToggle: () => void;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
/** Set of task ids that currently have pending user questions. */
pendingTaskIds: Set<string>;
/** Whether any pending question is associated with this directive. */
hasPendingForDirective: boolean;
}) {
const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;
// Lazy fetch full directive (with steps) only when folder is open.
const { directive: detailed } = useDirective(open ? directive.id : undefined);
const docSelected =
selection?.directiveId === directive.id && selection.taskId === null;
// Collect the tasks to surface in the folder body.
const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]);
const orchestratorRunning = !!directive.orchestratorTaskId;
// Tasks subfolder open state — independent of the directive folder.
const [tasksOpen, setTasksOpen] = useState<boolean>(true);
return (
<div className="select-none">
<button
type="button"
onClick={onToggle}
title={directive.title}
className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
<Caret open={open} />
<FolderIcon open={open} />
<span className="truncate flex-1 text-left">{directive.title}</span>
{/* Status dot — RIGHT side only. Glows when this directive has a
pending user question, or pulses when the orchestrator is live. */}
<StatusDot
color={dotColor}
live={orchestratorRunning}
glow={hasPendingForDirective}
status={directive.status}
/>
</button>
{open && (
<ul className="py-0.5">
{/* Pinned document entry — always at the top of the folder. */}
<li>
<button
type="button"
onClick={() =>
onSelect({ directiveId: directive.id, taskId: null })
}
className={`w-full text-left flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] transition-colors ${
docSelected
? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
}`}
>
<PinIcon />
<FileIcon />
<span className="truncate flex-1">{fileName}</span>
</button>
</li>
{/* tasks/ subfolder — collapsible, contains orchestrator/completion/steps. */}
<li>
<button
type="button"
onClick={() => setTasksOpen((p) => !p)}
className="w-full flex items-center gap-1.5 pl-8 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
<Caret open={tasksOpen} />
<FolderIcon open={tasksOpen} />
<span className="truncate flex-1 text-left">tasks/</span>
{tasks.length > 0 && (
<span className="text-[10px] text-[#556677]">{tasks.length}</span>
)}
</button>
{tasksOpen && (
<ul className="py-0.5">
{tasks.length === 0 ? (
<li className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677]">
No tasks yet
</li>
) : (
tasks.map((t) => {
const isSelected =
selection?.directiveId === directive.id &&
selection?.taskId === t.taskId;
const tdot = STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending;
const live =
t.status === "running" || t.kind === "orchestrator-active";
const glow = pendingTaskIds.has(t.taskId);
const Icon =
t.kind === "completion" ? CompletionIcon : TaskIcon;
return (
<li key={t.taskId}>
<button
type="button"
onClick={() =>
onSelect({
directiveId: directive.id,
taskId: t.taskId,
})
}
title={t.label}
className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
isSelected
? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
}`}
>
<Icon />
<span className="truncate flex-1">{t.label}</span>
<StatusDot
color={tdot}
live={live}
glow={glow}
status={t.status}
/>
</button>
</li>
);
})
)}
</ul>
)}
</li>
</ul>
)}
</div>
);
}
/**
* Right-side status indicator. Composes the colored status dot with optional
* "live" pulse (orchestrator running) and "glow" attention ring (pending user
* question waiting on a response).
*/
function StatusDot({
color,
live,
glow,
status,
}: {
color: string;
live: boolean;
glow: boolean;
status: string;
}) {
// The glow is a soft amber ring pulsed via box-shadow. Keep it subtle so it
// doesn't fight the live pulse for attention when both are present.
const ring = glow
? "shadow-[0_0_0_2px_rgba(251,191,36,0.45),0_0_8px_2px_rgba(251,191,36,0.55)] animate-pulse"
: "";
const livePulse = live && !glow ? "animate-pulse" : "";
const title = glow
? `${status} — needs response`
: live
? `${status} — running`
: `status: ${status}`;
return (
<span
className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`}
aria-label={title}
title={title}
/>
);
}
interface FolderTaskRow {
taskId: string;
label: string;
status: string;
kind: "orchestrator-active" | "completion" | "step";
}
function collectTasks(
detailed: DirectiveWithSteps | null,
summary: DirectiveSummary,
): FolderTaskRow[] {
const rows: FolderTaskRow[] = [];
// Orchestrator (planner) — surfaces only while it's actively running so
// the folder is not flooded with stale orchestrator entries.
const orchestratorId =
detailed?.orchestratorTaskId ?? summary.orchestratorTaskId ?? null;
if (orchestratorId) {
rows.push({
taskId: orchestratorId,
label: "orchestrator",
status: "running",
kind: "orchestrator-active",
});
}
// Completion (PR creation) task.
const completionId =
detailed?.completionTaskId ?? summary.completionTaskId ?? null;
if (completionId) {
rows.push({
taskId: completionId,
label: "completion",
status: "running",
kind: "completion",
});
}
// Step tasks — only steps that have actually been started have a taskId.
if (detailed) {
for (const step of detailed.steps) {
if (!step.taskId) continue;
rows.push({
taskId: step.taskId,
label: step.name,
status: step.status,
kind: "step",
});
}
}
return rows;
}
interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
}
function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) {
// Pending user questions — drives the "glow" attention ring. We split into
// two indices so the directive folder header glows whenever ANY of its
// tasks has a pending question, while individual task rows glow only for
// their own question.
const { pendingQuestions } = useSupervisorQuestions();
const { directivesWithPending, tasksWithPending } = useMemo(() => {
const dirs = new Set<string>();
const tasks = new Set<string>();
for (const q of pendingQuestions) {
if (q.directiveId) dirs.add(q.directiveId);
if (q.taskId) tasks.add(q.taskId);
}
return { directivesWithPending: dirs, tasksWithPending: tasks };
}, [pendingQuestions]);
// Sort active first, then idle, then paused, then archived.
const sorted = useMemo(() => {
const order: Record<DirectiveStatus, number> = {
active: 0,
paused: 1,
idle: 2,
draft: 3,
archived: 4,
};
return [...directives].sort((a, b) => {
const oa = order[a.status] ?? 99;
const ob = order[b.status] ?? 99;
if (oa !== ob) return oa - ob;
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
});
}, [directives]);
// Track which directive folders are open. The currently selected directive
// is forced open so deep links land on something visible.
const [openIds, setOpenIds] = useState<Set<string>>(new Set());
const lastSelectedRef = useRef<string | null>(null);
useEffect(() => {
if (selection && selection.directiveId !== lastSelectedRef.current) {
lastSelectedRef.current = selection.directiveId;
setOpenIds((prev) => {
if (prev.has(selection.directiveId)) return prev;
const next = new Set(prev);
next.add(selection.directiveId);
return next;
});
}
}, [selection]);
const toggleOpen = useCallback((id: string) => {
setOpenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
return (
<div className="flex flex-col h-full">
{/* Sidebar header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
<span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
Documents
</span>
<span className="text-[10px] font-mono text-[#556677]">
{directives.length}
</span>
</div>
{/* Top-level "directives/" folder header (informational, non-interactive). */}
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
<FolderIcon open />
<span>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>
) : (
sorted.map((d) => (
<DirectiveFolder
key={d.id}
directive={d}
open={openIds.has(d.id)}
onToggle={() => toggleOpen(d.id)}
selection={selection}
onSelect={onSelect}
pendingTaskIds={tasksWithPending}
hasPendingForDirective={directivesWithPending.has(d.id)}
/>
))
)}
</div>
</div>
);
}
// =============================================================================
// Editor shell — wraps DocumentEditor and handles the "no document selected"
// and loading states.
// =============================================================================
interface EditorShellProps {
selectedId: string | undefined;
selectedTaskId: string | null;
hasDirectives: boolean;
listLoading: boolean;
onClearTask: () => void;
}
function EditorShell({
selectedId,
selectedTaskId,
hasDirectives,
listLoading,
onClearTask,
}: EditorShellProps) {
const {
directive,
loading,
updateGoal,
cleanup,
createPR,
pickUpOrders,
} = useDirective(selectedId);
if (!selectedId) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">
{listLoading
? "Loading documents..."
: hasDirectives
? "Select a document from the sidebar"
: "No documents yet — create one from the legacy UI"}
</p>
</div>
);
}
if (loading && !directive) {
return (
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-[#556677] font-mono text-[12px]">Loading document...</p>
</div>
);
}
if (!directive) {
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>
);
}
// Resolve the label for the breadcrumb when a task is selected.
const taskLabel = selectedTaskId
? selectedTaskId === directive.orchestratorTaskId
? "orchestrator"
: selectedTaskId === directive.completionTaskId
? "completion"
: directive.steps.find((s) => s.taskId === selectedTaskId)?.name ??
selectedTaskId.slice(0, 8)
: null;
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Document header — breadcrumb-like, mirrors a code editor's tab bar */}
<div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
<div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
<FileIcon />
<span>directives /</span>
<span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span>
{selectedTaskId && (
<>
<span>/</span>
<span className="text-[#9bc3ff]">{taskLabel}</span>
<button
type="button"
onClick={onClearTask}
className="ml-2 px-1.5 py-0.5 text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded normal-case"
>
back to document
</button>
</>
)}
{!selectedTaskId && !!directive.orchestratorTaskId && (
<span className="ml-2 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>
{selectedTaskId ? (
<DocumentTaskStream
taskId={selectedTaskId}
label={taskLabel ?? selectedTaskId.slice(0, 8)}
/>
) : (
<DocumentEditor
directive={directive}
onUpdateGoal={async (goal) => {
await updateGoal(goal);
}}
onCleanup={async () => {
await cleanup();
}}
onCreatePR={async () => {
await createPR();
}}
onPickUpOrders={async () => {
await pickUpOrders();
}}
/>
)}
</div>
);
}
// =============================================================================
// Page
// =============================================================================
export default function DocumentDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const selectedTaskId = searchParams.get("task");
const { directives, loading: listLoading } = useDirectives();
useEffect(() => {
if (!authLoading && isAuthConfigured && !isAuthenticated) {
navigate("/login");
}
}, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
const onSelect = useCallback(
(sel: SidebarSelection) => {
const next = `/directives/${sel.directiveId}${
sel.taskId ? `?task=${sel.taskId}` : ""
}`;
navigate(next);
},
[navigate],
);
const onClearTask = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete("task");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
if (authLoading) {
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
<Masthead showNav />
<main className="flex-1 flex items-center justify-center">
<p className="text-[#7788aa] font-mono text-sm">Loading...</p>
</main>
</div>
);
}
const selection: SidebarSelection | null = selectedId
? { directiveId: selectedId, taskId: selectedTaskId }
: null;
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
<Masthead showNav />
<main
className="flex-1 flex overflow-hidden"
style={{ height: "calc(100vh - 80px)" }}
>
{/* Left: file-tree sidebar */}
<div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
<DocumentSidebar
directives={directives}
loading={listLoading}
selection={selection}
onSelect={onSelect}
/>
</div>
{/* Right: Lexical editor / task stream */}
<EditorShell
selectedId={selectedId}
selectedTaskId={selectedTaskId}
hasDirectives={directives.length > 0}
listLoading={listLoading}
onClearTask={onClearTask}
/>
</main>
</div>
);
}