diff options
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 555 |
1 files changed, 528 insertions, 27 deletions
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index ffd2a8b..7b0a89b 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -23,12 +23,16 @@ import { cleanupDirective, pickUpOrders, sendTaskMessage, + listDirectiveEphemeralTasks, + createDirectiveTask, + listOrphanTasks, } from "../lib/api"; import type { DirectiveStatus, DirectiveSummary, DirectiveWithSteps, DirectiveRevision, + TaskSummary, } from "../lib/api"; // Status dot color, matching the existing tabular UI's badge palette so the @@ -124,6 +128,26 @@ function TaskIcon() { ); } +/** 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 ( @@ -160,6 +184,31 @@ function PinIcon() { ); } +/** 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 @@ -355,13 +404,6 @@ interface SidebarSelection { taskId: string | null; } -interface SidebarProps { - directives: DirectiveSummary[]; - loading: boolean; - selection: SidebarSelection | null; - onSelect: (sel: SidebarSelection) => void; -} - /** * Per-directive folder. Renders the directive as a collapsible folder whose * body is the pinned document entry (always first) followed by a `tasks/` @@ -380,6 +422,8 @@ function DirectiveFolder({ hasPendingForDirective, onDirectiveContextMenu, onTaskContextMenu, + onCreateTask, + onQuickAction, }: { directive: DirectiveSummary; open: boolean; @@ -392,6 +436,10 @@ function DirectiveFolder({ 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; }) { const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft; const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`; @@ -402,8 +450,31 @@ function DirectiveFolder({ const docSelected = selection?.directiveId === directive.id && selection.taskId === null; + // 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]); + // Collect the tasks to surface in the folder body. - const tasks = useMemo(() => collectTasks(detailed, directive), [detailed, directive]); + const tasks = useMemo( + () => collectTasks(detailed, directive, ephemeralTasks), + [detailed, directive, ephemeralTasks], + ); const orchestratorRunning = !!directive.orchestratorTaskId; // Tasks subfolder open state — independent of the directive folder. @@ -430,18 +501,76 @@ function DirectiveFolder({ }; }, [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"; + return ( - <div className="select-none"> - <button - type="button" - onClick={onToggle} - onContextMenu={(e) => onDirectiveContextMenu(e, directive)} - title={directive.title} + <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)} > - <Caret open={open} /> - <FolderIcon open={open} /> - <span className="truncate flex-1 text-left">{directive.title}</span> + <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 @@ -450,7 +579,7 @@ function DirectiveFolder({ glow={hasPendingForDirective} status={directive.status} /> - </button> + </div> {open && ( <ul className="py-0.5"> @@ -504,7 +633,11 @@ function DirectiveFolder({ t.status === "running" || t.kind === "orchestrator-active"; const glow = pendingTaskIds.has(t.taskId); const Icon = - t.kind === "completion" ? CompletionIcon : TaskIcon; + t.kind === "completion" + ? CompletionIcon + : t.kind === "ephemeral" + ? EphemeralTaskIcon + : TaskIcon; return ( <li key={t.taskId}> <button @@ -753,12 +886,13 @@ interface FolderTaskRow { stepId: string | null; label: string; status: string; - kind: "orchestrator-active" | "completion" | "step"; + kind: "orchestrator-active" | "completion" | "step" | "ephemeral"; } function collectTasks( detailed: DirectiveWithSteps | null, summary: DirectiveSummary, + ephemeralTasks: TaskSummary[], ): FolderTaskRow[] { const rows: FolderTaskRow[] = []; @@ -803,6 +937,19 @@ function collectTasks( } } + // 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; } @@ -813,6 +960,12 @@ interface SidebarProps { 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; } function DocumentSidebar({ @@ -822,7 +975,33 @@ function DocumentSidebar({ onSelect, onDirectiveContextMenu, onTaskContextMenu, + onCreateTask, + onQuickAction, + onSelectOrphan, }: 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 */ + }); + }; + 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 @@ -902,6 +1081,58 @@ function DocumentSidebar({ {/* 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... @@ -923,6 +1154,8 @@ function DocumentSidebar({ hasPendingForDirective={directivesWithPending.has(d.id)} onDirectiveContextMenu={onDirectiveContextMenu} onTaskContextMenu={onTaskContextMenu} + onCreateTask={onCreateTask} + onQuickAction={onQuickAction} /> )) )} @@ -936,6 +1169,63 @@ function DocumentSidebar({ // and loading states. // ============================================================================= +/** + * 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; @@ -1011,6 +1301,23 @@ function EditorShell({ ? "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" }; + } + if (directive.completionTaskId) { + return { id: directive.completionTaskId, name: "completion" }; + } + const runningStep = directive.steps.find((s) => s.status === "running"); + if (runningStep && runningStep.taskId) { + return { id: runningStep.taskId, name: runningStep.name }; + } + 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 */} @@ -1032,21 +1339,37 @@ function EditorShell({ </button> </> )} - {!selectedTaskId && !!directive.orchestratorTaskId && ( - <span className="ml-2 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> + {/* 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> + )} + {revisionId ? ( <RevisionViewer directiveId={directive.id} revisionId={revisionId} /> ) : realTaskId ? ( - <DocumentTaskStream + <EphemeralAwareTaskStream taskId={realTaskId} label={taskLabel ?? realTaskId.slice(0, 8)} + directive={directive} /> ) : ( <DocumentEditor @@ -1135,6 +1458,62 @@ export default function DocumentDirectivesPage() { 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], + ); + + 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); + } + }, + [refreshList], + ); + + const onSelectOrphan = useCallback( + (taskId: string) => { + navigate(`/tmp/${taskId}`); + }, + [navigate], + ); + if (authLoading) { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -1166,6 +1545,9 @@ export default function DocumentDirectivesPage() { onSelect={onSelect} onDirectiveContextMenu={onDirectiveContextMenu} onTaskContextMenu={onTaskContextMenu} + onCreateTask={onCreateTask} + onQuickAction={onQuickAction} + onSelectOrphan={onSelectOrphan} /> </div> @@ -1304,6 +1686,125 @@ export default function DocumentDirectivesPage() { }} /> )} + + {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> ); } |
