Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`)

Expand All @@ -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`)

Expand All @@ -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.

Expand Down
32 changes: 32 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 && (
Expand Down
23 changes: 23 additions & 0 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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}
Expand Down
12 changes: 11 additions & 1 deletion packages/server/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -141,6 +150,7 @@ export async function startAnnotateServer(
annotations: unknown[];
};

deleteDraft(draftKey);
resolveDecision({
feedback: body.feedback || "",
annotations: body.annotations || [],
Expand Down
62 changes: 62 additions & 0 deletions packages/server/draft.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
15 changes: 14 additions & 1 deletion packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 } = {};
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
}
Expand Down
13 changes: 12 additions & 1 deletion packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -194,6 +204,7 @@ export async function startReviewServer(
agentSwitch?: string;
};

deleteDraft(draftKey);
resolveDecision({
feedback: body.feedback || "",
annotations: body.annotations || [],
Expand Down
34 changes: 32 additions & 2 deletions packages/server/shared-handlers.ts
Original file line number Diff line number Diff line change
@@ -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> {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`}>
Expand Down
Loading