diff options
| author | soryu <soryu@soryu.co> | 2026-04-30 15:48:26 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-30 15:48:26 +0100 |
| commit | 2dafe938f41edbb8ceb7c6a3655c9533bb50e47d (patch) | |
| tree | f72bbb7d841c4af3c5f377e845d51b34a046109a /makima/frontend/src/routes/document-directives.tsx | |
| parent | a2148d4e3117cdda2e1d0a8e3df289bfe04789a3 (diff) | |
| download | soryu-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/src/routes/document-directives.tsx')
| -rw-r--r-- | makima/frontend/src/routes/document-directives.tsx | 286 |
1 files changed, 284 insertions, 2 deletions
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> ); } |
