summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/DocumentEditor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx266
1 files changed, 221 insertions, 45 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
index 40fccf1..d953d45 100644
--- a/makima/frontend/src/components/directives/DocumentEditor.tsx
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -49,8 +49,34 @@ import {
// Constants
// =============================================================================
-const SAVE_COUNTDOWN_MS = 3000;
+/**
+ * Time between the user's last keystroke and the goal being persisted (which
+ * triggers the orchestrator to (re)plan). Longer when the directive is fresh
+ * so the user can think; shorter when the orchestrator is already running and
+ * we want changes to flow through quickly.
+ */
+const COUNTDOWN_FRESH_MS = 60_000;
+const COUNTDOWN_RUNNING_MS = 10_000;
+/** The countdown bar only appears once we're inside this many ms from firing. */
+const BAR_VISIBLE_MS = 10_000;
const SAVED_TOAST_MS = 1200;
+/** Debounce for writing the in-progress draft to localStorage (no backend hit). */
+const DRAFT_PERSIST_DEBOUNCE_MS = 250;
+const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`;
+const LIVE_START_KEY = "makima:liveStartEnabled";
+
+function isLiveStartEnabled(): boolean {
+ if (typeof window === "undefined") return true;
+ const raw = window.localStorage.getItem(LIVE_START_KEY);
+ // Default: live start ON — preserves existing behaviour for users who never
+ // touch the toggle.
+ return raw === null ? true : raw === "true";
+}
+
+function setLiveStartEnabled(value: boolean) {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(LIVE_START_KEY, value ? "true" : "false");
+}
// =============================================================================
// Editor theme — minimal, just enough so the rich-text plugin has something to
@@ -85,8 +111,10 @@ const editorTheme = {
*/
function SeedContentPlugin({
directive,
+ onDraftRestored,
}: {
directive: DirectiveWithSteps;
+ onDraftRestored: (draft: string) => void;
}) {
const [editor] = useLexicalComposerContext();
const seededIdRef = useRef<string | null>(null);
@@ -95,6 +123,22 @@ function SeedContentPlugin({
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;
+ let restoredDraft: string | null = null;
+ try {
+ const stored = window.localStorage.getItem(DRAFT_KEY(directive.id));
+ if (stored !== null && stored !== directive.goal) {
+ initialGoal = stored;
+ restoredDraft = stored;
+ }
+ } catch {
+ /* localStorage may be unavailable; fall back to persisted goal */
+ }
+
editor.update(
() => {
const root = $getRoot();
@@ -107,8 +151,8 @@ function SeedContentPlugin({
// Paragraph: goal (editable).
const goalPara = $createParagraphNode();
- if (directive.goal.length > 0) {
- goalPara.append($createTextNode(directive.goal));
+ if (initialGoal.length > 0) {
+ goalPara.append($createTextNode(initialGoal));
}
root.append(goalPara);
@@ -121,7 +165,13 @@ function SeedContentPlugin({
},
{ tag: "history-merge" },
);
- }, [editor, directive.id, directive.title, directive.goal]);
+
+ if (restoredDraft !== null) {
+ // Defer so the parent's state update lands AFTER the editor's seeded
+ // content (avoids the GoalChangePlugin firing first and double-tracking).
+ queueMicrotask(() => onDraftRestored(restoredDraft!));
+ }
+ }, [editor, directive.id, directive.title, directive.goal, onDraftRestored]);
return null;
}
@@ -340,19 +390,36 @@ function EditorContextMenu({
// =============================================================================
interface SaveCountdownBarProps {
- state: "idle" | "pending" | "saving" | "saved" | "error";
+ state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
remainingMs: number;
- totalMs: number;
+ liveStart: boolean;
+ orchestratorRunning: boolean;
+ onSaveNow: () => void;
onCancel: () => void;
+ onToggleLiveStart: (next: boolean) => void;
}
function SaveCountdownBar({
state,
remainingMs,
- totalMs,
+ liveStart,
+ orchestratorRunning,
+ onSaveNow,
onCancel,
+ onToggleLiveStart,
}: SaveCountdownBarProps) {
- if (state === "idle") return null;
+ // Visibility rules:
+ // - Always show when actually saving / saved / error (transient feedback).
+ // - Show when "dirty" if live-start is OFF (user must trigger save).
+ // - Show when "pending" only inside the last BAR_VISIBLE_MS so the user
+ // does not feel rushed during the long fresh countdown.
+ const visible =
+ state === "saving" ||
+ state === "saved" ||
+ state === "error" ||
+ (state === "dirty" && !liveStart) ||
+ (state === "pending" && remainingMs <= BAR_VISIBLE_MS);
+ if (!visible) return null;
let label: string;
let progressPct = 0;
@@ -360,8 +427,18 @@ function SaveCountdownBar({
if (state === "pending") {
const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
- label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`;
- progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100));
+ label = orchestratorRunning
+ ? `Replanning in ${seconds}s — Esc/Undo cancels.`
+ : `Saving goal in ${seconds}s — Esc/Undo cancels.`;
+ progressPct = Math.max(
+ 0,
+ Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100),
+ );
+ } else if (state === "dirty") {
+ label = orchestratorRunning
+ ? "Unsaved changes — saving will replan the directive."
+ : "Unsaved changes.";
+ progressPct = 0;
} else if (state === "saving") {
label = "Saving…";
progressPct = 100;
@@ -387,15 +464,36 @@ function SaveCountdownBar({
style={{ width: `${progressPct}%` }}
/>
</div>
- <div className="flex items-center justify-between px-4 py-1.5">
- <span className="text-[10px] font-mono">{label}</span>
- {state === "pending" && (
+ <div className="flex items-center gap-3 px-4 py-1.5">
+ <span className="text-[10px] font-mono flex-1 truncate">{label}</span>
+
+ {/* Live-start toggle is always shown so users can flip it from the bar. */}
+ <label className="flex items-center gap-1.5 text-[10px] font-mono text-[#7788aa] cursor-pointer select-none shrink-0">
+ <input
+ type="checkbox"
+ checked={liveStart}
+ onChange={(e) => onToggleLiveStart(e.target.checked)}
+ className="accent-[#75aafc]"
+ />
+ <span>Live start</span>
+ </label>
+
+ {(state === "dirty" || state === "pending") && (
+ <button
+ type="button"
+ onClick={onSaveNow}
+ className="text-[10px] font-mono text-emerald-300 hover:text-white border border-emerald-700/60 rounded px-2 py-0.5 shrink-0"
+ >
+ Save now
+ </button>
+ )}
+ {(state === "dirty" || state === "pending") && (
<button
type="button"
onClick={onCancel}
- className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5 shrink-0"
>
- Cancel
+ Discard
</button>
)}
</div>
@@ -415,7 +513,7 @@ export interface DocumentEditorProps {
onPickUpOrders: () => Promise<unknown> | unknown;
}
-type SaveState = "idle" | "pending" | "saving" | "saved" | "error";
+type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error";
export function DocumentEditor({
directive,
@@ -442,45 +540,66 @@ export function DocumentEditor({
[directive.id],
);
+ // ---- Live-start setting (localStorage-backed) -------------------------
+ const [liveStart, setLiveStartState] = useState<boolean>(isLiveStartEnabled);
+ const toggleLiveStart = useCallback((next: boolean) => {
+ setLiveStartEnabled(next);
+ setLiveStartState(next);
+ }, []);
+
// ---- Goal auto-save state machine --------------------------------------
+ const orchestratorRunning =
+ !!directive.orchestratorTaskId || !!directive.completionTaskId;
+ // Pick the right countdown based on whether we'd be restarting work.
+ const countdownMs = orchestratorRunning ? COUNTDOWN_RUNNING_MS : COUNTDOWN_FRESH_MS;
+
const [saveState, setSaveState] = useState<SaveState>("idle");
- const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS);
+ const [remainingMs, setRemainingMs] = useState(countdownMs);
const pendingGoalRef = useRef<string>(directive.goal);
const timerRef = useRef<number | null>(null);
const tickRef = useRef<number | null>(null);
const deadlineRef = useRef<number>(0);
+ const draftDebounceRef = useRef<number | null>(null);
const editorRef = useRef<LexicalEditor | null>(null);
+ function cancelTimers() {
+ if (timerRef.current != null) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ if (tickRef.current != null) {
+ window.clearInterval(tickRef.current);
+ tickRef.current = null;
+ }
+ }
+
// Reset state when switching directives.
useEffect(() => {
pendingGoalRef.current = directive.goal;
cancelTimers();
setSaveState("idle");
- setRemainingMs(SAVE_COUNTDOWN_MS);
+ setRemainingMs(countdownMs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [directive.id]);
// If the persisted goal updated externally and matches the pending goal,
// settle the bar.
useEffect(() => {
- if (saveState === "pending" && pendingGoalRef.current === directive.goal) {
+ if (
+ (saveState === "pending" || saveState === "dirty") &&
+ pendingGoalRef.current === directive.goal
+ ) {
cancelTimers();
setSaveState("idle");
+ try {
+ window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ } catch {
+ /* localStorage may be unavailable; ignore */
+ }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [directive.goal]);
- function cancelTimers() {
- if (timerRef.current != null) {
- window.clearTimeout(timerRef.current);
- timerRef.current = null;
- }
- if (tickRef.current != null) {
- window.clearInterval(tickRef.current);
- tickRef.current = null;
- }
- }
-
const fireSave = useCallback(async () => {
const next = pendingGoalRef.current;
cancelTimers();
@@ -488,6 +607,11 @@ export function DocumentEditor({
try {
await onUpdateGoal(next);
setSaveState("saved");
+ try {
+ window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ } catch {
+ /* ignore */
+ }
window.setTimeout(() => {
// Only fade if no new edit has reopened a pending state in the meantime.
setSaveState((s) => (s === "saved" ? "idle" : s));
@@ -500,13 +624,13 @@ export function DocumentEditor({
setSaveState((s) => (s === "error" ? "idle" : s));
}, 2500);
}
- }, [onUpdateGoal]);
+ }, [onUpdateGoal, directive.id]);
const startOrExtendCountdown = useCallback(() => {
cancelTimers();
- deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS;
+ deadlineRef.current = Date.now() + countdownMs;
setSaveState("pending");
- setRemainingMs(SAVE_COUNTDOWN_MS);
+ setRemainingMs(countdownMs);
tickRef.current = window.setInterval(() => {
const remaining = Math.max(0, deadlineRef.current - Date.now());
setRemainingMs(remaining);
@@ -514,18 +638,23 @@ export function DocumentEditor({
window.clearInterval(tickRef.current);
tickRef.current = null;
}
- }, 100);
+ }, 200);
timerRef.current = window.setTimeout(() => {
void fireSave();
- }, SAVE_COUNTDOWN_MS);
- }, [fireSave]);
+ }, countdownMs);
+ }, [fireSave, countdownMs]);
const cancelCountdown = useCallback(() => {
- if (saveState !== "pending") return;
+ if (saveState !== "pending" && saveState !== "dirty") return;
cancelTimers();
pendingGoalRef.current = directive.goal; // reset pending edit
setSaveState("idle");
- setRemainingMs(SAVE_COUNTDOWN_MS);
+ setRemainingMs(countdownMs);
+ try {
+ window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ } catch {
+ /* ignore */
+ }
// Also revert the editor's goal paragraph back to the persisted value, so
// the user sees the rollback.
const editor = editorRef.current;
@@ -543,27 +672,61 @@ export function DocumentEditor({
{ tag: "history-merge" },
);
}
- }, [directive.goal, saveState]);
+ }, [directive.goal, directive.id, saveState, countdownMs]);
// Cleanup on unmount.
useEffect(() => {
- return cancelTimers;
+ return () => {
+ cancelTimers();
+ if (draftDebounceRef.current != null) {
+ window.clearTimeout(draftDebounceRef.current);
+ draftDebounceRef.current = null;
+ }
+ };
}, []);
const handleGoalChange = useCallback(
(goal: string) => {
pendingGoalRef.current = goal;
+
+ // 1. Always persist work-in-progress to localStorage (debounced) so
+ // leaving the page does not lose typing. This is independent of
+ // whether we will trigger a backend save.
+ if (draftDebounceRef.current != null) {
+ window.clearTimeout(draftDebounceRef.current);
+ }
+ draftDebounceRef.current = window.setTimeout(() => {
+ try {
+ if (goal === directive.goal) {
+ window.localStorage.removeItem(DRAFT_KEY(directive.id));
+ } else {
+ window.localStorage.setItem(DRAFT_KEY(directive.id), goal);
+ }
+ } catch {
+ /* localStorage may be unavailable / full; ignore */
+ }
+ draftDebounceRef.current = null;
+ }, DRAFT_PERSIST_DEBOUNCE_MS);
+
+ // 2. State-machine.
if (goal === directive.goal) {
// Edit reverted — cancel the countdown (if any).
- if (saveState === "pending") {
+ if (saveState === "pending" || saveState === "dirty") {
cancelTimers();
setSaveState("idle");
}
return;
}
- startOrExtendCountdown();
+
+ if (liveStart) {
+ startOrExtendCountdown();
+ } else {
+ // Manual mode: stay "dirty" until the user clicks Save now.
+ cancelTimers();
+ setSaveState("dirty");
+ }
},
- [directive.goal, saveState, startOrExtendCountdown],
+ [directive.goal, directive.id, liveStart, saveState, startOrExtendCountdown],
);
// ---- Right-click context menu -----------------------------------------
@@ -581,7 +744,17 @@ export function DocumentEditor({
<LexicalComposer key={directive.id} initialConfig={initialConfig}>
{/* Capture the editor ref via a tiny inline plugin */}
<EditorRefCapture editorRef={editorRef} />
- <SeedContentPlugin directive={directive} />
+ <SeedContentPlugin
+ directive={directive}
+ onDraftRestored={(draft) => {
+ pendingGoalRef.current = draft;
+ if (liveStart) {
+ startOrExtendCountdown();
+ } else {
+ setSaveState("dirty");
+ }
+ }}
+ />
<ReadOnlyTitlePlugin title={directive.title} />
<HistoryPlugin />
<GoalChangePlugin onGoalChange={handleGoalChange} />
@@ -614,8 +787,11 @@ export function DocumentEditor({
<SaveCountdownBar
state={saveState}
remainingMs={remainingMs}
- totalMs={SAVE_COUNTDOWN_MS}
+ liveStart={liveStart}
+ orchestratorRunning={orchestratorRunning}
+ onSaveNow={() => void fireSave()}
onCancel={cancelCountdown}
+ onToggleLiveStart={toggleLiveStart}
/>
{menu && (