diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 18:06:38 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 18:06:38 +0100 |
| commit | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch) | |
| tree | 5802a9923eab572a98a6d8b21be2fdc56fdf7118 | |
| parent | 6d922307223d12f436b229d4c4b29b8835b93b6c (diff) | |
| download | soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.tar.gz soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.zip | |
feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec redirect, palette, SWR (#117)
Phases 1-3 of the unified-surface plan, bundled per the user's request.
The directive document/folder UI is now the canonical place to interact
with makima; legacy /exec is subsumed by routing redirects, /contracts is
already hidden in nav, and orphan tasks surface under a top-level tmp/
pseudo-folder. Phase 4 (porting contract-only features) was confirmed
out of scope; Phase 5 (deleting the contracts code) is a follow-up.
## Backend
- New migration `20260501000000_archive_existing_contracts.sql` flips
every legacy contract to `archived` so /contracts is read-only history.
- New endpoint `POST /api/v1/directives/{id}/tasks` creates an ephemeral
task — `directive_id` set, `directive_step_id` NULL, repo/branch
inherited from the directive. Reuses `create_task_for_owner`.
- New endpoint `GET /api/v1/directives/{id}/tasks` lists ephemeral tasks
attached to a directive (drives the per-folder ephemeral group).
- `GET /api/v1/mesh/tasks?orphan=true` returns top-level tasks with no
`directive_id` AND no `parent_task_id` — backs the sidebar's tmp/.
- New repo helpers `list_ephemeral_directive_tasks_for_owner` and
`list_orphan_tasks_for_owner`.
- The existing `mesh_merge` endpoints are reused as-is for ephemeral
task merge (no new merge logic needed).
## Frontend
### Sticky composer + auto-scroll fix (`DocumentTaskStream.tsx`)
- Sticky comment composer pinned to viewport bottom; padding compensates
so the last entry isn't hidden behind it.
- `autoScroll` now resumes when the user scrolls back within 80px of
the bottom (previously stuck off forever after a single scroll-up).
- Floating "↓ Jump to latest" chip when the user has scrolled away.
- Action header strip: explicit Stop / Send / Open-in-task-page +
conditional "Merge to base ↗" button on ephemeral terminal tasks.
- Module-level cache of historical entries by taskId so re-selecting a
task you've viewed renders instantly while a fresh fetch runs.
### Sidebar (`document-directives.tsx`)
- Top-level `tmp/` folder: orphan tasks, polled every 5s.
- Per-directive `tasks/` subfolder now also surfaces ephemeral tasks
(lazily fetched on folder open) with a distinct asterisk-on-terminal
icon (`EphemeralTaskIcon`).
- Inline hover-action chips on each directive folder header: Start /
Pause / PR / +New task. Right-click menu still works as a power-user
fallback.
- "Now executing" amber strip in the editor pane: surfaces the live
orchestrator/completion/running-step task with a one-click jump.
- Inline `+ New task` modal (name + plan); on submit calls
`createDirectiveTask` and navigates into the freshly-spawned task.
- New `EphemeralAwareTaskStream` wrapper passes `ephemeral` and
`status` to `DocumentTaskStream` so the merge button only shows when
the selected task is genuinely an ephemeral spinoff in a terminal
state. Step-spawned tasks merge via the directive's PR completion.
### SWR cache (`useDirectives.ts`)
- Module-level `listCache` and per-id `detailCache` (mirrors the pattern
in `useUserSettings.ts`). Mounting the hook renders the cache value
immediately if present and kicks a background refresh; subscribers see
the new value when it lands. Cuts perceived navigation latency to
near-zero on warm cache hits.
### QuickSwitcher (`QuickSwitcher.tsx`, new)
- IntelliJ-style double-Shift command palette mounted at app root.
- Listens at the document level for two `Shift` keydowns within 300ms
with no other key in between; ignores while focus is in an
input/textarea so capitalising letters doesn't pop the palette.
- Searches across directives + their tasks (orchestrator/completion/
steps/ephemerals) + orphan tmp tasks. Fuzzy matches on title.
- Eagerly loads task details for the first 20 directives on open so
searches don't block on per-directive fetches.
### Routing (`main.tsx` + `exec-redirect.tsx` + `tmp.tsx`)
- New `ExecRedirect` wrapper at `/exec/:id`: when documentMode is on
AND the task has a `directiveId`, replaces the URL with
`/directives/<directiveId>?task=<taskId>`. Otherwise renders the
legacy `MeshPage` as before.
- New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for
orphan tasks, with the masthead and a `tmp / <slug>` breadcrumb.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/src/components/QuickSwitcher.tsx | 314 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentTaskStream.tsx | 248 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 178 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 59 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 14 | ||||
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 555 | ||||
| -rw-r--r-- | makima/frontend/src/routes/exec-redirect.tsx | 59 | ||||
| -rw-r--r-- | makima/frontend/src/routes/tmp.tsx | 93 | ||||
| -rw-r--r-- | makima/migrations/20260501000000_archive_existing_contracts.sql | 18 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 65 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 161 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 25 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 3 |
14 files changed, 1705 insertions, 91 deletions
diff --git a/makima/frontend/src/components/QuickSwitcher.tsx b/makima/frontend/src/components/QuickSwitcher.tsx new file mode 100644 index 0000000..8fe1d4a --- /dev/null +++ b/makima/frontend/src/components/QuickSwitcher.tsx @@ -0,0 +1,314 @@ +/** + * QuickSwitcher — IntelliJ-style "double Shift" command palette. + * + * Listens at the document level for two `Shift` keydowns within ~300ms with + * no other key in between. Single-Shift presses (capitalising letters etc.) + * are pass-through. The palette pulls directives + their tasks (orchestrator, + * completion, started steps, ephemerals) + orphan tasks, fuzzy-matches on + * the typed query, arrows navigate, Enter selects, Esc dismisses. + * + * The directive list comes straight from `useDirectives()` (now backed by + * the SWR cache) so the palette opens instantly when the cache is warm. + * Tasks per-directive are fetched eagerly into the cache when the palette + * opens so subsequent searches don't block on a network round-trip. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router"; +import { useDirectives } from "../hooks/useDirectives"; +import { + getDirective, + listDirectiveEphemeralTasks, + listOrphanTasks, + type DirectiveWithSteps, + type TaskSummary, +} from "../lib/api"; + +interface PaletteEntry { + id: string; + label: string; + /** Subtitle shown to the right (status, parent context). */ + hint: string; + /** Where to navigate on selection. */ + href: string; + kind: "directive" | "task" | "orphan"; +} + +const DOUBLE_SHIFT_WINDOW_MS = 300; + +export function QuickSwitcher() { + const navigate = useNavigate(); + const { directives } = useDirectives(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIdx, setActiveIdx] = useState(0); + const inputRef = useRef<HTMLInputElement>(null); + + // ---- Double-shift detection ----------------------------------------------- + useEffect(() => { + let lastShift = 0; + let dirty = false; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Shift") { + const now = Date.now(); + if (!dirty && now - lastShift < DOUBLE_SHIFT_WINDOW_MS) { + // Don't open if we're typing in an input — otherwise typing a + // capital letter via Shift triggers the palette unexpectedly. + const target = e.target as HTMLElement | null; + const tag = target?.tagName?.toUpperCase(); + const editable = target?.isContentEditable; + if ( + tag !== "INPUT" && + tag !== "TEXTAREA" && + tag !== "SELECT" && + !editable + ) { + setOpen(true); + setQuery(""); + setActiveIdx(0); + } + lastShift = 0; + } else { + lastShift = now; + dirty = false; + } + } else { + // Any other keydown invalidates the in-flight shift sequence. + dirty = true; + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + + // ---- Eager-load tasks per directive on open ------------------------------ + const [directiveTasks, setDirectiveTasks] = useState< + Map<string, { detail: DirectiveWithSteps | null; ephemeral: TaskSummary[] }> + >(new Map()); + const [orphanTasks, setOrphanTasks] = useState<TaskSummary[]>([]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + // Orphan tasks + listOrphanTasks() + .then((res) => { + if (!cancelled) setOrphanTasks(res.tasks); + }) + .catch(() => { + /* swallow */ + }); + // Per-directive details + ephemeral tasks. We only fetch the first + // ~20 directives to keep the open-time bounded; the rest still appear + // in the palette (just without their tasks expanded). + const slice = directives.slice(0, 20); + Promise.all( + slice.map(async (d): Promise<[string, { detail: DirectiveWithSteps | null; ephemeral: TaskSummary[] }]> => { + try { + const [detail, ephRes] = await Promise.all([ + getDirective(d.id).catch(() => null), + listDirectiveEphemeralTasks(d.id).catch(() => ({ tasks: [] as TaskSummary[], total: 0 })), + ]); + return [d.id, { detail, ephemeral: ephRes.tasks }]; + } catch { + return [d.id, { detail: null, ephemeral: [] as TaskSummary[] }]; + } + }), + ).then((entries) => { + if (!cancelled) setDirectiveTasks(new Map(entries)); + }); + return () => { + cancelled = true; + }; + }, [open, directives]); + + // ---- Build the searchable entry list ------------------------------------- + const allEntries: PaletteEntry[] = useMemo(() => { + const out: PaletteEntry[] = []; + for (const d of directives) { + out.push({ + id: `dir:${d.id}`, + label: d.title, + hint: `contract · ${d.status}`, + href: `/directives/${d.id}`, + kind: "directive", + }); + const td = directiveTasks.get(d.id); + if (td) { + if (td.detail?.orchestratorTaskId) { + out.push({ + id: `task:${td.detail.orchestratorTaskId}`, + label: `${d.title} › orchestrator`, + hint: "task · running", + href: `/directives/${d.id}?task=${td.detail.orchestratorTaskId}`, + kind: "task", + }); + } + if (td.detail?.completionTaskId) { + out.push({ + id: `task:${td.detail.completionTaskId}`, + label: `${d.title} › completion`, + hint: "task · running", + href: `/directives/${d.id}?task=${td.detail.completionTaskId}`, + kind: "task", + }); + } + if (td.detail) { + for (const step of td.detail.steps) { + if (!step.taskId) continue; + out.push({ + id: `task:${step.taskId}`, + label: `${d.title} › ${step.name}`, + hint: `step · ${step.status}`, + href: `/directives/${d.id}?task=${step.taskId}`, + kind: "task", + }); + } + } + for (const t of td.ephemeral) { + out.push({ + id: `task:${t.id}`, + label: `${d.title} › ${t.name}`, + hint: `ephemeral · ${t.status}`, + href: `/directives/${d.id}?task=${t.id}`, + kind: "task", + }); + } + } + } + for (const t of orphanTasks) { + out.push({ + id: `orphan:${t.id}`, + label: t.name, + hint: `tmp · ${t.status}`, + href: `/tmp/${t.id}`, + kind: "orphan", + }); + } + return out; + }, [directives, directiveTasks, orphanTasks]); + + // ---- Fuzzy filter -------------------------------------------------------- + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return allEntries.slice(0, 50); + return allEntries + .filter((e) => fuzzyContains(e.label.toLowerCase(), q)) + .slice(0, 50); + }, [allEntries, query]); + + useEffect(() => { + setActiveIdx(0); + }, [query]); + + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + + const close = useCallback(() => setOpen(false), []); + const select = useCallback( + (entry: PaletteEntry) => { + navigate(entry.href); + setOpen(false); + }, + [navigate], + ); + + if (!open) return null; + + return ( + <div + className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh] bg-black/60" + onClick={close} + > + <div + className="w-[600px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-2xl flex flex-col" + onClick={(e) => e.stopPropagation()} + > + <input + ref={inputRef} + value={query} + onChange={(e) => setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + close(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIdx((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const entry = filtered[activeIdx]; + if (entry) select(entry); + } + }} + placeholder="Jump to a contract or task…" + className="bg-transparent border-b border-dashed border-[rgba(117,170,252,0.3)] px-4 py-3 text-[14px] text-white placeholder-[#445566] outline-none font-mono" + /> + <ul className="flex-1 overflow-y-auto max-h-[50vh]"> + {filtered.length === 0 ? ( + <li className="px-4 py-3 text-[#556677] font-mono text-xs italic"> + No matches. + </li> + ) : ( + filtered.map((entry, idx) => ( + <li + key={entry.id} + onMouseEnter={() => setActiveIdx(idx)} + onClick={() => select(entry)} + className={`px-4 py-2 font-mono text-[12px] cursor-pointer flex items-center gap-3 ${ + idx === activeIdx + ? "bg-[rgba(117,170,252,0.15)] text-white" + : "text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.06)]" + }`} + > + <KindBadge kind={entry.kind} /> + <span className="flex-1 truncate">{entry.label}</span> + <span className="text-[10px] text-[#556677] uppercase tracking-wide shrink-0"> + {entry.hint} + </span> + </li> + )) + )} + </ul> + <div className="px-4 py-2 border-t border-dashed border-[rgba(117,170,252,0.2)] text-[10px] font-mono text-[#556677] flex items-center gap-3"> + <span>↑↓ navigate</span> + <span>↵ open</span> + <span>Esc close</span> + <span className="ml-auto">Shift Shift to reopen</span> + </div> + </div> + </div> + ); +} + +function KindBadge({ kind }: { kind: PaletteEntry["kind"] }) { + const map: Record<PaletteEntry["kind"], { label: string; tone: string }> = { + directive: { label: "DOC", tone: "text-[#75aafc] border-[#3f6fb3]" }, + task: { label: "TASK", tone: "text-emerald-300 border-emerald-700/60" }, + orphan: { label: "TMP", tone: "text-[#7788aa] border-[#2a3a5a]" }, + }; + const m = map[kind]; + return ( + <span + className={`text-[9px] font-mono uppercase border rounded px-1 py-0.5 shrink-0 ${m.tone}`} + > + {m.label} + </span> + ); +} + +/** + * Crude fuzzy match: every char of `q` appears in `s` in order. Good enough + * for the typical "fold-by-substring" feel of an IntelliJ palette without + * pulling in a fuzzy library. + */ +function fuzzyContains(s: string, q: string): boolean { + let i = 0; + for (const c of s) { + if (c === q[i]) i++; + if (i === q.length) return true; + } + return i === q.length; +} diff --git a/makima/frontend/src/components/directives/DocumentTaskStream.tsx b/makima/frontend/src/components/directives/DocumentTaskStream.tsx index 62c1a52..b718ae4 100644 --- a/makima/frontend/src/components/directives/DocumentTaskStream.tsx +++ b/makima/frontend/src/components/directives/DocumentTaskStream.tsx @@ -5,39 +5,88 @@ * Key differences from TaskOutput: * - Document typography (serif-ish paragraphs, not monospace logs). * - Interleaved with subtle marginalia for tool calls and results. - * - "Comment" footer interrupts the running task via sendTaskMessage — - * same backend wire as the existing input bar, just framed as a comment. + * - Sticky comment composer at the bottom that's always in view. + * - Header strip with explicit Stop / Send / Open-in-task-page buttons so + * primary task controls don't require a right-click discovery step. + * - Module-level cache of historical entries per taskId so re-selecting a + * task you've already viewed renders instantly while a fresh fetch + * refreshes in the background. */ import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router"; import { SimpleMarkdown } from "../SimpleMarkdown"; import { useTaskSubscription, type TaskOutputEvent, } from "../../hooks/useTaskSubscription"; -import { getTaskOutput, sendTaskMessage } from "../../lib/api"; +import { getTaskOutput, sendTaskMessage, stopTask } from "../../lib/api"; interface DocumentTaskStreamProps { taskId: string; /** Human label used as the document header (e.g. "orchestrator", step name) */ label: string; + /** + * When this task is ephemeral (spawned via the directive's "+ New task" + * action) AND has reached a terminal state, surface a "Merge to base" + * affordance that navigates the user to the standalone task page where + * the existing merge UI handles the actual merge / conflict flow. + * + * Step-spawned tasks have their own merge path (the directive's PR), so + * this affordance is intentionally off by default. + */ + ephemeral?: boolean; + /** Current status of the task; drives whether merge button is enabled. */ + status?: string; } -export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { - const [entries, setEntries] = useState<TaskOutputEvent[]>([]); - const [loading, setLoading] = useState(true); +// ============================================================================= +// Module-level cache for historical task entries. +// +// Switching between tasks you've already viewed used to re-fire +// getTaskOutput and show "Loading transcript…" for the duration of the +// network round-trip. We now keep the entries cached per taskId; on +// re-selection we render the cache immediately and refetch in the +// background. The WS subscription continues to handle live deltas. +// ============================================================================= +const entriesCache = new Map<string, TaskOutputEvent[]>(); + +export function DocumentTaskStream({ + taskId, + label, + ephemeral, + status, +}: DocumentTaskStreamProps) { + const navigate = useNavigate(); + const [entries, setEntries] = useState<TaskOutputEvent[]>( + () => entriesCache.get(taskId) ?? [], + ); + const [loading, setLoading] = useState(!entriesCache.has(taskId)); const [isStreaming, setIsStreaming] = useState(false); const [comment, setComment] = useState(""); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null); + const [stopping, setStopping] = useState(false); const containerRef = useRef<HTMLDivElement>(null); - const [autoScroll, setAutoScroll] = useState(true); + const composerRef = useRef<HTMLDivElement>(null); + // autoScroll lives in a ref so the scroll handler reads the latest value + // synchronously without re-creating the effect. + const autoScrollRef = useRef(true); + const [showResumeScroll, setShowResumeScroll] = useState(false); - // Load historical output when the selected task changes. + // Load historical output when the selected task changes. Render the cache + // immediately if we have it; refetch in the background regardless. useEffect(() => { let cancelled = false; - setLoading(true); - setEntries([]); + const cached = entriesCache.get(taskId); + if (cached) { + setEntries(cached); + setLoading(false); + } else { + setEntries([]); + setLoading(true); + } setIsStreaming(false); + getTaskOutput(taskId) .then((res) => { if (cancelled) return; @@ -52,6 +101,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { durationMs: e.durationMs, isPartial: false, })); + entriesCache.set(taskId, mapped); setEntries(mapped); }) .catch((err) => { @@ -67,17 +117,27 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { }; }, [taskId]); - const handleOutput = useCallback((event: TaskOutputEvent) => { - if (event.isPartial) return; - setEntries((prev) => [...prev, event]); - setIsStreaming(true); - }, []); + const handleOutput = useCallback( + (event: TaskOutputEvent) => { + if (event.isPartial) return; + setEntries((prev) => { + const next = [...prev, event]; + entriesCache.set(taskId, next); + return next; + }); + setIsStreaming(true); + }, + [taskId], + ); const handleUpdate = useCallback((event: { status: string }) => { if ( event.status === "completed" || event.status === "failed" || - event.status === "cancelled" + event.status === "cancelled" || + event.status === "interrupted" || + event.status === "merged" || + event.status === "done" ) { setIsStreaming(false); } else if (event.status === "running") { @@ -92,18 +152,32 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { onUpdate: handleUpdate, }); - // Auto-scroll while at bottom. + // Auto-scroll while at bottom. The previous version only flipped autoScroll + // off and never resumed; now a scroll back into the bottom 80px reactivates + // it so a brief read-up doesn't permanently freeze the stream at the top. + useEffect(() => { + if (autoScrollRef.current && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries]); + + // After loading the initial transcript, snap to the bottom unconditionally + // so users see the latest output, not the start. useEffect(() => { - if (autoScroll && containerRef.current) { + if (!loading && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; + autoScrollRef.current = true; + setShowResumeScroll(false); } - }, [entries, autoScroll]); + }, [loading, taskId]); const handleScroll = useCallback(() => { if (!containerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - const atBottom = scrollHeight - scrollTop - clientHeight < 80; - setAutoScroll(atBottom); + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const atBottom = distanceFromBottom < 80; + autoScrollRef.current = atBottom; + setShowResumeScroll(!atBottom); }, []); const submitComment = useCallback( @@ -114,15 +188,19 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { setSending(true); setSendError(null); // Show the comment immediately as a user-input entry. - setEntries((prev) => [ - ...prev, - { - taskId, - messageType: "user_input", - content: trimmed, - isPartial: false, - }, - ]); + setEntries((prev) => { + const next: TaskOutputEvent[] = [ + ...prev, + { + taskId, + messageType: "user_input", + content: trimmed, + isPartial: false, + }, + ]; + entriesCache.set(taskId, next); + return next; + }); try { await sendTaskMessage(taskId, trimmed); setComment(""); @@ -138,15 +216,94 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { [comment, sending, taskId], ); + const handleStop = useCallback(async () => { + if (stopping || !isStreaming) return; + if (!window.confirm("Stop this task? It will be marked failed.")) return; + setStopping(true); + try { + await stopTask(taskId); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Failed to stop task", err); + } finally { + setStopping(false); + } + }, [taskId, stopping, isStreaming]); + + const focusComposer = useCallback(() => { + const input = composerRef.current?.querySelector("textarea"); + input?.focus(); + }, []); + + const resumeScroll = useCallback(() => { + if (!containerRef.current) return; + containerRef.current.scrollTop = containerRef.current.scrollHeight; + autoScrollRef.current = true; + setShowResumeScroll(false); + }, []); + return ( - <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]"> + <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628] relative"> + {/* Action header strip — explicit Stop / Send / Open-in-task-page so + users don't have to right-click to discover task controls. */} + <div className="shrink-0 flex items-center gap-2 px-6 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)] bg-[#091428]"> + <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide"> + Task actions + </span> + <button + type="button" + onClick={focusComposer} + className="ml-auto px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400" + > + Send (⌘↵) + </button> + <button + type="button" + onClick={handleStop} + disabled={!isStreaming || stopping} + className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-amber-300 border border-amber-600/60 hover:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed" + > + {stopping ? "Stopping…" : "Stop"} + </button> + + {/* Manual merge affordance — visible only on ephemeral tasks that + have reached a terminal state. Navigates to the standalone task + page where the existing mesh_merge UI drives the real merge / + conflict resolution flow. The user explicitly asked for this to + be a manual button press for safety. */} + {ephemeral && isTerminalStatus(status) && ( + <button + type="button" + onClick={() => { + const ok = window.confirm( + "Merge this ephemeral task into the base branch? You'll be taken to the task page where the merge runs and any conflicts are resolved.", + ); + if (!ok) return; + navigate(`/exec/${taskId}#merge`); + }} + title="Manual merge — opens the merge UI on the task page" + className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400" + > + Merge to base ↗ + </button> + )} + + <button + type="button" + onClick={() => navigate(`/exec/${taskId}`)} + className="px-2 py-1 font-mono text-[10px] uppercase tracking-wide text-[#9bc3ff] border border-[rgba(117,170,252,0.35)] hover:border-[#75aafc]" + > + Open in task page + </button> + </div> + {/* Document body */} <div ref={containerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto" > - <div className="max-w-3xl mx-auto px-8 py-10 text-[#dbe7ff]"> + <div className="max-w-3xl mx-auto px-8 py-10 pb-32 text-[#dbe7ff]"> <div className="flex items-center gap-3 mb-1"> <h1 className="text-[24px] font-medium text-white tracking-tight"> {label} @@ -183,8 +340,23 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { </div> </div> - {/* Comment / interrupt footer */} - <div className="shrink-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]"> + {/* "Resume auto-scroll" floating chip when the user has scrolled up. */} + {showResumeScroll && ( + <button + type="button" + onClick={resumeScroll} + className="absolute bottom-32 right-6 z-10 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-[#9bc3ff] bg-[#091428] border border-[rgba(117,170,252,0.4)] hover:border-[#75aafc] shadow-lg" + > + ↓ Jump to latest + </button> + )} + + {/* Sticky comment composer — always pinned to the viewport bottom so + users can interact with the task no matter where they've scrolled. */} + <div + ref={composerRef} + className="absolute bottom-0 left-0 right-0 border-t border-dashed border-[rgba(117,170,252,0.25)] bg-[#091428]/95 backdrop-blur" + > {sendError && ( <div className="px-6 py-1 bg-red-900/20 text-red-400 text-xs font-mono"> {sendError} @@ -192,7 +364,7 @@ export function DocumentTaskStream({ taskId, label }: DocumentTaskStreamProps) { )} <form onSubmit={submitComment} - className="max-w-3xl mx-auto px-8 py-4 flex items-start gap-3" + className="max-w-3xl mx-auto px-8 py-3 flex items-start gap-3" > <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide pt-2 shrink-0"> Comment @@ -325,6 +497,12 @@ function DocumentEntry({ entry }: { entry: TaskOutputEvent }) { } } +/** Terminal task statuses where the merge button is meaningful. */ +function isTerminalStatus(status?: string): boolean { + if (!status) return false; + return ["done", "completed", "merged"].includes(status); +} + function firstLineOfInput(input?: Record<string, unknown>): string { if (!input) return ""; // Common shapes — show the most informative single value. diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 898f671..8104de0 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -24,28 +24,111 @@ import { createDirectivePR, } from "../lib/api"; +// ============================================================================= +// Stale-while-revalidate cache +// +// Switching between directives in the document-mode sidebar used to feel +// noticeably laggy because every navigation re-fired the GET request and +// blocked the UI on it. We now keep a process-wide cache (mirrors the +// pattern in `useUserSettings.ts`) and use the cache as the immediate +// render value; a fresh fetch fires in the background and notifies all +// subscribed hook instances when it lands. +// +// Mutations (start/pause/etc.) push their result back into the cache so +// successive reads are also instant. Hard `refresh()` calls bypass the +// cache age check and refetch. +// ============================================================================= + +let listCache: DirectiveSummary[] | null = null; +let listInflight: Promise<DirectiveSummary[]> | null = null; +const listSubscribers = new Set<(d: DirectiveSummary[]) => void>(); + +const detailCache = new Map<string, DirectiveWithSteps>(); +const detailInflight = new Map<string, Promise<DirectiveWithSteps>>(); +const detailSubscribers = new Map<string, Set<(d: DirectiveWithSteps) => void>>(); + +function notifyList(value: DirectiveSummary[]) { + for (const sub of listSubscribers) sub(value); +} + +function notifyDetail(id: string, value: DirectiveWithSteps) { + const subs = detailSubscribers.get(id); + if (!subs) return; + for (const sub of subs) sub(value); +} + +function fetchList(): Promise<DirectiveSummary[]> { + if (listInflight) return listInflight; + listInflight = listDirectives() + .then((res) => { + listCache = res.directives; + notifyList(res.directives); + return res.directives; + }) + .finally(() => { + listInflight = null; + }); + return listInflight; +} + +function fetchDetail(id: string): Promise<DirectiveWithSteps> { + const existing = detailInflight.get(id); + if (existing) return existing; + const p = getDirective(id) + .then((d) => { + detailCache.set(id, d); + notifyDetail(id, d); + return d; + }) + .finally(() => { + detailInflight.delete(id); + }); + detailInflight.set(id, p); + return p; +} + export function useDirectives() { - const [directives, setDirectives] = useState<DirectiveSummary[]>([]); - const [loading, setLoading] = useState(true); + const [directives, setDirectives] = useState<DirectiveSummary[]>( + () => listCache ?? [], + ); + const [loading, setLoading] = useState<boolean>(listCache === null); const [error, setError] = useState<string | null>(null); + useEffect(() => { + let mounted = true; + const sub = (value: DirectiveSummary[]) => { + if (!mounted) return; + setDirectives(value); + setLoading(false); + }; + listSubscribers.add(sub); + + // Always kick a background fetch on mount so we don't ship stale data; + // subscribers see the new value when it lands. UI shows the cached + // value immediately if there is one. + fetchList().catch((e) => { + if (!mounted) return; + setError(e instanceof Error ? e.message : "Failed to load directives"); + setLoading(false); + }); + + return () => { + mounted = false; + listSubscribers.delete(sub); + }; + }, []); + const refresh = useCallback(async () => { try { - setLoading(true); - setError(null); const res = await listDirectives(); - setDirectives(res.directives); + listCache = res.directives; + notifyList(res.directives); + setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load directives"); - } finally { - setLoading(false); } }, []); - useEffect(() => { - refresh(); - }, [refresh]); - const create = useCallback(async (req: CreateDirectiveRequest) => { const d = await createDirective(req); await refresh(); @@ -54,6 +137,7 @@ export function useDirectives() { const remove = useCallback(async (id: string) => { await deleteDirective(id); + detailCache.delete(id); await refresh(); }, [refresh]); @@ -61,8 +145,12 @@ export function useDirectives() { } export function useDirective(id: string | undefined) { - const [directive, setDirective] = useState<DirectiveWithSteps | null>(null); - const [loading, setLoading] = useState(true); + const [directive, setDirective] = useState<DirectiveWithSteps | null>( + () => (id ? detailCache.get(id) ?? null : null), + ); + const [loading, setLoading] = useState<boolean>( + id !== undefined && !detailCache.has(id), + ); const [error, setError] = useState<string | null>(null); // Silently refresh without setting loading state (for polls) @@ -70,35 +158,73 @@ export function useDirective(id: string | undefined) { if (!id) return; try { const d = await getDirective(id); - setDirective(d); + detailCache.set(id, d); + notifyDetail(id, d); setError(null); - } catch (e) { + } catch { // Don't overwrite existing data on poll failure } }, [id]); - // Full refresh with loading state (for initial load / explicit refresh) + // Full refresh with loading state (for explicit refresh) const refresh = useCallback(async () => { if (!id) return; try { - setLoading(true); setError(null); const d = await getDirective(id); - setDirective(d); + detailCache.set(id, d); + notifyDetail(id, d); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load directive"); - } finally { - setLoading(false); } }, [id]); - // Reset state and fetch when ID changes + // Subscribe to detail updates for this id; render cached value + // immediately and kick a background fetch if the cache is missing. useEffect(() => { - setDirective(null); - setError(null); - setLoading(true); - refresh(); - }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + if (!id) { + setDirective(null); + setLoading(false); + setError(null); + return; + } + + let mounted = true; + const sub = (value: DirectiveWithSteps) => { + if (!mounted) return; + setDirective(value); + setLoading(false); + }; + + let subs = detailSubscribers.get(id); + if (!subs) { + subs = new Set(); + detailSubscribers.set(id, subs); + } + subs.add(sub); + + const cached = detailCache.get(id); + if (cached) { + setDirective(cached); + setLoading(false); + } else { + setDirective(null); + setLoading(true); + } + + // Always kick a fresh fetch so polling-driven UIs see updates. + fetchDetail(id).catch((e) => { + if (!mounted) return; + setError(e instanceof Error ? e.message : "Failed to load directive"); + setLoading(false); + }); + + return () => { + mounted = false; + subs!.delete(sub); + if (subs!.size === 0) detailSubscribers.delete(id); + }; + }, [id]); // Auto-poll while directive is active, has an orchestrator task, or has a completion task useEffect(() => { diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 3fcd728..4d664cf 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3509,6 +3509,65 @@ export async function newDirectiveDraft( return res.json(); } +/** + * Request body for spawning an ephemeral task under a directive. The task is + * not part of the DAG — it lives alongside the directive's planned steps but + * the orchestrator ignores it. + */ +export interface CreateDirectiveTaskRequest { + name: string; + plan: string; + /** Override the directive's repository_url; defaults to the directive's. */ + repositoryUrl?: string; + /** Override the directive's base_branch; defaults to the directive's. */ + baseBranch?: string; +} + +/** + * List ephemeral tasks (directive_id set, directive_step_id NULL) attached + * to a directive. Backs the "spinoff" group inside each directive folder + * in the document-mode sidebar. + */ +export async function listDirectiveEphemeralTasks( + directiveId: string, +): Promise<TaskListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/tasks`); + if (!res.ok) { + throw new Error(`Failed to list directive tasks: ${res.statusText}`); + } + return res.json(); +} + +export async function createDirectiveTask( + directiveId: string, + req: CreateDirectiveTaskRequest, +): Promise<Task> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/tasks`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }, + ); + if (!res.ok) { + throw new Error(`Failed to create directive task: ${res.statusText}`); + } + return res.json(); +} + +/** + * List tasks not attached to any directive (and not subtasks). Backs the + * `tmp/` pseudo-folder in the document-mode sidebar. + */ +export async function listOrphanTasks(): Promise<TaskListResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks?orphan=true`); + if (!res.ok) { + throw new Error(`Failed to list orphan tasks: ${res.statusText}`); + } + return res.json(); +} + export async function createDirectivePR(id: string): Promise<DirectiveWithSteps> { const res = await authFetch(`${API_BASE}/api/v1/directives/${id}/create-pr`, { method: "POST" }); if (!res.ok) throw new Error(`Failed to create PR: ${res.statusText}`); diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 4f7c525..bbb72f3 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { SupervisorQuestionsProvider } from "./contexts/SupervisorQuestionsConte import { GridOverlay } from "./components/GridOverlay"; import { SupervisorQuestionNotification } from "./components/SupervisorQuestionNotification"; import { PhaseConfirmationNotification } from "./components/PhaseConfirmationNotification"; +import { QuickSwitcher } from "./components/QuickSwitcher"; import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; @@ -21,6 +22,8 @@ import SettingsPage from "./routes/settings"; import ContractFilePage from "./routes/contract-file"; import SpeakPage from "./routes/speak"; import DirectivesPage from "./routes/directives"; +import ExecRedirect from "./routes/exec-redirect"; +import TmpTaskPage from "./routes/tmp"; createRoot(document.getElementById("root")!).render( <StrictMode> @@ -30,6 +33,7 @@ createRoot(document.getElementById("root")!).render( <GridOverlay /> <SupervisorQuestionNotification /> <PhaseConfirmationNotification /> + <QuickSwitcher /> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/login" element={<LoginPage />} /> @@ -109,7 +113,15 @@ createRoot(document.getElementById("root")!).render( path="/exec/:id" element={ <ProtectedRoute> - <MeshPage /> + <ExecRedirect /> + </ProtectedRoute> + } + /> + <Route + path="/tmp/:taskId" + element={ + <ProtectedRoute> + <TmpTaskPage /> </ProtectedRoute> } /> 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> ); } diff --git a/makima/frontend/src/routes/exec-redirect.tsx b/makima/frontend/src/routes/exec-redirect.tsx new file mode 100644 index 0000000..d2f863c --- /dev/null +++ b/makima/frontend/src/routes/exec-redirect.tsx @@ -0,0 +1,59 @@ +/** + * ExecRedirect — wraps the standalone task page (`MeshPage`) at `/exec/:id` + * so that, when the user has document-mode enabled AND the task is attached + * to a directive, we forward them to `/directives/<dirId>?task=<taskId>` + * (the doc-mode task surface). For orphan tasks or non-doc-mode users we + * just render the existing MeshPage. + * + * Why a wrapper rather than a redirect at route level: we need the task's + * `directiveId` field, which only the API returns. We optimistically render + * a loading shim while the lookup happens, then either replace the URL or + * mount the legacy page. + */ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { getTask, type Task } from "../lib/api"; +import { useUserSettings } from "../hooks/useUserSettings"; +import MeshPage from "./mesh"; + +export default function ExecRedirect() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { settings, loading: settingsLoading } = useUserSettings(); + const [decided, setDecided] = useState<"redirect" | "stay" | null>(null); + + useEffect(() => { + if (!id || settingsLoading) return; + let cancelled = false; + getTask(id) + .then((task: Task) => { + if (cancelled) return; + const documentMode = settings?.documentModeEnabled ?? false; + const directiveId = (task as Task & { directiveId?: string | null }) + .directiveId ?? null; + if (documentMode && directiveId) { + navigate(`/directives/${directiveId}?task=${id}`, { replace: true }); + return; + } + setDecided("stay"); + }) + .catch(() => { + // If we can't read the task (404, network, etc.), just let the + // legacy MeshPage handle the error display. + if (!cancelled) setDecided("stay"); + }); + return () => { + cancelled = true; + }; + }, [id, settingsLoading, settings?.documentModeEnabled, navigate]); + + if (decided === null) { + return ( + <div className="flex-1 flex items-center justify-center min-h-screen bg-[#0a1628]"> + <p className="text-[#7788aa] font-mono text-xs">Loading…</p> + </div> + ); + } + + return <MeshPage />; +} diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx new file mode 100644 index 0000000..69f13a2 --- /dev/null +++ b/makima/frontend/src/routes/tmp.tsx @@ -0,0 +1,93 @@ +/** + * Standalone task page for orphan tasks (`/tmp/:taskId`). These are tasks + * with no directive attachment — the document-mode sidebar surfaces them + * under the `tmp/` pseudo-folder. We render `DocumentTaskStream` directly + * without the directive sidebar selection, framed by the masthead so users + * still have global navigation. + */ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; +import { useAuth } from "../contexts/AuthContext"; +import { getTask, type Task } from "../lib/api"; + +export default function TmpTaskPage() { + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + const navigate = useNavigate(); + const { taskId } = useParams<{ taskId: string }>(); + const [task, setTask] = useState<Task | null>(null); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + useEffect(() => { + if (!taskId) return; + let cancelled = false; + getTask(taskId) + .then((t) => { + if (cancelled) return; + const directiveId = (t as Task & { directiveId?: string | null }) + .directiveId ?? null; + // If this task actually IS attached to a directive, redirect + // there — /tmp/ is reserved for genuine orphans. + if (directiveId) { + navigate(`/directives/${directiveId}?task=${taskId}`, { + replace: true, + }); + return; + } + setTask(t); + }) + .catch((e) => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, [taskId, navigate]); + + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + return ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main + className="flex-1 flex flex-col overflow-hidden" + style={{ height: "calc(100vh - 80px)" }} + > + {/* Breadcrumb echoing the document-mode header style. */} + <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]"> + <span>tmp /</span> + <span className="text-[#9bc3ff]"> + {taskId ? taskId.slice(0, 8) : ""} + </span> + {task && <span className="normal-case text-[#dbe7ff]">— {task.name}</span>} + </div> + </div> + + {error ? ( + <div className="flex-1 flex items-center justify-center"> + <p className="text-red-400 font-mono text-xs">{error}</p> + </div> + ) : taskId ? ( + <DocumentTaskStream taskId={taskId} label={task?.name ?? taskId.slice(0, 8)} /> + ) : null} + </main> + </div> + ); +} diff --git a/makima/migrations/20260501000000_archive_existing_contracts.sql b/makima/migrations/20260501000000_archive_existing_contracts.sql new file mode 100644 index 0000000..5793cd6 --- /dev/null +++ b/makima/migrations/20260501000000_archive_existing_contracts.sql @@ -0,0 +1,18 @@ +-- One-shot migration to archive every legacy contract. +-- +-- Background: the document-mode directive UI is becoming the single surface +-- for makima. The standalone contracts system is being phased out (see Phase +-- 4/5 work in the roadmap). New contract creation paths still exist for +-- internal directive-step use (`directive_steps.contract_type`), but +-- end-user-visible standalone contracts are no longer produced and should +-- read as historical from now on. +-- +-- This migration flips every existing contract to `archived` so the legacy +-- /contracts list collapses to "everything is archived" instead of mixing +-- live and archival data. New rows can still be created (e.g. by directive +-- orchestration's contract-backed steps) and will start in their normal +-- status — this only affects pre-existing data. +UPDATE contracts +SET status = 'archived', + updated_at = NOW() +WHERE status NOT IN ('archived'); diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 27bd47e..b41c74c 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -1189,6 +1189,71 @@ pub async fn list_tasks_for_owner( .await } +/// List ephemeral tasks attached to a directive — tasks with `directive_id` +/// set but no `directive_step_id`. These are the "spinoff" tasks the user +/// created via the directive folder context menu, distinct from +/// step-spawned execution tasks. Hidden tasks excluded. +pub async fn list_ephemeral_directive_tasks_for_owner( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, +) -> Result<Vec<TaskSummary>, sqlx::Error> { + sqlx::query_as::<_, TaskSummary>( + r#" + SELECT + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id + WHERE t.owner_id = $1 + AND t.directive_id = $2 + AND t.directive_step_id IS NULL + AND t.parent_task_id IS NULL + AND COALESCE(t.hidden, false) = false + ORDER BY t.created_at DESC + "#, + ) + .bind(owner_id) + .bind(directive_id) + .fetch_all(pool) + .await +} + +/// List "orphan" top-level tasks for an owner — tasks that are NOT attached +/// to a directive and NOT a subtask of another task. These surface in the +/// document-mode sidebar under a top-level `tmp/` folder. Hidden tasks +/// excluded. +pub async fn list_orphan_tasks_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<TaskSummary>, sqlx::Error> { + sqlx::query_as::<_, TaskSummary>( + r#" + SELECT + t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase, + c.status as contract_status, + t.parent_task_id, t.depth, t.name, t.status, t.priority, + t.progress_summary, + (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count, + t.version, t.is_supervisor, COALESCE(t.hidden, false) as hidden, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN contracts c ON t.contract_id = c.id + WHERE t.owner_id = $1 + AND t.parent_task_id IS NULL + AND t.directive_id IS NULL + AND COALESCE(t.hidden, false) = false + ORDER BY t.priority DESC, t.created_at DESC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + /// List subtasks of a parent task, scoped to owner. pub async fn list_subtasks_for_owner( pool: &PgPool, diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 7a7aff4..7b13f1c 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -12,6 +12,7 @@ use crate::db::models::{ CleanupResponse, CreateDirectiveRequest, CreateTaskRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, DirectiveRevision, DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse, + Task, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest, CreateDirectiveOrderGroupRequest, DirectiveOrderGroup, DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest, @@ -2194,3 +2195,163 @@ pub async fn list_directive_revisions( } } } + +// ============================================================================= +// Ephemeral tasks under a directive +// +// A directive can spin up ad-hoc one-off tasks that are NOT part of its DAG. +// They live in the `tasks` table with `directive_id` set and +// `directive_step_id = NULL` — the existing schema already supports this; we +// just expose the create path through a directive-scoped endpoint and +// inherit repo/branch from the parent directive when the caller doesn't +// override. +// ============================================================================= + +/// Request body for creating an ephemeral task under a directive. +#[derive(Debug, serde::Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveTaskRequest { + /// Human-readable name (used for branch/commit names). + pub name: String, + /// Plan / instructions for the Claude Code session. + pub plan: String, + /// Optional override for the directive's repository. + pub repository_url: Option<String>, + /// Optional override for the directive's base branch. + pub base_branch: Option<String>, +} + +/// List the ephemeral tasks (no directive_step_id) attached to a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/tasks", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "List of ephemeral tasks", body = crate::db::models::TaskListResponse), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_directive_tasks( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_ephemeral_directive_tasks_for_owner(pool, auth.owner_id, id).await { + Ok(tasks) => { + let total = tasks.len() as i64; + Json(crate::db::models::TaskListResponse { tasks, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list ephemeral tasks: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create an ephemeral task attached to a directive. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/tasks", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveTaskRequest, + responses( + (status = 201, description = "Ephemeral task created", body = Task), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn create_directive_task( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateDirectiveTaskRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Look up the parent directive so we can inherit its repository/base branch + // when the caller doesn't override them. Also gates the request on + // ownership: if the directive isn't visible to this owner, 404. + let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to load directive for ephemeral task: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(); + } + }; + + let repo_url = req + .repository_url + .or_else(|| directive.repository_url.clone()); + let base_branch = req.base_branch.or_else(|| directive.base_branch.clone()); + + let create_req = CreateTaskRequest { + contract_id: None, + name: req.name, + description: None, + plan: req.plan, + parent_task_id: None, + is_supervisor: false, + priority: 0, + repository_url: repo_url, + base_branch, + target_branch: None, + merge_mode: None, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + checkpoint_sha: None, + branched_from_task_id: None, + conversation_history: None, + supervisor_worktree_task_id: None, + directive_id: Some(directive.id), + // No directive_step_id — this is what makes the task "ephemeral": + // it lives under the directive folder but isn't part of the DAG. + directive_step_id: None, + }; + + match repository::create_task_for_owner(pool, auth.owner_id, create_req).await { + Ok(task) => (StatusCode::CREATED, Json(task)).into_response(), + Err(e) => { + tracing::error!("Failed to create ephemeral directive task: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 1a5b9c1..ac5652a 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -78,10 +78,24 @@ pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource { // Task Handlers // ============================================================================= -/// List all tasks for the current owner. +/// Query parameters for `list_tasks`. Currently only the `orphan` filter — +/// when set, returns tasks with NO parent_task_id AND NO directive_id, used +/// by the document-mode sidebar's `tmp/` folder. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTasksQuery { + #[serde(default)] + pub orphan: bool, +} + +/// List all tasks for the current owner. Pass `?orphan=true` to restrict to +/// top-level tasks with no directive attachment. #[utoipa::path( get, path = "/api/v1/mesh/tasks", + params( + ("orphan" = Option<bool>, Query, description = "Filter to tasks with no directive_id and no parent_task_id"), + ), responses( (status = 200, description = "List of tasks", body = TaskListResponse), (status = 401, description = "Unauthorized", body = ApiError), @@ -97,6 +111,7 @@ pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource { pub async fn list_tasks( State(state): State<SharedState>, Authenticated(auth): Authenticated, + axum::extract::Query(query): axum::extract::Query<ListTasksQuery>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -106,7 +121,13 @@ pub async fn list_tasks( .into_response(); }; - match repository::list_tasks_for_owner(pool, auth.owner_id).await { + let result = if query.orphan { + repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await + } else { + repository::list_tasks_for_owner(pool, auth.owner_id).await + }; + + match result { Ok(tasks) => { let total = tasks.len() as i64; Json(TaskListResponse { tasks, total }).into_response() diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index c577904..efae901 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -255,6 +255,10 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/goal", put(directives::update_goal)) .route("/directives/{id}/revisions", get(directives::list_directive_revisions)) .route("/directives/{id}/new-draft", post(directives::new_directive_draft)) + .route( + "/directives/{id}/tasks", + get(directives::list_directive_tasks).post(directives::create_directive_task), + ) .route("/directives/{id}/cleanup", post(directives::cleanup_directive)) .route("/directives/{id}/create-pr", post(directives::create_pr)) .route("/directives/{id}/pick-up-orders", post(directives::pick_up_orders)) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index e6d4547..7a4b004 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -132,6 +132,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::update_goal, directives::list_directive_revisions, directives::new_directive_draft, + directives::create_directive_task, + directives::list_directive_tasks, directives::cleanup_directive, directives::create_pr, // Order endpoints @@ -237,6 +239,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage DirectiveListResponse, DirectiveRevision, crate::server::handlers::directives::DirectiveRevisionListResponse, + crate::server::handlers::directives::CreateDirectiveTaskRequest, CreateDirectiveRequest, UpdateDirectiveRequest, UpdateGoalRequest, |
