diff options
| author | soryu <soryu@soryu.co> | 2026-05-08 13:43:17 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-08 13:43:17 +0100 |
| commit | e4f1622a0f0ac74707cc1c9810e0b99e948d1319 (patch) | |
| tree | f12d1e1aa25ba655966d406da58de0babc251414 /makima/frontend/src/components/directives/DocumentEditor.tsx | |
| parent | 2dda1f96a30eee2fda86be9a8a59ce5cb26dad7f (diff) | |
| download | soryu-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.tsx | 147 |
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} /> |
