feat(draft): add local autosave + restore prompt + publish label

This commit is contained in:
2026-01-29 03:16:31 +00:00
parent b3f3329c79
commit a7039c5875
3 changed files with 211 additions and 9 deletions

View 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;
},
};
}

View File

@@ -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
// ===================================================================== // =====================================================================

View File

@@ -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