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