From 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef Mon Sep 17 00:00:00 2001 From: soryu Date: Fri, 1 May 2026 18:06:38 +0100 Subject: feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec redirect, palette, SWR (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/?task=`. Otherwise renders the legacy `MeshPage` as before. - New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for orphan tasks, with the masthead and a `tmp / ` breadcrumb. Co-authored-by: Claude Opus 4.7 (1M context) --- makima/frontend/src/routes/document-directives.tsx | 555 ++++++++++++++++++++- makima/frontend/src/routes/exec-redirect.tsx | 59 +++ makima/frontend/src/routes/tmp.tsx | 93 ++++ 3 files changed, 680 insertions(+), 27 deletions(-) create mode 100644 makima/frontend/src/routes/exec-redirect.tsx create mode 100644 makima/frontend/src/routes/tmp.tsx (limited to 'makima/frontend/src/routes') 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 ( + + + + + + ); +} + /** 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 ( + + ); +} + function Caret({ open }: { open: boolean }) { return ( 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([]); + 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 ( -
- + + {/* Hover/open-only action chips — discoverable replacement for the + right-click menu. Right-click still works as a power-user fallback. */} +
+ {showStart && ( + onQuickAction(directive, "start")} + > + ▶ + + )} + {showPause && ( + onQuickAction(directive, "pause")} + > + ❚❚ + + )} + {directive.prUrl && ( + + window.open(directive.prUrl ?? "", "_blank", "noreferrer") + } + > + ↗ + + )} + onCreateTask(directive)} + > + + + +
+ {/* Status dot — RIGHT side only. Glows when this directive has a pending user question, or pulses when the orchestrator is live. */} - +
{open && (
    @@ -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 (
  • + {tmpOpen && ( +
      + {orphanTasks.length === 0 ? ( +
    • + No orphan tasks +
    • + ) : ( + orphanTasks.map((t) => { + const tdot = + STEP_STATUS_DOT[t.status] ?? STEP_STATUS_DOT.pending; + const live = t.status === "running"; + return ( +
    • + +
    • + ); + }) + )} +
    + )} + + {loading && directives.length === 0 ? (
    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(); + 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 ( + + ); +} + 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 (
    {/* Contract header — breadcrumb-like, mirrors a code editor's tab bar */} @@ -1032,21 +1339,37 @@ function EditorShell({ )} - {!selectedTaskId && !!directive.orchestratorTaskId && ( - - - orchestrator running - - )}
    + {/* Now-executing strip — only when viewing the contract doc itself. + Click to jump into the live task transcript. */} + {liveTask && ( + + )} + {revisionId ? ( ) : realTaskId ? ( - ) : ( 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(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 (
    @@ -1166,6 +1545,9 @@ export default function DocumentDirectivesPage() { onSelect={onSelect} onDirectiveContextMenu={onDirectiveContextMenu} onTaskContextMenu={onTaskContextMenu} + onCreateTask={onCreateTask} + onQuickAction={onQuickAction} + onSelectOrphan={onSelectOrphan} />
    @@ -1304,6 +1686,125 @@ export default function DocumentDirectivesPage() { }} /> )} + + {newTaskFor && ( + setNewTaskFor(null)} + onSubmit={handleSubmitNewTask} + /> + )} + + ); +} + +/** + * 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; +}) { + const [name, setName] = useState(""); + const [plan, setPlan] = useState(""); + const [submitting, setSubmitting] = useState(false); + const nameRef = useRef(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 ( +
    +
    e.stopPropagation()} + className="w-[480px] max-w-[90vw] bg-[#0a1628] border border-[rgba(117,170,252,0.4)] shadow-xl flex flex-col" + > +
    +

    + New task in +

    +

    + {directive.title} +

    +
    +
    +
    + + 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]" + /> +
    +
    + +