summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/document-directives.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/routes/document-directives.tsx')
-rw-r--r--makima/frontend/src/routes/document-directives.tsx286
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>
);
}