diff options
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> ); } |
