summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx147
-rw-r--r--makima/frontend/src/routes/document-directives.tsx21
2 files changed, 97 insertions, 71 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 981cab1..661665c 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -79,7 +79,14 @@ const SAVED_TOAST_MS = 1200;
* the pending timer before it could flush. localStorage.setItem on a small
* string is sub-millisecond, so debouncing was a premature optimisation.
*/
-const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`;
+/**
+ * Per-document draft key. Contracts under the same directive must not
+ * share localStorage, otherwise switching contracts would clobber the
+ * other's unsaved edits. The key is the document id; we keep the
+ * `directive-goal-draft` prefix for backwards compatibility with
+ * existing entries (the prefix string is opaque storage).
+ */
+const DRAFT_KEY = (documentId: string) => `makima:directive-goal-draft:${documentId}`;
const LIVE_START_KEY = "makima:liveStartEnabled";
// =============================================================================
@@ -87,7 +94,7 @@ const LIVE_START_KEY = "makima:liveStartEnabled";
//
// The directive goal is a single paragraph node in the editor (children[1]).
// We support inline formatting (bold, italic, underline, code, strikethrough)
-// and persist it as inline markdown in `directive.goal`. We deliberately do
+// and persist it as inline markdown in `body`. We deliberately do
// NOT handle headings, lists, or blocks here — those would change the document
// shape and the goal column is just TEXT on the backend.
//
@@ -283,33 +290,37 @@ const editorTheme = {
* happens mid-keystroke.
*/
function SeedContentPlugin({
- directive,
+ documentId,
+ title,
+ body,
onDraftRestored,
}: {
- directive: DirectiveWithSteps;
+ documentId: string;
+ title: string;
+ body: string;
onDraftRestored: (draft: string) => void;
}) {
const [editor] = useLexicalComposerContext();
const seededIdRef = useRef<string | null>(null);
useEffect(() => {
- if (seededIdRef.current === directive.id) return;
- seededIdRef.current = directive.id;
-
- // If a localStorage draft exists for this directive, prefer it over the
- // persisted goal so the user does not lose unsaved work after navigating
- // away. The parent is told about the restored draft so its state machine
- // can transition to "dirty" or "pending".
- let initialGoal = directive.goal;
+ if (seededIdRef.current === documentId) return;
+ seededIdRef.current = documentId;
+
+ // If a localStorage draft exists for this document, prefer it over
+ // the persisted body so the user does not lose unsaved work after
+ // navigating away. The parent is told about the restored draft so
+ // its state machine can transition to "dirty" or "pending".
+ let initialBody = body;
let restoredDraft: string | null = null;
try {
- const stored = window.localStorage.getItem(DRAFT_KEY(directive.id));
- if (stored !== null && stored !== directive.goal) {
- initialGoal = stored;
+ const stored = window.localStorage.getItem(DRAFT_KEY(documentId));
+ if (stored !== null && stored !== body) {
+ initialBody = stored;
restoredDraft = stored;
}
} catch {
- /* localStorage may be unavailable; fall back to persisted goal */
+ /* localStorage may be unavailable; fall back to persisted body */
}
editor.update(
@@ -319,17 +330,17 @@ function SeedContentPlugin({
// H1: title (read-only — see ReadOnlyTitlePlugin).
const heading = $createHeadingNode("h1");
- heading.append($createTextNode(directive.title));
+ heading.append($createTextNode(title));
root.append(heading);
- // Goal body. The persisted goal may contain multiple paragraphs
+ // Body content. The persisted body 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
+ // paragraph so users have a place to type even when the body is
// empty.
const blocks =
- initialGoal.length > 0 ? initialGoal.split(/\n{2,}/) : [""];
+ initialBody.length > 0 ? initialBody.split(/\n{2,}/) : [""];
for (const block of blocks) {
const p = $createParagraphNode();
if (block.length > 0) {
@@ -342,7 +353,7 @@ function SeedContentPlugin({
root.append($createStepsBlockNode());
// Trailing empty paragraph so the cursor has somewhere to land below
- // the steps block. The trailing area is also captured as goal
+ // the steps block. The trailing area is also captured as body
// content by serializeGoalFromRoot, so any typing here is preserved.
root.append($createParagraphNode());
},
@@ -354,7 +365,7 @@ function SeedContentPlugin({
// content (avoids the GoalChangePlugin firing first and double-tracking).
queueMicrotask(() => onDraftRestored(restoredDraft!));
}
- }, [editor, directive.id, directive.title, directive.goal, onDraftRestored]);
+ }, [editor, documentId, title, body, onDraftRestored]);
return null;
}
@@ -848,8 +859,21 @@ function SaveCountdownBar({
// =============================================================================
export interface DocumentEditorProps {
+ /** The parent directive, used for orchestrator state, repo metadata, and
+ * the embedded steps panel. The editor body content does NOT come from
+ * this — pass `body` and `title` explicitly so contracts under the
+ * same directive can have independent specs. */
directive: DirectiveWithSteps;
- onUpdateGoal: (goal: string) => Promise<void> | void;
+ /** Document id — scopes the localStorage draft key so per-contract
+ * drafts don't collide. */
+ documentId: string;
+ /** The contract's title, rendered as the H1 heading at the top of the
+ * editor (read-only). */
+ title: string;
+ /** The contract's body markdown, used to seed the editor on mount and
+ * whenever the document id changes. */
+ body: string;
+ onUpdateBody: (body: string) => Promise<void> | void;
onCleanup: () => Promise<void> | void;
onCreatePR: () => Promise<void> | void;
onPickUpOrders: () => Promise<unknown> | unknown;
@@ -859,7 +883,10 @@ type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
export function DocumentEditor({
directive,
- onUpdateGoal,
+ documentId,
+ title,
+ body,
+ onUpdateBody,
onCleanup,
onCreatePR,
onPickUpOrders,
@@ -870,7 +897,7 @@ export function DocumentEditor({
// Re-key the composer when the directive id changes so we get a clean
// editor state per document. We do this via the `key` prop on
// <LexicalComposer> below as well.
- namespace: `makima-doc-${directive.id}`,
+ namespace: `makima-doc-${documentId}`,
onError: (err: Error) => {
// eslint-disable-next-line no-console
console.error("[DocumentEditor]", err);
@@ -879,7 +906,7 @@ export function DocumentEditor({
theme: editorTheme,
editable: true,
}),
- [directive.id],
+ [documentId],
);
// ---- Live-start setting (localStorage-backed) -------------------------
@@ -897,14 +924,14 @@ export function DocumentEditor({
const [saveState, setSaveState] = useState<SaveState>("idle");
const [remainingMs, setRemainingMs] = useState(countdownMs);
- const pendingGoalRef = useRef<string>(directive.goal);
- const persistedGoalRef = useRef<string>(directive.goal);
+ const pendingGoalRef = useRef<string>(body);
+ const persistedGoalRef = useRef<string>(body);
const timerRef = useRef<number | null>(null);
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
+ // confirms `body === 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.
@@ -916,8 +943,8 @@ export function DocumentEditor({
// Track the persisted goal in a ref so beforeunload handlers can do their
// own freshness comparison without a stale closure.
useEffect(() => {
- persistedGoalRef.current = directive.goal;
- }, [directive.goal]);
+ persistedGoalRef.current = body;
+ }, [body]);
function cancelTimers() {
if (timerRef.current != null) {
@@ -932,30 +959,30 @@ export function DocumentEditor({
// Reset state when switching directives.
useEffect(() => {
- pendingGoalRef.current = directive.goal;
+ pendingGoalRef.current = body;
cancelTimers();
setSaveState("idle");
setRemainingMs(countdownMs);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [directive.id]);
+ }, [documentId]);
// If the persisted goal updated externally and matches the pending goal,
// settle the bar.
useEffect(() => {
if (
(saveState === "pending" || saveState === "dirty") &&
- pendingGoalRef.current === directive.goal
+ pendingGoalRef.current === body
) {
cancelTimers();
setSaveState("idle");
try {
- window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ window.localStorage.removeItem(DRAFT_KEY(documentId));
} catch {
/* localStorage may be unavailable; ignore */
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [directive.goal]);
+ }, [body]);
const fireSave = useCallback(async () => {
// Read DIRECTLY from the editor so we don't trust pendingGoalRef alone.
@@ -974,7 +1001,7 @@ export function DocumentEditor({
// 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);
+ window.localStorage.setItem(DRAFT_KEY(documentId), next);
setDraftSavedAt(Date.now());
} catch (err) {
// eslint-disable-next-line no-console
@@ -984,11 +1011,11 @@ export function DocumentEditor({
cancelTimers();
setSaveState("saving");
try {
- await onUpdateGoal(next);
+ await onUpdateBody(next);
lastSavedValueRef.current = next;
setSaveState("saved");
// NOTE: we deliberately do NOT clear localStorage here. The roundtrip
- // effect below clears it once the polled directive.goal confirms our
+ // effect below clears it once the polled body 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.
@@ -1002,27 +1029,27 @@ export function DocumentEditor({
setSaveState((s) => (s === "error" ? "idle" : s));
}, 4000);
}
- }, [onUpdateGoal, directive.id]);
+ }, [onUpdateBody, documentId]);
// Roundtrip-confirmed draft cleanup. Only drops the localStorage draft
- // when the polled directive.goal matches what we just saved AND the
+ // when the polled body 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 &&
+ body === lastSavedValueRef.current &&
pendingGoalRef.current === lastSavedValueRef.current
) {
try {
- window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ window.localStorage.removeItem(DRAFT_KEY(documentId));
} catch {
/* ignore */
}
lastSavedValueRef.current = null;
}
- }, [directive.goal, directive.id]);
+ }, [body, documentId]);
const startOrExtendCountdown = useCallback(() => {
cancelTimers();
@@ -1045,11 +1072,11 @@ export function DocumentEditor({
const cancelCountdown = useCallback(() => {
if (saveState !== "pending" && saveState !== "dirty") return;
cancelTimers();
- pendingGoalRef.current = directive.goal; // reset pending edit
+ pendingGoalRef.current = body; // reset pending edit
setSaveState("idle");
setRemainingMs(countdownMs);
try {
- window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ window.localStorage.removeItem(DRAFT_KEY(documentId));
} catch {
/* ignore */
}
@@ -1065,14 +1092,14 @@ export function DocumentEditor({
const goalNode = root.getChildren()[1];
if (!goalNode || !$isElementNode(goalNode)) return;
goalNode.getChildren().forEach((c) => c.remove());
- if (directive.goal.length > 0) {
- appendInlineMarkdownTo(goalNode, directive.goal);
+ if (body.length > 0) {
+ appendInlineMarkdownTo(goalNode, body);
}
},
{ tag: "history-merge" },
);
}
- }, [directive.goal, directive.id, saveState, countdownMs]);
+ }, [body, documentId, saveState, countdownMs]);
// Cleanup on unmount.
useEffect(() => {
@@ -1091,7 +1118,7 @@ export function DocumentEditor({
try {
const value = pendingGoalRef.current;
const persisted = persistedGoalRef.current;
- const key = DRAFT_KEY(directive.id);
+ const key = DRAFT_KEY(documentId);
if (value === persisted) {
window.localStorage.removeItem(key);
} else {
@@ -1117,7 +1144,7 @@ export function DocumentEditor({
// Final flush on React unmount (route navigation within the SPA).
flush();
};
- }, [directive.id]);
+ }, [documentId]);
const handleGoalChange = useCallback(
(goal: string) => {
@@ -1129,10 +1156,10 @@ export function DocumentEditor({
// before it flushed — losing the most recent edits exactly when
// we needed them most.
try {
- if (goal === directive.goal) {
- window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ if (goal === body) {
+ window.localStorage.removeItem(DRAFT_KEY(documentId));
} else {
- window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
+ window.localStorage.setItem(DRAFT_KEY(documentId), goal);
setDraftSavedAt(Date.now());
}
} catch (err) {
@@ -1141,7 +1168,7 @@ export function DocumentEditor({
}
// 2. State-machine.
- if (goal === directive.goal) {
+ if (goal === body) {
// Edit reverted — cancel the countdown (if any).
if (saveState === "pending" || saveState === "dirty") {
cancelTimers();
@@ -1158,7 +1185,7 @@ export function DocumentEditor({
setSaveState("dirty");
}
},
- [directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown],
+ [body, documentId, liveStart, saveState, startOrExtendCountdown],
);
// ---- Right-click context menu -----------------------------------------
@@ -1173,11 +1200,13 @@ export function DocumentEditor({
return (
<div className="flex flex-col h-full overflow-hidden">
<StepsBlockContextProvider value={{ directive }}>
- <LexicalComposer key={directive.id} initialConfig={initialConfig}>
+ <LexicalComposer key={documentId} initialConfig={initialConfig}>
{/* Capture the editor ref via a tiny inline plugin */}
<EditorRefCapture editorRef={editorRef} />
<SeedContentPlugin
- directive={directive}
+ documentId={documentId}
+ title={title}
+ body={body}
onDraftRestored={(draft) => {
pendingGoalRef.current = draft;
if (liveStart) {
@@ -1187,7 +1216,7 @@ export function DocumentEditor({
}
}}
/>
- <ReadOnlyTitlePlugin title={directive.title} />
+ <ReadOnlyTitlePlugin title={title} />
<HistoryPlugin />
<GoalChangePlugin onGoalChange={handleGoalChange} />
<CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} />
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index b89e841..63d0b96 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -1391,17 +1391,11 @@ function EditorShell({
);
}
- // Synthesise a directive-shaped object whose `goal` is the document body.
- // DocumentEditor was originally written against DirectiveWithSteps, so we
- // can keep its shape by overriding `goal` with `doc.body` and `title`
- // with the document's filename label. The steps panel still draws from
- // the real directive (passed through StepsBlockContextProvider).
+ // The contract title is the filename label; the contract body is the
+ // editor body. DocumentEditor takes these directly (no more synthesis
+ // hack) — `directive` is still passed for orchestrator state and the
+ // embedded steps panel via StepsBlockContextProvider.
const docTitle = `${fileLabel(doc, directive)}.md`;
- const directiveAsDocument = {
- ...directive,
- goal: doc.body,
- title: docTitle,
- };
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
@@ -1420,8 +1414,11 @@ function EditorShell({
// when the user switches documents, so the previous doc's body
// doesn't bleed into the new one.
key={doc.id}
- directive={directiveAsDocument}
- onUpdateGoal={onUpdateDocumentBody}
+ directive={directive}
+ documentId={doc.id}
+ title={docTitle}
+ body={doc.body}
+ onUpdateBody={onUpdateDocumentBody}
onCleanup={async () => {
await cleanup();
}}