diff options
5 files changed, 276 insertions, 43 deletions
diff --git a/makima/frontend/src/components/PhaseConfirmationNotification.tsx b/makima/frontend/src/components/PhaseConfirmationNotification.tsx index 2681fdc..1ff4e1a 100644 --- a/makima/frontend/src/components/PhaseConfirmationNotification.tsx +++ b/makima/frontend/src/components/PhaseConfirmationNotification.tsx @@ -1,5 +1,6 @@ import { useNavigate } from "react-router"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; +import { useUserSettings } from "../hooks/useUserSettings"; import { PhaseConfirmationModal, type PhaseConfirmationData } from "./contracts/PhaseConfirmationModal"; import type { PendingQuestion } from "../lib/api"; @@ -74,6 +75,8 @@ export function PhaseConfirmationNotification() { export function PhaseConfirmationToast() { const navigate = useNavigate(); const { notificationQuestions, dismissNotification } = useSupervisorQuestions(); + const { settings } = useUserSettings(); + const documentMode = settings?.documentModeEnabled ?? false; // Filter for phase_confirmation type questions const phaseConfirmationQuestions = notificationQuestions.filter( @@ -86,7 +89,11 @@ export function PhaseConfirmationToast() { const handleGoToTask = (question: PendingQuestion) => { dismissNotification(question.questionId); - navigate(`/exec/${question.taskId}`); + if (documentMode && question.directiveId) { + navigate(`/directives/${question.directiveId}?task=${question.taskId}`); + } else { + navigate(`/exec/${question.taskId}`); + } }; return ( diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx index e62638c..9c56188 100644 --- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx +++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx @@ -1,9 +1,13 @@ import { useNavigate } from "react-router"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; +import { useUserSettings } from "../hooks/useUserSettings"; +import type { PendingQuestion } from "../lib/api"; export function SupervisorQuestionNotification() { const navigate = useNavigate(); const { notificationQuestions, dismissNotification } = useSupervisorQuestions(); + const { settings } = useUserSettings(); + const documentMode = settings?.documentModeEnabled ?? false; // Filter out contract_complete questions - they are displayed on the task page instead const filteredQuestions = notificationQuestions.filter( @@ -14,9 +18,18 @@ export function SupervisorQuestionNotification() { return null; } - const handleGoToTask = (questionId: string, taskId: string) => { - dismissNotification(questionId); - navigate(`/exec/${taskId}`); + const handleGoToTask = (q: PendingQuestion) => { + dismissNotification(q.questionId); + // In document mode, route directly to the directive folder page with + // the task selected — that's the canonical surface where the question + // is answered (same comment/interrupt UI as the task stream). Fall + // back to /exec for non-doc-mode users or for tasks not tied to a + // directive. + if (documentMode && q.directiveId) { + navigate(`/directives/${q.directiveId}?task=${q.taskId}`); + } else { + navigate(`/exec/${q.taskId}`); + } }; return ( @@ -35,7 +48,7 @@ export function SupervisorQuestionNotification() { </span> </div> <button - onClick={() => handleGoToTask(question.questionId, question.taskId)} + onClick={() => handleGoToTask(question)} className="px-3 py-1 font-mono text-xs text-amber-400 border border-amber-500/30 hover:border-amber-400/50 hover:bg-amber-900/20 transition-colors uppercase" > View Task diff --git a/makima/frontend/src/components/directives/DirectiveContextMenu.tsx b/makima/frontend/src/components/directives/DirectiveContextMenu.tsx index 3f24ce1..eda7e7f 100644 --- a/makima/frontend/src/components/directives/DirectiveContextMenu.tsx +++ b/makima/frontend/src/components/directives/DirectiveContextMenu.tsx @@ -17,6 +17,14 @@ interface DirectiveContextMenuProps { * tabular UI doesn't have to wire it up. */ onNewDraft?: () => void; + /** Trigger a fresh PR creation from the current contract state. */ + onCreatePR?: () => void; + /** Manually advance the DAG (find newly-ready steps). */ + onAdvance?: () => void; + /** Run the cleanup task to prune merged/stale steps. */ + onCleanup?: () => void; + /** Pick up linked orders (queue them as new steps). */ + onPickUpOrders?: () => void; } export function DirectiveContextMenu({ @@ -30,6 +38,10 @@ export function DirectiveContextMenu({ onDelete, onGoToPR, onNewDraft, + onCreatePR, + onAdvance, + onCleanup, + onPickUpOrders, }: DirectiveContextMenuProps) { const menuRef = useRef<HTMLDivElement>(null); @@ -153,21 +165,72 @@ export function DirectiveContextMenu({ </button> )} - {/* Go to PR link */} + {/* Orchestration actions — Advance / Pick up orders / Cleanup. */} + {(onAdvance || onPickUpOrders || onCleanup) && ( + <div className={dividerClass} /> + )} + {onAdvance && ( + <button + className={menuItemClass} + onClick={() => { + onAdvance(); + onClose(); + }} + > + <span className="text-[#75aafc]">»</span> + Advance DAG + </button> + )} + {onPickUpOrders && ( + <button + className={menuItemClass} + onClick={() => { + onPickUpOrders(); + onClose(); + }} + > + <span className="text-[#c084fc]">◆</span> + Plan orders + </button> + )} + {onCleanup && ( + <button + className={menuItemClass} + onClick={() => { + onCleanup(); + onClose(); + }} + > + <span className="text-[#75aafc]">⎚</span> + Clean up + </button> + )} + + {/* PR actions — Create / Update / Go to PR. */} + {(onCreatePR || showGoToPR) && <div className={dividerClass} />} + {onCreatePR && ( + <button + className={menuItemClass} + onClick={() => { + onCreatePR(); + onClose(); + }} + > + <span className="text-emerald-300">↗</span> + {directive.prUrl ? "Update PR" : "Create PR"} + </button> + )} {showGoToPR && ( - <> - <div className={dividerClass} /> - <button - className={menuItemClass} - onClick={() => { - onGoToPR(); - onClose(); - }} - > - <span className="text-[#75aafc]">↗</span> - Go to PR - </button> - </> + <button + className={menuItemClass} + onClick={() => { + onGoToPR(); + onClose(); + }} + > + <span className="text-[#75aafc]">↗</span> + Go to PR + </button> )} <div className={dividerClass} /> diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index 08e4d73..981cab1 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -176,6 +176,32 @@ function appendInlineMarkdownTo(parent: ElementNode, markdown: string): void { } /** + * Walk the editor root and serialise every non-decorator paragraph between + * the H1 title and the end of the document into the goal markdown. This + * captures user typing wherever it lands — the goal paragraph proper, but + * also any extra paragraphs the user inserted above or below the StepsBlock. + * Previously we read only `children[1]` and lost anything outside it. + */ +function serializeGoalFromRoot(root: ElementNode): string { + const parts: string[] = []; + const children = root.getChildren(); + for (let i = 0; i < children.length; i++) { + const node = children[i]; + // Skip the H1 title (always at index 0 by construction). + if (i === 0 && node.getType() === "heading") continue; + // Skip the StepsBlock decorator and any other non-element nodes. + if ($isStepsBlockNode(node)) continue; + if (!$isElementNode(node)) continue; + parts.push(serializeInlineMarkdown(node)); + } + // Trim trailing empties; collapse runs of >2 blank lines. + return parts + .join("\n\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\s+|\s+$/g, ""); +} + +/** * Walk the goal paragraph's children and emit inline markdown. Only TextNodes * are emitted — anything else falls back to its plain text content. We always * wrap in the most specific marker pair available so the round-trip is stable. @@ -296,20 +322,28 @@ function SeedContentPlugin({ heading.append($createTextNode(directive.title)); root.append(heading); - // Paragraph: goal (editable). The persisted goal may contain inline - // markdown — parse it into formatted TextNodes so users see their - // bold/italic/code formatting on load. - const goalPara = $createParagraphNode(); - if (initialGoal.length > 0) { - appendInlineMarkdownTo(goalPara, initialGoal); + // Goal body. The persisted goal may contain multiple paragraphs + // (separated by blank lines) and inline markdown — split by blank + // lines so each block becomes its own ParagraphNode, and parse the + // inline formatting per paragraph. Always emit at least one + // paragraph so users have a place to type even when the goal is + // empty. + const blocks = + initialGoal.length > 0 ? initialGoal.split(/\n{2,}/) : [""]; + for (const block of blocks) { + const p = $createParagraphNode(); + if (block.length > 0) { + appendInlineMarkdownTo(p, block); + } + root.append(p); } - root.append(goalPara); // Steps block (decorator — non-editable). root.append($createStepsBlockNode()); // Trailing empty paragraph so the cursor has somewhere to land below - // the steps block. + // the steps block. The trailing area is also captured as goal + // content by serializeGoalFromRoot, so any typing here is preserved. root.append($createParagraphNode()); }, { tag: "history-merge" }, @@ -388,15 +422,11 @@ function GoalChangePlugin({ ignoreSelectionChange onChange={(editorState) => { editorState.read(() => { - const root = $getRoot(); - const children = root.getChildren(); - // The goal lives at index 1 (after the H1 title). - const goalNode = children[1]; - if (!goalNode || !$isElementNode(goalNode)) return; - // Serialize the goal paragraph as INLINE MARKDOWN so bold/italic/code - // formatting round-trips through `directive.goal` (a plain TEXT - // column on the backend). - onGoalChange(serializeInlineMarkdown(goalNode)); + // Walk the WHOLE root (minus title H1 and StepsBlock decorator) so + // typing anywhere in the document body is captured. Previously we + // only read children[1] and silently discarded edits placed in the + // trailing area below the StepsBlock. + onGoalChange(serializeGoalFromRoot($getRoot())); }); }} /> @@ -873,6 +903,12 @@ export function DocumentEditor({ const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); const editorRef = useRef<LexicalEditor | null>(null); + // Tracks the most recent value the backend was asked to save. Once a poll + // confirms `directive.goal === lastSavedValueRef.current` AND + // `pendingGoalRef.current` still matches (i.e. user hasn't typed more), + // the draft is safe to drop from localStorage. Until then we keep the + // draft so an interrupted save doesn't lose user content. + const lastSavedValueRef = useRef<string | 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); @@ -922,17 +958,38 @@ export function DocumentEditor({ }, [directive.goal]); const fireSave = useCallback(async () => { - const next = pendingGoalRef.current; + // Read DIRECTLY from the editor so we don't trust pendingGoalRef alone. + // If the OnChangePlugin missed an event for any reason, this still + // captures the user's current document state. + let next = pendingGoalRef.current; + const editor = editorRef.current; + if (editor) { + editor.getEditorState().read(() => { + next = serializeGoalFromRoot($getRoot()); + }); + } + pendingGoalRef.current = next; + + // DEFENSE IN DEPTH: write the draft to localStorage BEFORE talking to + // the backend. If the save errors, the page closes mid-flight, or the + // network drops, the user's content survives in the draft. + try { + window.localStorage.setItem(DRAFT_KEY(directive.id), next); + setDraftSavedAt(Date.now()); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[makima] pre-save draft flush failed", err); + } + cancelTimers(); setSaveState("saving"); try { await onUpdateGoal(next); + lastSavedValueRef.current = next; setSaveState("saved"); - try { - window.localStorage.removeItem(DRAFT_KEY(directive.id)); - } catch { - /* ignore */ - } + // NOTE: we deliberately do NOT clear localStorage here. The roundtrip + // effect below clears it once the polled directive.goal confirms our + // save persisted AND the user hasn't kept typing past it. window.setTimeout(() => { // Only fade if no new edit has reopened a pending state in the meantime. setSaveState((s) => (s === "saved" ? "idle" : s)); @@ -943,10 +1000,30 @@ export function DocumentEditor({ setSaveState("error"); window.setTimeout(() => { setSaveState((s) => (s === "error" ? "idle" : s)); - }, 2500); + }, 4000); } }, [onUpdateGoal, directive.id]); + // Roundtrip-confirmed draft cleanup. Only drops the localStorage draft + // when the polled directive.goal matches what we just saved AND the + // user hasn't typed anything new in the meantime. Keeps the draft alive + // through every "we hit save but the page reloads before the poll lands" + // edge case. + useEffect(() => { + if ( + lastSavedValueRef.current !== null && + directive.goal === lastSavedValueRef.current && + pendingGoalRef.current === lastSavedValueRef.current + ) { + try { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } catch { + /* ignore */ + } + lastSavedValueRef.current = null; + } + }, [directive.goal, directive.id]); + const startOrExtendCountdown = useCallback(() => { cancelTimers(); deadlineRef.current = Date.now() + countdownMs; diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index d442a41..ffd2a8b 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -18,6 +18,11 @@ import { stopTask, listDirectiveRevisions, newDirectiveDraft, + createDirectivePR, + advanceDirective, + cleanupDirective, + pickUpOrders, + sendTaskMessage, } from "../lib/api"; import type { DirectiveStatus, @@ -188,6 +193,10 @@ interface TaskContextMenuProps { onComplete?: () => void; onFail?: () => void; onSkip?: () => void; + /** Send a freeform message to the running task (same wire as the inline comment box). */ + onSendMessage?: () => void; + /** Navigate to the standalone task page for full-screen control. */ + onOpenInTaskPage?: () => void; } function TaskContextMenu({ @@ -199,6 +208,8 @@ function TaskContextMenu({ onComplete, onFail, onSkip, + onSendMessage, + onOpenInTaskPage, }: TaskContextMenuProps) { const ref = useRef<HTMLDivElement>(null); @@ -297,6 +308,34 @@ function TaskContextMenu({ Skip </button> )} + + {/* Direct task-page actions: send-message and open-in-task-page mirror + what the standalone /exec/:taskId page exposes. */} + {(onSendMessage || onOpenInTaskPage) && <div className={divider} />} + {onSendMessage && ( + <button + className={item} + onClick={() => { + onSendMessage(); + onClose(); + }} + > + <span className="text-cyan-300">⌨</span> + Send message + </button> + )} + {onOpenInTaskPage && ( + <button + className={item} + onClick={() => { + onOpenInTaskPage(); + onClose(); + }} + > + <span className="text-[#75aafc]">↗</span> + Open in task page + </button> + )} </div> ); } @@ -1188,6 +1227,22 @@ export default function DocumentDirectivesPage() { // start typing the next iteration immediately. navigate(`/directives/${contextMenu.directive.id}`); }} + onCreatePR={async () => { + await createDirectivePR(contextMenu.directive.id); + await refreshList(); + }} + onAdvance={async () => { + await advanceDirective(contextMenu.directive.id); + await refreshList(); + }} + onCleanup={async () => { + await cleanupDirective(contextMenu.directive.id); + await refreshList(); + }} + onPickUpOrders={async () => { + await pickUpOrders(contextMenu.directive.id); + await refreshList(); + }} /> )} {contextMenu?.kind === "task" && ( @@ -1229,6 +1284,24 @@ export default function DocumentDirectivesPage() { ); await refreshList(); }} + onSendMessage={async () => { + // Browser prompt is the lightest-weight surface that doesn't + // require redesigning a modal. The same comment box is also + // available below the live transcript when the task is selected. + const message = window.prompt("Send message to task:"); + if (!message || !message.trim()) return; + try { + await sendTaskMessage(contextMenu.task.taskId, message.trim()); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[makima] failed to send task message", err); + } + }} + onOpenInTaskPage={() => { + // The standalone /exec/:taskId page has the full task UI with + // worktree diff viewer, checkpoint controls, etc. + navigate(`/exec/${contextMenu.task.taskId}`); + }} /> )} </div> |
