mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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 || [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}`}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user