feat(draft): add local autosave + restore prompt + publish label
This commit is contained in:
121
atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts
Normal file
121
atomizer-dashboard/frontend/src/hooks/useSpecDraft.ts
Normal file
@@ -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<DraftPayload | null>(null);
|
||||||
|
|
||||||
|
// Debounce writes
|
||||||
|
const writeTimer = useRef<number | null>(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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -63,6 +63,9 @@ interface SpecStoreActions {
|
|||||||
// WebSocket integration - set spec directly without API call
|
// WebSocket integration - set spec directly without API call
|
||||||
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
|
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
|
// Full spec operations
|
||||||
saveSpec: (spec: AtomizerSpec) => Promise<void>;
|
saveSpec: (spec: AtomizerSpec) => Promise<void>;
|
||||||
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
|
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
|
||||||
@@ -402,6 +405,20 @@ export const useSpecStore = create<SpecStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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
|
// Full Spec Operations
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import { ChatPanel } from '../components/canvas/panels/ChatPanel';
|
|||||||
import { PanelContainer } from '../components/canvas/panels/PanelContainer';
|
import { PanelContainer } from '../components/canvas/panels/PanelContainer';
|
||||||
import { ResizeHandle } from '../components/canvas/ResizeHandle';
|
import { ResizeHandle } from '../components/canvas/ResizeHandle';
|
||||||
import { useCanvasStore } from '../hooks/useCanvasStore';
|
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';
|
import { useResizablePanel } from '../hooks/useResizablePanel';
|
||||||
// usePanelStore is now used by child components - PanelContainer handles panels
|
// usePanelStore is now used by child components - PanelContainer handles panels
|
||||||
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
||||||
|
import { useSpecDraft } from '../hooks/useSpecDraft';
|
||||||
import { useStudy } from '../context/StudyContext';
|
import { useStudy } from '../context/StudyContext';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
import { CanvasTemplate } from '../lib/canvas/templates';
|
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)
|
// Get study ID from URL params (supports nested paths like M1_Mirror/study_name)
|
||||||
const { '*': urlStudyId } = useParams<{ '*': string }>();
|
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)
|
// Legacy canvas store (for backwards compatibility)
|
||||||
const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore();
|
const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore();
|
||||||
|
|
||||||
@@ -70,11 +75,22 @@ export function CanvasView() {
|
|||||||
const spec = useSpec();
|
const spec = useSpec();
|
||||||
const specLoading = useSpecLoading();
|
const specLoading = useSpecLoading();
|
||||||
const specIsDirty = useSpecIsDirty();
|
const specIsDirty = useSpecIsDirty();
|
||||||
|
const specHash = useSpecHash();
|
||||||
const selectedNodeId = useSelectedNodeId();
|
const selectedNodeId = useSelectedNodeId();
|
||||||
const { loadSpec, saveSpec, reloadSpec } = useSpecStore();
|
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 { setSelectedStudy, studies } = useStudy();
|
||||||
const { clearSpec, setSpecFromWebSocket } = useSpecStore();
|
const { clearSpec, setSpecFromWebSocket, setSpecLocalDraft } = useSpecStore();
|
||||||
|
|
||||||
// Undo/Redo for spec mode
|
// Undo/Redo for spec mode
|
||||||
const undoRedo = useSpecUndoRedo();
|
const undoRedo = useSpecUndoRedo();
|
||||||
@@ -83,10 +99,6 @@ export function CanvasView() {
|
|||||||
// Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y)
|
// Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y)
|
||||||
useUndoRedoKeyboard(undoRedo);
|
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
|
// Chat hook for assistant panel
|
||||||
const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({
|
const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({
|
||||||
studyId: activeStudyId,
|
studyId: activeStudyId,
|
||||||
@@ -130,6 +142,18 @@ export function CanvasView() {
|
|||||||
}
|
}
|
||||||
}, [urlStudyId, useSpecMode]);
|
}, [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)
|
// Notify Claude when user edits the spec (bi-directional sync)
|
||||||
// This sends the updated spec to Claude so it knows what the user changed
|
// This sends the updated spec to Claude so it knows what the user changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,7 +207,7 @@ export function CanvasView() {
|
|||||||
if (useSpecMode && spec) {
|
if (useSpecMode && spec) {
|
||||||
// Save spec using new API
|
// Save spec using new API
|
||||||
await saveSpec(spec);
|
await saveSpec(spec);
|
||||||
showNotification('Saved to atomizer_spec.json');
|
showNotification('Published to atomizer_spec.json');
|
||||||
} else {
|
} else {
|
||||||
// Legacy save
|
// Legacy save
|
||||||
const intent = toIntent();
|
const intent = toIntent();
|
||||||
@@ -327,10 +351,10 @@ export function CanvasView() {
|
|||||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
|
: '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'}
|
||||||
>
|
>
|
||||||
<Save size={14} />
|
<Save size={14} />
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? 'Publishing...' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -614,6 +638,46 @@ export function CanvasView() {
|
|||||||
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
||||||
{useSpecMode && <PanelContainer />}
|
{useSpecMode && <PanelContainer />}
|
||||||
|
|
||||||
|
{/* Draft Restore Prompt (S2) */}
|
||||||
|
{useSpecMode && showDraftPrompt && draft && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-[640px] max-w-[92vw] bg-dark-850 rounded-xl border border-dark-600 shadow-2xl p-5">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Restore local draft?</h3>
|
||||||
|
<p className="text-sm text-dark-300 mt-2">
|
||||||
|
A local draft was found for this study (autosaved). You can restore it (recommended) or discard it and keep the published version.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-dark-900/40 border border-dark-700 rounded-lg text-xs text-dark-400">
|
||||||
|
<div>Draft updated: {new Date(draft.updatedAt).toLocaleString()}</div>
|
||||||
|
<div>Base hash: {draft.baseHash || '(unknown)'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
discardDraft();
|
||||||
|
setShowDraftPrompt(false);
|
||||||
|
showNotification('Discarded local draft');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-dark-700 text-dark-200 hover:bg-dark-600 rounded-lg border border-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
Discard Draft
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSpecLocalDraft(draft.spec, activeStudyId || undefined);
|
||||||
|
setShowDraftPrompt(false);
|
||||||
|
showNotification('Restored local draft');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white hover:bg-primary-500 rounded-lg border border-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
Restore Draft
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notification Toast */}
|
{/* Notification Toast */}
|
||||||
{notification && (
|
{notification && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user