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>
This commit is contained in:
Michael Ramos
2026-03-04 10:12:58 -08:00
committed by GitHub
parent d9c2d46113
commit aa71ede0f3
11 changed files with 443 additions and 7 deletions
+5 -1
View File
@@ -37,6 +37,7 @@ plannotator/
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
│ │ ├── browser.ts # openBrowser()
│ │ ├── draft.ts # Annotation draft persistence (~/.plannotator/drafts/)
│ │ ├── integrations.ts # Obsidian, Bear integrations
│ │ ├── ide.ts # VS Code diff integration (openEditorDiff)
│ │ └── project.ts # Project name detection for tags
@@ -45,7 +46,7 @@ plannotator/
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts
│ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts
│ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts
│ │ └── types.ts
│ ├── editor/ # Plan review App.tsx
│ └── review-editor/ # Code review UI
@@ -160,6 +161,7 @@ Send Annotations → feedback sent to agent session
| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=<path>&path=<file>`) |
| `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) |
| `/api/doc` | GET | Serve linked .md/.mdx file (`?path=<path>`) |
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
### Review Server (`packages/server/review.ts`)
@@ -169,6 +171,7 @@ Send Annotations → feedback sent to agent session
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
| `/api/image` | GET | Serve image by path query param |
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
### Annotate Server (`packages/server/annotate.ts`)
@@ -178,6 +181,7 @@ Send Annotations → feedback sent to agent session
| `/api/feedback` | POST | Submit annotations (body: feedback, annotations) |
| `/api/image` | GET | Serve image by path query param |
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
All servers use random ports locally or fixed port (`19432`) in remote mode.
+32
View File
@@ -42,6 +42,7 @@ import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser';
import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft';
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
@@ -525,6 +526,27 @@ const App: React.FC = () => {
pasteApiUrl
);
// Auto-save annotation drafts
const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({
annotations,
globalAttachments,
isApiMode,
isSharedSession,
submitted: !!submitted,
});
const handleRestoreDraft = React.useCallback(() => {
const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft();
if (restored.length > 0) {
setAnnotations(restored);
if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal);
// Apply highlights to DOM after a tick
setTimeout(() => {
viewerRef.current?.applySharedAnnotations(restored);
}, 100);
}
}, [restoreDraft]);
// Fetch available agents for OpenCode (for validation on approve)
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin);
@@ -1298,6 +1320,16 @@ const App: React.FC = () => {
{/* Document Area */}
<main ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
<ConfirmDialog
isOpen={!!draftBanner}
onClose={dismissDraft}
onConfirm={handleRestoreDraft}
title="Draft Recovered"
message={draftBanner ? `Found ${draftBanner.count} annotation${draftBanner.count !== 1 ? 's' : ''} from ${draftBanner.timeAgo}. Would you like to restore them?` : ''}
confirmText="Restore"
cancelText="Dismiss"
showCancel
/>
<div className="min-h-full flex flex-col items-center px-4 py-3 md:px-10 md:py-8 xl:px-16">
{/* Mode Switcher (hidden during plan diff) */}
{!isPlanDiffActive && (
+23
View File
@@ -10,6 +10,7 @@ import { getIdentity } from '@plannotator/ui/utils/identity';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
import { CodeAnnotation, CodeAnnotationType, SelectedLineRange } from '@plannotator/ui/types';
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
import { DiffViewer } from './components/DiffViewer';
import { ReviewPanel } from './components/ReviewPanel';
@@ -159,6 +160,18 @@ const ReviewApp: React.FC = () => {
const identity = useMemo(() => getIdentity(), []);
// Auto-save code annotation drafts
const { draftBanner, restoreDraft, dismissDraft } = useCodeAnnotationDraft({
annotations,
isApiMode: !!origin,
submitted: !!submitted,
});
const handleRestoreDraft = useCallback(() => {
const restored = restoreDraft();
if (restored.length > 0) setAnnotations(restored);
}, [restoreDraft]);
// Resizable panels
const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' });
const fileTreeResize = useResizablePanel({
@@ -769,6 +782,16 @@ const ReviewApp: React.FC = () => {
{/* Diff viewer */}
<main className="flex-1 min-w-0 overflow-hidden">
<ConfirmDialog
isOpen={!!draftBanner}
onClose={dismissDraft}
onConfirm={handleRestoreDraft}
title="Draft Recovered"
message={draftBanner ? `Found ${draftBanner.count} annotation${draftBanner.count !== 1 ? 's' : ''} from ${draftBanner.timeAgo}. Would you like to restore them?` : ''}
confirmText="Restore"
cancelText="Dismiss"
showCancel
/>
{activeFile ? (
<DiffViewer
patch={activeFile.patch}
+11 -1
View File
@@ -13,7 +13,8 @@
import { isRemoteSession, getServerPort } from "./remote";
import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleServerReady } from "./shared-handlers";
import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
@@ -83,6 +84,7 @@ export async function startAnnotateServer(
const isRemote = isRemoteSession();
const configuredPort = getServerPort();
const draftKey = contentHash(markdown);
// Detect repo info (cached for this session)
const repoInfo = await getRepoInfo();
@@ -133,6 +135,13 @@ export async function startAnnotateServer(
return handleUpload(req);
}
// API: Annotation draft persistence
if (url.pathname === "/api/draft") {
if (req.method === "POST") return handleDraftSave(req, draftKey);
if (req.method === "DELETE") return handleDraftDelete(draftKey);
return handleDraftLoad(draftKey);
}
// API: Submit annotation feedback
if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
@@ -141,6 +150,7 @@ export async function startAnnotateServer(
annotations: unknown[];
};
deleteDraft(draftKey);
resolveDecision({
feedback: body.feedback || "",
annotations: body.annotations || [],
+62
View File
@@ -0,0 +1,62 @@
/**
* Draft Storage
*
* Persists annotation drafts to ~/.plannotator/drafts/ so they survive
* server crashes. Each draft is keyed by a content hash of the plan/diff
* it was created against.
*/
import { homedir } from "os";
import { join } from "path";
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from "fs";
import { createHash } from "crypto";
/**
* Get the drafts directory, creating it if needed.
*/
export function getDraftDir(): string {
const dir = join(homedir(), ".plannotator", "drafts");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Generate a stable key from content using truncated SHA-256.
* Same content always produces the same key across server restarts.
*/
export function contentHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
/**
* Save a draft to disk.
*/
export function saveDraft(key: string, data: object): void {
const dir = getDraftDir();
writeFileSync(join(dir, `${key}.json`), JSON.stringify(data), "utf-8");
}
/**
* Load a draft from disk. Returns null if not found.
*/
export function loadDraft(key: string): object | null {
const filePath = join(getDraftDir(), `${key}.json`);
try {
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
return null;
}
}
/**
* Delete a draft from disk. No-op if not found.
*/
export function deleteDraft(key: string): void {
const filePath = join(getDraftDir(), `${key}.json`);
try {
if (existsSync(filePath)) unlinkSync(filePath);
} catch {
// Ignore delete failures
}
}
+14 -1
View File
@@ -33,7 +33,8 @@ import {
} from "./storage";
import { getRepoInfo } from "./repo";
import { detectProjectName } from "./project";
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers";
// Re-export utilities
@@ -107,6 +108,7 @@ export async function startPlannotatorServer(
const isRemote = isRemoteSession();
const configuredPort = getServerPort();
const draftKey = contentHash(plan);
// Generate slug for potential saving (actual save happens on decision)
const slug = generateSlug(plan);
@@ -257,6 +259,13 @@ export async function startPlannotatorServer(
return handleAgents(options.opencodeClient);
}
// API: Annotation draft persistence
if (url.pathname === "/api/draft") {
if (req.method === "POST") return handleDraftSave(req, draftKey);
if (req.method === "DELETE") return handleDraftDelete(draftKey);
return handleDraftLoad(draftKey);
}
// API: Save to notes (decoupled from approve/deny)
if (url.pathname === "/api/save-notes" && req.method === "POST") {
const results: { obsidian?: IntegrationResult; bear?: IntegrationResult } = {};
@@ -365,6 +374,9 @@ export async function startPlannotatorServer(
savedPath = saveFinalSnapshot(slug, "approved", plan, annotations, planSaveCustomPath);
}
// Clean up draft on successful submit
deleteDraft(draftKey);
// Use permission mode from client request if provided, otherwise fall back to hook input
const effectivePermissionMode = requestedPermissionMode || permissionMode;
resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode });
@@ -399,6 +411,7 @@ export async function startPlannotatorServer(
savedPath = saveFinalSnapshot(slug, "denied", plan, feedback, planSaveCustomPath);
}
deleteDraft(draftKey);
resolveDecision({ approved: false, feedback, savedPath });
return Response.json({ ok: true, savedPath });
}
+12 -1
View File
@@ -12,7 +12,8 @@
import { isRemoteSession, getServerPort } from "./remote";
import { type DiffType, type GitContext, runGitDiff } from "./git";
import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
@@ -82,6 +83,8 @@ export async function startReviewServer(
): Promise<ReviewServerResult> {
const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options;
const draftKey = contentHash(options.rawPatch);
// Mutable state for diff switching
let currentPatch = options.rawPatch;
let currentGitRef = options.gitRef;
@@ -185,6 +188,13 @@ export async function startReviewServer(
return handleAgents(options.opencodeClient);
}
// API: Annotation draft persistence
if (url.pathname === "/api/draft") {
if (req.method === "POST") return handleDraftSave(req, draftKey);
if (req.method === "DELETE") return handleDraftDelete(draftKey);
return handleDraftLoad(draftKey);
}
// API: Submit review feedback
if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
@@ -194,6 +204,7 @@ export async function startReviewServer(
agentSwitch?: string;
};
deleteDraft(draftKey);
resolveDecision({
feedback: body.feedback || "",
annotations: body.annotations || [],
+32 -2
View File
@@ -1,13 +1,15 @@
/**
* Shared route handlers used by plan, review, and annotate servers.
*
* Eliminates duplication of /api/image, /api/upload, and the server-ready
* handler across all three server files. Also shares /api/agents for plan + review.
* Eliminates duplication of /api/image, /api/upload, /api/draft, and the
* server-ready handler across all three server files. Also shares /api/agents
* for plan + review.
*/
import { mkdirSync } from "fs";
import { openBrowser } from "./browser";
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
import { saveDraft, loadDraft, deleteDraft } from "./draft";
/** Serve images from local paths or temp uploads. Used by all 3 servers. */
export async function handleImage(req: Request): Promise<Response> {
@@ -82,6 +84,34 @@ export async function handleAgents(opencodeClient?: OpencodeClient): Promise<Res
}
}
/** Save annotation draft. Used by all 3 servers. */
export async function handleDraftSave(req: Request, contentKey: string): Promise<Response> {
try {
const body = await req.json();
saveDraft(contentKey, body);
return Response.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save draft";
console.error(`[draft] save failed: ${message}`);
return Response.json({ error: message }, { status: 500 });
}
}
/** Load annotation draft. Used by all 3 servers. */
export function handleDraftLoad(contentKey: string): Response {
const draft = loadDraft(contentKey);
if (!draft) {
return Response.json({ found: false }, { status: 404 });
}
return Response.json(draft);
}
/** Delete annotation draft. Used by all 3 servers. */
export function handleDraftDelete(contentKey: string): Response {
deleteDraft(contentKey);
return Response.json({ ok: true });
}
/** Open browser for local sessions. Used by all 3 servers. */
export async function handleServerReady(
url: string,
+1 -1
View File
@@ -55,7 +55,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
+134
View File
@@ -0,0 +1,134 @@
/**
* Auto-save annotation drafts to the server.
*
* Silently saves annotations on a debounced interval so they survive
* server crashes. On mount, checks for an existing draft and exposes
* banner state for the UI to offer restoration.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Annotation, ImageAttachment } from '../types';
import { toShareable, toShareableImages, fromShareable, parseShareableImages } from '../utils/sharing';
const DEBOUNCE_MS = 500;
interface DraftData {
a: unknown[];
g?: unknown[];
ts: number;
}
function formatTimeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
interface UseAnnotationDraftOptions {
annotations: Annotation[];
globalAttachments: ImageAttachment[];
isApiMode: boolean;
isSharedSession: boolean;
submitted: boolean;
}
interface UseAnnotationDraftResult {
draftBanner: { count: number; timeAgo: string } | null;
restoreDraft: () => { annotations: Annotation[]; globalAttachments: ImageAttachment[] };
dismissDraft: () => void;
}
export function useAnnotationDraft({
annotations,
globalAttachments,
isApiMode,
isSharedSession,
submitted,
}: UseAnnotationDraftOptions): UseAnnotationDraftResult {
const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null);
const draftDataRef = useRef<DraftData | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMountedRef = useRef(false);
// Load draft on mount
useEffect(() => {
if (!isApiMode || isSharedSession) return;
fetch('/api/draft')
.then(res => {
if (!res.ok) return null;
return res.json();
})
.then((data: DraftData | null) => {
if (data?.a && Array.isArray(data.a) && data.a.length > 0) {
draftDataRef.current = data;
setDraftBanner({
count: data.a.length,
timeAgo: formatTimeAgo(data.ts || 0),
});
}
hasMountedRef.current = true;
})
.catch(() => {
hasMountedRef.current = true;
});
}, [isApiMode, isSharedSession]);
// Debounced auto-save on annotation changes
useEffect(() => {
if (!isApiMode || isSharedSession || submitted) return;
if (!hasMountedRef.current) return;
if (annotations.length === 0) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const payload: DraftData = {
a: toShareable(annotations) as unknown[],
g: toShareableImages(globalAttachments) as unknown[] | undefined,
ts: Date.now(),
};
fetch('/api/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {
// Silent failure — draft is best-effort
});
}, DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [annotations, globalAttachments, isApiMode, isSharedSession, submitted]);
const restoreDraft = useCallback(() => {
const data = draftDataRef.current;
setDraftBanner(null);
draftDataRef.current = null;
if (!data?.a) return { annotations: [], globalAttachments: [] };
const restored = fromShareable(data.a as Parameters<typeof fromShareable>[0]);
const restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters<typeof parseShareableImages>[0]) ?? []) : [];
return { annotations: restored, globalAttachments: restoredGlobal };
}, []);
const dismissDraft = useCallback(() => {
setDraftBanner(null);
draftDataRef.current = null;
fetch('/api/draft', { method: 'DELETE' }).catch(() => {
// Silent failure
});
}, []);
return { draftBanner, restoreDraft, dismissDraft };
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Auto-save code review annotation drafts to the server.
*
* Similar to useAnnotationDraft but stores CodeAnnotation[] directly
* (they're already compact — no tuple conversion needed).
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { CodeAnnotation } from '../types';
const DEBOUNCE_MS = 500;
interface DraftData {
codeAnnotations: CodeAnnotation[];
ts: number;
}
function formatTimeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
interface UseCodeAnnotationDraftOptions {
annotations: CodeAnnotation[];
isApiMode: boolean;
submitted: boolean;
}
interface UseCodeAnnotationDraftResult {
draftBanner: { count: number; timeAgo: string } | null;
restoreDraft: () => CodeAnnotation[];
dismissDraft: () => void;
}
export function useCodeAnnotationDraft({
annotations,
isApiMode,
submitted,
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null);
const draftDataRef = useRef<DraftData | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMountedRef = useRef(false);
// Load draft on mount
useEffect(() => {
if (!isApiMode) return;
fetch('/api/draft')
.then(res => {
if (!res.ok) return null;
return res.json();
})
.then((data: DraftData | null) => {
if (data?.codeAnnotations && Array.isArray(data.codeAnnotations) && data.codeAnnotations.length > 0) {
draftDataRef.current = data;
setDraftBanner({
count: data.codeAnnotations.length,
timeAgo: formatTimeAgo(data.ts || 0),
});
}
hasMountedRef.current = true;
})
.catch(() => {
hasMountedRef.current = true;
});
}, [isApiMode]);
// Debounced auto-save on annotation changes
useEffect(() => {
if (!isApiMode || submitted) return;
if (!hasMountedRef.current) return;
if (annotations.length === 0) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const payload: DraftData = {
codeAnnotations: annotations,
ts: Date.now(),
};
fetch('/api/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {
// Silent failure
});
}, DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [annotations, isApiMode, submitted]);
const restoreDraft = useCallback(() => {
const data = draftDataRef.current;
setDraftBanner(null);
draftDataRef.current = null;
return data?.codeAnnotations ?? [];
}, []);
const dismissDraft = useCallback(() => {
setDraftBanner(null);
draftDataRef.current = null;
fetch('/api/draft', { method: 'DELETE' }).catch(() => {});
}, []);
return { draftBanner, restoreDraft, dismissDraft };
}