diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 335 |
1 files changed, 330 insertions, 5 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index b583bef..427122c 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -17,6 +17,7 @@ import { getDirectiveDocument, updateDirectiveDocument, listDirectiveDocumentTasks, + createDirectiveTask, } from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the @@ -174,6 +175,8 @@ interface DirectiveFolderProps { selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; onCreateDocument: (directive: DirectiveSummary) => Promise<void>; + /** 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 @@ -190,6 +193,7 @@ function DirectiveFolder({ selection, onSelectDocument, onCreateDocument, + onCreateEphemeralTask, refreshNonce, }: DirectiveFolderProps) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; @@ -326,6 +330,19 @@ function DirectiveFolder({ <span>New document</span> </button> + {/* + 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. */} + <button + type="button" + onClick={() => onCreateEphemeralTask(directive)} + className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#c084fc] hover:bg-[rgba(192,132,252,0.06)]" + title="Spawn a one-off ephemeral task under this directive" + > + <span className="text-[12px] leading-none">+</span> + <span>New ephemeral task</span> + </button> + {activeDocs.length === 0 && !docsLoading && ( <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic"> no active documents @@ -668,6 +685,8 @@ interface SidebarProps { onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; onSelectDirective: (directiveId: string) => void; onCreateDocument: (directive: DirectiveSummary) => Promise<void>; + onCreateContract: () => void; + onCreateEphemeralTask: (directive: DirectiveSummary) => void; refreshNonce: number; } @@ -678,6 +697,8 @@ function DocumentSidebar({ onSelectDocument, onSelectDirective, onCreateDocument, + onCreateContract, + onCreateEphemeralTask, refreshNonce, }: SidebarProps) { const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => { @@ -725,13 +746,23 @@ function DocumentSidebar({ 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)]"> + <div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]"> <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide"> - Documents - </span> - <span className="text-[10px] font-mono text-[#556677]"> - {directives.length} + Contracts </span> + <div className="flex items-center gap-2"> + <span className="text-[10px] font-mono text-[#556677]"> + {directives.length} + </span> + <button + type="button" + onClick={onCreateContract} + className="text-[11px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 hover:border-emerald-400 rounded px-1.5 py-0.5 leading-none" + title="Create a new contract (directive)" + > + + New + </button> + </div> </div> {/* Top-level "directives/" folder */} @@ -784,6 +815,7 @@ function DocumentSidebar({ selection={selection} onSelectDocument={onSelectDocument} onCreateDocument={onCreateDocument} + onCreateEphemeralTask={onCreateEphemeralTask} refreshNonce={refreshNonce} /> ))} @@ -1152,6 +1184,38 @@ export default function DocumentDirectivesPage() { [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<DirectiveSummary | null>(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 ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -1183,6 +1247,8 @@ export default function DocumentDirectivesPage() { onSelectDocument={handleSelectDocument} onSelectDirective={handleSelectDirective} onCreateDocument={handleCreateDocument} + onCreateContract={() => setShowNewContract(true)} + onCreateEphemeralTask={(d) => setNewEphemeralFor(d)} refreshNonce={refreshNonce} /> </div> @@ -1195,6 +1261,265 @@ export default function DocumentDirectivesPage() { onDocumentChanged={bumpRefresh} /> </main> + + {showNewContract && ( + <NewContractModal + onClose={() => setShowNewContract(false)} + onSubmit={handleSubmitNewContract} + /> + )} + {newEphemeralFor && ( + <NewEphemeralTaskModal + directive={newEphemeralFor} + onClose={() => setNewEphemeralFor(null)} + onSubmit={handleSubmitNewEphemeral} + /> + )} + </div> + ); +} + +/** + * 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<void>; +}) { + const [title, setTitle] = useState(""); + const [goal, setGoal] = useState(""); + const [repo, setRepo] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [err, setErr] = useState<string | null>(null); + const titleRef = useRef<HTMLInputElement>(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 ( + <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-[520px] 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 contract + </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"> + Title + </label> + <input + ref={titleRef} + type="text" + value={title} + onChange={(e) => 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]" + /> + </div> + <div className="space-y-1"> + <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide"> + Goal + </label> + <textarea + value={goal} + onChange={(e) => setGoal(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + void submit(e as unknown as React.FormEvent); + } + }} + rows={4} + placeholder="Describe what the contract should achieve" + 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 className="space-y-1"> + <label className="text-[10px] font-mono text-[#7788aa] uppercase tracking-wide"> + Repository URL (optional) + </label> + <input + type="text" + value={repo} + onChange={(e) => setRepo(e.target.value)} + placeholder="e.g. https://github.com/owner/repo" + 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> + {err && ( + <p className="text-[11px] font-mono text-red-400">{err}</p> + )} + </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={!title.trim() || !goal.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…" : "Create contract"} + </button> + </div> + </form> + </div> + ); +} + +/** + * Modal for spawning an ephemeral task under a directive. Mirrors the + * existing right-click "+ New task" flow. + */ +function NewEphemeralTaskModal({ + 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 [err, setErr] = useState<string | null>(null); + 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 n = name.trim(); + const p = plan.trim(); + if (!n || !p || submitting) return; + setSubmitting(true); + setErr(null); + try { + await onSubmit(n, p); + } catch (caught) { + setErr(caught instanceof Error ? caught.message : String(caught)); + } 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 ephemeral 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" + 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> + {err && ( + <p className="text-[11px] font-mono text-red-400">{err}</p> + )} + </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-[#c084fc] border border-[#c084fc]/40 hover:border-[#c084fc] disabled:opacity-40 disabled:cursor-not-allowed" + > + {submitting ? "Spawning…" : "Spawn task"} + </button> + </div> + </form> </div> ); } |
