import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router"; import { Masthead } from "../components/Masthead"; import { useDirective, useDirectives } from "../hooks/useDirectives"; import { useAuth } from "../contexts/AuthContext"; import { DocumentEditor } from "../components/directives/DocumentEditor"; import { type DirectiveSummary, type DirectiveStatus, type DirectiveDocument, type DirectiveDocumentStatus, type DirectiveStep, type Task, type DocumentTasksResponse, listDirectiveDocuments, createDirectiveDocument, getDirectiveDocument, updateDirectiveDocument, listDirectiveDocumentTasks, createDirectiveTask, } from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the // document mode feels like a sibling of the existing list, not a foreign UI. const STATUS_DOT: Record = { draft: "bg-[#556677]", active: "bg-green-400", idle: "bg-yellow-400", paused: "bg-orange-400", inactive: "bg-[#75aafc]", archived: "bg-[#3a4a6a]", }; // Per-document status palette. Active/draft documents use the same bright // green-ish accent as a running directive; shipped/archived use a muted blue. const DOC_STATUS_DOT: Record = { 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 = { active: "active", idle: "idle", archived: "archived", }; function bucketOf(status: DirectiveStatus): SidebarGroup { if (status === "active" || status === "paused") return "active"; if (status === "archived") return "archived"; // draft + idle land in the idle bucket (i.e. "not currently running"). return "idle"; } // Slugify a document title for the displayed `.md` filename, falling back to // the directive title and finally the document id slice when the title is // empty. Mirrors the file-naming fix from step 1 (use the user-readable label // rather than just an id slice). Accepts either a DirectiveSummary or a full // DirectiveWithSteps — only `title` is read. function fileLabel( doc: DirectiveDocument, directive: { title: string }, ): string { const docTitle = doc.title.trim(); if (docTitle.length > 0) return docTitle; const dirTitle = directive.title.trim(); if (dirTitle.length > 0) return dirTitle; return doc.id.slice(0, 8); } // ============================================================================= // Sidebar icons (inline SVG, no new deps) // ============================================================================= function FolderIcon({ open = false }: { open?: boolean }) { return ( {open ? ( ) : ( )} ); } function FileIcon() { return ( ); } function Caret({ open }: { open: boolean }) { return ( ); } // ============================================================================= // SidebarSelection — exactly one of taskId/documentId is non-null. taskId is // reserved for a future "task selection" feature (we expose it in the URL // already so the param shape is stable). documentId picks one of the // directive's documents. // ============================================================================= interface SidebarSelection { directiveId: string; taskId: string | null; documentId: string | null; } // ============================================================================= // Per-directive folder — renders as a collapsible folder containing the // directive's documents. Loads documents lazily on first open (mirroring the // pattern from step 1's DirectiveFolder, which fetched the full directive // only when expanded). // ============================================================================= interface DirectiveFolderProps { directive: DirectiveSummary; open: boolean; onToggle: () => void; /** Called when the user clicks the folder header itself (after toggle). */ onHeaderClick: () => void; selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; onCreateDocument: (directive: DirectiveSummary) => Promise; /** Open the inline "+ New ephemeral task" form for this directive. */ onCreateEphemeralTask: (directive: DirectiveSummary) => void; /** * Document refresh trigger — bumped externally so the folder refetches its * document list after a create/update happens elsewhere. Primarily used so * a freshly-created document shows up immediately. */ refreshNonce: number; } function DirectiveFolder({ directive, open, onToggle, onHeaderClick, selection, onSelectDocument, onCreateDocument, onCreateEphemeralTask, refreshNonce, }: DirectiveFolderProps) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; const orchestratorRunning = !!directive.orchestratorTaskId; // Documents fetched lazily on open. We deliberately scope the fetch to the // open-state so closed folders don't pay the network cost on initial render. const [docs, setDocs] = useState(null); const [docsLoading, setDocsLoading] = useState(false); const [docsError, setDocsError] = useState(null); // shipped/ subfolder open state — independent of the directive folder. const [shippedOpen, setShippedOpen] = useState(false); // Whether a "+ New document" call is in flight (disables the button). const [creating, setCreating] = useState(false); const refresh = useCallback(async () => { setDocsLoading(true); setDocsError(null); try { const list = await listDirectiveDocuments(directive.id); setDocs(list); } catch (e) { setDocsError(e instanceof Error ? e.message : "Failed to load documents"); } finally { setDocsLoading(false); } }, [directive.id]); // Fetch on open; refetch when refreshNonce bumps and the folder is open. useEffect(() => { if (!open) return; void refresh(); }, [open, refresh, refreshNonce]); // Split the documents into the two visual groups. Memoised so we don't // recompute on every render. const { activeDocs, shippedDocs } = useMemo(() => { const active: DirectiveDocument[] = []; const shipped: DirectiveDocument[] = []; for (const d of docs ?? []) { if (d.status === "shipped" || d.status === "archived") { shipped.push(d); } else { active.push(d); } } // Stable order: by createdAt ascending so the first row is the oldest doc. active.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); shipped.sort((a, b) => (b.shippedAt ?? "").localeCompare(a.shippedAt ?? "")); return { activeDocs: active, shippedDocs: shipped }; }, [docs]); const handleCreate = useCallback(async () => { if (creating) return; setCreating(true); try { await onCreateDocument(directive); // Refresh after creating so the new doc appears in the list. await refresh(); } finally { setCreating(false); } }, [creating, onCreateDocument, directive, refresh]); // Selection helpers — used to highlight the currently-selected doc row. const selectedDocumentId = selection && selection.directiveId === directive.id ? selection.documentId : null; return (
{/* Directive folder header */} {/* Folder body — rendered only when open */} {open && (
{docsLoading && !docs && (
Loading documents…
)} {docsError && (
{docsError}
)} {/* 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. */} {/* + New ephemeral task — sibling affordance for spawning a one-off task under this directive that's NOT part of the DAG. Useful for sidebar scratch work, debugging, etc. */} {activeDocs.length === 0 && !docsLoading && (
no active documents
)} {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.
onSelectDocument(directive.id, doc)} />
))} {/* shipped/ subfolder — only rendered when at least one shipped or archived doc exists. Hidden entirely otherwise so empty directives stay tidy. */} {shippedDocs.length > 0 && (
{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.
onSelectDocument(directive.id, doc)} indent="deep" />
))}
)} )}
)}
); } // ============================================================================= // 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 ( ); } // ============================================================================= // 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Inner row indent is one level deeper than the folder header. Folder // header uses pl-[88px] (deep) or pl-14 (normal); tasks rows go one // step beyond that. const headerPadLeft = depth === "deep" ? "pl-[88px]" : "pl-14"; const rowPadLeft = depth === "deep" ? "pl-[112px]" : "pl-[72px]"; const refresh = useCallback(async () => { setLoading(true); setError(null); try { const res = await listDirectiveDocumentTasks(documentId); setData(res); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load tasks"); } finally { setLoading(false); } }, [documentId]); // Fetch when the folder is open (initial open or refresh). We don't // pre-fetch on closed folders so we don't waste bandwidth on the long // tail of historical shipped docs the user never expands. useEffect(() => { if (!open) return; void refresh(); }, [open, refresh, refreshNonce]); const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0); // Don't render the folder at all if we've fetched and the document has // no tasks. This is the cleanest visual: a draft document just shows up // as a single row with no children. The empty-folder check is gated on // a successful fetch so we don't flash "no tasks/" rows during loading. if (data && total === 0 && !loading && !error) { return null; } return (
{open && (
{loading && !data && (
Loading tasks…
)} {error && (
{error}
)} {data?.steps.map((step) => ( ))} {data?.tasks.map((task) => ( ))}
)}
); } // Step status → coloured dot, mirroring directive status palette so the // sidebar reads consistently. const STEP_STATUS_DOT: Record = { 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 = { pending: "bg-[#556677]", starting: "bg-yellow-400", running: "bg-yellow-400", completed: "bg-green-400", failed: "bg-red-400", cancelled: "bg-[#3a4a6a]", interrupted: "bg-orange-400", }; interface StepRowProps { step: DirectiveStep; padLeft: string; } function StepRow({ step, padLeft }: StepRowProps) { const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]"; return (
{step.name} step
); } interface TaskRowProps { task: Task; padLeft: string; } function TaskRow({ task, padLeft }: TaskRowProps) { const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]"; // Supervisor tasks get a small "sup" tag so the user can spot // contract orchestrators in the list. const isSup = task.isSupervisor; return (
{task.name} {isSup ? "sup" : "task"}
); } // ============================================================================= // Sidebar // ============================================================================= interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; onSelectDirective: (directiveId: string) => void; onCreateDocument: (directive: DirectiveSummary) => Promise; onCreateContract: () => void; onCreateEphemeralTask: (directive: DirectiveSummary) => void; refreshNonce: number; } function DocumentSidebar({ directives, loading, selection, onSelectDocument, onSelectDirective, onCreateDocument, onCreateContract, onCreateEphemeralTask, refreshNonce, }: SidebarProps) { const groups: Record = useMemo(() => { const out: Record = { active: [], idle: [], archived: [], }; for (const d of directives) { out[bucketOf(d.status)].push(d); } // Sort each group alphabetically so it feels like a stable file tree. (Object.keys(out) as SidebarGroup[]).forEach((k) => { out[k].sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }), ); }); return out; }, [directives]); // Default-collapsed state per group folder. const [openGroups, setOpenGroups] = useState>({ 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>({}); useEffect(() => { if (!selection) return; setOpenDirectives((prev) => prev[selection.directiveId] ? prev : { ...prev, [selection.directiveId]: true }, ); }, [selection?.directiveId]); const toggleGroup = (g: SidebarGroup) => setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] })); const toggleDirective = (id: string) => setOpenDirectives((prev) => ({ ...prev, [id]: !prev[id] })); return (
{/* Sidebar header */}
Contracts
{directives.length}
{/* Top-level "directives/" folder */}
directives/
{/* Body */}
{loading && directives.length === 0 ? (
Loading...
) : directives.length === 0 ? (
No directives yet
) : ( (Object.keys(groups) as SidebarGroup[]).map((group) => { const list = groups[group]; if (list.length === 0) return null; const open = openGroups[group]; return (
{/* Group header (sub-folder) */} {/* Each directive is a folder containing N documents. */} {open && (
{list.map((d) => ( toggleDirective(d.id)} onHeaderClick={() => onSelectDirective(d.id)} selection={selection} onSelectDocument={onSelectDocument} onCreateDocument={onCreateDocument} onCreateEphemeralTask={onCreateEphemeralTask} refreshNonce={refreshNonce} /> ))}
)}
); }) )}
); } // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. Two modes: // 1) documentId selected → fetch the DirectiveDocument and edit doc.body via // updateDirectiveDocument (the call that auto-reactivates a shipped doc). // 2) no documentId (legacy fallback, kept for the "select a directive but // not a document" transitional case) → edit directive.goal as before. // ============================================================================= interface EditorShellProps { selection: SidebarSelection | null; hasDirectives: boolean; listLoading: boolean; /** Bumped after a successful document save so the sidebar refetches. */ onDocumentChanged: () => void; } function EditorShell({ selection, hasDirectives, listLoading, onDocumentChanged, }: EditorShellProps) { const directiveId = selection?.directiveId; const documentId = selection?.documentId ?? null; // We deliberately don't pull `updateGoal` here — in the multi-document // world, edits flow through updateDirectiveDocument (which auto-reactivates // a shipped doc when its body changes). The legacy directive.goal is // unused on this surface. const { directive, loading: directiveLoading, cleanup, createPR, pickUpOrders, } = useDirective(directiveId); // Document fetch — only when documentId is selected. Refetched whenever the // id changes; not polled (the document stream is too low-traffic to warrant // background refresh in this iteration). const [doc, setDoc] = useState(null); const [docLoading, setDocLoading] = useState(false); const [docError, setDocError] = useState(null); useEffect(() => { if (!documentId) { setDoc(null); setDocLoading(false); setDocError(null); return; } let cancelled = false; setDocLoading(true); setDocError(null); getDirectiveDocument(documentId) .then((d) => { if (cancelled) return; setDoc(d); }) .catch((e) => { if (cancelled) return; setDocError(e instanceof Error ? e.message : "Failed to load document"); }) .finally(() => { if (cancelled) return; setDocLoading(false); }); return () => { cancelled = true; }; }, [documentId]); // Save callback for the document path. The backend re-stamps a shipped doc // back to active when its body changes, so we just optimistically update // local state with the server's response. const onUpdateDocumentBody = useCallback( async (body: string) => { if (!documentId) return; const updated = await updateDirectiveDocument(documentId, { body }); setDoc(updated); // Tell the sidebar to refetch the directive's document list so the // status chip flips from `shipped` back to `active` (and any title // changes propagate). Cheap — folders only refetch when open. onDocumentChanged(); }, [documentId, onDocumentChanged], ); // ---- Empty / error / loading states ------------------------------------ if (!directiveId) { return (

