summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/PhaseConfirmationNotification.tsx9
-rw-r--r--makima/frontend/src/components/SupervisorQuestionNotification.tsx21
-rw-r--r--makima/frontend/src/components/directives/DirectiveContextMenu.tsx91
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx125
-rw-r--r--makima/frontend/src/routes/document-directives.tsx73
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>