summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-01 18:06:38 +0100
committerGitHub <noreply@github.com>2026-05-01 18:06:38 +0100
commit80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch)
tree5802a9923eab572a98a6d8b21be2fdc56fdf7118
parent6d922307223d12f436b229d4c4b29b8835b93b6c (diff)
downloadsoryu-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.tsx314
-rw-r--r--makima/frontend/src/components/directives/DocumentTaskStream.tsx248
-rw-r--r--makima/frontend/src/hooks/useDirectives.ts178
-rw-r--r--makima/frontend/src/lib/api.ts59
-rw-r--r--makima/frontend/src/main.tsx14
-rw-r--r--makima/frontend/src/routes/document-directives.tsx555
-rw-r--r--makima/frontend/src/routes/exec-redirect.tsx59
-rw-r--r--makima/frontend/src/routes/tmp.tsx93
-rw-r--r--makima/migrations/20260501000000_archive_existing_contracts.sql18
-rw-r--r--makima/src/db/repository.rs65
-rw-r--r--makima/src/server/handlers/directives.rs161
-rw-r--r--makima/src/server/handlers/mesh.rs25
-rw-r--r--makima/src/server/mod.rs4
-rw-r--r--makima/src/server/openapi.rs3
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,