import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { Masthead } from "../components/Masthead";
import { useDirective, useDirectives } from "../hooks/useDirectives";
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
import {
startDirective,
pauseDirective,
updateDirective,
deleteDirective,
completeDirectiveStep,
failDirectiveStep,
skipDirectiveStep,
stopTask,
} from "../lib/api";
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
// =============================================================================
// =============================================================================
// Task row context menu — sits next to DirectiveContextMenu and offers the
// task-level controls (interrupt for orchestrator/completion, complete/fail/
// skip for step tasks).
// =============================================================================
interface TaskContextMenuProps {
x: number;
y: number;
task: FolderTaskRow;
onClose: () => void;
onInterrupt: () => void;
onComplete?: () => void;
onFail?: () => void;
onSkip?: () => void;
}
function TaskContextMenu({
x,
y,
task,
onClose,
onInterrupt,
onComplete,
onFail,
onSkip,
}: TaskContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const click = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", click);
document.addEventListener("keydown", key);
return () => {
document.removeEventListener("mousedown", click);
document.removeEventListener("keydown", key);
};
}, [onClose]);
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
if (rect.right > window.innerWidth) ref.current.style.left = `${x - rect.width}px`;
if (rect.bottom > window.innerHeight) ref.current.style.top = `${y - rect.height}px`;
}, [x, y]);
const item =
"w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
const divider = "border-t border-[rgba(117,170,252,0.2)] my-1";
// Interrupt is meaningful for live tasks (orchestrator-active or running steps).
const showInterrupt =
task.kind === "orchestrator-active" ||
task.kind === "completion" ||
task.status === "running";
// Step lifecycle controls only apply to step tasks.
const isStep = task.kind === "step";
const showComplete = isStep && task.status !== "done";
const showFail = isStep && task.status !== "failed";
const showSkip = isStep && task.status !== "skipped";
return (
<div
ref={ref}
className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
style={{ left: x, top: y }}
>
<div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[220px]">
{task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label}
</div>
{showInterrupt && (
<button
className={item}
onClick={() => {
onInterrupt();
onClose();
}}
>
<span className="text-amber-300">⏹</span>
Interrupt
</button>
)}
{(showComplete || showFail || showSkip) && <div className={divider} />}
{showComplete && (
<button
className={item}
onClick={() => {
onComplete?.();
onClose();
}}
>
<span className="text-emerald-400">✓</span>
Mark complete
</button>
)}
{showFail && (
<button
className={item}
onClick={() => {
onFail?.();
onClose();
}}
>
<span className="text-red-400">✗</span>
Mark failed
</button>
)}
{showSkip && (
<button
className={item}
onClick={() => {
onSkip?.();
onClose();
}}
>
<span className="text-[#7788aa]">⤳</span>
Skip
</button>
)}
</div>
);
}
function slugify(title: string, fallback: string): string {
const slug = title
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9._-]/g, "")
.toLowerCase();
return slug.length > 0 ? slug : fallback;
}
interface SidebarSelection {
directiveId: string;
/** null = the directive's document; otherwise a task id (orchestrator/step). */
taskId: string | null;
}
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,
onDirectiveContextMenu,
onTaskContextMenu,
}: {
directive: DirectiveSummary;
open: boolean;
onToggle: () => void;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
/** Set of task ids that currently have pending user questions. */
pendingTaskIds: Set<string>;
/** Whether any pending question is associated with this directive. */
hasPendingForDirective: boolean;
onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
}) {
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}
onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
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,
})
}
onContextMenu={(e) =>
onTaskContextMenu(e, t, directive.id)
}
title={t.label}
className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
isSelected
? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
: "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
}`}
>
<Icon />
<span className="truncate flex-1">{t.label}</span>
<StatusDot
color={tdot}
live={live}
glow={glow}
status={t.status}
/>
</button>
</li>
);
})
)}
</ul>
)}
</li>
</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;
/** Directive step id for step kinds — needed for complete/fail/skip APIs. */
stepId: string | null;
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,
stepId: null,
label: "orchestrator",
status: "running",
kind: "orchestrator-active",
});
}
// Completion (PR creation) task.
const completionId =
detailed?.completionTaskId ?? summary.completionTaskId ?? null;
if (completionId) {
rows.push({
taskId: completionId,
stepId: null,
label: "completion",
status: "running",
kind: "completion",
});
}
// Step tasks — only steps that have actually been started have a taskId.
if (detailed) {
for (const step of detailed.steps) {
if (!step.taskId) continue;
rows.push({
taskId: step.taskId,
stepId: step.id,
label: step.name,
status: step.status,
kind: "step",
});
}
}
return rows;
}
interface SidebarProps {
directives: DirectiveSummary[];
loading: boolean;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
}
function DocumentSidebar({
directives,
loading,
selection,
onSelect,
onDirectiveContextMenu,
onTaskContextMenu,
}: 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)}
onDirectiveContextMenu={onDirectiveContextMenu}
onTaskContextMenu={onTaskContextMenu}
/>
))
)}
</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
// =============================================================================
type ContextMenuState =
| { kind: "directive"; x: number; y: number; directive: DirectiveSummary }
| {
kind: "task";
x: number;
y: number;
task: FolderTaskRow;
directiveId: string;
}
| null;
export default function DocumentDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const selectedTaskId = searchParams.get("task");
const { directives, loading: listLoading, refresh: refreshList } = useDirectives();
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
useEffect(() => {
if (!authLoading && isAuthConfigured && !isAuthenticated) {
navigate("/login");
}
}, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
const onSelect = useCallback(
(sel: SidebarSelection) => {
const next = `/directives/${sel.directiveId}${
sel.taskId ? `?task=${sel.taskId}` : ""
}`;
navigate(next);
},
[navigate],
);
const onClearTask = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete("task");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const onDirectiveContextMenu = useCallback(
(e: React.MouseEvent, d: DirectiveSummary) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ kind: "directive", x: e.clientX, y: e.clientY, directive: d });
},
[],
);
const onTaskContextMenu = useCallback(
(e: React.MouseEvent, task: FolderTaskRow, directiveId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ kind: "task", x: e.clientX, y: e.clientY, task, directiveId });
},
[],
);
const closeContextMenu = useCallback(() => setContextMenu(null), []);
if (authLoading) {
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
<Masthead showNav />
<main className="flex-1 flex items-center justify-center">
<p className="text-[#7788aa] font-mono text-sm">Loading...</p>
</main>
</div>
);
}
const selection: SidebarSelection | null = selectedId
? { directiveId: selectedId, taskId: selectedTaskId }
: null;
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
<Masthead showNav />
<main
className="flex-1 flex overflow-hidden"
style={{ height: "calc(100vh - 80px)" }}
>
{/* Left: file-tree sidebar */}
<div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
<DocumentSidebar
directives={directives}
loading={listLoading}
selection={selection}
onSelect={onSelect}
onDirectiveContextMenu={onDirectiveContextMenu}
onTaskContextMenu={onTaskContextMenu}
/>
</div>
{/* Right: Lexical editor / task stream */}
<EditorShell
selectedId={selectedId}
selectedTaskId={selectedTaskId}
hasDirectives={directives.length > 0}
listLoading={listLoading}
onClearTask={onClearTask}
/>
</main>
{/* Context menus — rendered at page level so they overlay everything. */}
{contextMenu?.kind === "directive" && (
<DirectiveContextMenu
x={contextMenu.x}
y={contextMenu.y}
directive={contextMenu.directive}
onClose={closeContextMenu}
onStart={async () => {
await startDirective(contextMenu.directive.id);
await refreshList();
}}
onPause={async () => {
await pauseDirective(contextMenu.directive.id);
await refreshList();
}}
onArchive={async () => {
await updateDirective(contextMenu.directive.id, {
status: "archived",
});
await refreshList();
}}
onDelete={async () => {
if (
!window.confirm(
`Delete "${contextMenu.directive.title}"? This cannot be undone.`,
)
) {
return;
}
await deleteDirective(contextMenu.directive.id);
await refreshList();
// If the deleted one was selected, clear selection.
if (selectedId === contextMenu.directive.id) {
navigate("/directives");
}
}}
onGoToPR={() => {
if (contextMenu.directive.prUrl) {
window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
}
}}
/>
)}
{contextMenu?.kind === "task" && (
<TaskContextMenu
x={contextMenu.x}
y={contextMenu.y}
task={contextMenu.task}
onClose={closeContextMenu}
onInterrupt={async () => {
try {
await stopTask(contextMenu.task.taskId);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[makima] failed to interrupt task", err);
}
await refreshList();
}}
onComplete={async () => {
if (!contextMenu.task.stepId) return;
await completeDirectiveStep(
contextMenu.directiveId,
contextMenu.task.stepId,
);
await refreshList();
}}
onFail={async () => {
if (!contextMenu.task.stepId) return;
await failDirectiveStep(
contextMenu.directiveId,
contextMenu.task.stepId,
);
await refreshList();
}}
onSkip={async () => {
if (!contextMenu.task.stepId) return;
await skipDirectiveStep(
contextMenu.directiveId,
contextMenu.task.stepId,
);
await refreshList();
}}
/>
)}
</div>
);
}