From a7039c587534c2e9a364939b009f71ed2f3e3c5f Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 29 Jan 2026 03:16:31 +0000 Subject: [PATCH] feat(draft): add local autosave + restore prompt + publish label --- .../frontend/src/hooks/useSpecDraft.ts | 121 ++++++++++++++++++ .../frontend/src/hooks/useSpecStore.ts | 17 +++ .../frontend/src/pages/CanvasView.tsx | 82 ++++++++++-- 3 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts diff --git a/atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts b/atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts new file mode 100644 index 00000000..c08441b8 --- /dev/null +++ b/atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts @@ -0,0 +1,121 @@ +/** + * useSpecDraft (S2 Draft + Publish) + * + * Local autosave for AtomizerSpec so users don't lose work. + * "Publish" still uses useSpecStore.saveSpec() to write atomizer_spec.json. + * + * NOTE: This is a partial S2 implementation because the current store + * still patches the backend during edits. This draft layer still provides: + * - crash/refresh protection + * - explicit restore/discard prompt + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { AtomizerSpec } from '../types/atomizer-spec'; + +const draftKey = (studyId: string) => `atomizer:draft:${studyId}`; + +type DraftPayload = { + spec: AtomizerSpec; + baseHash: string | null; + updatedAt: number; +}; + +export function useSpecDraft(params: { + studyId: string | null | undefined; + spec: AtomizerSpec | null | undefined; + serverHash: string | null | undefined; + enabled?: boolean; +}) { + const { studyId, spec, serverHash, enabled = true } = params; + + const [hasDraft, setHasDraft] = useState(false); + const [draft, setDraft] = useState(null); + + // Debounce writes + const writeTimer = useRef(null); + + const key = useMemo(() => (studyId ? draftKey(studyId) : null), [studyId]); + + const loadDraft = useCallback(() => { + if (!enabled || !key) return null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as DraftPayload; + if (!parsed?.spec) return null; + return parsed; + } catch { + return null; + } + }, [enabled, key]); + + const discardDraft = useCallback(() => { + if (!enabled || !key) return; + localStorage.removeItem(key); + setHasDraft(false); + setDraft(null); + }, [enabled, key]); + + const saveDraftNow = useCallback( + (payload: DraftPayload) => { + if (!enabled || !key) return; + try { + localStorage.setItem(key, JSON.stringify(payload)); + setHasDraft(true); + setDraft(payload); + } catch { + // ignore storage failures + } + }, + [enabled, key] + ); + + // Load draft on study change + useEffect(() => { + if (!enabled || !key) return; + const existing = loadDraft(); + if (existing) { + setHasDraft(true); + setDraft(existing); + } else { + setHasDraft(false); + setDraft(null); + } + }, [enabled, key, loadDraft]); + + // Autosave whenever spec changes + useEffect(() => { + if (!enabled || !key) return; + if (!studyId || !spec) return; + + // Clear existing debounce + if (writeTimer.current) { + window.clearTimeout(writeTimer.current); + writeTimer.current = null; + } + + writeTimer.current = window.setTimeout(() => { + saveDraftNow({ spec, baseHash: serverHash ?? null, updatedAt: Date.now() }); + }, 750); + + return () => { + if (writeTimer.current) { + window.clearTimeout(writeTimer.current); + writeTimer.current = null; + } + }; + }, [enabled, key, studyId, spec, serverHash, saveDraftNow]); + + return { + hasDraft, + draft, + discardDraft, + reloadDraft: () => { + const d = loadDraft(); + setDraft(d); + setHasDraft(Boolean(d)); + return d; + }, + }; +} diff --git a/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts b/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts index 1d150db8..f1fc13c9 100644 --- a/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts +++ b/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts @@ -63,6 +63,9 @@ interface SpecStoreActions { // WebSocket integration - set spec directly without API call setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void; + // Local draft integration (S2) - set spec locally (no API call) and mark dirty + setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => void; + // Full spec operations saveSpec: (spec: AtomizerSpec) => Promise; replaceSpec: (spec: AtomizerSpec) => Promise; @@ -402,6 +405,20 @@ export const useSpecStore = create()( }); }, + // Set spec locally as a draft (no API call). This is used by DraftManager (S2). + // Marks the spec as dirty to indicate "not published". + setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => { + const currentStudyId = studyId || get().studyId; + console.log('[useSpecStore] Setting spec from local draft:', spec.meta?.study_name); + set({ + spec, + studyId: currentStudyId, + isLoading: false, + isDirty: true, + error: null, + }); + }, + // ===================================================================== // Full Spec Operations // ===================================================================== diff --git a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx index 947d1ba1..996655eb 100644 --- a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx +++ b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx @@ -13,10 +13,11 @@ import { ChatPanel } from '../components/canvas/panels/ChatPanel'; import { PanelContainer } from '../components/canvas/panels/PanelContainer'; import { ResizeHandle } from '../components/canvas/ResizeHandle'; import { useCanvasStore } from '../hooks/useCanvasStore'; -import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore'; +import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId, useSpecHash } from '../hooks/useSpecStore'; import { useResizablePanel } from '../hooks/useResizablePanel'; // usePanelStore is now used by child components - PanelContainer handles panels import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo'; +import { useSpecDraft } from '../hooks/useSpecDraft'; import { useStudy } from '../context/StudyContext'; import { useChat } from '../hooks/useChat'; import { CanvasTemplate } from '../lib/canvas/templates'; @@ -63,6 +64,10 @@ export function CanvasView() { // Get study ID from URL params (supports nested paths like M1_Mirror/study_name) const { '*': urlStudyId } = useParams<{ '*': string }>(); + // Active study ID comes ONLY from URL - don't auto-load from context + // This ensures /canvas shows empty canvas, /canvas/{id} shows the study + const activeStudyId = urlStudyId; + // Legacy canvas store (for backwards compatibility) const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore(); @@ -70,11 +75,22 @@ export function CanvasView() { const spec = useSpec(); const specLoading = useSpecLoading(); const specIsDirty = useSpecIsDirty(); + const specHash = useSpecHash(); const selectedNodeId = useSelectedNodeId(); const { loadSpec, saveSpec, reloadSpec } = useSpecStore(); + // S2: local autosave draft (crash-proof) — publish remains explicit + const { hasDraft, draft, discardDraft, reloadDraft } = useSpecDraft({ + studyId: activeStudyId, + spec, + serverHash: specHash, + enabled: useSpecMode, + }); + + const [showDraftPrompt, setShowDraftPrompt] = useState(false); + const { setSelectedStudy, studies } = useStudy(); - const { clearSpec, setSpecFromWebSocket } = useSpecStore(); + const { clearSpec, setSpecFromWebSocket, setSpecLocalDraft } = useSpecStore(); // Undo/Redo for spec mode const undoRedo = useSpecUndoRedo(); @@ -83,10 +99,6 @@ export function CanvasView() { // Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y) useUndoRedoKeyboard(undoRedo); - // Active study ID comes ONLY from URL - don't auto-load from context - // This ensures /canvas shows empty canvas, /canvas/{id} shows the study - const activeStudyId = urlStudyId; - // Chat hook for assistant panel const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({ studyId: activeStudyId, @@ -130,6 +142,18 @@ export function CanvasView() { } }, [urlStudyId, useSpecMode]); + // If a local draft exists for this study, prompt user to restore/discard. + useEffect(() => { + if (!useSpecMode) return; + if (!activeStudyId) return; + if (specLoading) return; + if (!spec) return; + if (!hasDraft || !draft) return; + + // Show prompt once per navigation + setShowDraftPrompt(true); + }, [useSpecMode, activeStudyId, specLoading, spec, hasDraft, draft]); + // Notify Claude when user edits the spec (bi-directional sync) // This sends the updated spec to Claude so it knows what the user changed useEffect(() => { @@ -183,7 +207,7 @@ export function CanvasView() { if (useSpecMode && spec) { // Save spec using new API await saveSpec(spec); - showNotification('Saved to atomizer_spec.json'); + showNotification('Published to atomizer_spec.json'); } else { // Legacy save const intent = toIntent(); @@ -327,10 +351,10 @@ export function CanvasView() { ? 'bg-green-600 hover:bg-green-500 text-white' : 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600' }`} - title={specIsDirty ? 'Save changes to atomizer_spec.json' : 'No changes to save'} + title={specIsDirty ? 'Publish draft to atomizer_spec.json' : 'No changes to publish'} > - {isSaving ? 'Saving...' : 'Save'} + {isSaving ? 'Publishing...' : 'Publish'} )} @@ -614,6 +638,46 @@ export function CanvasView() { {/* Floating Panels (Introspection, Validation, Error, Results) */} {useSpecMode && } + {/* Draft Restore Prompt (S2) */} + {useSpecMode && showDraftPrompt && draft && ( +
+
+

Restore local draft?

+

+ A local draft was found for this study (autosaved). You can restore it (recommended) or discard it and keep the published version. +

+ +
+
Draft updated: {new Date(draft.updatedAt).toLocaleString()}
+
Base hash: {draft.baseHash || '(unknown)'}
+
+ +
+ + +
+
+
+ )} + {/* Notification Toast */} {notification && (