diff options
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 152 |
1 files changed, 120 insertions, 32 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index 270f5c3..3dd8522 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -440,6 +440,23 @@ function CountdownKeyBridge({ return null; } +/** + * Render a "Draft saved Ns ago" label that ticks once per second. Returns + * null when the timestamp is older than 60 seconds (clutter-management). + */ +function useDraftFreshnessLabel(draftSavedAt: number | null): string | null { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + if (draftSavedAt == null) return null; + const ageSec = Math.max(0, Math.floor((now - draftSavedAt) / 1000)); + if (ageSec > 60) return null; + if (ageSec < 2) return "Draft saved"; + return `Draft saved ${ageSec}s ago`; +} + // ============================================================================= // Floating formatting toolbar // @@ -669,6 +686,7 @@ interface SaveCountdownBarProps { remainingMs: number; liveStart: boolean; orchestratorRunning: boolean; + draftSavedAt: number | null; onSaveNow: () => void; onCancel: () => void; onToggleLiveStart: (next: boolean) => void; @@ -679,22 +697,14 @@ function SaveCountdownBar({ remainingMs, liveStart, orchestratorRunning, + draftSavedAt, onSaveNow, onCancel, onToggleLiveStart, }: SaveCountdownBarProps) { - // 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; + // The bar is now ALWAYS visible. Users explicitly asked to be able to + // observe save state at all times — and to have a "Save now" button they + // can hit without waiting for the countdown. let label: string; let progressPct = 0; @@ -702,13 +712,19 @@ function SaveCountdownBar({ if (state === "pending") { const seconds = Math.max(0, Math.ceil(remainingMs / 1000)); - 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), - ); + // Show ticking countdown in the last 10s, otherwise a quieter label. + if (remainingMs <= BAR_VISIBLE_MS) { + label = orchestratorRunning + ? `Replanning in ${seconds}s — Esc/Undo cancels.` + : `Saving in ${seconds}s — Esc/Undo cancels.`; + progressPct = Math.max( + 0, + Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100), + ); + } else { + label = "Unsaved changes — auto-save soon."; + progressPct = 0; + } } else if (state === "dirty") { label = orchestratorRunning ? "Unsaved changes — saving will replan the directive." @@ -722,12 +738,23 @@ function SaveCountdownBar({ label = "Saved"; progressPct = 100; tone = "border-emerald-700 text-emerald-300"; - } else { + } else if (state === "error") { label = "Save failed — try again."; progressPct = 100; tone = "border-red-700 text-red-300"; + } else { + label = "Up to date."; + progressPct = 0; + tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]"; } + // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so + // the user can see drafts being captured. We only ever surface this when + // a write has happened in the last minute; otherwise we hide it. + const draftLabel = useDraftFreshnessLabel(draftSavedAt); + + const dirtyish = state === "dirty" || state === "pending"; + return ( <div className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`} @@ -742,6 +769,15 @@ function SaveCountdownBar({ <div className="flex items-center gap-3 px-4 py-1.5"> <span className="text-[10px] font-mono flex-1 truncate">{label}</span> + {draftLabel && ( + <span + className="text-[10px] font-mono text-[#556677] shrink-0" + title="Drafts auto-save to this device on every keystroke" + > + {draftLabel} + </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 @@ -753,16 +789,17 @@ function SaveCountdownBar({ <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") && ( + {/* "Save now" is always available when there are unsaved edits, so + users don't have to wait for the auto-save countdown. */} + <button + type="button" + onClick={onSaveNow} + disabled={!dirtyish} + className="text-[10px] font-mono text-emerald-300 hover:text-white disabled:text-[#445566] disabled:cursor-not-allowed border border-emerald-700/60 disabled:border-[#1f2a3a] rounded px-2 py-0.5 shrink-0" + > + Save now + </button> + {dirtyish && ( <button type="button" onClick={onCancel} @@ -831,10 +868,20 @@ 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 timerRef = useRef<number | null>(null); const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); const editorRef = useRef<LexicalEditor | null>(null); + // Timestamp of the most recent localStorage draft write — drives the + // "Draft saved Xs ago" indicator so users can SEE that drafts are working. + const [draftSavedAt, setDraftSavedAt] = useState<number | null>(null); + + // 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]); function cancelTimers() { if (timerRef.current != null) { @@ -957,6 +1004,44 @@ export function DocumentEditor({ }; }, []); + // Belt-and-braces draft persistence: even though we write synchronously on + // every keystroke, browsers can swallow the very last edit if the user hits + // a hard close (tab close, browser quit, mobile background) before React + // processes the keystroke. These handlers flush whatever is in pendingGoalRef + // straight to localStorage on every "we're about to be paused" signal. + useEffect(() => { + const flush = () => { + try { + const value = pendingGoalRef.current; + const persisted = persistedGoalRef.current; + const key = DRAFT_KEY(directive.id); + if (value === persisted) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, value); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[makima] flush handler failed to persist draft", err); + } + }; + const onBeforeUnload = () => flush(); + const onPageHide = () => flush(); + const onVisibility = () => { + if (document.visibilityState === "hidden") flush(); + }; + window.addEventListener("beforeunload", onBeforeUnload); + window.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + window.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onVisibility); + // Final flush on React unmount (route navigation within the SPA). + flush(); + }; + }, [directive.id]); + const handleGoalChange = useCallback( (goal: string) => { pendingGoalRef.current = goal; @@ -971,9 +1056,11 @@ export function DocumentEditor({ window.localStorage.removeItem(DRAFT_KEY(directive.id)); } else { window.localStorage.setItem(DRAFT_KEY(directive.id), goal); + setDraftSavedAt(Date.now()); } - } catch { - /* localStorage may be unavailable / full; ignore */ + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[makima] failed to persist draft", err); } // 2. State-machine. @@ -1063,6 +1150,7 @@ export function DocumentEditor({ remainingMs={remainingMs} liveStart={liveStart} orchestratorRunning={orchestratorRunning} + draftSavedAt={draftSavedAt} onSaveNow={() => void fireSave()} onCancel={cancelCountdown} onToggleLiveStart={toggleLiveStart} |
