Skip to content

Commit aa71ede

Browse files
backnotpropclaude
andauthored
Auto-save annotation drafts to survive server crashes (#217)
* feat: auto-save annotation drafts to survive server crashes Adds server-side draft persistence so annotations are not lost when the server process dies. Drafts are saved to ~/.plannotator/drafts/ keyed by a SHA-256 content hash, debounced at 500ms. On reload, a dialog prompts the user to restore or dismiss. Drafts are auto-deleted on approve/deny/feedback. Closes #212 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove duplicate imports and update CLAUDE.md for draft feature Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: wrap handleRestoreDraft in useCallback and update shared-handlers comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9c2d46 commit aa71ede

File tree

11 files changed

+443
-7
lines changed

11 files changed

+443
-7
lines changed

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ plannotator/
3737
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
3838
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
3939
│ │ ├── browser.ts # openBrowser()
40+
│ │ ├── draft.ts # Annotation draft persistence (~/.plannotator/drafts/)
4041
│ │ ├── integrations.ts # Obsidian, Bear integrations
4142
│ │ ├── ide.ts # VS Code diff integration (openEditorDiff)
4243
│ │ └── project.ts # Project name detection for tags
@@ -45,7 +46,7 @@ plannotator/
4546
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
4647
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser
4748
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts
48-
│ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts
49+
│ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts
4950
│ │ └── types.ts
5051
│ ├── editor/ # Plan review App.tsx
5152
│ └── review-editor/ # Code review UI
@@ -160,6 +161,7 @@ Send Annotations → feedback sent to agent session
160161
| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=<path>&path=<file>`) |
161162
| `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) |
162163
| `/api/doc` | GET | Serve linked .md/.mdx file (`?path=<path>`) |
164+
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
163165

164166
### Review Server (`packages/server/review.ts`)
165167

@@ -169,6 +171,7 @@ Send Annotations → feedback sent to agent session
169171
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
170172
| `/api/image` | GET | Serve image by path query param |
171173
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
174+
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
172175

173176
### Annotate Server (`packages/server/annotate.ts`)
174177

@@ -178,6 +181,7 @@ Send Annotations → feedback sent to agent session
178181
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
179182
| `/api/image` | GET | Serve image by path query param |
180183
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
184+
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
181185

182186
All servers use random ports locally or fixed port (`19432`) in remote mode.
183187

packages/editor/App.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
4242
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
4343
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
4444
import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser';
45+
import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft';
4546
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
4647
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
4748
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
@@ -525,6 +526,27 @@ const App: React.FC = () => {
525526
pasteApiUrl
526527
);
527528

529+
// Auto-save annotation drafts
530+
const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({
531+
annotations,
532+
globalAttachments,
533+
isApiMode,
534+
isSharedSession,
535+
submitted: !!submitted,
536+
});
537+
538+
const handleRestoreDraft = React.useCallback(() => {
539+
const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft();
540+
if (restored.length > 0) {
541+
setAnnotations(restored);
542+
if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal);
543+
// Apply highlights to DOM after a tick
544+
setTimeout(() => {
545+
viewerRef.current?.applySharedAnnotations(restored);
546+
}, 100);
547+
}
548+
}, [restoreDraft]);
549+
528550
// Fetch available agents for OpenCode (for validation on approve)
529551
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin);
530552