{listLoading ? "Loading documents..." : hasDirectives ? "Select a document from the sidebar" : "No directives yet — create one from the legacy UI"}

); } if (directiveLoading && !directive) { return (

Loading directive...

); } if (!directive) { return (

Directive not found

); } // --- Document path: documentId selected -------------------------------- if (documentId) { if (docLoading && !doc) { return (

Loading document...

); } if (docError) { return (

{docError}

); } if (!doc) { return (

Document not found

); } // Synthesise a directive-shaped object whose `goal` is the document body. // DocumentEditor was originally written against DirectiveWithSteps, so we // can keep its shape by overriding `goal` with `doc.body` and `title` // with the document's filename label. The steps panel still draws from // the real directive (passed through StepsBlockContextProvider). const docTitle = `${fileLabel(doc, directive)}.md`; const directiveAsDocument = { ...directive, goal: doc.body, title: docTitle, }; return (
{/* Breadcrumb — directives / / .md */}
directives / {directive.title.trim().length > 0 ? directive.title : directive.id.slice(0, 8)} / {docTitle} {doc.status === "shipped" && ( shipped )} {doc.status === "archived" && ( archived )} {doc.status === "draft" && ( draft )} {!!directive.orchestratorTaskId && ( orchestrator running )}
{ await cleanup(); }} onCreatePR={async () => { await createPR(); }} onPickUpOrders={async () => { await pickUpOrders(); }} />
); } // --- 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 (
directives / {directive.title.trim().length > 0 ? directive.title : directive.id.slice(0, 8)}

