summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/DocumentEditor.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-08 13:43:17 +0100
committerGitHub <noreply@github.com>2026-05-08 13:43:17 +0100
commite4f1622a0f0ac74707cc1c9810e0b99e948d1319 (patch)
treef12d1e1aa25ba655966d406da58de0babc251414 /makima/frontend/src/components/directives/DocumentEditor.tsx
parent2dda1f96a30eee2fda86be9a8a59ce5cb26dad7f (diff)
downloadsoryu-e4f1622a0f0ac74707cc1c9810e0b99e948d1319.tar.gz
soryu-e4f1622a0f0ac74707cc1c9810e0b99e948d1319.zip
refactor(frontend): DocumentEditor takes explicit body/title/documentId props (#131)
Stops shadowing directive.goal with the contract body via a synthesised directive object. DocumentEditor now accepts: * documentId — scopes the localStorage draft key per contract so switching contracts under the same directive doesn't clobber the other's unsaved edits. * title — the contract title rendered as the H1. * body — the contract body, used to seed the editor. * onUpdateBody (was onUpdateGoal) The `directive` prop stays for orchestrator state + embedded steps panel. document-directives.tsx drops the directiveAsDocument synthesis hack and passes body/title from the contract directly. This is the prep-work for dropping `directives.goal` from the schema — once nothing reads it, the column can be dropped in a follow-up without touching the editor. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx147
1 files changed, 88 insertions, 59 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} />