diff options
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 2352 |
1 files changed, 869 insertions, 1483 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index a3ea969..b583bef 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -3,36 +3,20 @@ 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, - listDirectiveRevisions, - newDirectiveDraft, - createDirectivePR, - advanceDirective, - cleanupDirective, - pickUpOrders, - sendTaskMessage, - listDirectiveEphemeralTasks, - createDirectiveTask, - listOrphanTasks, -} from "../lib/api"; -import type { - DirectiveStatus, - DirectiveSummary, - DirectiveWithSteps, - DirectiveRevision, - TaskSummary, + 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 @@ -46,17 +30,52 @@ const STATUS_DOT: Record<DirectiveStatus, string> = { 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]", +// 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) // ============================================================================= @@ -112,103 +131,6 @@ function FileIcon() { ); } -/** 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> - ); -} - -/** Asterisk-on-terminal icon for ephemeral spinoff tasks — visually - distinct from the plain TaskIcon used for step-spawned execution tasks - so users can tell at a glance which tasks are part of the DAG vs which - are user-spun side quests. */ -function EphemeralTaskIcon() { - 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="#c084fc" strokeWidth="1" /> - <path d="M3.5 6l2 2-2 2 M7 10h4" stroke="#c084fc" strokeWidth="1" fill="none" strokeLinecap="round" /> - <path d="M11 4l1 1m-1 0l1-1" stroke="#c084fc" strokeWidth="1" fill="none" /> - </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> - ); -} - -/** Tiny chip used for the inline directive-folder hover actions. */ -function FolderActionButton({ - children, - title, - onClick, -}: { - children: React.ReactNode; - title: string; - onClick: () => void; -}) { - return ( - <button - type="button" - title={title} - onClick={(e) => { - e.stopPropagation(); - onClick(); - }} - className="w-5 h-5 flex items-center justify-center text-[10px] text-[#7788aa] hover:text-white hover:bg-[rgba(117,170,252,0.15)] rounded transition-colors" - > - {children} - </button> - ); -} - function Caret({ open }: { open: boolean }) { return ( <svg @@ -224,940 +146,652 @@ function Caret({ open }: { open: boolean }) { } // ============================================================================= -// 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). +// 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 TaskContextMenuProps { - x: number; - y: number; - task: FolderTaskRow; - onClose: () => void; - onInterrupt: () => void; - onComplete?: () => void; - onFail?: () => void; - onSkip?: () => void; - /** Send a freeform message to the running task (same wire as the inline comment box). */ - onSendMessage?: () => void; - /** Navigate to the standalone task page for full-screen control. */ - onOpenInTaskPage?: () => void; -} - -function TaskContextMenu({ - x, - y, - task, - onClose, - onInterrupt, - onComplete, - onFail, - onSkip, - onSendMessage, - onOpenInTaskPage, -}: 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> - )} - - {/* Direct task-page actions: send-message and open-in-task-page mirror - what the standalone /exec/:taskId page exposes. */} - {(onSendMessage || onOpenInTaskPage) && <div className={divider} />} - {onSendMessage && ( - <button - className={item} - onClick={() => { - onSendMessage(); - onClose(); - }} - > - <span className="text-cyan-300">⌨</span> - Send message - </button> - )} - {onOpenInTaskPage && ( - <button - className={item} - onClick={() => { - onOpenInTaskPage(); - onClose(); - }} - > - <span className="text-[#75aafc]">↗</span> - Open in task page - </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; + 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; } -/** - * 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, + onHeaderClick, selection, - onSelect, - pendingTaskIds, - hasPendingForDirective, - onDirectiveContextMenu, - onTaskContextMenu, - onCreateTask, - onQuickAction, -}: { - 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; - /** Open the inline "+ New task" form for this directive. */ - onCreateTask: (d: DirectiveSummary) => void; - /** Trigger a quick action (start/pause/PR) on the directive. */ - onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void; -}) { + onSelectDocument, + onCreateDocument, + refreshNonce, +}: DirectiveFolderProps) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; - const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; + const orchestratorRunning = !!directive.orchestratorTaskId; - // Lazy fetch full directive (with steps) only when folder is open. - const { directive: detailed } = useDirective(open ? directive.id : undefined); + // 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); - const docSelected = - selection?.directiveId === directive.id && selection.taskId === null; + // shipped/ subfolder open state — independent of the directive folder. + const [shippedOpen, setShippedOpen] = useState(false); - // Ephemeral tasks attached to this directive (no directive_step_id). Fetched - // lazily when the folder opens; refetched whenever a poll lands on the - // directive's detail (poll-driven freshness). - const [ephemeralTasks, setEphemeralTasks] = useState<TaskSummary[]>([]); - useEffect(() => { - if (!open) return; - let cancelled = false; - listDirectiveEphemeralTasks(directive.id) - .then((res) => { - if (!cancelled) setEphemeralTasks(res.tasks); - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.warn("[makima] failed to load ephemeral tasks", err); - }); - return () => { - cancelled = true; - }; - }, [open, directive.id, directive.updatedAt]); + // Whether a "+ New document" call is in flight (disables the button). + const [creating, setCreating] = useState(false); - // Collect the tasks to surface in the folder body. - const tasks = useMemo( - () => collectTasks(detailed, directive, ephemeralTasks), - [detailed, directive, ephemeralTasks], - ); + 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]); - const orchestratorRunning = !!directive.orchestratorTaskId; - // Tasks subfolder open state — independent of the directive folder. - const [tasksOpen, setTasksOpen] = useState<boolean>(true); - // Revisions subfolder — collapsed by default since most contracts have - // no merged history yet. - const [revisionsOpen, setRevisionsOpen] = useState<boolean>(false); - const [revisions, setRevisions] = useState<DirectiveRevision[]>([]); - // Fetch revisions only when the parent folder is open. Re-fetch whenever - // the directive's pr_url changes so a freshly-raised PR appears. + // Fetch on open; refetch when refreshNonce bumps and the folder is open. useEffect(() => { if (!open) return; - let cancelled = false; - listDirectiveRevisions(directive.id) - .then((res) => { - if (!cancelled) setRevisions(res.revisions); - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.warn("[makima] failed to load revisions", err); - }); - return () => { - cancelled = true; - }; - }, [open, directive.id, directive.prUrl]); - - // Inline action buttons on the folder header — visible on hover (and when - // the folder is open) so users don't have to right-click to discover the - // primary directive controls. Mirrors a code-editor sidebar's affordance. - const showStart = - directive.status === "draft" || - directive.status === "paused" || - directive.status === "idle" || - directive.status === "inactive"; - const showPause = directive.status === "active"; + 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 group/dir"> - <div - 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)]" - onContextMenu={(e) => onDirectiveContextMenu(e, directive)} + <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)]" > - <button - type="button" - onClick={onToggle} - title={directive.title} - className="flex items-center gap-1.5 flex-1 min-w-0 text-left" - > - <Caret open={open} /> - <FolderIcon open={open} /> - <span className="truncate flex-1">{directive.title}</span> - </button> - - {/* Hover/open-only action chips — discoverable replacement for the - right-click menu. Right-click still works as a power-user fallback. */} - <div - className={`flex items-center gap-0.5 transition-opacity ${ - open - ? "opacity-100" - : "opacity-0 group-hover/dir:opacity-100" - }`} - > - {showStart && ( - <FolderActionButton - title="Start" - onClick={() => onQuickAction(directive, "start")} - > - ▶ - </FolderActionButton> - )} - {showPause && ( - <FolderActionButton - title="Pause" - onClick={() => onQuickAction(directive, "pause")} - > - ❚❚ - </FolderActionButton> - )} - {directive.prUrl && ( - <FolderActionButton - title="Open PR" - onClick={() => - window.open(directive.prUrl ?? "", "_blank", "noreferrer") - } - > - ↗ - </FolderActionButton> - )} - <FolderActionButton - title="New task" - onClick={() => onCreateTask(directive)} - > - + - </FolderActionButton> - </div> - - {/* 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} + <Caret open={open} /> + <FolderIcon open={open} /> + <span + className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`} + aria-hidden /> - </div> + <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 && ( - <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 - : t.kind === "ephemeral" - ? EphemeralTaskIcon - : 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> + <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> + )} - {/* revisions/ subfolder — per-PR frozen snapshots of this contract. - Only rendered when there's at least one revision; otherwise the - folder body would be a confusing empty placeholder. */} - {revisions.length > 0 && ( - <li> + {/* 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={() => setRevisionsOpen((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)]" + 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" > - <Caret open={revisionsOpen} /> - <FolderIcon open={revisionsOpen} /> - <span className="truncate flex-1 text-left">revisions/</span> - <span className="text-[10px] text-[#556677]"> - {revisions.length} - </span> + <span className="text-[12px] leading-none">+</span> + <span>New document</span> </button> - {revisionsOpen && ( - <ul className="py-0.5"> - {revisions.map((r) => { - const isSelected = - selection?.directiveId === directive.id && - selection?.taskId === `revision:${r.id}`; - return ( - <li key={r.id}> - <button - type="button" - onClick={() => - onSelect({ - directiveId: directive.id, - taskId: `revision:${r.id}`, - }) - } - title={`v${r.version} · ${r.prState} · ${r.prUrl}`} - 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" - }`} - > - <FileIcon /> - <span className="truncate flex-1"> - v{r.version}.md - </span> - <RevisionStateBadge prState={r.prState} /> - </button> - </li> - ); - })} - </ul> + {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> )} - </li> + </> )} - </ul> + </div> )} </div> ); } -/** - * Read-only viewer for a frozen contract revision. We render the markdown as - * plain pre-formatted text — these are immutable historical records, not - * places to edit. A header strip shows the PR state and a deep link. - */ -function RevisionViewer({ - directiveId, - revisionId, -}: { - directiveId: string; - revisionId: string; -}) { - const [revision, setRevision] = useState<DirectiveRevision | null>(null); - const [loading, setLoading] = useState(true); +// ============================================================================= +// 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); - useEffect(() => { - let cancelled = false; + // 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); - listDirectiveRevisions(directiveId) - .then((res) => { - if (cancelled) return; - const found = res.revisions.find((r) => r.id === revisionId) ?? null; - if (!found) setError("Revision not found"); - setRevision(found); - }) - .catch((err) => { - if (cancelled) return; - setError(err instanceof Error ? err.message : String(err)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [directiveId, revisionId]); + try { + const res = await listDirectiveDocumentTasks(documentId); + setData(res); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load tasks"); + } finally { + setLoading(false); + } + }, [documentId]); - if (loading) { - return ( - <div className="flex-1 flex items-center justify-center"> - <p className="text-[#556677] font-mono text-[12px]">Loading revision…</p> - </div> - ); - } - if (error || !revision) { - return ( - <div className="flex-1 flex items-center justify-center"> - <p className="text-red-400 font-mono text-[12px]"> - {error ?? "Revision not found"} - </p> - </div> - ); + // 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 className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]"> - <div className="flex-1 overflow-y-auto"> - <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]"> - <div className="flex items-center gap-3 mb-1"> - <h1 className="text-[24px] font-medium text-white tracking-tight"> - v{revision.version} - </h1> - <RevisionStateBadge prState={revision.prState} /> - </div> - <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-1"> - Frozen {new Date(revision.frozenAt).toLocaleString()} - </p> - <p className="text-[11px] font-mono text-[#7788aa] mb-8"> - <a - href={revision.prUrl} - target="_blank" - rel="noreferrer" - className="text-[#75aafc] hover:text-[#9bc3ff] underline" - > - {revision.prUrl} - </a> - </p> - - {/* Render the frozen markdown as plain pre-formatted text. We - deliberately do not parse it into rich nodes — the goal is to - show the historical record exactly as it was at PR time. */} - <pre className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-[#e0eaf8]"> - {revision.content} - </pre> + <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> + )} </div> ); } -/** Tiny pill showing the PR state of a revision (open / merged / closed). */ -function RevisionStateBadge({ prState }: { prState: string }) { - const tone = - prState === "merged" - ? "text-emerald-300 border-emerald-700/60" - : prState === "closed" - ? "text-[#7788aa] border-[#2a3a5a]" - : "text-amber-300 border-amber-600/40"; - return ( - <span - className={`text-[9px] font-mono uppercase border rounded px-1 py-0 ${tone}`} - > - {prState} - </span> - ); +// 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; } -/** - * 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}`; +function StepRow({ step, padLeft }: StepRowProps) { + const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]"; return ( - <span - className={`inline-block w-2 h-2 rounded-full shrink-0 ${color} ${ring} ${livePulse}`} - aria-label={title} - title={title} - /> + <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 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" | "ephemeral"; +interface TaskRowProps { + task: Task; + padLeft: string; } -function collectTasks( - detailed: DirectiveWithSteps | null, - summary: DirectiveSummary, - ephemeralTasks: TaskSummary[], -): 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", - }); - } - } - - // Ephemeral tasks — user-spawned spinoffs not part of the DAG. Surfaced - // alongside step tasks but with a different icon and the "ephemeral" kind - // so context menus and the merge button behave correctly. - for (const t of ephemeralTasks) { - rows.push({ - taskId: t.id, - stepId: null, - label: t.name, - status: t.status, - kind: "ephemeral", - }); - } - - return rows; +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; - onSelect: (sel: SidebarSelection) => void; - onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void; - onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void; - /** Open the inline "+ New task" form for this directive. */ - onCreateTask: (d: DirectiveSummary) => void; - /** Trigger a quick action (start/pause/PR) on the directive. */ - onQuickAction: (d: DirectiveSummary, action: "start" | "pause" | "pr") => void; - /** Navigate to an orphan (no-directive) task's standalone view. */ - onSelectOrphan: (taskId: string) => void; + onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; + onSelectDirective: (directiveId: string) => void; + onCreateDocument: (directive: DirectiveSummary) => Promise<void>; + refreshNonce: number; } function DocumentSidebar({ directives, loading, selection, - onSelect, - onDirectiveContextMenu, - onTaskContextMenu, - onCreateTask, - onQuickAction, - onSelectOrphan, + onSelectDocument, + onSelectDirective, + onCreateDocument, + refreshNonce, }: SidebarProps) { - // Orphan tasks (no directive) — top-level "tmp/" pseudo-folder. Polled - // every 5s so newly-spawned standalone tasks appear without a manual - // refresh. - const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]); - useEffect(() => { - let cancelled = false; - const load = () => { - listOrphanTasks() - .then((res) => { - if (!cancelled) setOrphanTasks(res.tasks); - }) - .catch(() => { - /* swallow — tmp/ is a nice-to-have, never blocking */ - }); + const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { + const out: Record<SidebarGroup, DirectiveSummary[]> = { + active: [], + idle: [], + archived: [], }; - load(); - const interval = setInterval(load, 5000); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, []); - const [tmpOpen, setTmpOpen] = useState<boolean>(true); - // 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); + for (const d of directives) { + out[bucketOf(d.status)].push(d); } - return { directivesWithPending: dirs, tasksWithPending: tasks }; - }, [pendingQuestions]); - - // Sort active first, then idle, then paused, then drafts, then inactive - // (shipped contracts are quieter), then archived. - const sorted = 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" }); + // 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]); - // 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); + // 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 && 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; - }); - }, []); + 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"> - Contracts + Documents </span> <span className="text-[10px] font-mono text-[#556677]"> {directives.length} </span> </div> - {/* Top-level "contracts/" folder header (informational, non-interactive). */} + {/* 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>contracts/</span> + <span>directives/</span> </div> {/* Body */} <div className="flex-1 overflow-y-auto pb-4"> - {/* tmp/ pseudo-folder — orphan tasks (directive_id NULL). Always - rendered so users can create scratchpad tasks even when zero - directives exist; collapses to a thin header when empty. */} - <div className="select-none border-b border-dashed border-[rgba(117,170,252,0.1)] pb-1 mb-1"> - <button - type="button" - onClick={() => setTmpOpen((p) => !p)} - 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={tmpOpen} /> - <FolderIcon open={tmpOpen} /> - <span className="truncate flex-1 text-left text-[#7788aa]">tmp/</span> - <span className="text-[10px] text-[#556677]"> - {orphanTasks.length} - </span> - </button> - {tmpOpen && ( - <ul className="py-0.5"> - {orphanTasks.length === 0 ? ( - <li className="pl-8 pr-3 py-1 font-mono text-[10px] text-[#556677] italic"> - No orphan tasks - </li> - ) : ( - orphanTasks.map((t) => { - const tdot = - STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending; - const live = t.status === "running"; - return ( - <li key={t.id}> - <button - type="button" - onClick={() => onSelectOrphan(t.id)} - title={t.name} - className="w-full text-left 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.06)] border-l-2 border-transparent transition-colors" - > - <TaskIcon /> - <span className="truncate flex-1">{t.name}</span> - <StatusDot - color={tdot} - live={live} - glow={false} - status={t.status} - /> - </button> - </li> - ); - }) - )} - </ul> - )} - </div> - {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 + 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} - onCreateTask={onCreateTask} - onQuickAction={onQuickAction} - /> - )) + (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> @@ -1166,108 +800,112 @@ function DocumentSidebar({ // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" -// and loading states. +// 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. // ============================================================================= -/** - * Wraps DocumentTaskStream with ephemeral-aware metadata. Determines whether - * the selected task is part of the directive's DAG (orchestrator/completion/ - * steps) or an ephemeral spinoff, and looks up its current status from the - * ephemeral list — that decides whether the "Merge to base" affordance - * should appear in the stream's action header. - */ -function EphemeralAwareTaskStream({ - taskId, - label, - directive, -}: { - taskId: string; - label: string; - directive: DirectiveWithSteps; -}) { - const isStepBound = - taskId === directive.orchestratorTaskId || - taskId === directive.completionTaskId || - directive.steps.some((s) => s.taskId === taskId); - - // Status lookup for ephemeral tasks. We poll the ephemeral list lazily — - // this is a lightweight call and only triggers when the user is viewing a - // task in the editor pane. - const [ephemeralStatus, setEphemeralStatus] = useState<string | undefined>(); - useEffect(() => { - if (isStepBound) return; - let cancelled = false; - const load = () => { - listDirectiveEphemeralTasks(directive.id) - .then((res) => { - if (cancelled) return; - const match = res.tasks.find((t) => t.id === taskId); - setEphemeralStatus(match?.status); - }) - .catch(() => { - /* non-blocking */ - }); - }; - load(); - const interval = setInterval(load, 5000); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [taskId, directive.id, isStepBound]); - - return ( - <DocumentTaskStream - taskId={taskId} - label={label} - ephemeral={!isStepBound} - status={ephemeralStatus} - /> - ); -} - interface EditorShellProps { - selectedId: string | undefined; - selectedTaskId: string | null; + selection: SidebarSelection | null; hasDirectives: boolean; listLoading: boolean; - onClearTask: () => void; + /** Bumped after a successful document save so the sidebar refetches. */ + onDocumentChanged: () => void; } function EditorShell({ - selectedId, - selectedTaskId, + selection, hasDirectives, listLoading, - onClearTask, + 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, - updateGoal, + loading: directiveLoading, cleanup, createPR, pickUpOrders, - } = useDirective(selectedId); + } = 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], + ); - if (!selectedId) { + // ---- 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 contracts..." + ? "Loading documents..." : hasDirectives - ? "Select a contract from the sidebar" - : "No contracts yet — create one from the legacy UI"} + ? "Select a document from the sidebar" + : "No directives yet — create one from the legacy UI"} </p> </div> ); } - if (loading && !directive) { + if (directiveLoading && !directive) { return ( <div className="flex-1 flex items-center justify-center h-full"> - <p className="text-[#556677] font-mono text-[12px]">Loading contract...</p> + <p className="text-[#556677] font-mono text-[12px]">Loading directive...</p> </div> ); } @@ -1275,108 +913,86 @@ function EditorShell({ if (!directive) { return ( <div className="flex-1 flex items-center justify-center h-full"> - <p className="text-[#7788aa] font-mono text-[12px]">Contract not found</p> + <p className="text-[#7788aa] font-mono text-[12px]">Directive not found</p> </div> ); } - // The "task" param can encode either a real task id, or a revision via the - // `revision:<uuid>` prefix. Split that out so the right pane can switch - // between the live task stream and the read-only revision viewer. - const revisionId = - selectedTaskId && selectedTaskId.startsWith("revision:") - ? selectedTaskId.slice("revision:".length) - : null; - const realTaskId = revisionId ? null : selectedTaskId; - - // Resolve the label for the breadcrumb when a task is selected. - const taskLabel = realTaskId - ? realTaskId === directive.orchestratorTaskId - ? "orchestrator" - : realTaskId === directive.completionTaskId - ? "completion" - : directive.steps.find((s) => s.taskId === realTaskId)?.name ?? - realTaskId.slice(0, 8) - : revisionId - ? "revision" - : null; - - // "Now executing" strip — surfaces what's live when looking at the - // contract editor, so users don't have to scan the sidebar to find it. - const liveTask = (() => { - if (selectedTaskId) return null; // already viewing a task; strip is redundant - if (directive.orchestratorTaskId) { - return { id: directive.orchestratorTaskId, name: "orchestrator" }; + // --- 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 (directive.completionTaskId) { - return { id: directive.completionTaskId, name: "completion" }; + 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> + ); } - const runningStep = directive.steps.find((s) => s.status === "running"); - if (runningStep && runningStep.taskId) { - return { id: runningStep.taskId, name: runningStep.name }; + 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> + ); } - return null; - })(); - return ( - <div className="flex-1 flex flex-col h-full overflow-hidden"> - {/* Contract 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>contracts /</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 contract - </button> - </> - )} - </div> - </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, + }; - {/* Now-executing strip — only when viewing the contract doc itself. - Click to jump into the live task transcript. */} - {liveTask && ( - <button - type="button" - onClick={() => - // Navigate via the search-param so EditorShell switches to the - // task stream for this live task. - (window.location.search = `?task=${liveTask.id}`) - } - className="shrink-0 flex items-center gap-2 px-6 py-1.5 bg-amber-900/15 border-b border-amber-700/40 text-amber-300 font-mono text-[11px] hover:bg-amber-900/30 transition-colors" - > - <span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" /> - <span className="uppercase tracking-wide text-[10px]">Now executing</span> - <span className="text-[#dbe7ff]">{liveTask.name}</span> - <span className="ml-auto text-[10px] text-amber-200/70"> - click to view transcript ↗ - </span> - </button> - )} + 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> - {revisionId ? ( - <RevisionViewer directiveId={directive.id} revisionId={revisionId} /> - ) : realTaskId ? ( - <EphemeralAwareTaskStream - taskId={realTaskId} - label={taskLabel ?? realTaskId.slice(0, 8)} - directive={directive} - /> - ) : ( <DocumentEditor - directive={directive} - onUpdateGoal={async (goal) => { - await updateGoal(goal); - }} + // 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(); }} @@ -1387,7 +1003,33 @@ function EditorShell({ 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> ); } @@ -1396,25 +1038,31 @@ function EditorShell({ // 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 { id: routeDirectiveId } = 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); + 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) { @@ -1422,96 +1070,86 @@ export default function DocumentDirectivesPage() { } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - const onSelect = useCallback( - (sel: SidebarSelection) => { - const next = `/directives/${sel.directiveId}${ - sel.taskId ? `?task=${sel.taskId}` : "" - }`; - navigate(next); - }, - [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; - 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 }); - }, - [], - ); + 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 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 handleSelectDocument = useCallback( + (directiveId: string, doc: DirectiveDocument) => { + navigate(`/directives/${directiveId}?document=${doc.id}`); }, - [], - ); - - const closeContextMenu = useCallback(() => setContextMenu(null), []); - - // Inline "+ New task" form state. When set, we render a small modal-ish - // overlay anchored to the directive folder; submitting calls the - // ephemeral-task endpoint. - const [newTaskFor, setNewTaskFor] = useState<DirectiveSummary | null>(null); - - const onCreateTask = useCallback((d: DirectiveSummary) => { - setNewTaskFor(d); - }, []); - - const handleSubmitNewTask = useCallback( - async (name: string, plan: string) => { - if (!newTaskFor) return; - try { - const task = await createDirectiveTask(newTaskFor.id, { name, plan }); - // Navigate the user into the freshly-spawned task's transcript. - navigate(`/directives/${newTaskFor.id}?task=${task.id}`); - setNewTaskFor(null); - } catch (err) { - // eslint-disable-next-line no-console - console.error("[makima] failed to create ephemeral task", err); - alert( - err instanceof Error - ? `Failed to create task: ${err.message}` - : "Failed to create task", - ); - } - }, - [newTaskFor, navigate], + [navigate], ); - const onQuickAction = useCallback( - async (d: DirectiveSummary, action: "start" | "pause" | "pr") => { - try { - if (action === "start") { - await startDirective(d.id); - } else if (action === "pause") { - await pauseDirective(d.id); - } else if (action === "pr") { - await createDirectivePR(d.id); - } - await refreshList(); - } catch (err) { - // eslint-disable-next-line no-console - console.error(`[makima] quick action ${action} failed`, err); - } + // 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}`); }, - [refreshList], + [navigate, routeDirectiveId], ); - const onSelectOrphan = useCallback( - (taskId: string) => { - navigate(`/tmp/${taskId}`); + 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}`); }, - [navigate], + [bumpRefresh, navigate], ); if (authLoading) { @@ -1525,10 +1163,6 @@ export default function DocumentDirectivesPage() { ); } - const selection: SidebarSelection | null = selectedId - ? { directiveId: selectedId, taskId: selectedTaskId } - : null; - return ( // h-screen + overflow-hidden so the page itself never scrolls; the // sidebar and editor pane each manage their own scroll via flex-1 @@ -1546,269 +1180,21 @@ export default function DocumentDirectivesPage() { directives={directives} loading={listLoading} selection={selection} - onSelect={onSelect} - onDirectiveContextMenu={onDirectiveContextMenu} - onTaskContextMenu={onTaskContextMenu} - onCreateTask={onCreateTask} - onQuickAction={onQuickAction} - onSelectOrphan={onSelectOrphan} + onSelectDocument={handleSelectDocument} + onSelectDirective={handleSelectDirective} + onCreateDocument={handleCreateDocument} + refreshNonce={refreshNonce} /> </div> - {/* Right: Lexical editor / task stream */} + {/* Right: Lexical editor */} <EditorShell - selectedId={selectedId} - selectedTaskId={selectedTaskId} + selection={selection} hasDirectives={directives.length > 0} listLoading={listLoading} - onClearTask={onClearTask} + onDocumentChanged={bumpRefresh} /> </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"); - } - }} - onNewDraft={async () => { - await newDirectiveDraft(contextMenu.directive.id); - await refreshList(); - // Send the user into the freshly-cleared contract so they can - // start typing the next iteration immediately. - navigate(`/directives/${contextMenu.directive.id}`); - }} - onCreatePR={async () => { - await createDirectivePR(contextMenu.directive.id); - await refreshList(); - }} - onAdvance={async () => { - await advanceDirective(contextMenu.directive.id); - await refreshList(); - }} - onCleanup={async () => { - await cleanupDirective(contextMenu.directive.id); - await refreshList(); - }} - onPickUpOrders={async () => { - await pickUpOrders(contextMenu.directive.id); - await refreshList(); - }} - /> - )} - {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(); - }} - onSendMessage={async () => { - // Browser prompt is the lightest-weight surface that doesn't - // require redesigning a modal. The same comment box is also - // available below the live transcript when the task is selected. - const message = window.prompt("Send message to task:"); - if (!message || !message.trim()) return; - try { - await sendTaskMessage(contextMenu.task.taskId, message.trim()); - } catch (err) { - // eslint-disable-next-line no-console - console.error("[makima] failed to send task message", err); - } - }} - onOpenInTaskPage={() => { - // The standalone /exec/:taskId page has the full task UI with - // worktree diff viewer, checkpoint controls, etc. - navigate(`/exec/${contextMenu.task.taskId}`); - }} - /> - )} - - {newTaskFor && ( - <NewTaskModal - directive={newTaskFor} - onClose={() => setNewTaskFor(null)} - onSubmit={handleSubmitNewTask} - /> - )} - </div> - ); -} - -/** - * Inline "+ New task" form for spawning an ephemeral task under a - * directive. Surfaced as a centered modal, dismissible with Esc / click-out. - */ -function NewTaskModal({ - 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 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 trimmedName = name.trim(); - const trimmedPlan = plan.trim(); - if (!trimmedName || !trimmedPlan || submitting) return; - setSubmitting(true); - try { - await onSubmit(trimmedName, trimmedPlan); - } 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 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 in auth.test.ts" - 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> - </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-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed" - > - {submitting ? "Creating…" : "Spawn task"} - </button> - </div> - </form> </div> ); } |
