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

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