Select a document, or click "+ New document" to create one.

); } // ============================================================================= // Page // ============================================================================= export default function DocumentDirectivesPage() { const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const navigate = useNavigate(); const { id: routeDirectiveId } = useParams<{ id: string }>(); const [searchParams, setSearchParams] = useSearchParams(); const { directives, loading: listLoading } = useDirectives(); // refreshNonce — bumped to tell open directive folders to refetch their // document lists (after a create or save). const [refreshNonce, setRefreshNonce] = useState(0); const bumpRefresh = useCallback(() => setRefreshNonce((n) => n + 1), []); // Derive the SidebarSelection from the URL. The route param is the // directive id; ?document=:id and ?task=:id pick a specific child. Exactly // one of taskId/documentId can be set; if both happen to be present in the // URL (which shouldn't happen via our nav code) we prefer ?task= since // task selection is the more disruptive action. const selection: SidebarSelection | null = useMemo(() => { if (!routeDirectiveId) return null; const taskId = searchParams.get("task"); const documentId = searchParams.get("document"); if (taskId) return { directiveId: routeDirectiveId, taskId, documentId: null }; if (documentId) return { directiveId: routeDirectiveId, taskId: null, documentId }; return { directiveId: routeDirectiveId, taskId: null, documentId: null }; }, [routeDirectiveId, searchParams]); useEffect(() => { if (!authLoading && isAuthConfigured && !isAuthenticated) { navigate("/login"); } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); // ------------------------------------------------------------------ // Default-selection: when the user clicks a directive's folder header (or // lands on /directives/:id without ?document=) we pick the first active or // draft document and update the URL to point at it. This avoids the // "directive selected, but nothing in the editor" intermediate state. // ------------------------------------------------------------------ const lastResolvedRef = useRef(null); useEffect(() => { if (!routeDirectiveId) { lastResolvedRef.current = null; return; } // Only auto-resolve when no document/task has been picked yet, AND we // haven't already resolved this directive in a prior tick (otherwise // navigating away from the doc would instantly re-pick the same one). if (selection?.documentId || selection?.taskId) { lastResolvedRef.current = routeDirectiveId; return; } if (lastResolvedRef.current === routeDirectiveId) return; lastResolvedRef.current = routeDirectiveId; let cancelled = false; listDirectiveDocuments(routeDirectiveId) .then((list) => { if (cancelled) return; // Prefer the first 'active' doc; fall back to the first 'draft'. const firstActive = list.find((d) => d.status === "active"); const firstDraft = list.find((d) => d.status === "draft"); const pick = firstActive ?? firstDraft; if (pick) { setSearchParams( (prev) => { const next = new URLSearchParams(prev); next.set("document", pick.id); next.delete("task"); return next; }, { replace: true }, ); } }) .catch(() => { // Swallow — the editor pane will show "Document not found" and the // user can click "+ New document" to recover. }); return () => { cancelled = true; }; }, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]); const handleSelectDocument = useCallback( (directiveId: string, doc: DirectiveDocument) => { navigate(`/directives/${directiveId}?document=${doc.id}`); }, [navigate], ); // When the user clicks a directive folder header (not a document row), we // jump to /directives/:id without ?document= — the default-selection // effect above will then pick the first active doc. const handleSelectDirective = useCallback( (directiveId: string) => { if (routeDirectiveId === directiveId) return; navigate(`/directives/${directiveId}`); }, [navigate, routeDirectiveId], ); const handleCreateDocument = useCallback( async (directive: DirectiveSummary) => { const created = await createDirectiveDocument(directive.id, { title: "", body: "", }); bumpRefresh(); // Navigate to the new doc so it's selected immediately. navigate(`/directives/${directive.id}?document=${created.id}`); }, [bumpRefresh, navigate], ); // Modal state for the two new creation surfaces in the sidebar: // * + New contract → opens NewContractModal, calls useDirectives.create // * + New ephemeral task (per directive) → opens NewEphemeralTaskModal const { create: createDirective } = useDirectives(); const [showNewContract, setShowNewContract] = useState(false); const [newEphemeralFor, setNewEphemeralFor] = useState(null); const handleSubmitNewContract = useCallback( async (title: string, goal: string, repositoryUrl: string) => { const d = await createDirective({ title, goal, repositoryUrl: repositoryUrl.length > 0 ? repositoryUrl : undefined, }); setShowNewContract(false); navigate(`/directives/${d.id}`); }, [createDirective, navigate], ); const handleSubmitNewEphemeral = useCallback( async (name: string, plan: string) => { if (!newEphemeralFor) return; const task = await createDirectiveTask(newEphemeralFor.id, { name, plan }); const target = newEphemeralFor.id; setNewEphemeralFor(null); bumpRefresh(); navigate(`/directives/${target}?task=${task.id}`); }, [newEphemeralFor, bumpRefresh, navigate], ); if (authLoading) { return (

