diff --git a/CLAUDE.md b/CLAUDE.md index 4137090..4d73342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`) | | `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) | | `/api/doc` | GET | Serve linked .md/.mdx file (`?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. diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 158ffaf..405c07e 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -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 */}
+
{/* Mode Switcher (hidden during plan diff) */} {!isPlanDiffActive && ( diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index d8f92dd..7197ba2 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -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 */}
+ {activeFile ? ( { 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 || [], diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 8ce7ccd..aeae7db 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -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 { @@ -82,6 +84,34 @@ export async function handleAgents(opencodeClient?: OpencodeClient): Promise { + 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, diff --git a/packages/ui/components/ConfirmDialog.tsx b/packages/ui/components/ConfirmDialog.tsx index 239582e..4b10053 100644 --- a/packages/ui/components/ConfirmDialog.tsx +++ b/packages/ui/components/ConfirmDialog.tsx @@ -55,7 +55,7 @@ export const ConfirmDialog: React.FC = ({ }; return ( -
+
diff --git a/packages/ui/hooks/useAnnotationDraft.ts b/packages/ui/hooks/useAnnotationDraft.ts new file mode 100644 index 0000000..535ecd7 --- /dev/null +++ b/packages/ui/hooks/useAnnotationDraft.ts @@ -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(null); + const timerRef = useRef | 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[0]); + const restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters[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 }; +} diff --git a/packages/ui/hooks/useCodeAnnotationDraft.ts b/packages/ui/hooks/useCodeAnnotationDraft.ts new file mode 100644 index 0000000..5e92abb --- /dev/null +++ b/packages/ui/hooks/useCodeAnnotationDraft.ts @@ -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(null); + const timerRef = useRef | 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 }; +}