summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx2352
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>
);
}