Loading...

); } return ( // h-screen + overflow-hidden so the page itself never scrolls; the // sidebar and editor pane each manage their own scroll via flex-1 // children with overflow-y-auto. Previously we set // height: calc(100vh - 80px) on
, which assumed an 80px masthead // and quietly clipped content when the masthead was taller (or pushed // the page below the viewport on shorter screens, which made the // whole page scroll instead of the sidebar/editor independently).
{/* Left: file-tree sidebar — independent scroll. */}
setShowNewContract(true)} onCreateEphemeralTask={(d) => setNewEphemeralFor(d)} refreshNonce={refreshNonce} />
{/* Right: Lexical editor */} 0} listLoading={listLoading} onDocumentChanged={bumpRefresh} />
{showNewContract && ( setShowNewContract(false)} onSubmit={handleSubmitNewContract} /> )} {newEphemeralFor && ( setNewEphemeralFor(null)} onSubmit={handleSubmitNewEphemeral} /> )}
); } /** * Modal for creating a new directive (= "contract" in the doc-mode UI). * Title + goal are required; repository_url is optional. On submit calls * useDirectives.create and navigates the user into the new directive. */ function NewContractModal({ onClose, onSubmit, }: { onClose: () => void; onSubmit: (title: string, goal: string, repositoryUrl: string) => Promise; }) { const [title, setTitle] = useState(""); const [goal, setGoal] = useState(""); const [repo, setRepo] = useState(""); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); const titleRef = useRef(null); useEffect(() => { titleRef.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 t = title.trim(); const g = goal.trim(); if (!t || !g || submitting) return; setSubmitting(true); setErr(null); try { await onSubmit(t, g, repo.trim()); } catch (caught) { setErr(caught instanceof Error ? caught.message : String(caught)); } finally { setSubmitting(false); } }; return (
e.stopPropagation()} className="w-[520px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col" >

New contract

setTitle(e.target.value)} placeholder="e.g. Migrate auth to Supabase" 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]" />