summaryrefslogtreecommitdiff
path: root/makima/frontend
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-30 15:48:26 +0100
committerGitHub <noreply@github.com>2026-04-30 15:48:26 +0100
commit2dafe938f41edbb8ceb7c6a3655c9533bb50e47d (patch)
treef72bbb7d841c4af3c5f377e845d51b34a046109a /makima/frontend
parenta2148d4e3117cdda2e1d0a8e3df289bfe04789a3 (diff)
downloadsoryu-2dafe938f41edbb8ceb7c6a3655c9533bb50e47d.tar.gz
soryu-2dafe938f41edbb8ceb7c6a3655c9533bb50e47d.zip
fix(doc-mode): autosave robustness, draft→active flip, save-now, sidebar context menus (#108)
Stage 1 of the planned doc-mode revamp — bug fixes + UX polish ahead of the larger contract-revisioning architecture work. ## Backend: 'draft' included in goal-update status flip repository::update_directive_goal previously flipped only idle/paused → active on a goal save, leaving 'draft' alone. That meant brand-new directives got their goal persisted on save but never spawned a planner — exactly the "orchestrator never runs" report. Extended the CASE clause so 'draft' also flips to 'active' on save. The status remains visible to users; this just makes the implicit "first goal save = start" behaviour work end-to-end. ## Autosave robustness (DocumentEditor.tsx) The synchronous-write fix from the previous PR was correct in principle but not visible enough for users to confirm it was working, and could still drop the very last edit on an abrupt tab close. This change: - Adds beforeunload / pagehide / visibilitychange handlers that synchronously flush pendingGoalRef → localStorage (skipping if it matches the persisted value). Backed by a persistedGoalRef that tracks directive.goal in real time so the handler doesn't capture a stale closure. - Tracks the timestamp of every successful draft write (draftSavedAt) and surfaces it as a "Draft saved Ns ago" stamp in the bar — refreshed on a 1Hz ticker so users can SEE the autosave is alive. - Logs a console.warn on localStorage write failure (was silently swallowed) so quota / storage-disabled environments are diagnosable. ## Always-visible save bar + Save now button The bar now renders in every state (was hiding when idle/pending-with-time- remaining). Idle shows a quiet "Up to date." Pending outside the last 10s shows "Unsaved changes — auto-save soon." Save now is always present; disabled only when truly idle. ## EXEC and CONTRACTS hidden in document mode NavStrip filters Contracts and Exec links when settings.documentModeEnabled is true. Those areas are subsumed by the directive-document interface; the nav strip stops surfacing them so document mode users have one canonical place to work. ## Right-click context menus on sidebar Right-clicking a directive folder header opens DirectiveContextMenu with start / pause / archive / delete / Go-to-PR — same component the legacy list page uses. Right-clicking a task row inside the tasks/ subfolder opens a smaller TaskContextMenu with Interrupt (for orchestrator/completion/ running steps) and Mark complete / failed / skipped (for step rows). Step lifecycle calls require the directive_step.id, so FolderTaskRow now carries stepId alongside taskId. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend')
-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
3 files changed, 428 insertions, 37 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>
);
}