From 8cd7b40ace4e5e2b22ad89aafec74c7655def19b Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 16 May 2026 19:55:34 +0100 Subject: feat(directives): strict orchestration flow + sidebar overhaul + task page rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end rewrite addressing the issues from the user's UX review. The system now feels like a daemon-orchestration tool: lock a contract and the orchestrator just goes; PR raised → auto-ship → reopen for amendments. The sidebar tree shows real entities only (no duplicates, no inline action buttons polluting the file list), and every entity gets a right-click context menu. Task page matches the old /exec layout (diff on the left, feed + composer on the right). ## Backend — strict lifecycle (the orchestrator-never-spawned bug) Root cause: `phase_planning()` gates on `directive.status='active'`, but `start_contract()` only flipped the contract row — the parent directive stayed in whatever state it was. So locking a contract did nothing visible. Fix: contract lifecycle now drives directive status in the same transaction. start_contract → if contract becomes active, flip directive draft|paused|idle|inactive → active pause_contract → after promote, if no active contract left, directive → paused complete_contract→ after promote, if no active left, directive → inactive (also fires on auto-ship from PR detect) unlock_contract → if was active and no active left, directive → paused reopen_contract → NEW. shipped → active. Directive → active, orchestrator_task_id/pr_url/pr_branch cleared so the reconciler spawns a fresh planner. The planner reads get_latest_merged_revision and frames the new plan as an amendment. handlers::directive_documents lifts state.kick_directive_reconciler() into run_contract_transition so every successful transition wakes the reconciler immediately (no 15s wait). handlers::directives `update_directive` (PR-detection branch) calls `complete_contract(active_contract_id, pr_url, pr_branch)` instead of `set_directive_inactive`. The contract auto-ships; the directive follows via the sync above. No more manual "Mark complete" click. POST /api/v1/contracts/{id}/reopen added + wired through openapi. Spawn task names dropped the directive-title prefix that looked redundant in the sidebar: "Plan: " → "orchestrator" "Re-plan: <title>" → "orchestrator (re-plan)" "PR: <title>" → "completion" "Update PR: <title>" → "completion (update)" ## Frontend — sidebar * De-dupe: DocumentTasksFolder filters tasks[] to exclude any task whose id already appears in steps[].taskId. Single row per task, single highlight on click. * Generic SidebarContextMenu (new) replaces the directive-only DirectiveContextMenu (deleted). Per-entity item arrays built at the page level — directive, contract, step, task each have their own contextual actions. * Right-click works on every sidebar entity now (was directive-only). * `+ New document` / `+ New ephemeral task` inline buttons removed. Reachable via the directive folder right-click OR the hover-only `+` button on the directive folder row. * ContractHeader: dropped "Mark complete" button (auto-fires on PR). Added "Reopen for amendment" button when contract is shipped. ## Frontend — task page rewrite TaskPage.tsx replaces DocumentTaskStream.tsx (deleted). Two-column layout matches the old /exec page that the user preferred: ┌────────────────────────┬──────────────────────────────────┐ │ Changed files (~30%) │ Transcript feed (scrollable) │ │ ────────────────── │ ────────────────────── │ │ src/foo.rs │ [user] do thing │ │ src/bar.rs │ [tool] Read foo.rs │ │ │ │ │ Diff (selected file) │ │ │ ├──────────────────────────────────┤ │ │ Composer (sticky bottom) │ └────────────────────────┴──────────────────────────────────┘ Diff comes from getTaskDiff(); parseDiff + DiffFileView exported from OverlayDiffViewer for reuse (no duplication). Diff auto-refreshes when the task transitions to a terminal state. Transcript styling + sticky composer keep the parts the user liked. "Open in task page" button removed — the right pane IS the task page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../frontend/src/components/SidebarContextMenu.tsx | 108 ++++ .../components/directives/DirectiveContextMenu.tsx | 251 -------- .../components/directives/DocumentTaskStream.tsx | 516 ---------------- .../src/components/directives/TaskPage.tsx | 651 +++++++++++++++++++++ .../src/components/mesh/OverlayDiffViewer.tsx | 8 +- makima/frontend/src/lib/api.ts | 10 +- makima/frontend/src/routes/document-directives.tsx | 466 +++++++++------ makima/frontend/src/routes/tmp.tsx | 10 +- makima/src/db/repository.rs | 185 ++++++ makima/src/orchestration/directive.rs | 10 +- makima/src/server/handlers/directive_documents.rs | 85 +-- makima/src/server/handlers/directives.rs | 54 +- makima/src/server/mod.rs | 4 + makima/src/server/openapi.rs | 1 + 14 files changed, 1366 insertions(+), 993 deletions(-) create mode 100644 makima/frontend/src/components/SidebarContextMenu.tsx delete mode 100644 makima/frontend/src/components/directives/DirectiveContextMenu.tsx delete mode 100644 makima/frontend/src/components/directives/DocumentTaskStream.tsx create mode 100644 makima/frontend/src/components/directives/TaskPage.tsx diff --git a/makima/frontend/src/components/SidebarContextMenu.tsx b/makima/frontend/src/components/SidebarContextMenu.tsx new file mode 100644 index 0000000..a7b0ae6 --- /dev/null +++ b/makima/frontend/src/components/SidebarContextMenu.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; + +// Generic right-click context menu for the sidebar tree. Each call site +// (directive folder, contract row, step row, task row, …) builds its own +// items array. Lifts the viewport-clamping + click-outside / Esc logic +// out of the deleted DirectiveContextMenu so we don't end up with one +// component per entity type. + +export interface ContextMenuItem { + /** Display text. Empty for separators. */ + label: string; + /** Click handler. Ignored when `separator` is true. */ + onClick?: () => void; + /** Render in red — for destructive operations (delete, archive). */ + danger?: boolean; + /** Render as a thin divider instead of a button. */ + separator?: boolean; + /** Greyed out, non-clickable. Used for items whose preconditions + * aren't met (e.g. "Reopen" on an already-draft contract). */ + disabled?: boolean; +} + +interface SidebarContextMenuProps { + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; +} + +export function SidebarContextMenu({ x, y, items, onClose }: SidebarContextMenuProps) { + const menuRef = useRef<HTMLDivElement>(null); + + // Close on click outside or Esc. + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + // Clamp into viewport if the menu would overflow off the right or + // bottom edge. + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + if (rect.right > viewportWidth) { + menuRef.current.style.left = `${x - rect.width}px`; + } + if (rect.bottom > viewportHeight) { + menuRef.current.style.top = `${y - rect.height}px`; + } + } + }, [x, y]); + + const baseItemClass = + "w-full px-3 py-1.5 text-left text-xs font-mono flex items-center gap-2"; + const enabledClass = "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"; + const dangerClass = "text-red-400 hover:bg-[rgba(239,68,68,0.1)]"; + const disabledClass = "text-[#3a4a6a] cursor-not-allowed"; + const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; + + return ( + <div + ref={menuRef} + className="fixed z-50 min-w-[180px] bg-[#0a1628] border border-[rgba(117,170,252,0.3)] rounded shadow-lg py-1" + style={{ left: x, top: y }} + onContextMenu={(e) => e.preventDefault()} + > + {items.map((item, i) => { + if (item.separator) { + return <div key={`sep-${i}`} className={dividerClass} />; + } + const cls = item.disabled + ? disabledClass + : item.danger + ? dangerClass + : enabledClass; + return ( + <button + key={`item-${i}-${item.label}`} + type="button" + disabled={item.disabled} + onClick={() => { + if (item.disabled) return; + item.onClick?.(); + onClose(); + }} + className={`${baseItemClass} ${cls}`} + > + {item.label} + </button> + ); + })} + </div> + ); +} diff --git a/makima/frontend/src/components/directives/DirectiveContextMenu.tsx b/makima/frontend/src/components/directives/DirectiveContextMenu.tsx deleted file mode 100644 index eda7e7f..0000000 --- a/makima/frontend/src/components/directives/DirectiveContextMenu.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { DirectiveSummary } from "../../lib/api"; - -interface DirectiveContextMenuProps { - x: number; - y: number; - directive: DirectiveSummary; - onClose: () => void; - onStart: () => void; - onPause: () => void; - onArchive: () => void; - onDelete: () => void; - onGoToPR: () => void; - /** - * Reset the contract to a fresh empty draft (clears goal + pr_url, status - * back to 'draft'). Past revisions stay as history. Optional so the legacy - * tabular UI doesn't have to wire it up. - */ - onNewDraft?: () => void; - /** Trigger a fresh PR creation from the current contract state. */ - onCreatePR?: () => void; - /** Manually advance the DAG (find newly-ready steps). */ - onAdvance?: () => void; - /** Run the cleanup task to prune merged/stale steps. */ - onCleanup?: () => void; - /** Pick up linked orders (queue them as new steps). */ - onPickUpOrders?: () => void; -} - -export function DirectiveContextMenu({ - x, - y, - directive, - onClose, - onStart, - onPause, - onArchive, - onDelete, - onGoToPR, - onNewDraft, - onCreatePR, - onAdvance, - onCleanup, - onPickUpOrders, -}: DirectiveContextMenuProps) { - const menuRef = useRef<HTMLDivElement>(null); - - // Close on click outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [onClose]); - - // Adjust position if menu would overflow viewport - useEffect(() => { - if (menuRef.current) { - const rect = menuRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (rect.right > viewportWidth) { - menuRef.current.style.left = `${x - rect.width}px`; - } - if (rect.bottom > viewportHeight) { - menuRef.current.style.top = `${y - rect.height}px`; - } - } - }, [x, y]); - - const menuItemClass = - "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; - const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; - - const showStart = directive.status === "draft" || directive.status === "paused" || directive.status === "idle"; - const showPause = directive.status === "active"; - const showArchive = directive.status !== "archived"; - const showGoToPR = !!directive.prUrl; - // "New draft" appears once the contract is inactive (its iteration has - // shipped) — that's the explicit affordance for starting the next cycle - // on a clean slate while keeping prior revisions as history. - const showNewDraft = !!onNewDraft && directive.status === "inactive"; - - return ( - <div - ref={menuRef} - className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" - style={{ left: x, top: y }} - > - {/* Header showing directive title */} - <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[200px]"> - {directive.title} - </div> - - {/* New draft — the canonical action on an inactive (shipped) contract. */} - {showNewDraft && ( - <> - <button - className={menuItemClass} - onClick={() => { - onNewDraft?.(); - onClose(); - }} - > - <span className="text-emerald-300">+</span> - New draft - </button> - <div className={dividerClass} /> - </> - )} - - {/* Status actions */} - {showStart && ( - <button - className={menuItemClass} - onClick={() => { - onStart(); - onClose(); - }} - > - <span className="text-[#75aafc]">▶</span> - Start - </button> - )} - - {showPause && ( - <button - className={menuItemClass} - onClick={() => { - onPause(); - onClose(); - }} - > - <span className="text-[#75aafc]">❚❚</span> - Pause - </button> - )} - - {showArchive && ( - <button - className={menuItemClass} - onClick={() => { - onArchive(); - onClose(); - }} - > - <span className="text-[#75aafc]">▣</span> - Archive - </button> - )} - - {/* Orchestration actions — Advance / Pick up orders / Cleanup. */} - {(onAdvance || onPickUpOrders || onCleanup) && ( - <div className={dividerClass} /> - )} - {onAdvance && ( - <button - className={menuItemClass} - onClick={() => { - onAdvance(); - onClose(); - }} - > - <span className="text-[#75aafc]">»</span> - Advance DAG - </button> - )} - {onPickUpOrders && ( - <button - className={menuItemClass} - onClick={() => { - onPickUpOrders(); - onClose(); - }} - > - <span className="text-[#c084fc]">◆</span> - Plan orders - </button> - )} - {onCleanup && ( - <button - className={menuItemClass} - onClick={() => { - onCleanup(); - onClose(); - }} - > - <span className="text-[#75aafc]">⎚</span> - Clean up - </button> - )} - - {/* PR actions — Create / Update / Go to PR. */} - {(onCreatePR || showGoToPR) && <div className={dividerClass} />} - {onCreatePR && ( - <button - className={menuItemClass} - onClick={() => { - onCreatePR(); - onClose(); - }} - > - <span className="text-emerald-300">↗</span> - {directive.prUrl ? "Update PR" : "Create PR"} - </button> - )} - {showGoToPR && ( - <button - className={menuItemClass} - onClick={() => { - onGoToPR(); - onClose(); - }} - > - <span className="text-[#75aafc]">↗</span> - Go to PR - </button> - )} - - <div className={dividerClass} /> - - {/* Delete action */} - <button - className={`${menuItemClass} text-red-400 hover:bg-red-400/10`} - onClick={() => { - onDelete(); - onClose(); - }} - > - <span className="text-red-400">✕</span> - Delete - </button> - </div> - ); -} diff --git a/makima/frontend/src/components/directives/DocumentTaskStream.tsx b/makima/frontend/src/components/directives/DocumentTaskStream.tsx deleted file mode 100644 index b718ae4..0000000 --- a/makima/frontend/src/components/directives/DocumentTaskStream.tsx +++ /dev/null @@ -1,516 +0,0 @@ -/** - * DocumentTaskStream — renders a running task's output as a flowing document - * (assistant prose, tool blocks) instead of the boxy log style of TaskOutput. - * - * Key differences from TaskOutput: - * - Document typography (serif-ish paragraphs, not monospace logs). - * - Interleaved with subtle marginalia for tool calls and results. - * - 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, 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; -} - -// ============================================================================= -// 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 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. Render the cache - // immediately if we have it; refetch in the background regardless. - useEffect(() => { - let cancelled = false; - const cached = entriesCache.get(taskId); - if (cached) { - setEntries(cached); - setLoading(false); - } else { - setEntries([]); - setLoading(true); - } - setIsStreaming(false); - - getTaskOutput(taskId) - .then((res) => { - if (cancelled) return; - const mapped: TaskOutputEvent[] = res.entries.map((e) => ({ - taskId: e.taskId, - messageType: e.messageType, - content: e.content, - toolName: e.toolName, - toolInput: e.toolInput, - isError: e.isError, - costUsd: e.costUsd, - durationMs: e.durationMs, - isPartial: false, - })); - entriesCache.set(taskId, mapped); - setEntries(mapped); - }) - .catch((err) => { - if (cancelled) return; - // eslint-disable-next-line no-console - console.error("Failed to load task output history:", err); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [taskId]); - - 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 === "interrupted" || - event.status === "merged" || - event.status === "done" - ) { - setIsStreaming(false); - } else if (event.status === "running") { - setIsStreaming(true); - } - }, []); - - useTaskSubscription({ - taskId, - subscribeOutput: true, - onOutput: handleOutput, - onUpdate: handleUpdate, - }); - - // 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 (!loading && containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - autoScrollRef.current = true; - setShowResumeScroll(false); - } - }, [loading, taskId]); - - const handleScroll = useCallback(() => { - if (!containerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - const atBottom = distanceFromBottom < 80; - autoScrollRef.current = atBottom; - setShowResumeScroll(!atBottom); - }, []); - - const submitComment = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = comment.trim(); - if (!trimmed || sending) return; - setSending(true); - setSendError(null); - // Show the comment immediately as a user-input entry. - 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(""); - } catch (err) { - setSendError( - err instanceof Error ? err.message : "Failed to send comment", - ); - window.setTimeout(() => setSendError(null), 5000); - } finally { - setSending(false); - } - }, - [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] 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 pb-32 text-[#dbe7ff]"> - <div className="flex items-center gap-3 mb-1"> - <h1 className="text-[24px] font-medium text-white tracking-tight"> - {label} - </h1> - {isStreaming && ( - <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase"> - <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> - Live - </span> - )} - </div> - <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide mb-8"> - Live transcript — comments below are sent to the task as input. - </p> - - {loading && entries.length === 0 ? ( - <p className="text-[#556677] font-mono text-xs italic"> - Loading transcript… - </p> - ) : entries.length === 0 ? ( - <p className="text-[#556677] font-mono text-xs italic"> - {isStreaming ? "Waiting for output…" : "No output yet."} - </p> - ) : ( - <div className="space-y-4"> - {entries.map((entry, idx) => ( - <DocumentEntry key={idx} entry={entry} /> - ))} - {isStreaming && ( - <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse align-baseline" /> - )} - </div> - )} - </div> - </div> - - {/* "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} - </div> - )} - <form - onSubmit={submitComment} - 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 - </span> - <textarea - value={comment} - onChange={(e) => setComment(e.target.value)} - onKeyDown={(e) => { - // ⌘/Ctrl-Enter submits. - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - void submitComment(e as unknown as React.FormEvent); - } - }} - placeholder={ - isStreaming - ? "Add a comment to interrupt and redirect…" - : "Task is not streaming — comments will queue if accepted." - } - rows={2} - disabled={sending} - className="flex-1 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" - /> - <button - type="submit" - disabled={sending || !comment.trim()} - className="px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed shrink-0" - > - {sending ? "Sending…" : "Send"} - </button> - </form> - </div> - </div> - ); -} - -// --------------------------------------------------------------------------- -// Entry rendering — document-style, not log-style. -// --------------------------------------------------------------------------- - -function DocumentEntry({ entry }: { entry: TaskOutputEvent }) { - switch (entry.messageType) { - case "user_input": - return ( - <blockquote className="border-l-2 border-cyan-400/60 pl-4 py-1 italic text-cyan-200"> - <span className="not-italic text-[10px] font-mono text-cyan-400 uppercase tracking-wide block mb-1"> - You - </span> - {entry.content} - </blockquote> - ); - - case "assistant": - return ( - <div className="leading-relaxed text-[14px]"> - <SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" /> - </div> - ); - - case "system": - return ( - <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide"> - {entry.content} - </p> - ); - - case "tool_use": - return ( - <p className="text-[11px] font-mono text-[#7788aa] flex items-center gap-2"> - <span className="text-yellow-500">·</span> - <span className="text-[#75aafc]">{entry.toolName || "tool"}</span> - {firstLineOfInput(entry.toolInput) && ( - <span className="text-[#445566] truncate"> - {firstLineOfInput(entry.toolInput)} - </span> - )} - </p> - ); - - case "tool_result": - if (!entry.content) return null; - return ( - <p className="text-[11px] font-mono pl-4"> - <span className={entry.isError ? "text-red-400" : "text-emerald-400"}> - {entry.isError ? "✗" : "→"} - </span>{" "} - <span className="text-[#7788aa]"> - {entry.content.split("\n")[0]} - {entry.content.includes("\n") && "…"} - </span> - </p> - ); - - case "result": - return ( - <div className="border-t border-[rgba(117,170,252,0.15)] pt-3 mt-6"> - <p className="text-[10px] font-mono text-emerald-400 uppercase tracking-wide mb-2"> - Result - </p> - <div className="leading-relaxed text-[13px]"> - <SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" /> - </div> - {(entry.costUsd !== undefined || entry.durationMs !== undefined) && ( - <p className="text-[10px] font-mono text-[#556677] mt-2"> - {entry.durationMs !== undefined && - `Duration: ${(entry.durationMs / 1000).toFixed(1)}s`} - {entry.costUsd !== undefined && entry.durationMs !== undefined && " · "} - {entry.costUsd !== undefined && - `Cost: $${entry.costUsd.toFixed(4)}`} - </p> - )} - </div> - ); - - case "error": - return ( - <p className="border-l-2 border-red-400/60 pl-4 py-1 text-red-300 text-[13px]"> - {entry.content} - </p> - ); - - default: - // Fall back to a quiet rendering for unknown message types so users - // still see the data, just inconspicuously. - if (!entry.content) return null; - return ( - <p className="text-[11px] font-mono text-[#556677]"> - {entry.content} - </p> - ); - } -} - -/** 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. - for (const key of ["command", "file_path", "path", "url", "pattern", "query"]) { - const v = input[key]; - if (typeof v === "string" && v.length > 0) { - return v.split("\n")[0].slice(0, 96); - } - } - return ""; -} diff --git a/makima/frontend/src/components/directives/TaskPage.tsx b/makima/frontend/src/components/directives/TaskPage.tsx new file mode 100644 index 0000000..41b4418 --- /dev/null +++ b/makima/frontend/src/components/directives/TaskPage.tsx @@ -0,0 +1,651 @@ +/** + * TaskPage — the right-pane view for a selected task in document mode. + * + * Layout (replaces the old single-column DocumentTaskStream): + * + * ┌───────────────────────────────────────────────────────────┐ + * │ Header: title · status · branch · Stop │ + * ├────────────────────────┬──────────────────────────────────┤ + * │ Changed files (~30%) │ Transcript feed (scrollable) │ + * │ src/foo.rs │ [user] do thing │ + * │ src/bar.rs │ [tool] Read foo.rs │ + * │ (selected file diff) │ │ + * │ + added │ │ + * │ - removed │ │ + * │ ├──────────────────────────────────┤ + * │ │ Composer (sticky bottom) │ + * └────────────────────────┴──────────────────────────────────┘ + * + * Diff data comes from getTaskDiff(); we parse it with parseDiff (reused + * from OverlayDiffViewer) so we don't duplicate the parser. The file + * list on the left is the parsed file paths; selecting one filters the + * diff render to that single file. Refresh button re-fetches on demand; + * by default the diff loads once on mount + after each task status + * change so the user sees fresh changes as soon as the daemon commits. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SimpleMarkdown } from "../SimpleMarkdown"; +import { + useTaskSubscription, + type TaskOutputEvent, +} from "../../hooks/useTaskSubscription"; +import { getTaskOutput, getTaskDiff, sendTaskMessage, stopTask } from "../../lib/api"; +import { parseDiff, DiffFileView, type DiffFile } from "../mesh/OverlayDiffViewer"; + +interface TaskPageProps { + taskId: string; + /** Human label for the task header (e.g. "orchestrator", step name). */ + label: string; + /** True for tasks spawned via the directive's `+ New ephemeral task` + * action (no backing step). Drives the optional "Merge to base" + * affordance — step-spawned tasks merge via the directive's PR. */ + ephemeral?: boolean; + /** Current task status — drives whether merge button is enabled. */ + status?: string; +} + +// Module-level caches so navigation between tasks is instant on re-visit. +const entriesCache = new Map<string, TaskOutputEvent[]>(); +const diffCache = new Map<string, string>(); + +export function TaskPage({ taskId, label, ephemeral, status }: TaskPageProps) { + // ---- Transcript state (mirrors the old DocumentTaskStream) ---- + 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 transcriptRef = useRef<HTMLDivElement>(null); + const composerRef = useRef<HTMLDivElement>(null); + const autoScrollRef = useRef(true); + const [showResumeScroll, setShowResumeScroll] = useState(false); + + // ---- Diff state ---- + const [diffText, setDiffText] = useState<string>(() => diffCache.get(taskId) ?? ""); + const [diffLoading, setDiffLoading] = useState(false); + const [diffError, setDiffError] = useState<string | null>(null); + const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null); + const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set()); + + // Reset both panes when the task id changes. + useEffect(() => { + setSelectedFilePath(null); + setCollapsedFiles(new Set()); + setDiffText(diffCache.get(taskId) ?? ""); + }, [taskId]); + + // ---- Load historical transcript on task change. ---- + useEffect(() => { + let cancelled = false; + const cached = entriesCache.get(taskId); + if (cached) { + setEntries(cached); + setLoading(false); + } else { + setEntries([]); + setLoading(true); + } + setIsStreaming(false); + + getTaskOutput(taskId) + .then((res) => { + if (cancelled) return; + const mapped: TaskOutputEvent[] = res.entries.map((e) => ({ + taskId: e.taskId, + messageType: e.messageType, + content: e.content, + toolName: e.toolName, + toolInput: e.toolInput, + isError: e.isError, + costUsd: e.costUsd, + durationMs: e.durationMs, + isPartial: false, + })); + entriesCache.set(taskId, mapped); + setEntries(mapped); + }) + .catch((err) => { + if (cancelled) return; + // eslint-disable-next-line no-console + console.error("Failed to load task output history:", err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [taskId]); + + // ---- Load diff on task change + after each task status update. ---- + const refreshDiff = useCallback(async () => { + setDiffLoading(true); + setDiffError(null); + try { + const res = await getTaskDiff(taskId); + if (res.success && res.diff !== null) { + setDiffText(res.diff); + diffCache.set(taskId, res.diff); + } else if (res.error) { + setDiffError(res.error); + } else { + setDiffText(""); + diffCache.set(taskId, ""); + } + } catch (e) { + setDiffError(e instanceof Error ? e.message : "Failed to load diff"); + } finally { + setDiffLoading(false); + } + }, [taskId]); + + useEffect(() => { + void refreshDiff(); + }, [refreshDiff]); + + // ---- Live subscription. ---- + 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 }) => { + const terminal = [ + "completed", + "failed", + "cancelled", + "interrupted", + "merged", + "done", + ]; + if (terminal.includes(event.status)) { + setIsStreaming(false); + // Daemon may have written final commits — refresh the diff. + void refreshDiff(); + } else if (event.status === "running") { + setIsStreaming(true); + } + }, + [refreshDiff], + ); + + useTaskSubscription({ + taskId, + subscribeOutput: true, + onOutput: handleOutput, + onUpdate: handleUpdate, + }); + + // ---- Auto-scroll (transcript pane). ---- + useEffect(() => { + if (autoScrollRef.current && transcriptRef.current) { + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; + } + }, [entries]); + + useEffect(() => { + if (!loading && transcriptRef.current) { + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; + autoScrollRef.current = true; + setShowResumeScroll(false); + } + }, [loading, taskId]); + + const handleScroll = useCallback(() => { + if (!transcriptRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = transcriptRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const atBottom = distanceFromBottom < 80; + autoScrollRef.current = atBottom; + setShowResumeScroll(!atBottom); + }, []); + + const resumeScroll = useCallback(() => { + if (!transcriptRef.current) return; + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; + autoScrollRef.current = true; + setShowResumeScroll(false); + }, []); + + // ---- Composer + stop. ---- + const submitComment = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = comment.trim(); + if (!trimmed || sending) return; + setSending(true); + setSendError(null); + 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(""); + } catch (err) { + setSendError(err instanceof Error ? err.message : "Failed to send comment"); + window.setTimeout(() => setSendError(null), 5000); + } finally { + setSending(false); + } + }, + [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(); + }, []); + + // ---- Parse diff once, derive file list. ---- + const parsedFiles = useMemo(() => parseDiff(diffText), [diffText]); + + // Default selection: first file in the parsed list. + useEffect(() => { + if (!selectedFilePath && parsedFiles.length > 0) { + setSelectedFilePath(parsedFiles[0].path); + } + }, [parsedFiles, selectedFilePath]); + + const visibleFiles: DiffFile[] = useMemo(() => { + if (!selectedFilePath) return parsedFiles; + const match = parsedFiles.find((f) => f.path === selectedFilePath); + return match ? [match] : parsedFiles; + }, [parsedFiles, selectedFilePath]); + + const toggleFile = (path: string) => { + setCollapsedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return ( + <div className="flex-1 flex flex-col h-full overflow-hidden bg-[#0a1628]"> + {/* Header strip — title + live indicator + actions. */} + <div className="shrink-0 flex items-center gap-3 px-6 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)] bg-[#091428]"> + <h1 className="text-[14px] font-medium text-white tracking-tight"> + {label} + </h1> + {isStreaming && ( + <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + Live + </span> + )} + {status && ( + <span className="font-mono text-[10px] uppercase tracking-wide text-[#7788aa]"> + {status} + </span> + )} + <div className="ml-auto flex items-center gap-2"> + <button + type="button" + onClick={focusComposer} + className="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> + {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 standalone task page where the merge runs.", + ); + if (!ok) return; + window.open(`/exec/${taskId}#merge`, "_blank"); + }} + title="Manual merge — opens the merge UI on the standalone 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> + )} + </div> + </div> + + {/* Two-column body. */} + <div className="flex-1 flex min-h-0 overflow-hidden"> + {/* Left: changed files + diff. */} + <div className="w-[40%] min-w-[320px] max-w-[640px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] flex flex-col bg-[#0a1628] overflow-hidden"> + <div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.15)]"> + <span className="text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> + Changed files + </span> + <span className="text-[10px] font-mono text-[#556677]"> + {parsedFiles.length} + </span> + <button + type="button" + onClick={() => void refreshDiff()} + className="ml-auto text-[10px] font-mono text-[#9bc3ff] hover:text-white" + title="Refresh diff" + > + ↻ + </button> + </div> + {/* File list (top half of left pane). */} + <div className="overflow-y-auto max-h-[40%] border-b border-dashed border-[rgba(117,170,252,0.15)]"> + {parsedFiles.length === 0 ? ( + <div className="px-3 py-3 font-mono text-[10px] italic text-[#556677]"> + {diffLoading + ? "Loading diff…" + : diffError + ? diffError + : "No changes yet"} + </div> + ) : ( + parsedFiles.map((f) => ( + <button + key={f.path} + type="button" + onClick={() => setSelectedFilePath(f.path)} + className={`w-full text-left flex items-center gap-2 px-3 py-1 font-mono text-[11px] ${ + selectedFilePath === f.path + ? "bg-[rgba(117,170,252,0.12)] text-white" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)]" + }`} + > + <span className={fileStatusClass(f.status)}> + {fileStatusInitial(f.status)} + </span> + <span className="truncate flex-1">{f.path}</span> + <span className="font-mono text-[9px]"> + {f.additions > 0 && ( + <span className="text-green-400 mr-1">+{f.additions}</span> + )} + {f.deletions > 0 && ( + <span className="text-red-400">-{f.deletions}</span> + )} + </span> + </button> + )) + )} + </div> + {/* Diff content (bottom of left pane). */} + <div className="flex-1 overflow-y-auto p-2"> + {visibleFiles.length === 0 ? ( + <div className="px-2 py-3 font-mono text-[10px] italic text-[#556677]"> + Select a file to view its diff. + </div> + ) : ( + visibleFiles.map((file) => ( + <DiffFileView + key={file.path} + file={file} + collapsed={collapsedFiles.has(file.path)} + onToggle={() => toggleFile(file.path)} + /> + )) + )} + </div> + </div> + + {/* Right: transcript + sticky composer. */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden relative"> + <div + ref={transcriptRef} + onScroll={handleScroll} + className="flex-1 overflow-y-auto" + > + <div className="max-w-3xl mx-auto px-6 py-6 pb-32 text-[#dbe7ff]"> + {loading && entries.length === 0 ? ( + <p className="text-[#556677] font-mono text-xs italic"> + Loading transcript… + </p> + ) : entries.length === 0 ? ( + <p className="text-[#556677] font-mono text-xs italic"> + {isStreaming ? "Waiting for output…" : "No output yet."} + </p> + ) : ( + <div className="space-y-4"> + {entries.map((entry, idx) => ( + <FeedEntry key={idx} entry={entry} /> + ))} + {isStreaming && ( + <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse align-baseline" /> + )} + </div> + )} + </div> + </div> + + {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> + )} + + <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} + </div> + )} + <form + onSubmit={submitComment} + className="max-w-3xl mx-auto px-6 py-3 flex items-start gap-3" + > + <span className="text-[10px] font-mono text-[#556677] uppercase tracking-wide pt-2 shrink-0"> + Comment + </span> + <textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + void submitComment(e as unknown as React.FormEvent); + } + }} + placeholder={ + isStreaming + ? "Add a comment to interrupt and redirect…" + : "Task is not streaming — comments will queue if accepted." + } + rows={2} + disabled={sending} + className="flex-1 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" + /> + <button + type="submit" + disabled={sending || !comment.trim()} + className="px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-emerald-300 border border-emerald-700/60 hover:border-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed shrink-0" + > + {sending ? "Sending…" : "Send"} + </button> + </form> + </div> + </div> + </div> + </div> + ); +} + +// --------------------------------------------------------------------------- +// Feed entry rendering — document-style, not log-style. Mirrors the old +// DocumentTaskStream's DocumentEntry exactly (the user liked that part). +// --------------------------------------------------------------------------- + +function FeedEntry({ entry }: { entry: TaskOutputEvent }) { + switch (entry.messageType) { + case "user_input": + return ( + <blockquote className="border-l-2 border-cyan-400/60 pl-4 py-1 italic text-cyan-200"> + <span className="not-italic text-[10px] font-mono text-cyan-400 uppercase tracking-wide block mb-1"> + You + </span> + {entry.content} + </blockquote> + ); + + case "assistant": + return ( + <div className="leading-relaxed text-[14px]"> + <SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" /> + </div> + ); + + case "system": + return ( + <p className="text-[10px] font-mono text-[#556677] uppercase tracking-wide"> + {entry.content} + </p> + ); + + case "tool_use": + return ( + <p className="text-[11px] font-mono text-[#7788aa] flex items-center gap-2"> + <span className="text-yellow-500">·</span> + <span className="text-[#75aafc]">{entry.toolName || "tool"}</span> + {firstLineOfInput(entry.toolInput) && ( + <span className="text-[#445566] truncate"> + {firstLineOfInput(entry.toolInput)} + </span> + )} + </p> + ); + + case "tool_result": + if (!entry.content) return null; + return ( + <p className="text-[11px] font-mono pl-4"> + <span className={entry.isError ? "text-red-400" : "text-emerald-400"}> + {entry.isError ? "✗" : "→"} + </span>{" "} + <span className="text-[#7788aa]"> + {entry.content.split("\n")[0]} + {entry.content.includes("\n") && "…"} + </span> + </p> + ); + + case "result": + return ( + <div className="border-t border-[rgba(117,170,252,0.15)] pt-3 mt-6"> + <p className="text-[10px] font-mono text-emerald-400 uppercase tracking-wide mb-2"> + Result + </p> + <div className="leading-relaxed text-[13px]"> + <SimpleMarkdown content={entry.content} className="text-[#e0eaf8]" /> + </div> + {(entry.costUsd !== undefined || entry.durationMs !== undefined) && ( + <p className="text-[10px] font-mono text-[#556677] mt-2"> + {entry.durationMs !== undefined && + `Duration: ${(entry.durationMs / 1000).toFixed(1)}s`} + {entry.costUsd !== undefined && entry.durationMs !== undefined && " · "} + {entry.costUsd !== undefined && + `Cost: $${entry.costUsd.toFixed(4)}`} + </p> + )} + </div> + ); + + case "error": + return ( + <p className="border-l-2 border-red-400/60 pl-4 py-1 text-red-300 text-[13px]"> + {entry.content} + </p> + ); + + default: + if (!entry.content) return null; + return ( + <p className="text-[11px] font-mono text-[#556677]"> + {entry.content} + </p> + ); + } +} + +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 ""; + for (const key of ["command", "file_path", "path", "url", "pattern", "query"]) { + const v = input[key]; + if (typeof v === "string" && v.length > 0) { + return v.split("\n")[0].slice(0, 96); + } + } + return ""; +} + +function fileStatusClass(status: DiffFile["status"]): string { + switch (status) { + case "added": + return "text-green-400 bg-green-400/10 px-1 py-0.5 text-[9px] font-bold"; + case "deleted": + return "text-red-400 bg-red-400/10 px-1 py-0.5 text-[9px] font-bold"; + case "renamed": + return "text-purple-400 bg-purple-400/10 px-1 py-0.5 text-[9px] font-bold"; + case "modified": + default: + return "text-yellow-400 bg-yellow-400/10 px-1 py-0.5 text-[9px] font-bold"; + } +} + +function fileStatusInitial(status: DiffFile["status"]): string { + switch (status) { + case "added": + return "A"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "modified": + default: + return "M"; + } +} diff --git a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx index 74059a0..704ec80 100644 --- a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx +++ b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx @@ -1,13 +1,13 @@ import { useState, useMemo } from "react"; -interface DiffLine { +export interface DiffLine { type: "add" | "remove" | "context" | "header" | "hunk"; content: string; oldLineNumber?: number; newLineNumber?: number; } -interface DiffFile { +export interface DiffFile { path: string; status: "added" | "modified" | "deleted" | "renamed"; oldPath?: string; // For renames @@ -25,7 +25,7 @@ interface OverlayDiffViewerProps { title?: string; } -function parseDiff(diffText: string): DiffFile[] { +export function parseDiff(diffText: string): DiffFile[] { if (!diffText.trim()) return []; const files: DiffFile[] = []; @@ -140,7 +140,7 @@ function parseDiff(diffText: string): DiffFile[] { return files; } -function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) { +export function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) { const statusColors: Record<DiffFile["status"], string> = { added: "text-green-400 bg-green-400/10", modified: "text-yellow-400 bg-yellow-400/10", diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index a4ec4db..b34f786 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3703,7 +3703,7 @@ export async function reorderDirectiveContract( async function postContractAction( contractId: string, - action: "start" | "pause" | "complete" | "unlock", + action: "start" | "pause" | "complete" | "unlock" | "reopen", body?: object, ): Promise<DirectiveContract> { const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/${action}`, { @@ -3743,6 +3743,14 @@ export async function unlockDirectiveContract(contractId: string): Promise<Direc return postContractAction(contractId, "unlock"); } +/** Reopen a shipped contract for amendment. Backend flips the contract + * to `active`, re-activates the directive, and clears the prior PR + * linkage so the orchestrator replans against the contract body with + * an amendment framing. */ +export async function reopenDirectiveContract(contractId: string): Promise<DirectiveContract> { + return postContractAction(contractId, "reopen"); +} + /** Steps and tasks attached to a single contract. Drives the per-contract * `tasks/` subfolder in the sidebar — when the contract ships, its * tasks visually move with it. */ diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 479dcd8..801e397 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -20,8 +20,8 @@ import { listDirectiveContractTasks as listContractTasks, startDirectiveContract, pauseDirectiveContract, - completeDirectiveContract, unlockDirectiveContract, + reopenDirectiveContract, reorderDirectiveContract, createDirectiveTask, startDirective, @@ -32,9 +32,11 @@ import { advanceDirective, cleanupDirective, pickUpOrders, + stopTask, + skipDirectiveStep, } from "../lib/api"; -import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu"; -import { DocumentTaskStream } from "../components/directives/DocumentTaskStream"; +import { SidebarContextMenu, type ContextMenuItem } from "../components/SidebarContextMenu"; +import { TaskPage } from "../components/directives/TaskPage"; // Status dot color, matching the existing tabular UI's badge palette so the // document mode feels like a sibling of the existing list, not a foreign UI. @@ -182,11 +184,12 @@ interface DirectiveFolderProps { onHeaderClick: () => void; selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: Contract) => void; - onCreateDocument: (directive: DirectiveSummary) => Promise<void>; - /** Open the inline "+ New ephemeral task" form for this directive. */ - onCreateEphemeralTask: (directive: DirectiveSummary) => void; - /** Right-click handler — opens DirectiveContextMenu with start/pause/PR/etc. */ - onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void; + /** Right-click handler — opens the generic context menu with items + * built for the given entity type. */ + onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void; + onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void; + onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; + onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void; /** Click handler for task/step rows — navigates to the live transcript. */ onSelectTask: (directiveId: string, taskId: string) => void; /** @@ -204,9 +207,10 @@ function DirectiveFolder({ onHeaderClick, selection, onSelectDocument, - onCreateDocument, - onCreateEphemeralTask, - onContextMenu, + onContextMenuDirective, + onContextMenuContract, + onContextMenuStep, + onContextMenuTask, onSelectTask, refreshNonce, }: DirectiveFolderProps) { @@ -226,9 +230,6 @@ function DirectiveFolder({ // shipped/ subfolder open state — independent of the directive folder. const [shippedOpen, setShippedOpen] = useState(false); - // Whether a "+ New document" call is in flight (disables the button). - const [creating, setCreating] = useState(false); - const refresh = useCallback(async () => { setDocsLoading(true); setDocsError(null); @@ -271,18 +272,6 @@ function DirectiveFolder({ // so it can rename itself to `tasks - <contract name>/` for clarity. const multipleContracts = activeDocs.length + shippedDocs.length > 1; - const handleCreate = useCallback(async () => { - if (creating) return; - setCreating(true); - try { - await onCreateDocument(directive); - // Refresh after creating so the new doc appears in the list. - await refresh(); - } finally { - setCreating(false); - } - }, [creating, onCreateDocument, directive, refresh]); - // Selection helpers — used to highlight the currently-selected doc row. const selectedDocumentId = selection && selection.directiveId === directive.id @@ -318,16 +307,29 @@ function DirectiveFolder({ {/* Directive folder header. Status is shown as a colored dot on the RIGHT (per the user's spec — flat list, no per-status grouping). Right-click opens the context menu (start / pause / archive / - delete / create-PR / update-PR / etc.). */} - <button - type="button" + delete / create-PR / update-PR / etc.). + + Row is a div with onClick (not a <button>) so we can nest a + real `+` button inside that surfaces on hover for quick + contract / ephemeral-task creation without leaving the file + tree. */} + <div + role="button" + tabIndex={0} onClick={() => { onToggle(); onHeaderClick(); }} - onContextMenu={(e) => onContextMenu(e, directive)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + onHeaderClick(); + } + }} + onContextMenu={(e) => onContextMenuDirective(e, directive)} title={`${directive.title} — ${directive.status}`} - 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.06)]" + className="group 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.06)] cursor-pointer" > <Caret open={open} /> <FolderIcon open={open} /> @@ -337,6 +339,23 @@ function DirectiveFolder({ : directive.id.slice(0, 8)} / </span> + {/* Hover-only quick-add affordance — replaces the old inline + "+ New document" / "+ New ephemeral task" buttons that used + to sit inside the folder body. Builds the same items array + the directive context menu would, so right-click and `+` + stay in sync. */} + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + onContextMenuDirective(e, directive); + }} + className="opacity-0 group-hover:opacity-100 text-[12px] leading-none text-emerald-300 hover:text-white px-1" + title="New contract / ephemeral task" + aria-label="New contract or ephemeral task" + > + + + </button> {orchestratorRunning && ( <span className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse" @@ -350,7 +369,7 @@ function DirectiveFolder({ aria-label={`status: ${directive.status}`} title={`status: ${directive.status}`} /> - </button> + </div> {/* Folder body — rendered only when open */} {open && ( @@ -369,36 +388,16 @@ function DirectiveFolder({ {/* Active group */} {docs && ( <> - {/* + New document affordance — sits at the top of the active list - so the user can always reach it without scrolling past - existing docs. */} - <button - type="button" - onClick={handleCreate} - disabled={creating} - className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-emerald-400 hover:bg-[rgba(74,222,128,0.06)] disabled:opacity-50" - title="Create a new document under this directive" - > - <span className="text-[12px] leading-none">+</span> - <span>New document</span> - </button> - - {/* + New ephemeral task — sibling affordance for spawning a - one-off task under this directive that's NOT part of the - DAG. Useful for sidebar scratch work, debugging, etc. */} - <button - type="button" - onClick={() => onCreateEphemeralTask(directive)} - className="w-full flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] text-[#c084fc] hover:bg-[rgba(192,132,252,0.06)]" - title="Spawn a one-off ephemeral task under this directive" - > - <span className="text-[12px] leading-none">+</span> - <span>New ephemeral task</span> - </button> + {/* "New contract" / "New ephemeral task" used to be rendered + here as inline buttons. They're now reachable via the + directive folder's right-click context menu and the `+` + hover-button on the directive header (see DirectiveFolder + row above). Keeping the file tree free of action rows + makes the hierarchy easier to scan. */} {activeDocs.length === 0 && !docsLoading && ( <div className="pl-14 pr-3 py-1 font-mono text-[10px] text-[#556677] italic"> - no active documents + no contracts yet — right-click to add </div> )} @@ -413,6 +412,7 @@ function DirectiveFolder({ directive={directive} selected={doc.id === selectedDocumentId} onSelect={() => onSelectDocument(directive.id, doc)} + onContextMenu={onContextMenuContract} draggable onDragStart={() => setDragId(doc.id)} onDragEnd={() => { @@ -438,6 +438,8 @@ function DirectiveFolder({ refreshNonce={refreshNonce} selectedTaskId={selectedTaskIdForFolder} onSelectTask={onSelectTask} + onContextMenuStep={onContextMenuStep} + onContextMenuTask={onContextMenuTask} contractLabel={fileLabel(doc, directive)} multipleContracts={multipleContracts} /> @@ -474,6 +476,7 @@ function DirectiveFolder({ directive={directive} selected={doc.id === selectedDocumentId} onSelect={() => onSelectDocument(directive.id, doc)} + onContextMenu={onContextMenuContract} indent="deep" /> <DocumentTasksFolder @@ -484,6 +487,8 @@ function DirectiveFolder({ refreshNonce={refreshNonce} selectedTaskId={selectedTaskIdForFolder} onSelectTask={onSelectTask} + onContextMenuStep={onContextMenuStep} + onContextMenuTask={onContextMenuTask} contractLabel={fileLabel(doc, directive)} multipleContracts={multipleContracts} /> @@ -509,6 +514,7 @@ interface DocumentRowProps { directive: DirectiveSummary; selected: boolean; onSelect: () => void; + onContextMenu: (e: React.MouseEvent, contract: Contract) => void; indent?: "normal" | "deep"; // ----- Drag-to-reorder props (optional — only wired by the active list) --- /** Whether this row participates in HTML5 drag (active docs only). */ @@ -527,6 +533,7 @@ function DocumentRow({ directive, selected, onSelect, + onContextMenu, indent = "normal", draggable = false, onDragStart, @@ -549,6 +556,7 @@ function DocumentRow({ <button type="button" onClick={onSelect} + onContextMenu={(e) => onContextMenu(e, doc)} title={name} draggable={draggable} onDragStart={(e) => { @@ -636,6 +644,8 @@ interface DocumentTasksFolderProps { selectedTaskId: string | null; /** Click handler for step/task rows — navigates to the live transcript. */ onSelectTask: (directiveId: string, taskId: string) => void; + onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; + onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void; /** Human-readable contract label (already resolved via fileLabel). Used to * disambiguate multiple tasks/ folders under the same directive. */ contractLabel: string; @@ -653,6 +663,8 @@ function DocumentTasksFolder({ refreshNonce, selectedTaskId, onSelectTask, + onContextMenuStep, + onContextMenuTask, contractLabel, multipleContracts, }: DocumentTasksFolderProps) { @@ -688,7 +700,20 @@ function DocumentTasksFolder({ void refresh(); }, [open, refresh, refreshNonce]); - const total = (data?.steps.length ?? 0) + (data?.tasks.length ?? 0); + // De-duplicate: a step that has spawned a task appears in `steps[]` + // with step.taskId === task.id, and the same task ALSO appears in + // `tasks[]`. Rendering both produces a double-row + double-highlight + // when the user clicks. Keep the step row (it carries the step name, + // which is the meaningful label) and drop the matching task row. + const stepTaskIds = useMemo( + () => new Set((data?.steps ?? []).map((s) => s.taskId).filter((id): id is string => !!id)), + [data], + ); + const ephemeralTasks = useMemo( + () => (data?.tasks ?? []).filter((t) => !stepTaskIds.has(t.id)), + [data, stepTaskIds], + ); + const total = (data?.steps.length ?? 0) + ephemeralTasks.length; // Folder always renders (even when empty) so the user can click into a // fresh contract's tasks/ folder and see it stay visible. The empty state @@ -742,9 +767,10 @@ function DocumentTasksFolder({ selected={!!selectedTaskId && step.taskId === selectedTaskId} padLeft={rowPadLeft} onSelect={onSelectTask} + onContextMenu={onContextMenuStep} /> ))} - {data?.tasks.map((task) => ( + {ephemeralTasks.map((task) => ( <TaskRow key={`task-${task.id}`} task={task} @@ -752,6 +778,7 @@ function DocumentTasksFolder({ selected={task.id === selectedTaskId} padLeft={rowPadLeft} onSelect={onSelectTask} + onContextMenu={onContextMenuTask} /> ))} </div> @@ -790,6 +817,7 @@ interface StepRowProps { selected: boolean; padLeft: string; onSelect: (directiveId: string, taskId: string) => void; + onContextMenu: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; } function StepRow({ @@ -798,6 +826,7 @@ function StepRow({ selected, padLeft, onSelect, + onContextMenu, }: StepRowProps) { const dot = STEP_STATUS_DOT[step.status] ?? "bg-[#556677]"; // Steps without an underlying task can't be opened — the executor @@ -811,6 +840,7 @@ function StepRow({ type="button" disabled={!clickable} onClick={() => clickable && onSelect(directiveId, taskId!)} + onContextMenu={(e) => onContextMenu(e, step, directiveId)} title={ clickable ? `${step.name} (${step.status})` @@ -844,6 +874,7 @@ interface TaskRowProps { selected: boolean; padLeft: string; onSelect: (directiveId: string, taskId: string) => void; + onContextMenu: (e: React.MouseEvent, task: Task, directiveId: string) => void; } function TaskRow({ @@ -852,6 +883,7 @@ function TaskRow({ selected, padLeft, onSelect, + onContextMenu, }: TaskRowProps) { const dot = TASK_STATUS_DOT[task.status] ?? "bg-[#556677]"; // Supervisor tasks get a small "sup" tag so the user can spot @@ -861,6 +893,7 @@ function TaskRow({ <button type="button" onClick={() => onSelect(directiveId, task.id)} + onContextMenu={(e) => onContextMenu(e, task, directiveId)} title={`${task.name} (${task.status})`} className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${ selected @@ -892,10 +925,14 @@ interface SidebarProps { selection: SidebarSelection | null; onSelectDocument: (directiveId: string, doc: Contract) => void; onSelectDirective: (directiveId: string) => void; - onCreateDocument: (directive: DirectiveSummary) => Promise<void>; + /** Top-level "Contracts" header `+ New` button — opens the + * NewContractModal to create a brand-new directive (along with its + * first contract). */ onCreateContract: () => void; - onCreateEphemeralTask: (directive: DirectiveSummary) => void; - onContextMenu: (e: React.MouseEvent, directive: DirectiveSummary) => void; + onContextMenuDirective: (e: React.MouseEvent, directive: DirectiveSummary) => void; + onContextMenuContract: (e: React.MouseEvent, contract: Contract) => void; + onContextMenuStep: (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => void; + onContextMenuTask: (e: React.MouseEvent, task: Task, directiveId: string) => void; onSelectTask: (directiveId: string, taskId: string) => void; refreshNonce: number; } @@ -906,10 +943,11 @@ function DocumentSidebar({ selection, onSelectDocument, onSelectDirective, - onCreateDocument, onCreateContract, - onCreateEphemeralTask, - onContextMenu, + onContextMenuDirective, + onContextMenuContract, + onContextMenuStep, + onContextMenuTask, onSelectTask, refreshNonce, }: SidebarProps) { @@ -997,9 +1035,10 @@ function DocumentSidebar({ onHeaderClick={() => onSelectDirective(d.id)} selection={selection} onSelectDocument={onSelectDocument} - onCreateDocument={onCreateDocument} - onCreateEphemeralTask={onCreateEphemeralTask} - onContextMenu={onContextMenu} + onContextMenuDirective={onContextMenuDirective} + onContextMenuContract={onContextMenuContract} + onContextMenuStep={onContextMenuStep} + onContextMenuTask={onContextMenuTask} onSelectTask={onSelectTask} refreshNonce={refreshNonce} /> @@ -1043,7 +1082,7 @@ function ContractHeader({ docTitle, onContractChanged, }: ContractHeaderProps) { - const [busy, setBusy] = useState<null | "start" | "pause" | "complete" | "unlock" | "merge_mode">( + const [busy, setBusy] = useState<null | "start" | "pause" | "unlock" | "reopen" | "merge_mode">( null, ); const [error, setError] = useState<string | null>(null); @@ -1072,14 +1111,14 @@ function ContractHeader({ () => wrap("pause", () => pauseDirectiveContract(doc.id)), [doc.id, wrap], ); - const onComplete = useCallback( - () => wrap("complete", () => completeDirectiveContract(doc.id)), - [doc.id, wrap], - ); const onUnlock = useCallback( () => wrap("unlock", () => unlockDirectiveContract(doc.id)), [doc.id, wrap], ); + const onReopen = useCallback( + () => wrap("reopen", () => reopenDirectiveContract(doc.id)), + [doc.id, wrap], + ); const onMergeMode = useCallback( (mode: ContractMergeMode) => wrap("merge_mode", () => updateContract(doc.id, { mergeMode: mode })), @@ -1127,14 +1166,20 @@ function ContractHeader({ <ContractActionButton onClick={onPause} disabled={busy !== null}> {busy === "pause" ? "Pausing…" : "Pause"} </ContractActionButton> - <ContractActionButton onClick={onComplete} disabled={busy !== null} variant="primary"> - {busy === "complete" ? "Completing…" : "Mark complete"} - </ContractActionButton> <ContractActionButton onClick={onUnlock} disabled={busy !== null}> {busy === "unlock" ? "Unlocking…" : "Unlock"} </ContractActionButton> + {/* No "Mark complete" — the contract auto-ships when the + orchestrator raises a PR (server-side; see + update_directive's PR-detection branch in handlers/ + directives.rs). */} </> )} + {doc.status === "shipped" && ( + <ContractActionButton onClick={onReopen} disabled={busy !== null} variant="primary"> + {busy === "reopen" ? "Reopening…" : "Reopen for amendment"} + </ContractActionButton> + )} {/* Merge mode radios — visible always, editable only in draft/queued */} <div className="ml-auto flex items-center gap-2 text-[#7788aa]"> @@ -1352,7 +1397,7 @@ function EditorShell({ } // --- Task path: task row clicked in the sidebar ------------------------ - // Renders the live transcript via DocumentTaskStream. Selection wins over + // Renders the live transcript via TaskPage. Selection wins over // the document path when both are somehow present (defensive). if (selection?.taskId) { const taskId = selection.taskId; @@ -1386,7 +1431,7 @@ function EditorShell({ <span className="text-white">{label}</span> </div> </div> - <DocumentTaskStream + <TaskPage taskId={taskId} label={label} ephemeral={!isStepBound} @@ -1617,7 +1662,7 @@ export default function DocumentDirectivesPage() { ); // Click on a task or step row → open the live transcript pane via - // ?task=<id>. EditorShell switches to DocumentTaskStream when this is set. + // ?task=<id>. EditorShell switches to TaskPage when this is set. const handleSelectTask = useCallback( (directiveId: string, taskId: string) => { navigate(`/directives/${directiveId}?task=${taskId}`); @@ -1657,25 +1702,18 @@ export default function DocumentDirectivesPage() { [newEphemeralFor, bumpRefresh, navigate], ); - // Right-click context menu state. Right-clicking any directive header - // opens the menu; menu actions (start/pause/archive/delete/PR/etc.) hit - // the directives API and trigger a sidebar refresh on success. + // Right-click context menu — generic. The state holds whichever items + // array the entity's builder produced; the SidebarContextMenu just + // renders them. Each entity type has its own builder (directive, + // contract, step, task) hung off the page so all the action callbacks + // (start/pause/archive/delete/etc.) live in one place. const { refresh: refreshDirectiveList } = useDirectives(); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; - directive: DirectiveSummary; + items: ContextMenuItem[]; } | null>(null); - const handleContextMenu = useCallback( - (e: React.MouseEvent, directive: DirectiveSummary) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY, directive }); - }, - [], - ); - const closeContextMenu = useCallback(() => setContextMenu(null), []); const runAction = useCallback( @@ -1695,6 +1733,166 @@ export default function DocumentDirectivesPage() { [refreshDirectiveList, bumpRefresh], ); + const openMenu = useCallback( + (e: React.MouseEvent, items: ContextMenuItem[]) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, items }); + }, + [], + ); + + // ---- Per-entity menu builders. Each returns its items array; the + // handler wraps them with openMenu so each row can fire its own + // right-click with one line of wiring. ---- + const handleContextMenuDirective = useCallback( + (e: React.MouseEvent, d: DirectiveSummary) => { + const items: ContextMenuItem[] = [ + { + label: "+ New contract here", + onClick: () => { + void handleCreateDocument(d); + }, + }, + { label: "+ New ephemeral task", onClick: () => setNewEphemeralFor(d) }, + { label: "", separator: true }, + { + label: "Start", + onClick: () => runAction(() => startDirective(d.id), "Failed to start directive"), + disabled: d.status === "active" || d.status === "archived", + }, + { + label: "Pause", + onClick: () => runAction(() => pauseDirective(d.id), "Failed to pause directive"), + disabled: d.status !== "active", + }, + { label: "", separator: true }, + { + label: d.prUrl ? "Update PR" : "Create PR", + onClick: () => + runAction( + () => createDirectivePR(d.id), + d.prUrl ? "Failed to update PR" : "Failed to create PR", + ), + }, + ...(d.prUrl + ? [{ + label: "Go to PR", + onClick: () => window.open(d.prUrl!, "_blank", "noreferrer"), + } as ContextMenuItem] + : []), + { + label: "Advance DAG", + onClick: () => runAction(() => advanceDirective(d.id), "Failed to advance DAG"), + }, + { + label: "Cleanup merged steps", + onClick: () => runAction(() => cleanupDirective(d.id), "Failed to clean up"), + }, + { + label: "Pick up orders", + onClick: () => runAction(() => pickUpOrders(d.id), "Failed to pick up orders"), + }, + { label: "", separator: true }, + { + label: "Archive", + danger: true, + onClick: () => + runAction( + () => updateDirective(d.id, { status: "archived" }), + "Failed to archive", + ), + disabled: d.status === "archived", + }, + { + label: "Delete", + danger: true, + onClick: async () => { + if (!window.confirm(`Delete "${d.title}"? This cannot be undone.`)) return; + await runAction(() => deleteDirective(d.id), "Failed to delete"); + if (selection?.directiveId === d.id) navigate("/directives"); + }, + }, + ]; + openMenu(e, items); + }, + [openMenu, runAction, navigate, selection], + ); + + const handleContextMenuContract = useCallback( + (e: React.MouseEvent, c: Contract) => { + const items: ContextMenuItem[] = [ + { + label: "Lock & Start", + onClick: () => + runAction(() => startDirectiveContract(c.id), "Failed to start contract"), + disabled: c.status !== "draft", + }, + { + label: "Pause", + onClick: () => + runAction(() => pauseDirectiveContract(c.id), "Failed to pause contract"), + disabled: c.status !== "active", + }, + { + label: "Unlock", + onClick: () => + runAction(() => unlockDirectiveContract(c.id), "Failed to unlock contract"), + disabled: c.status !== "active" && c.status !== "queued", + }, + { + label: "Reopen for amendment", + onClick: () => + runAction(() => reopenDirectiveContract(c.id), "Failed to reopen contract"), + disabled: c.status !== "shipped", + }, + ]; + openMenu(e, items); + }, + [openMenu, runAction], + ); + + const handleContextMenuStep = useCallback( + (e: React.MouseEvent, step: DirectiveStep, directiveId: string) => { + const items: ContextMenuItem[] = [ + { + label: "Stop task", + onClick: () => + step.taskId + ? runAction(() => stopTask(step.taskId!), "Failed to stop task") + : undefined, + disabled: !step.taskId || step.status !== "running", + }, + { + label: "Skip step", + danger: true, + onClick: () => + runAction( + () => skipDirectiveStep(directiveId, step.id), + "Failed to skip step", + ), + disabled: step.status === "completed" || step.status === "skipped", + }, + ]; + openMenu(e, items); + }, + [openMenu, runAction], + ); + + const handleContextMenuTask = useCallback( + (e: React.MouseEvent, task: Task, _directiveId: string) => { + const items: ContextMenuItem[] = [ + { + label: "Stop task", + onClick: () => runAction(() => stopTask(task.id), "Failed to stop task"), + disabled: task.status === "done" || task.status === "failed" || task.status === "merged", + }, + ]; + openMenu(e, items); + }, + [openMenu, runAction], + ); + if (authLoading) { return ( <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> @@ -1725,10 +1923,11 @@ export default function DocumentDirectivesPage() { selection={selection} onSelectDocument={handleSelectDocument} onSelectDirective={handleSelectDirective} - onCreateDocument={handleCreateDocument} onCreateContract={() => setShowNewContract(true)} - onCreateEphemeralTask={(d) => setNewEphemeralFor(d)} - onContextMenu={handleContextMenu} + onContextMenuDirective={handleContextMenuDirective} + onContextMenuContract={handleContextMenuContract} + onContextMenuStep={handleContextMenuStep} + onContextMenuTask={handleContextMenuTask} onSelectTask={handleSelectTask} refreshNonce={refreshNonce} /> @@ -1758,80 +1957,11 @@ export default function DocumentDirectivesPage() { )} {contextMenu && ( - <DirectiveContextMenu + <SidebarContextMenu x={contextMenu.x} y={contextMenu.y} - directive={contextMenu.directive} + items={contextMenu.items} onClose={closeContextMenu} - onStart={() => - runAction( - () => startDirective(contextMenu.directive.id), - "Failed to start contract", - ) - } - onPause={() => - runAction( - () => pauseDirective(contextMenu.directive.id), - "Failed to pause contract", - ) - } - onArchive={() => - runAction( - () => - updateDirective(contextMenu.directive.id, { - status: "archived", - }), - "Failed to archive contract", - ) - } - onDelete={async () => { - if ( - !window.confirm( - `Delete "${contextMenu.directive.title}"? This cannot be undone.`, - ) - ) { - return; - } - await runAction( - () => deleteDirective(contextMenu.directive.id), - "Failed to delete contract", - ); - // If the deleted contract was selected, clear the URL. - if (selection?.directiveId === contextMenu.directive.id) { - navigate("/directives"); - } - }} - onGoToPR={() => { - if (contextMenu.directive.prUrl) { - window.open(contextMenu.directive.prUrl, "_blank", "noreferrer"); - } - }} - onCreatePR={() => - runAction( - () => createDirectivePR(contextMenu.directive.id), - contextMenu.directive.prUrl - ? "Failed to update PR" - : "Failed to create PR", - ) - } - onAdvance={() => - runAction( - () => advanceDirective(contextMenu.directive.id), - "Failed to advance DAG", - ) - } - onCleanup={() => - runAction( - () => cleanupDirective(contextMenu.directive.id), - "Failed to clean up contract", - ) - } - onPickUpOrders={() => - runAction( - () => pickUpOrders(contextMenu.directive.id), - "Failed to pick up orders", - ) - } /> )} </div> diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx index c0c7365..5ac7233 100644 --- a/makima/frontend/src/routes/tmp.tsx +++ b/makima/frontend/src/routes/tmp.tsx @@ -1,14 +1,14 @@ /** * 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. + * under the `tmp/` pseudo-folder. We render `TaskPage` 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 { TaskPage } from "../components/directives/TaskPage"; import { useAuth } from "../contexts/AuthContext"; import { getTask, type Task } from "../lib/api"; @@ -82,7 +82,7 @@ export default function TmpTaskPage() { <p className="text-red-400 font-mono text-xs">{error}</p> </div> ) : taskId ? ( - <DocumentTaskStream taskId={taskId} label={task?.name ?? taskId.slice(0, 8)} /> + <TaskPage taskId={taskId} label={task?.name ?? taskId.slice(0, 8)} /> ) : null} </main> </div> diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 20f3268..ee4b561 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6132,6 +6132,11 @@ pub async fn archive_directive_document( /// or queue it. Returns the updated row, or `Ok(None)` if the contract /// doesn't exist. Errors with `RepositoryError::Validation` if the /// contract is in any state other than `draft`. +/// +/// Side effect: if the contract enters `active`, the parent directive +/// is flipped to `active` (from `draft|paused|idle|inactive`). This is +/// what makes the orchestrator reconciler pick the directive up — its +/// gate is `directive.status = 'active' AND orchestrator_task_id IS NULL`. pub async fn start_contract( pool: &PgPool, contract_id: Uuid, @@ -6184,6 +6189,13 @@ pub async fn start_contract( .fetch_optional(&mut *tx) .await?; + // Flip the parent directive to active so the reconciler picks it up. + // Only when this contract is actually entering the active slot — a + // queued contract doesn't drive planning by itself. + if new_status == "active" { + activate_parent_directive(&mut tx, current.directive_id).await?; + } + tx.commit().await?; Ok(updated) } @@ -6234,6 +6246,16 @@ pub async fn pause_contract( // position, excluding the one we just paused). promote_next_queued_contract(&mut tx, current.directive_id).await?; + // If no contract is active after the pause+promote, pause the + // directive too — stops the reconciler from spawning new planners + // on what is now an idle directive. + deactivate_parent_directive_if_no_active( + &mut tx, + current.directive_id, + "paused", + ) + .await?; + tx.commit().await?; Ok(updated) } @@ -6289,6 +6311,17 @@ pub async fn complete_contract( promote_next_queued_contract(&mut tx, current.directive_id).await?; + // If the ship freed the active slot AND no queued contract was + // available to promote, the directive itself goes inactive — its + // iteration is shipped; the next cycle starts via reopen or a new + // contract. + deactivate_parent_directive_if_no_active( + &mut tx, + current.directive_id, + "inactive", + ) + .await?; + tx.commit().await?; Ok(updated) } @@ -6339,12 +6372,164 @@ pub async fn unlock_contract( if was_active { promote_next_queued_contract(&mut tx, current.directive_id).await?; + // If unlocking the active contract leaves no other active under + // the directive, pause the directive too. + deactivate_parent_directive_if_no_active( + &mut tx, + current.directive_id, + "paused", + ) + .await?; } tx.commit().await?; Ok(updated) } +/// Reopen a shipped contract for amendment. Flips the contract back to +/// `active`, re-activates the parent directive, and clears the +/// directive's PR linkage + orchestrator task so the reconciler spawns a +/// fresh planner. The planner uses `get_latest_merged_revision` to +/// detect the previously-shipped PR and frame the new plan as a delta. +pub async fn reopen_contract( + pool: &PgPool, + contract_id: Uuid, +) -> Result<Option<DirectiveDocument>, RepositoryError> { + let mut tx = pool.begin().await?; + + let current = sqlx::query_as::<_, DirectiveDocument>( + r#"SELECT * FROM directive_documents WHERE id = $1"#, + ) + .bind(contract_id) + .fetch_optional(&mut *tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + if current.status != "shipped" { + return Err(RepositoryError::Validation(format!( + "contract is in status '{}'; only 'shipped' contracts can be reopened", + current.status + ))); + } + + let updated = sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET status = 'active', + version = version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(contract_id) + .fetch_optional(&mut *tx) + .await?; + + // Re-activate the directive and clear the prior PR + orchestrator + // linkage. Status is forced to `active` regardless of prior value + // (except archived — guard against re-opening under an archived + // directive). + sqlx::query( + r#" + UPDATE directives + SET status = 'active', + orchestrator_task_id = NULL, + pr_url = NULL, + pr_branch = NULL, + updated_at = NOW(), + version = version + 1 + WHERE id = $1 AND status <> 'archived' + "#, + ) + .bind(current.directive_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(updated) +} + +/// Resolve the directive's currently-active contract id. Returns +/// `Ok(None)` when no active contract exists. Used by the +/// auto-complete-on-PR path so the contract row can be shipped at the +/// same moment the directive registers its PR url. +pub async fn get_active_contract_id_for_directive( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Option<Uuid>, sqlx::Error> { + let row: Option<(Uuid,)> = sqlx::query_as( + r#" + SELECT id FROM directive_documents + WHERE directive_id = $1 AND status = 'active' + ORDER BY position ASC, created_at ASC + LIMIT 1 + "#, + ) + .bind(directive_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} + +/// Flip the parent directive to `active` when a child contract just +/// became active. Only promotes from `draft|paused|idle|inactive` — +/// leaves `archived` directives untouched. +async fn activate_parent_directive( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + directive_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE directives + SET status = 'active', + updated_at = NOW(), + version = version + 1 + WHERE id = $1 + AND status IN ('draft', 'paused', 'idle', 'inactive') + "#, + ) + .bind(directive_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// After a contract lifecycle change that may have left no active +/// contract under the directive, transition the directive to the +/// supplied `new_status` (typically `'paused'` for unlock/pause flows, +/// `'inactive'` for ship). No-op if the directive still has an active +/// contract or is already past the destination state. +async fn deactivate_parent_directive_if_no_active( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + directive_id: Uuid, + new_status: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE directives + SET status = $2, + updated_at = NOW(), + version = version + 1 + WHERE id = $1 + AND status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM directive_documents + WHERE directive_id = $1 AND status = 'active' + ) + "#, + ) + .bind(directive_id) + .bind(new_status) + .execute(&mut **tx) + .await?; + Ok(()) +} + /// Find the lowest-position `queued` contract under a directive and /// flip it to `active`. No-op when no queued contract exists. /// diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 384fa23..7f90bcd 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -97,7 +97,7 @@ impl DirectiveOrchestrator { .spawn_orchestrator_task( directive.id, directive.owner_id, - format!("Plan: {}", directive.title), + "orchestrator".to_string(), plan, directive.repository_url.as_deref(), directive.base_branch.as_deref(), @@ -517,7 +517,7 @@ impl DirectiveOrchestrator { .spawn_orchestrator_task( directive.id, directive.owner_id, - format!("Re-plan: {}", directive.title), + "orchestrator (re-plan)".to_string(), plan, directive.repository_url.as_deref(), directive.base_branch.as_deref(), @@ -844,7 +844,7 @@ impl DirectiveOrchestrator { .spawn_completion_task( directive.id, directive.owner_id, - format!("PR: {}", directive.title), + "completion".to_string(), prompt, directive.repository_url.as_deref(), directive.base_branch.as_deref(), @@ -1367,9 +1367,9 @@ pub async fn trigger_completion_task( let prompt = build_completion_prompt(&directive, &contract_body, &step_tasks, &step_branches, &directive_branch, base_branch); let task_name = if directive.pr_url.is_some() { - format!("Update PR: {}", directive.title) + "completion (update)".to_string() } else { - format!("PR: {}", directive.title) + "completion".to_string() }; // Create the completion task FIRST so we have a real task ID for the FK diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs index 23081b5..ee98a61 100644 --- a/makima/src/server/handlers/directive_documents.rs +++ b/makima/src/server/handlers/directive_documents.rs @@ -680,7 +680,7 @@ pub async fn reorder_contract( /// closure. Cuts the boilerplate from start/pause/complete/unlock down /// to a couple of lines each. async fn run_contract_transition<F, Fut>( - pool: sqlx::PgPool, + state: SharedState, owner_id: Uuid, contract_id: Uuid, f: F, @@ -691,6 +691,14 @@ where Output = Result<Option<crate::db::models::DirectiveDocument>, RepositoryError>, >, { + let Some(pool) = state.db_pool.clone() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + match load_owned_document(&pool, owner_id, contract_id).await { Ok(Some(_)) => {} Ok(None) => { @@ -710,7 +718,14 @@ where } match f(pool, contract_id).await { - Ok(Some(doc)) => Json(doc).into_response(), + Ok(Some(doc)) => { + // Any successful lifecycle transition may have flipped the + // parent directive's status (see repository helpers). Wake + // the reconciler so the user doesn't wait up to 15s before + // the orchestrator daemon spawns / stops. + state.kick_directive_reconciler(); + Json(doc).into_response() + } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), @@ -759,14 +774,7 @@ pub async fn start_contract( Authenticated(auth): Authenticated, Path(document_id): Path<Uuid>, ) -> impl IntoResponse { - let Some(pool) = state.db_pool.clone() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move { + run_contract_transition(state, auth.owner_id, document_id, |pool, id| async move { repository::start_contract(&pool, id).await }) .await @@ -793,14 +801,7 @@ pub async fn pause_contract( Authenticated(auth): Authenticated, Path(document_id): Path<Uuid>, ) -> impl IntoResponse { - let Some(pool) = state.db_pool.clone() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move { + run_contract_transition(state, auth.owner_id, document_id, |pool, id| async move { repository::pause_contract(&pool, id).await }) .await @@ -829,14 +830,7 @@ pub async fn complete_contract( Path(document_id): Path<Uuid>, Json(req): Json<CompleteContractRequest>, ) -> impl IntoResponse { - let Some(pool) = state.db_pool.clone() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - run_contract_transition(pool, auth.owner_id, document_id, move |pool, id| async move { + run_contract_transition(state, auth.owner_id, document_id, move |pool, id| async move { repository::complete_contract(&pool, id, req.pr_url.as_deref(), req.pr_branch.as_deref()).await }) .await @@ -863,16 +857,39 @@ pub async fn unlock_contract( Authenticated(auth): Authenticated, Path(document_id): Path<Uuid>, ) -> impl IntoResponse { - let Some(pool) = state.db_pool.clone() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move { + run_contract_transition(state, auth.owner_id, document_id, |pool, id| async move { repository::unlock_contract(&pool, id).await }) .await .into_response() } + +/// Reopen a shipped contract for amendment. The contract goes back to +/// `active`, the parent directive flips to `active`, and the directive's +/// PR linkage + orchestrator task id are cleared so the reconciler +/// spawns a fresh planner. The planner reads +/// `get_latest_merged_revision` and frames the new plan as a delta on +/// top of the previously-merged PR. +#[utoipa::path( + post, + path = "/api/v1/contracts/{document_id}/reopen", + params(("document_id" = Uuid, Path, description = "Contract ID")), + responses( + (status = 200, description = "Contract reopened", body = crate::db::models::DirectiveDocument), + (status = 400, description = "Invalid state transition", body = ApiError), + (status = 404, description = "Not found", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn reopen_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_id): Path<Uuid>, +) -> impl IntoResponse { + run_contract_transition(state, auth.owner_id, document_id, |pool, id| async move { + repository::reopen_contract(&pool, id).await + }) + .await + .into_response() +} diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index 6d99179..35a46a0 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -232,15 +232,51 @@ pub async fn update_directive( ); } - // Transition the contract to 'inactive' now that its - // iteration is "shipped" — editing the goal again starts - // an amendment cycle, surfaced via the New draft action. - if let Err(e) = repository::set_directive_inactive(pool, directive.id).await { - tracing::warn!( - directive_id = %directive.id, - error = %e, - "Failed to mark directive inactive after PR creation" - ); + // Auto-complete the active contract — flips its status + // to `shipped`, records pr_url/pr_branch, and (via the + // contract↔directive sync in repository) transitions + // the directive itself to `inactive`. This removes the + // need for a manual "Mark complete" click; the PR + // raise IS the completion signal. + match repository::get_active_contract_id_for_directive(pool, directive.id).await { + Ok(Some(contract_id)) => { + if let Err(e) = repository::complete_contract( + pool, + contract_id, + Some(new_pr_url.as_str()), + directive.pr_branch.as_deref(), + ) + .await + { + tracing::warn!( + directive_id = %directive.id, + contract_id = %contract_id, + error = %e, + "Failed to auto-complete contract after PR creation — \ + directive status not synced; user may need to manually ship" + ); + } + } + Ok(None) => { + // No active contract — fall back to the old + // behaviour (mark directive inactive). This is + // the legacy path for directives without + // contracts attached yet. + if let Err(e) = repository::set_directive_inactive(pool, directive.id).await { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to mark directive inactive after PR creation" + ); + } + } + Err(e) => { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to resolve active contract for auto-complete" + ); + } } } } diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 604caea..a6c7787 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -256,6 +256,10 @@ pub fn make_router(state: SharedState) -> Router { "/contracts/{document_id}/unlock", post(directive_documents::unlock_contract), ) + .route( + "/contracts/{document_id}/reopen", + post(directive_documents::reopen_contract), + ) .route( "/contracts/{document_id}/tasks", get(directive_documents::list_document_tasks), diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 437285f..5bbd0fe 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -127,6 +127,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directive_documents::pause_contract, directive_documents::complete_contract, directive_documents::unlock_contract, + directive_documents::reopen_contract, directive_documents::list_document_tasks, // Order endpoints orders::list_orders, -- cgit v1.2.3