summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/NavStrip.tsx27
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx152
-rw-r--r--makima/frontend/src/routes/document-directives.tsx286
-rw-r--r--makima/src/db/repository.rs10
4 files changed, 435 insertions, 40 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 17013ac..a6e483d 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -1,5 +1,6 @@
import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
+import { useUserSettings } from "../hooks/useUserSettings";
import { RewriteLink } from "./RewriteLink";
interface NavLink {
@@ -7,14 +8,30 @@ interface NavLink {
href: string;
requiresAuth?: boolean;
external?: boolean;
+ /**
+ * When true the link is hidden once the user has flipped on the
+ * document-mode UI — those areas (Exec, Contracts) are subsumed by the
+ * directive-document interface and surfacing them just creates noise.
+ */
+ hideInDocumentMode?: boolean;
}
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Directives", href: "/directives", requiresAuth: true },
{ label: "Orders", href: "/orders", requiresAuth: true },
- { label: "Contracts", href: "/contracts", requiresAuth: true },
- { label: "Exec", href: "/exec", requiresAuth: true },
+ {
+ label: "Contracts",
+ href: "/contracts",
+ requiresAuth: true,
+ hideInDocumentMode: true,
+ },
+ {
+ label: "Exec",
+ href: "/exec",
+ requiresAuth: true,
+ hideInDocumentMode: true,
+ },
{ label: "Daemons", href: "/daemons", requiresAuth: true },
{ label: "History", href: "/history", requiresAuth: true },
];
@@ -22,6 +39,8 @@ const NAV_LINKS: NavLink[] = [
export function NavStrip() {
const { isAuthenticated, isAuthConfigured, signOut, user } = useAuth();
const { pendingQuestions } = useSupervisorQuestions();
+ const { settings } = useUserSettings();
+ const documentMode = settings?.documentModeEnabled ?? false;
const directiveQuestionCount = pendingQuestions.filter(q => q.directiveId).length;
const handleSignOut = async () => {
@@ -41,7 +60,9 @@ export function NavStrip() {
NAV//
</span>
<div className="flex flex-wrap gap-2 items-center flex-1">
- {NAV_LINKS.map((link) => (
+ {NAV_LINKS.filter(
+ (link) => !(documentMode && link.hideInDocumentMode),
+ ).map((link) => (
<span key={link.label} className="relative inline-flex items-center">
<RewriteLink
to={link.href}
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 270f5c3..3dd8522 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -440,6 +440,23 @@ function CountdownKeyBridge({
return null;
}
+/**
+ * Render a "Draft saved Ns ago" label that ticks once per second. Returns
+ * null when the timestamp is older than 60 seconds (clutter-management).
+ */
+function useDraftFreshnessLabel(draftSavedAt: number | null): string | null {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(id);
+ }, []);
+ if (draftSavedAt == null) return null;
+ const ageSec = Math.max(0, Math.floor((now - draftSavedAt) / 1000));
+ if (ageSec > 60) return null;
+ if (ageSec < 2) return "Draft saved";
+ return `Draft saved ${ageSec}s ago`;
+}
+
// =============================================================================
// Floating formatting toolbar
//
@@ -669,6 +686,7 @@ interface SaveCountdownBarProps {
remainingMs: number;
liveStart: boolean;
orchestratorRunning: boolean;
+ draftSavedAt: number | null;
onSaveNow: () => void;
onCancel: () => void;
onToggleLiveStart: (next: boolean) => void;
@@ -679,22 +697,14 @@ function SaveCountdownBar({
remainingMs,
liveStart,
orchestratorRunning,
+ draftSavedAt,
onSaveNow,
onCancel,
onToggleLiveStart,
}: SaveCountdownBarProps) {
- // Visibility rules:
- // - Always show when actually saving / saved / error (transient feedback).
- // - Show when "dirty" if live-start is OFF (user must trigger save).
- // - Show when "pending" only inside the last BAR_VISIBLE_MS so the user
- // does not feel rushed during the long fresh countdown.
- const visible =
- state === "saving" ||
- state === "saved" ||
- state === "error" ||
- (state === "dirty" && !liveStart) ||
- (state === "pending" && remainingMs <= BAR_VISIBLE_MS);
- if (!visible) return null;
+ // The bar is now ALWAYS visible. Users explicitly asked to be able to
+ // observe save state at all times — and to have a "Save now" button they
+ // can hit without waiting for the countdown.
let label: string;
let progressPct = 0;
@@ -702,13 +712,19 @@ function SaveCountdownBar({
if (state === "pending") {
const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
- label = orchestratorRunning
- ? `Replanning in ${seconds}s — Esc/Undo cancels.`
- : `Saving goal in ${seconds}s — Esc/Undo cancels.`;
- progressPct = Math.max(
- 0,
- Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
- );
+ // Show ticking countdown in the last 10s, otherwise a quieter label.
+ if (remainingMs <= BAR_VISIBLE_MS) {
+ label = orchestratorRunning
+ ? `Replanning in ${seconds}s — Esc/Undo cancels.`
+ : `Saving in ${seconds}s — Esc/Undo cancels.`;
+ progressPct = Math.max(
+ 0,
+ Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
+ );
+ } else {
+ label = "Unsaved changes — auto-save soon.";
+ progressPct = 0;
+ }
} else if (state === "dirty") {
label = orchestratorRunning
? "Unsaved changes — saving will replan the directive."
@@ -722,12 +738,23 @@ function SaveCountdownBar({
label = "Saved";
progressPct = 100;
tone = "border-emerald-700 text-emerald-300";
- } else {
+ } else if (state === "error") {
label = "Save failed — try again.";
progressPct = 100;
tone = "border-red-700 text-red-300";
+ } else {
+ label = "Up to date.";
+ progressPct = 0;
+ tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]";
}
+ // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so
+ // the user can see drafts being captured. We only ever surface this when
+ // a write has happened in the last minute; otherwise we hide it.
+ const draftLabel = useDraftFreshnessLabel(draftSavedAt);
+
+ const dirtyish = state === "dirty" || state === "pending";
+
return (
<div
className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
@@ -742,6 +769,15 @@ function SaveCountdownBar({
<div className="flex items-center gap-3 px-4 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+ {draftLabel && (
+ <span
+ className="text-[10px] font-mono text-[#556677] shrink-0"
+ title="Drafts auto-save to this device on every keystroke"
+ >
+ {draftLabel}
+ </span>
+ )}
+
{/* Live-start toggle is always shown so users can flip it from the bar. */}
<label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0">
<input
@@ -753,16 +789,17 @@ function SaveCountdownBar({
<span>Live start</span>
</label>
- {(state === "dirty" || state === "pending") && (
- <button
- type="button"
- onClick={onSaveNow}
- className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0"
- >
- Save now
- </button>
- )}
- {(state === "dirty" || state === "pending") && (
+ {/* "Save now" is always available when there are unsaved edits, so
+ users don't have to wait for the auto-save countdown. */}
+ <button
+ type="button"
+ onClick={onSaveNow}
+ disabled={!dirtyish}
+ className="text-[10px] font-mono text-emerald-300 hover:text-white disabled:text-[#445566] disabled:cursor-not-allowed border border-emerald-700/60 disabled:border-[#1f2a3a] rounded px-2 py-0.5 shrink-0"
+ >
+ Save now
+ </button>
+ {dirtyish && (
<button
type="button"
onClick={onCancel}
@@ -831,10 +868,20 @@ export function DocumentEditor({
const [saveState, setSaveState] = useState<SaveState>("idle");
const [remainingMs, setRemainingMs] = useState(countdownMs);
const pendingGoalRef = useRef<string>(directive.goal);
+ const persistedGoalRef = useRef<string>(directive.goal);
const timerRef = useRef<number | null>(null);
const tickRef = useRef<number | null>(null);
const deadlineRef = useRef<number>(0);
const editorRef = useRef<LexicalEditor | null>(null);
+ // Timestamp of the most recent localStorage draft write — drives the
+ // "Draft saved Xs ago" indicator so users can SEE that drafts are working.
+ const [draftSavedAt, setDraftSavedAt] = useState<number | null>(null);
+
+ // Track the persisted goal in a ref so beforeunload handlers can do their
+ // own freshness comparison without a stale closure.
+ useEffect(() => {
+ persistedGoalRef.current = directive.goal;
+ }, [directive.goal]);
function cancelTimers() {
if (timerRef.current != null) {
@@ -957,6 +1004,44 @@ export function DocumentEditor({
};
}, []);
+ // Belt-and-braces draft persistence: even though we write synchronously on
+ // every keystroke, browsers can swallow the very last edit if the user hits
+ // a hard close (tab close, browser quit, mobile background) before React
+ // processes the keystroke. These handlers flush whatever is in pendingGoalRef
+ // straight to localStorage on every "we're about to be paused" signal.
+ useEffect(() => {
+ const flush = () => {
+ try {
+ const value = pendingGoalRef.current;
+ const persisted = persistedGoalRef.current;
+ const key = DRAFT_KEY(directive.id);
+ if (value === persisted) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, value);
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("[makima] flush handler failed to persist draft", err);
+ }
+ };
+ const onBeforeUnload = () => flush();
+ const onPageHide = () => flush();
+ const onVisibility = () => {
+ if (document.visibilityState === "hidden") flush();
+ };
+ window.addEventListener("beforeunload", onBeforeUnload);
+ window.addEventListener("pagehide", onPageHide);
+ document.addEventListener("visibilitychange", onVisibility);
+ return () => {
+ window.removeEventListener("beforeunload", onBeforeUnload);
+ window.removeEventListener("pagehide", onPageHide);
+ document.removeEventListener("visibilitychange", onVisibility);
+ // Final flush on React unmount (route navigation within the SPA).
+ flush();
+ };
+ }, [directive.id]);
+
const handleGoalChange = useCallback(
(goal: string) => {
pendingGoalRef.current = goal;
@@ -971,9 +1056,11 @@ export function DocumentEditor({
window.localStorage.removeItem(DRAFT_KEY(directive.id));
} else {
window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
+ setDraftSavedAt(Date.now());
}
- } catch {
- /* localStorage may be unavailable / full; ignore */
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("[makima] failed to persist draft", err);
}
// 2. State-machine.
@@ -1063,6 +1150,7 @@ export function DocumentEditor({
remainingMs={remainingMs}
liveStart={liveStart}
orchestratorRunning={orchestratorRunning}
+ draftSavedAt={draftSavedAt}
onSaveNow={() => void fireSave()}
onCancel={cancelCountdown}
onToggleLiveStart={toggleLiveStart}
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index aba3613..9cb984b 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -6,6 +6,17 @@ import { useAuth } from "../contexts/AuthContext";
import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext";
import { DocumentEditor } from "../components/directives/DocumentEditor";
import { DocumentTaskStream } from "../components/directives/DocumentTaskStream";
+import { DirectiveContextMenu } from "../components/directives/DirectiveContextMenu";
+import {
+ startDirective,
+ pauseDirective,
+ updateDirective,
+ deleteDirective,
+ completeDirectiveStep,
+ failDirectiveStep,
+ skipDirectiveStep,
+ stopTask,
+} from "../lib/api";
import type {
DirectiveStatus,
DirectiveSummary,
@@ -158,6 +169,134 @@ function Caret({ open }: { open: boolean }) {
// Sidebar
// =============================================================================
+// =============================================================================
+// Task row context menu — sits next to DirectiveContextMenu and offers the
+// task-level controls (interrupt for orchestrator/completion, complete/fail/
+// skip for step tasks).
+// =============================================================================
+
+interface TaskContextMenuProps {
+ x: number;
+ y: number;
+ task: FolderTaskRow;
+ onClose: () => void;
+ onInterrupt: () => void;
+ onComplete?: () => void;
+ onFail?: () => void;
+ onSkip?: () => void;
+}
+
+function TaskContextMenu({
+ x,
+ y,
+ task,
+ onClose,
+ onInterrupt,
+ onComplete,
+ onFail,
+ onSkip,
+}: TaskContextMenuProps) {
+ const ref = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const click = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
+ };
+ const key = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("mousedown", click);
+ document.addEventListener("keydown", key);
+ return () => {
+ document.removeEventListener("mousedown", click);
+ document.removeEventListener("keydown", key);
+ };
+ }, [onClose]);
+
+ useEffect(() => {
+ if (!ref.current) return;
+ const rect = ref.current.getBoundingClientRect();
+ if (rect.right > window.innerWidth) ref.current.style.left = `${x - rect.width}px`;
+ if (rect.bottom > window.innerHeight) ref.current.style.top = `${y - rect.height}px`;
+ }, [x, y]);
+
+ const item =
+ "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 divider = "border-t border-[rgba(117,170,252,0.2)] my-1";
+
+ // Interrupt is meaningful for live tasks (orchestrator-active or running steps).
+ const showInterrupt =
+ task.kind === "orchestrator-active" ||
+ task.kind === "completion" ||
+ task.status === "running";
+ // Step lifecycle controls only apply to step tasks.
+ const isStep = task.kind === "step";
+ const showComplete = isStep && task.status !== "done";
+ const showFail = isStep && task.status !== "failed";
+ const showSkip = isStep && task.status !== "skipped";
+
+ return (
+ <div
+ ref={ref}
+ className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
+ style={{ left: x, top: y }}
+ >
+ <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[220px]">
+ {task.kind === "orchestrator-active" ? "Orchestrator" : task.kind === "completion" ? "Completion" : task.label}
+ </div>
+ {showInterrupt && (
+ <button
+ className={item}
+ onClick={() => {
+ onInterrupt();
+ onClose();
+ }}
+ >
+ <span className="text-amber-300">⏹</span>
+ Interrupt
+ </button>
+ )}
+ {(showComplete || showFail || showSkip) && <div className={divider} />}
+ {showComplete && (
+ <button
+ className={item}
+ onClick={() => {
+ onComplete?.();
+ onClose();
+ }}
+ >
+ <span className="text-emerald-400">✓</span>
+ Mark complete
+ </button>
+ )}
+ {showFail && (
+ <button
+ className={item}
+ onClick={() => {
+ onFail?.();
+ onClose();
+ }}
+ >
+ <span className="text-red-400">✗</span>
+ Mark failed
+ </button>
+ )}
+ {showSkip && (
+ <button
+ className={item}
+ onClick={() => {
+ onSkip?.();
+ onClose();
+ }}
+ >
+ <span className="text-[#7788aa]">⤳</span>
+ Skip
+ </button>
+ )}
+ </div>
+ );
+}
+
function slugify(title: string, fallback: string): string {
const slug = title
.trim()
@@ -196,6 +335,8 @@ function DirectiveFolder({
onSelect,
pendingTaskIds,
hasPendingForDirective,
+ onDirectiveContextMenu,
+ onTaskContextMenu,
}: {
directive: DirectiveSummary;
open: boolean;
@@ -206,6 +347,8 @@ function DirectiveFolder({
pendingTaskIds: Set<string>;
/** Whether any pending question is associated with this directive. */
hasPendingForDirective: boolean;
+ onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
+ onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
}) {
const dotColor = STATUS_DOT[directive.status] ?? STATUS_DOT.draft;
const fileName = `${slugify(directive.title, directive.id.slice(0, 8))}.md`;
@@ -228,6 +371,7 @@ function DirectiveFolder({
<button
type="button"
onClick={onToggle}
+ onContextMenu={(e) => onDirectiveContextMenu(e, directive)}
title={directive.title}
className="w-full flex items-center gap-1.5 pl-3 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
>
@@ -307,6 +451,9 @@ function DirectiveFolder({
taskId: t.taskId,
})
}
+ onContextMenu={(e) =>
+ onTaskContextMenu(e, t, directive.id)
+ }
title={t.label}
className={`w-full text-left flex items-center gap-1.5 pl-14 pr-3 py-1 font-mono text-[11px] transition-colors ${
isSelected
@@ -374,6 +521,8 @@ function StatusDot({
interface FolderTaskRow {
taskId: string;
+ /** Directive step id for step kinds — needed for complete/fail/skip APIs. */
+ stepId: string | null;
label: string;
status: string;
kind: "orchestrator-active" | "completion" | "step";
@@ -392,6 +541,7 @@ function collectTasks(
if (orchestratorId) {
rows.push({
taskId: orchestratorId,
+ stepId: null,
label: "orchestrator",
status: "running",
kind: "orchestrator-active",
@@ -404,6 +554,7 @@ function collectTasks(
if (completionId) {
rows.push({
taskId: completionId,
+ stepId: null,
label: "completion",
status: "running",
kind: "completion",
@@ -416,6 +567,7 @@ function collectTasks(
if (!step.taskId) continue;
rows.push({
taskId: step.taskId,
+ stepId: step.id,
label: step.name,
status: step.status,
kind: "step",
@@ -431,9 +583,18 @@ interface SidebarProps {
loading: boolean;
selection: SidebarSelection | null;
onSelect: (sel: SidebarSelection) => void;
+ onDirectiveContextMenu: (e: React.MouseEvent, d: DirectiveSummary) => void;
+ onTaskContextMenu: (e: React.MouseEvent, t: FolderTaskRow, directiveId: string) => void;
}
-function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarProps) {
+function DocumentSidebar({
+ directives,
+ loading,
+ selection,
+ onSelect,
+ onDirectiveContextMenu,
+ onTaskContextMenu,
+}: SidebarProps) {
// Pending user questions — drives the "glow" attention ring. We split into
// two indices so the directive folder header glows whenever ANY of its
// tasks has a pending question, while individual task rows glow only for
@@ -530,6 +691,8 @@ function DocumentSidebar({ directives, loading, selection, onSelect }: SidebarPr
onSelect={onSelect}
pendingTaskIds={tasksWithPending}
hasPendingForDirective={directivesWithPending.has(d.id)}
+ onDirectiveContextMenu={onDirectiveContextMenu}
+ onTaskContextMenu={onTaskContextMenu}
/>
))
)}
@@ -667,13 +830,25 @@ function EditorShell({
// Page
// =============================================================================
+type ContextMenuState =
+ | { kind: "directive"; x: number; y: number; directive: DirectiveSummary }
+ | {
+ kind: "task";
+ x: number;
+ y: number;
+ task: FolderTaskRow;
+ directiveId: string;
+ }
+ | null;
+
export default function DocumentDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const selectedTaskId = searchParams.get("task");
- const { directives, loading: listLoading } = useDirectives();
+ const { directives, loading: listLoading, refresh: refreshList } = useDirectives();
+ const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
useEffect(() => {
if (!authLoading && isAuthConfigured && !isAuthenticated) {
@@ -697,6 +872,26 @@ export default function DocumentDirectivesPage() {
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
+ const onDirectiveContextMenu = useCallback(
+ (e: React.MouseEvent, d: DirectiveSummary) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({ kind: "directive", x: e.clientX, y: e.clientY, directive: d });
+ },
+ [],
+ );
+
+ const onTaskContextMenu = useCallback(
+ (e: React.MouseEvent, task: FolderTaskRow, directiveId: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({ kind: "task", x: e.clientX, y: e.clientY, task, directiveId });
+ },
+ [],
+ );
+
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
+
if (authLoading) {
return (
<div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
@@ -726,6 +921,8 @@ export default function DocumentDirectivesPage() {
loading={listLoading}
selection={selection}
onSelect={onSelect}
+ onDirectiveContextMenu={onDirectiveContextMenu}
+ onTaskContextMenu={onTaskContextMenu}
/>
</div>
@@ -738,6 +935,91 @@ export default function DocumentDirectivesPage() {
onClearTask={onClearTask}
/>
</main>
+
+ {/* Context menus — rendered at page level so they overlay everything. */}
+ {contextMenu?.kind === "directive" && (
+ <DirectiveContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ directive={contextMenu.directive}
+ onClose={closeContextMenu}
+ onStart={async () => {
+ await startDirective(contextMenu.directive.id);
+ await refreshList();
+ }}
+ onPause={async () => {
+ await pauseDirective(contextMenu.directive.id);
+ await refreshList();
+ }}
+ onArchive={async () => {
+ await updateDirective(contextMenu.directive.id, {
+ status: "archived",
+ });
+ await refreshList();
+ }}
+ onDelete={async () => {
+ if (
+ !window.confirm(
+ `Delete "${contextMenu.directive.title}"? This cannot be undone.`,
+ )
+ ) {
+ return;
+ }
+ await deleteDirective(contextMenu.directive.id);
+ await refreshList();
+ // If the deleted one was selected, clear selection.
+ if (selectedId === contextMenu.directive.id) {
+ navigate("/directives");
+ }
+ }}
+ onGoToPR={() => {
+ if (contextMenu.directive.prUrl) {
+ window.open(contextMenu.directive.prUrl, "_blank", "noreferrer");
+ }
+ }}
+ />
+ )}
+ {contextMenu?.kind === "task" && (
+ <TaskContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ task={contextMenu.task}
+ onClose={closeContextMenu}
+ onInterrupt={async () => {
+ try {
+ await stopTask(contextMenu.task.taskId);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error("[makima] failed to interrupt task", err);
+ }
+ await refreshList();
+ }}
+ onComplete={async () => {
+ if (!contextMenu.task.stepId) return;
+ await completeDirectiveStep(
+ contextMenu.directiveId,
+ contextMenu.task.stepId,
+ );
+ await refreshList();
+ }}
+ onFail={async () => {
+ if (!contextMenu.task.stepId) return;
+ await failDirectiveStep(
+ contextMenu.directiveId,
+ contextMenu.task.stepId,
+ );
+ await refreshList();
+ }}
+ onSkip={async () => {
+ if (!contextMenu.task.stepId) return;
+ await skipDirectiveStep(
+ contextMenu.directiveId,
+ contextMenu.task.stepId,
+ );
+ await refreshList();
+ }}
+ />
+ )}
</div>
);
}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index ca07d92..cec9a82 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -5625,8 +5625,12 @@ pub async fn check_directive_idle(
}
/// Update a directive's goal and bump goal_updated_at.
-/// Reactivates idle/paused directives and clears any stale orchestrator task
-/// so that replanning triggers on the next tick.
+/// Reactivates draft/idle/paused directives and clears any stale orchestrator
+/// task so that planning/replanning triggers on the next reconciler tick.
+///
+/// `draft` is included in the flip set because the document-mode UI treats
+/// the first goal save as the implicit "start" — without this, a brand-new
+/// directive's goal save would persist but never spawn a planner.
pub async fn update_directive_goal(
pool: &PgPool,
owner_id: Uuid,
@@ -5638,7 +5642,7 @@ pub async fn update_directive_goal(
UPDATE directives
SET goal = $3,
goal_updated_at = NOW(),
- status = CASE WHEN status IN ('idle', 'paused') THEN 'active' ELSE status END,
+ status = CASE WHEN status IN ('draft', 'idle', 'paused') THEN 'active' ELSE status END,
orchestrator_task_id = NULL,
updated_at = NOW(),
version = version + 1