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
|
||||
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<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
|
||||
// =====================================================================
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
{isSaving ? 'Publishing...' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -614,6 +638,46 @@ export function CanvasView() {
|
||||
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
||||
{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 && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user