@@ -1298,6 +1320,16 @@ const App: React.FC = () => {
12981320

12991321
{/* Document Area */}
13001322
<main ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
1323+
<ConfirmDialog
1324+
isOpen={!!draftBanner}
1325+
onClose={dismissDraft}
1326+
onConfirm={handleRestoreDraft}
1327+
title="Draft Recovered"
1328+
message={draftBanner ? `Found ${draftBanner.count} annotation${draftBanner.count !== 1 ? 's' : ''} from ${draftBanner.timeAgo}. Would you like to restore them?` : ''}
1329+
confirmText="Restore"
1330+
cancelText="Dismiss"
1331+
showCancel
1332+
/>
13011333
<div className="min-h-full flex flex-col items-center px-4 py-3 md:px-10 md:py-8 xl:px-16">
13021334
{/* Mode Switcher (hidden during plan diff) */}
13031335
{!isPlanDiffActive && (

packages/review-editor/App.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getIdentity } from '@plannotator/ui/utils/identity';
1010
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
1111
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types';
1212
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
13+
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
1314
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
1415
import { DiffViewer } from './components/DiffViewer';
1516
import { ReviewPanel } from './components/ReviewPanel';
@@ -159,6 +160,18 @@ const ReviewApp: React.FC = () => {
159160

160161
const identity = useMemo(() => getIdentity(), []);
161162

163+
// Auto-save code annotation drafts
164+
const { draftBanner, restoreDraft, dismissDraft } = useCodeAnnotationDraft({
165+
annotations,
166+
isApiMode: !!origin,
167+
submitted: !!submitted,
168+
});
169+
170+
const handleRestoreDraft = useCallback(() => {
171+
const restored = restoreDraft();
172+
if (restored.length > 0) setAnnotations(restored);
173+
}, [restoreDraft]);
174+
162175
// Resizable panels
163176
const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' });
164177
const fileTreeResize = useResizablePanel({
@@ -769,6 +782,16 @@ const ReviewApp: React.FC = () => {
769782

770783
{/* Diff viewer */}
771784
<main className="flex-1 min-w-0 overflow-hidden">
785+
<ConfirmDialog
786+
isOpen={!!draftBanner}
787+
onClose={dismissDraft}
788+
onConfirm={handleRestoreDraft}
789+
title="Draft Recovered"
790+
message={draftBanner ? `Found ${draftBanner.count} annotation${draftBanner.count !== 1 ? 's' : ''} from ${draftBanner.timeAgo}. Would you like to restore them?` : ''}
791+
confirmText="Restore"
792+
cancelText="Dismiss"
793+
showCancel
794+
/>
772795
{activeFile ? (
773796
<DiffViewer
774797
patch={activeFile.patch}

packages/server/annotate.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
import { isRemoteSession, getServerPort } from "./remote";
1515
import { getRepoInfo } from "./repo";
16-
import { handleImage, handleUpload, handleServerReady } from "./shared-handlers";
16+
import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers";
17+
import { contentHash, deleteDraft } from "./draft";
1718

1819
// Re-export utilities
1920
export { isRemoteSession, getServerPort } from "./remote";
@@ -83,6 +84,7 @@ export async function startAnnotateServer(
8384

8485
const isRemote = isRemoteSession();
8586
const configuredPort = getServerPort();
87+
const draftKey = contentHash(markdown);
8688

8789
// Detect repo info (cached for this session)
8890
const repoInfo = await getRepoInfo();
@@ -133,6 +135,13 @@ export async function startAnnotateServer(
133135
return handleUpload(req);
134136
}
135137

138+
// API: Annotation draft persistence
139+
if (url.pathname === "/api/draft") {
140+
if (req.method === "POST") return handleDraftSave(req, draftKey);
141+
if (req.method === "DELETE") return handleDraftDelete(draftKey);
142+
return handleDraftLoad(draftKey);
143+
}
144+
136145
// API: Submit annotation feedback
137146
if (url.pathname === "/api/feedback" && req.method === "POST") {
138147
try {
@@ -141,6 +150,7 @@ export async function startAnnotateServer(
141150
annotations: unknown[];
142151
};
143152

153+
deleteDraft(draftKey);
144154
resolveDecision({
145155
feedback: body.feedback || "",
146156
annotations: body.annotations || [],

packages/server/draft.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Draft Storage
3+
*
4+
* Persists annotation drafts to ~/.plannotator/drafts/ so they survive
5+
* server crashes. Each draft is keyed by a content hash of the plan/diff
6+
* it was created against.
7+
*/
8+
9+
import { homedir } from "os";
10+
import { join } from "path";
11+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from "fs";
12+
import { createHash } from "crypto";
13+
14+
/**
15+
* Get the drafts directory, creating it if needed.
16+
*/
17+
export function getDraftDir(): string {
18+
const dir = join(homedir(), ".plannotator", "drafts");
19+
mkdirSync(dir, { recursive: true });
20+
return dir;
21+
}
22+
23+
/**
24+
* Generate a stable key from content using truncated SHA-256.
25+
* Same content always produces the same key across server restarts.
26+
*/
27+
export function contentHash(content: string): string {
28+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
29+
}
30+
31+
/**
32+
* Save a draft to disk.
33+
*/
34+
export function saveDraft(key: string, data: object): void {
35+
const dir = getDraftDir();
36+
writeFileSync(join(dir, `${key}.json`), JSON.stringify(data), "utf-8");
37+
}
38+
39+
/**
40+
* Load a draft from disk. Returns null if not found.
41+
*/
42+
export function loadDraft(key: string): object | null {
43+
const filePath = join(getDraftDir(), `${key}.json`);
44+
try {
45+
if (!existsSync(filePath)) return null;
46+
return JSON.parse(readFileSync(filePath, "utf-8"));
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
/**
53+
* Delete a draft from disk. No-op if not found.
54+
*/
55+
export function deleteDraft(key: string): void {
56+
const filePath = join(getDraftDir(), `${key}.json`);
57+
try {
58+
if (existsSync(filePath)) unlinkSync(filePath);
59+
} catch {
60+
// Ignore delete failures
61+
}
62+
}

packages/server/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import {
3333
} from "./storage";
3434
import { getRepoInfo } from "./repo";
3535
import { detectProjectName } from "./project";
36-
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
36+
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
37+
import { contentHash, deleteDraft } from "./draft";
3738
import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers";
3839

3940
// Re-export utilities
@@ -107,6 +108,7 @@ export async function startPlannotatorServer(
107108

108109
const isRemote = isRemoteSession();
109110
const configuredPort = getServerPort();
111+
const draftKey = contentHash(plan);
110112

111113
// Generate slug for potential saving (actual save happens on decision)
112114
const slug = generateSlug(plan);
@@ -257,6 +259,13 @@ export async function startPlannotatorServer(
257259
return handleAgents(options.opencodeClient);
258260
}
259261

262+
// API: Annotation draft persistence
263+
if (url.pathname === "/api/draft") {
264+
if (req.method === "POST") return handleDraftSave(req, draftKey);
265+
if (req.method === "DELETE") return handleDraftDelete(draftKey);
266+
return handleDraftLoad(draftKey);
267+
}
268+
260269
// API: Save to notes (decoupled from approve/deny)
261270
if (url.pathname === "/api/save-notes" && req.method === "POST") {
262271
const results: { obsidian?: IntegrationResult; bear?: IntegrationResult } = {};
@@ -365,6 +374,9 @@ export async function startPlannotatorServer(
365374
savedPath = saveFinalSnapshot(slug, "approved", plan, annotations, planSaveCustomPath);
366375
}
367376

377+
// Clean up draft on successful submit
378+
deleteDraft(draftKey);
379+
368380
// Use permission mode from client request if provided, otherwise fall back to hook input
369381
const effectivePermissionMode = requestedPermissionMode || permissionMode;
370382
resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode });
@@ -399,6 +411,7 @@ export async function startPlannotatorServer(
399411
savedPath = saveFinalSnapshot(slug, "denied", plan, feedback, planSaveCustomPath);
400412
}
401413

414+
deleteDraft(draftKey);
402415
resolveDecision({ approved: false, feedback, savedPath });
403416
return Response.json({ ok: true, savedPath });
404417
}

packages/server/review.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
import { isRemoteSession, getServerPort } from "./remote";
1313
import { type DiffType, type GitContext, runGitDiff } from "./git";
1414
import { getRepoInfo } from "./repo";
15-
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
15+
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
16+
import { contentHash, deleteDraft } from "./draft";
1617

1718
// Re-export utilities
1819
export { isRemoteSession, getServerPort } from "./remote";
@@ -82,6 +83,8 @@ export async function startReviewServer(
8283
): Promise<ReviewServerResult> {
8384
const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options;
8485

86+
const draftKey = contentHash(options.rawPatch);
87+
8588
// Mutable state for diff switching
8689
let currentPatch = options.rawPatch;
8790
let currentGitRef = options.gitRef;
@@ -185,6 +188,13 @@ export async function startReviewServer(
185188
return handleAgents(options.opencodeClient);
186189
}
187190

191+
// API: Annotation draft persistence
192+
if (url.pathname === "/api/draft") {
193+
if (req.method === "POST") return handleDraftSave(req, draftKey);
194+
if (req.method === "DELETE") return handleDraftDelete(draftKey);
195+
return handleDraftLoad(draftKey);
196+
}
197+
188198
// API: Submit review feedback
189199
if (url.pathname === "/api/feedback" && req.method === "POST") {
190200
try {
@@ -194,6 +204,7 @@ export async function startReviewServer(
194204
agentSwitch?: string;
195205
};
196206

207+
deleteDraft(draftKey);
197208
resolveDecision({
198209
feedback: body.feedback || "",
199210
annotations: body.annotations || [],

packages/server/shared-handlers.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/**
22
* Shared route handlers used by plan, review, and annotate servers.
33
*
4-
* Eliminates duplication of /api/image, /api/upload, and the server-ready
5-
* handler across all three server files. Also shares /api/agents for plan + review.
4+
* Eliminates duplication of /api/image, /api/upload, /api/draft, and the
5+
* server-ready handler across all three server files. Also shares /api/agents
6+
* for plan + review.
67
*/
78

89
import { mkdirSync } from "fs";
910
import { openBrowser } from "./browser";
1011
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
12+
import { saveDraft, loadDraft, deleteDraft } from "./draft";
1113

1214
/** Serve images from local paths or temp uploads. Used by all 3 servers. */
1315
export async function handleImage(req: Request): Promise<Response> {
@@ -82,6 +84,34 @@ export async function handleAgents(opencodeClient?: OpencodeClient): Promise<Res
8284
}
8385
}
8486

87+
/** Save annotation draft. Used by all 3 servers. */
88+
export async function handleDraftSave(req: Request, contentKey: string): Promise<Response> {
89+
try {
90+
const body = await req.json();
91+
saveDraft(contentKey, body);
92+
return Response.json({ ok: true });
93+
} catch (err) {
94+
const message = err instanceof Error ? err.message : "Failed to save draft";
95+
console.error(`[draft] save failed: ${message}`);
96+
return Response.json({ error: message }, { status: 500 });
97+
}
98+
}
99+
100+
/** Load annotation draft. Used by all 3 servers. */
101+
export function handleDraftLoad(contentKey: string): Response {
102+
const draft = loadDraft(contentKey);
103+
if (!draft) {
104+
return Response.json({ found: false }, { status: 404 });
105+
}
106+
return Response.json(draft);
107+
}
108+
109+
/** Delete annotation draft. Used by all 3 servers. */
110+
export function handleDraftDelete(contentKey: string): Response {
111+
deleteDraft(contentKey);
112+
return Response.json({ ok: true });
113+
}
114+
85115
/** Open browser for local sessions. Used by all 3 servers. */
86116
export async function handleServerReady(
87117
url: string,

packages/ui/components/ConfirmDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
5555
};
5656

5757
return (
58-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
58+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
5959
<div className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl p-6">
6060
<div className="flex items-center gap-3 mb-4">
6161
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${iconColors[variant]}`}>

0 commit comments

Comments
 (0)