From c8b169da8cb7eae0957e0ab5e7370b071093a224 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Tue, 28 Apr 2026 00:18:40 +0100
Subject: feat: Document UI for directive orchestration with Lexical editor
 (#93)

* WIP: heartbeat checkpoint

* feat: soryu-co/soryu - makima: Save previous goal on update and include history in re-planning prompt

* feat: soryu-co/soryu - makima: Install Lexical and create base document editor component

* feat: soryu-co/soryu - makima: Create directive file system sidebar and document layout

* feat: soryu-co/soryu - makima: Create custom Lexical step diagram block

* feat: soryu-co/soryu - makima: Add context menu and goal auto-update integration

* WIP: heartbeat checkpoint
---
 .../src/components/document/AutoSavePlugin.tsx     | 140 +++++++++++++++++++++
 1 file changed, 140 insertions(+)
 create mode 100644 frontend/src/components/document/AutoSavePlugin.tsx

(limited to 'frontend/src/components/document/AutoSavePlugin.tsx')

diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx
new file mode 100644
index 0000000..d3d0eb5
--- /dev/null
+++ b/frontend/src/components/document/AutoSavePlugin.tsx
@@ -0,0 +1,140 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
+import { UNDO_COMMAND } from 'lexical';
+
+const COUNTDOWN_DURATION_MS = 3000;
+const TICK_INTERVAL_MS = 50;
+
+interface AutoSavePluginProps {
+  onAutoSave: (content: string) => void;
+  getContent: () => string;
+  enabled?: boolean;
+}
+
+export default function AutoSavePlugin({
+  onAutoSave,
+  getContent,
+  enabled = true,
+}: AutoSavePluginProps) {
+  const [editor] = useLexicalComposerContext();
+  const [countdown, setCountdown] = useState<number | null>(null);
+  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+  const startTimeRef = useRef<number>(0);
+  const pendingContentRef = useRef<string>('');
+  const lastSavedContentRef = useRef<string>('');
+
+  const clearTimer = useCallback(() => {
+    if (timerRef.current !== null) {
+      clearInterval(timerRef.current);
+      timerRef.current = null;
+    }
+    setCountdown(null);
+  }, []);
+
+  const cancelCountdown = useCallback(() => {
+    clearTimer();
+  }, [clearTimer]);
+
+  const startCountdown = useCallback(
+    (content: string) => {
+      pendingContentRef.current = content;
+      clearTimer();
+
+      startTimeRef.current = Date.now();
+      setCountdown(COUNTDOWN_DURATION_MS);
+
+      timerRef.current = setInterval(() => {
+        const elapsed = Date.now() - startTimeRef.current;
+        const remaining = COUNTDOWN_DURATION_MS - elapsed;
+
+        if (remaining <= 0) {
+          clearTimer();
+          lastSavedContentRef.current = pendingContentRef.current;
+          onAutoSave(pendingContentRef.current);
+        } else {
+          setCountdown(remaining);
+        }
+      }, TICK_INTERVAL_MS);
+    },
+    [clearTimer, onAutoSave]
+  );
+
+  // Listen for editor updates (content changes)
+  useEffect(() => {
+    if (!enabled) return;
+
+    const unregister = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
+      // Only trigger on actual content changes
+      if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
+
+      const content = getContent();
+      if (content !== lastSavedContentRef.current) {
+        startCountdown(content);
+      }
+    });
+
+    return unregister;
+  }, [editor, enabled, getContent, startCountdown]);
+
+  // Listen for undo command to cancel countdown
+  useEffect(() => {
+    const unregister = editor.registerCommand(
+      UNDO_COMMAND,
+      () => {
+        cancelCountdown();
+        return false; // Don't prevent the undo from executing
+      },
+      1 // COMMAND_PRIORITY_LOW
+    );
+
+    return unregister;
+  }, [editor, cancelCountdown]);
+
+  // Listen for Escape key to cancel countdown
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && countdown !== null) {
+        e.preventDefault();
+        cancelCountdown();
+      }
+    };
+
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [countdown, cancelCountdown]);
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (timerRef.current !== null) {
+        clearInterval(timerRef.current);
+      }
+    };
+  }, []);
+
+  if (countdown === null) return null;
+
+  const progressPercent = (countdown / COUNTDOWN_DURATION_MS) * 100;
+  const secondsLeft = Math.ceil(countdown / 1000);
+
+  return (
+    <div className="autosave-bar">
+      <span className="autosave-bar-text">
+        Saving in {secondsLeft}s... <kbd>Esc</kbd> to cancel
+      </span>
+      <div className="autosave-bar-progress-track">
+        <div
+          className="autosave-bar-progress-fill"
+          style={{ width: `${progressPercent}%` }}
+        />
+      </div>
+      <button
+        className="autosave-bar-cancel"
+        onClick={cancelCountdown}
+        type="button"
+      >
+        Cancel
+      </button>
+    </div>
+  );
+}
-- 
cgit v1.2.3

