diff --git a/CLAUDE.md b/CLAUDE.md index 4751986d..81fa3992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Plannotator -A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs. +A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. ## Project Structure @@ -9,13 +9,13 @@ plannotator/ ├── apps/ │ ├── hook/ # Claude Code plugin │ │ ├── .claude-plugin/plugin.json -│ │ ├── commands/ # Slash commands (plannotator-review.md) +│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) │ │ ├── hooks/hooks.json # PermissionRequest hook config -│ │ ├── server/index.ts # Entry point (plan + review subcommand) +│ │ ├── server/index.ts # Entry point (plan + review + annotate subcommands) │ │ └── dist/ # Built single-file apps (index.html, review.html) │ ├── opencode-plugin/ # OpenCode plugin -│ │ ├── commands/ # Slash commands (plannotator-review.md) -│ │ ├── index.ts # Plugin entry with submit_plan tool + review event handler +│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md) +│ │ ├── index.ts # Plugin entry with submit_plan tool + review/annotate event handlers │ │ ├── plannotator.html # Built plan review app │ │ └── review-editor.html # Built code review app │ └── review/ # Standalone review server (for development) @@ -26,6 +26,7 @@ plannotator/ │ ├── server/ # Shared server implementation │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() │ │ ├── review.ts # startReviewServer(), handleReviewServerReady() +│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() │ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.) │ │ ├── remote.ts # isRemoteSession(), getServerPort() │ │ ├── browser.ts # openBrowser() @@ -112,6 +113,23 @@ Send Feedback → feedback sent to agent session Approve → "LGTM" sent to agent session ``` +## Annotate Flow + +``` +User runs /plannotator-annotate command + ↓ +Claude Code: plannotator annotate subcommand runs +OpenCode: event handler intercepts command + ↓ +Markdown file read from disk + ↓ +Annotate server starts (reuses plan editor HTML with mode:"annotate") + ↓ +User annotates markdown, provides feedback + ↓ +Send Annotations → feedback sent to agent session +``` + ## Server API ### Plan Server (`packages/server/index.ts`) @@ -122,7 +140,7 @@ Approve → "LGTM" sent to agent session | `/api/approve` | POST | Approve plan (body: planSave, agentSwitch, obsidian, bear, feedback) | | `/api/deny` | POST | Deny plan (body: feedback, planSave) | | `/api/image` | GET | Serve image by path query param | -| `/api/upload` | POST | Upload image, returns temp path | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | | `/api/obsidian/vaults`| GET | Detect available Obsidian vaults | ### Review Server (`packages/server/review.ts`) @@ -132,9 +150,18 @@ Approve → "LGTM" sent to agent session | `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` | | `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) | | `/api/image` | GET | Serve image by path query param | -| `/api/upload` | POST | Upload image, returns temp path | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | -Both servers use random ports locally or fixed port (`19432`) in remote mode. +### Annotate Server (`packages/server/annotate.ts`) + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/api/plan` | GET | Returns `{ plan, origin, mode: "annotate", filePath }` | +| `/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 }` | + +All servers use random ports locally or fixed port (`19432`) in remote mode. ## Data Types @@ -149,6 +176,11 @@ enum AnnotationType { GLOBAL_COMMENT = "GLOBAL_COMMENT", } +interface ImageAttachment { + path: string; // temp file path + name: string; // human-readable label (e.g., "login-mockup") +} + interface Annotation { id: string; blockId: string; @@ -159,6 +191,7 @@ interface Annotation { originalText: string; // The selected text createdA: number; // Timestamp author?: string; // Tater identity + images?: ImageAttachment[]; // Attached images with names startMeta?: { parentTagName; parentIndex; textOffset }; endMeta?: { parentTagName; parentIndex; textOffset }; } @@ -187,7 +220,7 @@ interface Block { - Horizontal rules (`---`) - Paragraphs (default) -`exportDiff(blocks, annotations)` generates human-readable feedback for Claude. +`exportDiff(blocks, annotations, globalAttachments)` generates human-readable feedback for Claude. Images are referenced by name: `[image-name] /tmp/path...`. ## Annotation System @@ -205,17 +238,21 @@ Shares full plan + annotations via URL hash using deflate compression. **Payload format:** ```typescript +// Image in shareable format: plain string (old) or [path, name] tuple (new) +type ShareableImage = string | [string, string]; + interface SharePayload { p: string; // Plan markdown a: ShareableAnnotation[]; // Compact annotations + g?: ShareableImage[]; // Global attachments } type ShareableAnnotation = - | ["D", string, string | null] // [type, original, author] - | ["R", string, string, string | null] // [type, original, replacement, author] - | ["C", string, string, string | null] // [type, original, comment, author] - | ["I", string, string, string | null] // [type, context, newText, author] - | ["G", string, string | null]; // [type, comment, author] - global comment + | ["D", string, string | null, ShareableImage[]?] // [type, original, author, images?] + | ["R", string, string, string | null, ShareableImage[]?] // [type, original, replacement, author, images?] + | ["C", string, string, string | null, ShareableImage[]?] // [type, original, comment, author, images?] + | ["I", string, string, string | null, ShareableImage[]?] // [type, context, newText, author, images?] + | ["G", string, string | null, ShareableImage[]?]; // [type, comment, author, images?] ``` **Compression pipeline:** diff --git a/apps/hook/commands/plannotator-annotate.md b/apps/hook/commands/plannotator-annotate.md new file mode 100644 index 00000000..017a4354 --- /dev/null +++ b/apps/hook/commands/plannotator-annotate.md @@ -0,0 +1,12 @@ +--- +description: Open interactive annotation UI for a markdown file +allowed-tools: Bash(plannotator:*) +--- + +## Markdown Annotations + +!`plannotator annotate $ARGUMENTS` + +## Your task + +Address the annotation feedback above. The user has reviewed the markdown file and provided specific annotations and comments. diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1c5a9cea..1b8c553e 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,7 +1,7 @@ /** * Plannotator CLI for Claude Code * - * Supports two modes: + * Supports three modes: * * 1. Plan Review (default, no args): * - Spawned by ExitPlanMode hook @@ -13,6 +13,11 @@ * - Runs git diff, opens review UI * - Outputs feedback to stdout (captured by slash command) * + * 3. Annotate (`plannotator annotate `): + * - Triggered by /plannotator-annotate slash command + * - Opens any markdown file in the annotation UI + * - Outputs structured feedback to stdout + * * Environment variables: * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (preferred) * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) @@ -26,6 +31,10 @@ import { startReviewServer, handleReviewServerReady, } from "@plannotator/server/review"; +import { + startAnnotateServer, + handleAnnotateServerReady, +} from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; // Embed the built HTML at compile time @@ -86,6 +95,53 @@ if (args[0] === "review") { console.log(result.feedback || "No feedback provided."); process.exit(0); +} else if (args[0] === "annotate") { + // ============================================ + // ANNOTATE MODE + // ============================================ + + const filePath = args[1]; + if (!filePath) { + console.error("Usage: plannotator annotate "); + process.exit(1); + } + + // Resolve to absolute path + const path = await import("path"); + const absolutePath = path.resolve(filePath); + + // Read the markdown file + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + console.error(`File not found: ${absolutePath}`); + process.exit(1); + } + const markdown = await file.text(); + + // Start the annotate server (reuses plan editor HTML) + const server = await startAnnotateServer({ + markdown, + filePath: absolutePath, + origin: "claude-code", + sharingEnabled, + shareBaseUrl, + htmlContent: planHtmlContent, + onReady: handleAnnotateServerReady, + }); + + // Wait for user feedback + const result = await server.waitForDecision(); + + // Give browser time to receive response and update UI + await Bun.sleep(1500); + + // Cleanup + server.stop(); + + // Output feedback (captured by slash command) + console.log(result.feedback || "No feedback provided."); + process.exit(0); + } else { // ============================================ // PLAN REVIEW MODE (default) diff --git a/apps/opencode-plugin/commands/plannotator-annotate.md b/apps/opencode-plugin/commands/plannotator-annotate.md new file mode 100644 index 00000000..a8b03084 --- /dev/null +++ b/apps/opencode-plugin/commands/plannotator-annotate.md @@ -0,0 +1,6 @@ +--- +description: Open interactive annotation UI for a markdown file +--- + +The Plannotator Annotate UI has been triggered. Opening the annotation UI... +Acknowledge "Opening annotation UI..." and wait for the user's feedback. diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index f6ee2a7a..adc557b0 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -21,6 +21,10 @@ import { startReviewServer, handleReviewServerReady, } from "@plannotator/server/review"; +import { + startAnnotateServer, + handleAnnotateServerReady, +} from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; // @ts-ignore - Bun import attribute for text @@ -209,6 +213,81 @@ Do NOT proceed with implementation until your plan is approved. } } } + + // Handle /plannotator-annotate command + const isAnnotateCommand = commandName === "plannotator-annotate"; + + if (isCommandEvent && isAnnotateCommand) { + // @ts-ignore - Event properties contain arguments + const filePath = event.properties?.arguments || event.arguments || ""; + + if (!filePath) { + ctx.client.app.log({ + level: "error", + message: "Usage: /plannotator-annotate ", + }); + return; + } + + ctx.client.app.log({ + level: "info", + message: `Opening annotation UI for ${filePath}...`, + }); + + // Resolve to absolute path + const path = await import("path"); + const absolutePath = path.resolve(filePath); + + // Read the markdown file + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + ctx.client.app.log({ + level: "error", + message: `File not found: ${absolutePath}`, + }); + return; + } + const markdown = await file.text(); + + // Start annotate server (reuses plan editor HTML) + const server = await startAnnotateServer({ + markdown, + filePath: absolutePath, + origin: "opencode", + sharingEnabled: await getSharingEnabled(), + shareBaseUrl: getShareBaseUrl(), + htmlContent: htmlContent, + onReady: handleAnnotateServerReady, + }); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + // Send feedback back to the session if provided + if (result.feedback) { + // @ts-ignore - Event properties contain sessionID for command.executed events + const sessionId = event.properties?.sessionID; + + if (sessionId) { + try { + await ctx.client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [ + { + type: "text", + text: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, + }, + ], + }, + }); + } catch { + // Session may not be available + } + } + } + } }, tool: { diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index fe458fe3..772e1e7a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -5,7 +5,7 @@ import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; import { ExportModal } from '@plannotator/ui/components/ExportModal'; import { ImportModal } from '@plannotator/ui/components/ImportModal'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; -import { Annotation, Block, EditorMode } from '@plannotator/ui/types'; +import { Annotation, Block, EditorMode, type ImageAttachment } from '@plannotator/ui/types'; import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { ModeSwitcher } from '@plannotator/ui/components/ModeSwitcher'; @@ -35,6 +35,7 @@ import { import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; import { UIFeaturesSetup } from '@plannotator/ui/components/UIFeaturesSetup'; import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; +import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration @@ -350,11 +351,12 @@ const App: React.FC = () => { const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences()); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState<'claude-code' | 'opencode' | null>(null); - const [globalAttachments, setGlobalAttachments] = useState([]); + const [globalAttachments, setGlobalAttachments] = useState([]); + const [annotateMode, setAnnotateMode] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); - const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string } | null>(null); + const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [showUIFeaturesSetup, setShowUIFeaturesSetup] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); @@ -441,9 +443,12 @@ const App: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: 'claude-code' | 'opencode'; sharingEnabled?: boolean; shareBaseUrl?: string; repoInfo?: { display: string; branch?: string } }) => { + .then((data: { plan: string; origin?: 'claude-code' | 'opencode'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; repoInfo?: { display: string; branch?: string } }) => { setMarkdown(data.plan); setIsApiMode(true); + if (data.mode === 'annotate') { + setAnnotateMode(true); + } if (data.sharingEnabled !== undefined) { setSharingEnabled(data.sharingEnabled); } @@ -489,9 +494,10 @@ const App: React.FC = () => { e.preventDefault(); const file = item.getAsFile(); if (file) { - // Show annotator instead of direct upload + // Derive name before showing annotator so user sees it immediately + const initialName = deriveImageName(file.name, globalAttachments.map(g => g.name)); const blobUrl = URL.createObjectURL(file); - setPendingPasteImage({ file, blobUrl }); + setPendingPasteImage({ file, blobUrl, initialName }); } break; } @@ -500,10 +506,10 @@ const App: React.FC = () => { document.addEventListener('paste', handlePaste); return () => document.removeEventListener('paste', handlePaste); - }, []); + }, [globalAttachments]); - // Handle paste annotator accept - const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean) => { + // Handle paste annotator accept — name comes from ImageAnnotator + const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { if (!pendingPasteImage) return; try { @@ -515,8 +521,8 @@ const App: React.FC = () => { const res = await fetch('/api/upload', { method: 'POST', body: formData }); if (res.ok) { - const { path } = await res.json(); - setGlobalAttachments(prev => [...prev, path]); + const data = await res.json(); + setGlobalAttachments(prev => [...prev, { path: data.path, name }]); } } catch { // Upload failed silently @@ -618,6 +624,27 @@ const App: React.FC = () => { } }; + // Annotate mode handler — sends feedback via /api/feedback + const handleAnnotateFeedback = async () => { + setIsSubmitting(true); + try { + await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: diffOutput, + annotations, + }), + }); + if (getAutoClose()) { + window.close(); + } + setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay + } catch { + setIsSubmitting(false); + } + }; + // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -640,6 +667,16 @@ const App: React.FC = () => { e.preventDefault(); + // Annotate mode: always send feedback + if (annotateMode) { + if (annotations.length === 0) { + setShowFeedbackPrompt(true); + } else { + handleAnnotateFeedback(); + } + return; + } + // No annotations → Approve, otherwise → Send Feedback if (annotations.length === 0) { // Check if agent exists for OpenCode users @@ -662,7 +699,7 @@ const App: React.FC = () => { }, [ showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, showPermissionModeSetup, showUIFeaturesSetup, pendingPasteImage, - submitted, isSubmitting, isApiMode, annotations.length, + submitted, isSubmitting, isApiMode, annotations.length, annotateMode, origin, getAgentWarning, ]); @@ -690,14 +727,15 @@ const App: React.FC = () => { )); }; - const handleAddGlobalAttachment = (path: string) => { - setGlobalAttachments(prev => [...prev, path]); + const handleAddGlobalAttachment = (image: ImageAttachment) => { + setGlobalAttachments(prev => [...prev, image]); }; const handleRemoveGlobalAttachment = (path: string) => { - setGlobalAttachments(prev => prev.filter(p => p !== path)); + setGlobalAttachments(prev => prev.filter(p => p.path !== path)); }; + const handleTocNavigate = (blockId: string) => { // Navigation handled by TableOfContents component // This is just a placeholder for future custom logic @@ -853,6 +891,8 @@ const App: React.FC = () => { onClick={() => { if (annotations.length === 0) { setShowFeedbackPrompt(true); + } else if (annotateMode) { + handleAnnotateFeedback(); } else { handleDeny(); } @@ -868,10 +908,10 @@ const App: React.FC = () => { - {isSubmitting ? 'Sending...' : 'Send Feedback'} + {isSubmitting ? 'Sending...' : annotateMode ? 'Send Annotations' : 'Send Feedback'} -
+ {!annotateMode &&
)} -
+ }
@@ -1211,12 +1251,14 @@ const App: React.FC = () => {

- {submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'} + {submitted === 'approved' ? 'Plan Approved' : annotateMode ? 'Annotations Sent' : 'Feedback Sent'}

{submitted === 'approved' ? `${agentName} will proceed with the implementation.` - : `${agentName} will revise the plan based on your annotations.`} + : annotateMode + ? `${agentName} will address your annotations on the file.` + : `${agentName} will revise the plan based on your annotations.`}

@@ -1239,6 +1281,7 @@ const App: React.FC = () => { diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts new file mode 100644 index 00000000..3cfb3f14 --- /dev/null +++ b/packages/server/annotate.ts @@ -0,0 +1,250 @@ +/** + * Annotate Server + * + * Provides a server for annotating arbitrary markdown files. + * Follows the same patterns as the review server but serves + * markdown content via /api/plan so the plan editor UI can + * render it without modifications. + * + * Environment variables: + * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode + * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) + */ + +import { mkdirSync } from "fs"; +import { isRemoteSession, getServerPort } from "./remote"; +import { openBrowser } from "./browser"; +import { getRepoInfo } from "./repo"; + +// Re-export utilities +export { isRemoteSession, getServerPort } from "./remote"; +export { openBrowser } from "./browser"; + +// --- Types --- + +export interface AnnotateServerOptions { + /** Markdown content of the file to annotate */ + markdown: string; + /** Original file path (for display purposes) */ + filePath: string; + /** HTML content to serve for the UI */ + htmlContent: string; + /** Origin identifier for UI customization */ + origin?: "opencode" | "claude-code"; + /** Whether URL sharing is enabled (default: true) */ + sharingEnabled?: boolean; + /** Custom base URL for share links */ + shareBaseUrl?: string; + /** Called when server starts with the URL, remote status, and port */ + onReady?: (url: string, isRemote: boolean, port: number) => void; +} + +export interface AnnotateServerResult { + /** The port the server is running on */ + port: number; + /** The full URL to access the server */ + url: string; + /** Whether running in remote mode */ + isRemote: boolean; + /** Wait for user feedback submission */ + waitForDecision: () => Promise<{ + feedback: string; + annotations: unknown[]; + }>; + /** Stop the server */ + stop: () => void; +} + +// --- Server Implementation --- + +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 500; + +/** + * Start the Annotate server + * + * Handles: + * - Remote detection and port configuration + * - API routes (/api/plan with mode:"annotate", /api/feedback) + * - Port conflict retries + */ +export async function startAnnotateServer( + options: AnnotateServerOptions +): Promise { + const { + markdown, + filePath, + htmlContent, + origin, + sharingEnabled = true, + shareBaseUrl, + onReady, + } = options; + + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + // Detect repo info (cached for this session) + const repoInfo = await getRepoInfo(); + + // Decision promise + let resolveDecision: (result: { + feedback: string; + annotations: unknown[]; + }) => void; + const decisionPromise = new Promise<{ + feedback: string; + annotations: unknown[]; + }>((resolve) => { + resolveDecision = resolve; + }); + + // Start server with retry logic + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + port: configuredPort, + + async fetch(req) { + const url = new URL(req.url); + + // API: Get plan content (reuse /api/plan so the plan editor UI works) + if (url.pathname === "/api/plan" && req.method === "GET") { + return Response.json({ + plan: markdown, + origin, + mode: "annotate", + filePath, + sharingEnabled, + shareBaseUrl, + repoInfo, + }); + } + + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + const imagePath = url.searchParams.get("path"); + if (!imagePath) { + return new Response("Missing path parameter", { status: 400 }); + } + try { + const file = Bun.file(imagePath); + if (!(await file.exists())) { + return new Response("File not found", { status: 404 }); + } + return new Response(file); + } catch { + return new Response("Failed to read file", { status: 500 }); + } + } + + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + if (!file) { + return new Response("No file provided", { status: 400 }); + } + + const ext = file.name.split(".").pop() || "png"; + const tempDir = "/tmp/plannotator"; + mkdirSync(tempDir, { recursive: true }); + const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`; + + await Bun.write(tempPath, file); + return Response.json({ path: tempPath, originalName: file.name }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Upload failed"; + return Response.json({ error: message }, { status: 500 }); + } + } + + // API: Submit annotation feedback + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as { + feedback: string; + annotations: unknown[]; + }; + + resolveDecision({ + feedback: body.feedback || "", + annotations: body.annotations || [], + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to process feedback"; + return Response.json({ error: message }, { status: 500 }); + } + } + + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + + break; // Success, exit retry loop + } catch (err: unknown) { + const isAddressInUse = + err instanceof Error && err.message.includes("EADDRINUSE"); + + if (isAddressInUse && attempt < MAX_RETRIES) { + await Bun.sleep(RETRY_DELAY_MS); + continue; + } + + if (isAddressInUse) { + const hint = isRemote + ? " (set PLANNOTATOR_PORT to use different port)" + : ""; + throw new Error( + `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}` + ); + } + + throw err; + } + } + + if (!server) { + throw new Error("Failed to start server"); + } + + const serverUrl = `http://localhost:${server.port}`; + + // Notify caller that server is ready + if (onReady) { + onReady(serverUrl, isRemote, server.port); + } + + return { + port: server.port, + url: serverUrl, + isRemote, + waitForDecision: () => decisionPromise, + stop: () => server.stop(), + }; +} + +/** + * Default behavior: open browser for local sessions + */ +export async function handleAnnotateServerReady( + url: string, + isRemote: boolean, + _port: number +): Promise { + if (!isRemote) { + await openBrowser(url); + } +} diff --git a/packages/server/index.ts b/packages/server/index.ts index 1a66c0e9..3642e1e9 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -172,7 +172,7 @@ export async function startPlannotatorServer( const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`; await Bun.write(tempPath, file); - return Response.json({ path: tempPath }); + return Response.json({ path: tempPath, originalName: file.name }); } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; diff --git a/packages/server/package.json b/packages/server/package.json index fd4f304e..01db5e90 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./index.ts", "./review": "./review.ts", + "./annotate": "./annotate.ts", "./remote": "./remote.ts", "./browser": "./browser.ts", "./storage": "./storage.ts", diff --git a/packages/server/review.ts b/packages/server/review.ts index 786416a3..47448639 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -200,7 +200,7 @@ export async function startReviewServer( const tempPath = `${tempDir}/${crypto.randomUUID()}.${ext}`; await Bun.write(tempPath, file); - return Response.json({ path: tempPath }); + return Response.json({ path: tempPath, originalName: file.name }); } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 24afd7c8..902618b6 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -394,15 +394,17 @@ const AnnotationCard: React.FC<{ )} {/* Attached Images */} - {annotation.imagePaths && annotation.imagePaths.length > 0 && ( + {annotation.images && annotation.images.length > 0 && (
- {annotation.imagePaths.map((path, idx) => ( - + {annotation.images.map((img, idx) => ( +
+ +
{img.name}
+
))}
)} diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index ecbaa5f9..654bcf80 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { AnnotationType } from "../types"; +import { AnnotationType, type ImageAttachment } from "../types"; import { createPortal } from "react-dom"; import { AttachmentsButton } from "./AttachmentsButton"; @@ -8,7 +8,7 @@ type PositionMode = 'center-above' | 'top-right'; interface AnnotationToolbarProps { element: HTMLElement; positionMode: PositionMode; - onAnnotate: (type: AnnotationType, text?: string, imagePaths?: string[]) => void; + onAnnotate: (type: AnnotationType, text?: string, images?: ImageAttachment[]) => void; onClose: () => void; /** Text to copy (for text selection, pass source.text) */ copyText?: string; @@ -43,7 +43,7 @@ export const AnnotationToolbar: React.FC = ({ const [step, setStep] = useState<"menu" | "input">(initialStep); const [activeType, setActiveType] = useState(initialType ?? null); const [inputValue, setInputValue] = useState(""); - const [imagePaths, setImagePaths] = useState([]); + const [images, setImages] = useState([]); const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); const [copied, setCopied] = useState(false); const inputRef = useRef(null); @@ -81,7 +81,7 @@ export const AnnotationToolbar: React.FC = ({ setStep(initialStep); setActiveType(initialType ?? null); setInputValue(""); - setImagePaths([]); + setImages([]); setCopied(false); }, [element, initialStep, initialType]); @@ -159,8 +159,8 @@ export const AnnotationToolbar: React.FC = ({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (activeType && (inputValue.trim() || imagePaths.length > 0)) { - onAnnotate(activeType, inputValue || undefined, imagePaths.length > 0 ? imagePaths : undefined); + if (activeType && (inputValue.trim() || images.length > 0)) { + onAnnotate(activeType, inputValue || undefined, images.length > 0 ? images : undefined); } }; @@ -238,21 +238,21 @@ export const AnnotationToolbar: React.FC = ({ if (e.key === "Escape") setStep("menu"); if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - if (inputValue.trim() || imagePaths.length > 0) { - onAnnotate(activeType!, inputValue || undefined, imagePaths.length > 0 ? imagePaths : undefined); + if (inputValue.trim() || images.length > 0) { + onAnnotate(activeType!, inputValue || undefined, images.length > 0 ? images : undefined); } } }} /> setImagePaths((prev) => [...prev, path])} - onRemove={(path) => setImagePaths((prev) => prev.filter((p) => p !== path))} + images={images} + onAdd={(img) => setImages((prev) => [...prev, img])} + onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} variant="inline" /> @@ -207,9 +255,9 @@ export const AttachmentsButton: React.FC = ({ {/* Header */}
Attachments
- {paths.length > 0 && ( + {images.length > 0 && ( - {paths.length} image{paths.length !== 1 ? 's' : ''} + {images.length} image{images.length !== 1 ? 's' : ''} )}
@@ -277,22 +325,29 @@ export const AttachmentsButton: React.FC = ({
{/* Grid of current attachments */} - {paths.length > 0 && ( + {images.length > 0 && (
Current
- {paths.map((path) => ( - { - setEditingPath(path); - setIsOpen(false); - }} - onRemove={() => onRemove(path)} - showRemove - /> + {images.map((img) => ( +
+ { + setEditingImage({ path: img.path, name: img.name }); + setIsOpen(false); + }} + onRemove={() => onRemove(img.path)} + showRemove + /> +
+ {img.name} +
+
))}
@@ -304,10 +359,11 @@ export const AttachmentsButton: React.FC = ({ )} {/* Image Annotator Dialog - portaled to body for correct positioning */} - {(!!annotatorImage || !!editingPath) && createPortal( + {annotatorOpen && createPortal( , diff --git a/packages/ui/components/ImageAnnotator/index.tsx b/packages/ui/components/ImageAnnotator/index.tsx index bd26ac74..987c2ddc 100644 --- a/packages/ui/components/ImageAnnotator/index.tsx +++ b/packages/ui/components/ImageAnnotator/index.tsx @@ -8,8 +8,10 @@ import { DEFAULT_STATE } from './types'; interface ImageAnnotatorProps { imageSrc: string; isOpen: boolean; - onAccept: (blob: Blob, hasDrawings: boolean) => Promise; + onAccept: (blob: Blob, hasDrawings: boolean, name: string) => Promise; onClose: () => void; + /** Pre-populated image name (derived from filename) */ + initialName?: string; } export const ImageAnnotator: React.FC = ({ @@ -17,23 +19,38 @@ export const ImageAnnotator: React.FC = ({ isOpen, onAccept, onClose, + initialName = '', }) => { const [state, setState] = useState(DEFAULT_STATE); const [saving, setSaving] = useState(false); + const [name, setName] = useState(initialName); const imageRef = useRef(null); + const nameInputRef = useRef(null); // Reset state when dialog opens useEffect(() => { if (isOpen) { setState(DEFAULT_STATE); + setName(initialName); } - }, [isOpen]); + }, [isOpen, initialName]); // Keyboard shortcuts useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { + // Don't intercept when typing in the name input + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT') { + if (e.key === 'Escape') { + // Blur and let the next Escape close + target.blur(); + e.preventDefault(); + } + return; + } + // Escape or Enter to accept if (e.key === 'Escape' || e.key === 'Enter') { e.preventDefault(); @@ -129,12 +146,13 @@ export const ImageAnnotator: React.FC = ({ } const hasDrawings = state.strokes.length > 0; + const finalName = name.trim() || initialName || 'image'; // If no drawings, just pass through original image if (!hasDrawings) { const response = await fetch(imageSrc); const blob = await response.blob(); - await onAccept(blob, false); + await onAccept(blob, false, finalName); onClose(); return; } @@ -160,7 +178,7 @@ export const ImageAnnotator: React.FC = ({ // Convert to blob canvas.toBlob(async (blob) => { if (blob) { - await onAccept(blob, true); + await onAccept(blob, true, finalName); } onClose(); }, 'image/png'); @@ -213,6 +231,27 @@ export const ImageAnnotator: React.FC = ({ onImageLoad={handleImageLoad} /> + {/* Image name input */} + {initialName && ( +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAccept(); + } + }} + className="flex-1 px-2 py-1 text-xs bg-muted/50 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary" + placeholder="Image name..." + /> +
+ )} + {/* Accept hint */}
Press Esc or Enter or click outside to accept diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index d7dd04a6..a598397e 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, us import Highlighter from '@plannotator/web-highlighter'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; -import { Block, Annotation, AnnotationType, EditorMode } from '../types'; +import { Block, Annotation, AnnotationType, EditorMode, type ImageAttachment } from '../types'; import { Frontmatter } from '../utils/parser'; import { AnnotationToolbar } from './AnnotationToolbar'; import { TaterSpriteSitting } from './TaterSpriteSitting'; @@ -20,8 +20,8 @@ interface ViewerProps { selectedAnnotationId: string | null; mode: EditorMode; taterMode: boolean; - globalAttachments?: string[]; - onAddGlobalAttachment?: (path: string) => void; + globalAttachments?: ImageAttachment[]; + onAddGlobalAttachment?: (image: ImageAttachment) => void; onRemoveGlobalAttachment?: (path: string) => void; repoInfo?: { display: string; branch?: string } | null; stickyActions?: boolean; @@ -194,7 +194,7 @@ export const Viewer = forwardRef(({ source: any, type: AnnotationType, text?: string, - imagePaths?: string[] + images?: ImageAttachment[] ) => { const doms = highlighter.getDoms(source.id); let blockId = ''; @@ -226,7 +226,7 @@ export const Viewer = forwardRef(({ author: getIdentity(), startMeta: source.startMeta, endMeta: source.endMeta, - imagePaths, + images, }; if (type === AnnotationType.DELETION) { @@ -548,11 +548,11 @@ export const Viewer = forwardRef(({ }); }, [annotations]); - const handleAnnotate = (type: AnnotationType, text?: string, imagePaths?: string[]) => { + const handleAnnotate = (type: AnnotationType, text?: string, images?: ImageAttachment[]) => { const highlighter = highlighterRef.current; if (!toolbarState || !highlighter) return; - createAnnotationFromSource(highlighter, toolbarState.source, type, text, imagePaths); + createAnnotationFromSource(highlighter, toolbarState.source, type, text, images); pendingSourceRef.current = null; setToolbarState(null); window.getSelection()?.removeAllRanges(); @@ -567,7 +567,7 @@ export const Viewer = forwardRef(({ window.getSelection()?.removeAllRanges(); }; - const handleCodeBlockAnnotate = (type: AnnotationType, text?: string, imagePaths?: string[]) => { + const handleCodeBlockAnnotate = (type: AnnotationType, text?: string, images?: ImageAttachment[]) => { const highlighter = highlighterRef.current; if (!hoveredCodeBlock || !highlighter) return; @@ -609,7 +609,7 @@ export const Viewer = forwardRef(({ originalText: codeText, createdA: Date.now(), author: getIdentity(), - imagePaths, + images, }; onAddAnnotationRef.current(newAnnotation); @@ -657,7 +657,7 @@ export const Viewer = forwardRef(({ {/* Attachments button */} {onAddGlobalAttachment && onRemoveGlobalAttachment && ( void; @@ -53,13 +53,30 @@ interface UseSharingResult { importFromShareUrl: (url: string) => Promise; } +/** + * Parse SharePayload.g (which can be old string[] or new [path,name][] format) into ImageAttachment[] + */ +function parseGlobalAttachments(g: unknown[] | undefined): ImageAttachment[] { + if (!g?.length) return []; + return g.map((item, idx) => { + if (typeof item === 'string') { + const name = item.split('/').pop()?.replace(/\.[^.]+$/, '') || `image-${idx + 1}`; + return { path: item, name }; + } + if (Array.isArray(item) && item.length >= 2) { + return { path: item[0] as string, name: item[1] as string }; + } + return { path: String(item), name: `image-${idx + 1}` }; + }); +} + export function useSharing( markdown: string, annotations: Annotation[], - globalAttachments: string[], + globalAttachments: ImageAttachment[], setMarkdown: (m: string) => void, setAnnotations: (a: Annotation[]) => void, - setGlobalAttachments: (g: string[]) => void, + setGlobalAttachments: (g: ImageAttachment[]) => void, onSharedLoad?: () => void, shareBaseUrl?: string ): UseSharingResult { @@ -68,7 +85,7 @@ export function useSharing( const [shareUrl, setShareUrl] = useState(''); const [shareUrlSize, setShareUrlSize] = useState(''); const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState(null); - const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState(null); + const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState(null); const clearPendingSharedAnnotations = useCallback(() => { setPendingSharedAnnotations(null); @@ -90,8 +107,9 @@ export function useSharing( // Restore global attachments if present if (payload.g?.length) { - setGlobalAttachments(payload.g); - setSharedGlobalAttachments(payload.g); + const parsed = parseGlobalAttachments(payload.g); + setGlobalAttachments(parsed); + setSharedGlobalAttachments(parsed); } // Store for later application to DOM @@ -200,11 +218,13 @@ export function useSharing( // Handle global attachments (deduplicate by path) if (payload.g?.length) { - const newPaths = payload.g.filter(p => !globalAttachments.includes(p)); - if (newPaths.length > 0) { - setGlobalAttachments([...globalAttachments, ...newPaths]); + const parsed = parseGlobalAttachments(payload.g); + const existingPaths = new Set(globalAttachments.map(g => g.path)); + const newAttachments = parsed.filter(p => !existingPaths.has(p.path)); + if (newAttachments.length > 0) { + setGlobalAttachments([...globalAttachments, ...newAttachments]); } - setSharedGlobalAttachments(payload.g); + setSharedGlobalAttachments(parsed); } } diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 5671ecce..28759223 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -8,6 +8,11 @@ export enum AnnotationType { export type EditorMode = 'selection' | 'comment' | 'redline'; +export interface ImageAttachment { + path: string; + name: string; +} + export interface Annotation { id: string; blockId: string; // Legacy - not used with web-highlighter @@ -18,7 +23,7 @@ export interface Annotation { originalText: string; // The text that was selected createdA: number; author?: string; // Tater identity for collaborative sharing - imagePaths?: string[]; // Attached images (local paths or URLs) + images?: ImageAttachment[]; // Attached images with human-readable names // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 7f7ca478..7e0b454a 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -1,4 +1,4 @@ -import { Block } from '../types'; +import { Block, type ImageAttachment } from '../types'; /** * Parsed YAML frontmatter as key-value pairs. @@ -243,7 +243,7 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { return blocks; }; -export const exportDiff = (blocks: Block[], annotations: any[], globalAttachments: string[] = []): string => { +export const exportDiff = (blocks: Block[], annotations: any[], globalAttachments: ImageAttachment[] = []): string => { if (annotations.length === 0 && globalAttachments.length === 0) { return 'No changes detected.'; } @@ -262,8 +262,8 @@ export const exportDiff = (blocks: Block[], annotations: any[], globalAttachment if (globalAttachments.length > 0) { output += `## Reference Images\n`; output += `Please review these reference images (use the Read tool to view):\n`; - globalAttachments.forEach((path, idx) => { - output += `${idx + 1}. \`${path}\`\n`; + globalAttachments.forEach((img, idx) => { + output += `${idx + 1}. [${img.name}] \`${img.path}\`\n`; }); output += `\n`; } @@ -307,10 +307,10 @@ export const exportDiff = (blocks: Block[], annotations: any[], globalAttachment } // Add attached images for this annotation - if (ann.imagePaths && ann.imagePaths.length > 0) { + if (ann.images && ann.images.length > 0) { output += `**Attached images:**\n`; - ann.imagePaths.forEach((path: string) => { - output += `- \`${path}\`\n`; + ann.images.forEach((img: ImageAttachment) => { + output += `- [${img.name}] \`${img.path}\`\n`; }); } diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 8762cbc3..28f8479b 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -8,20 +8,46 @@ * Inspired by textarea.my's approach. */ -import { Annotation, AnnotationType } from '../types'; +import { Annotation, AnnotationType, type ImageAttachment } from '../types'; -// Minimal shareable annotation format: [type, originalText, text?, author?, imagePaths?] +// Image in shareable format: plain string (old) or [path, name] tuple (new) +type ShareableImage = string | [string, string]; + +// Minimal shareable annotation format: [type, originalText, text?, author?, images?] export type ShareableAnnotation = - | ['D', string, string | null, string[]?] // Deletion: type, original, author, images - | ['R', string, string, string | null, string[]?] // Replacement: type, original, replacement, author, images - | ['C', string, string, string | null, string[]?] // Comment: type, original, comment, author, images - | ['I', string, string, string | null, string[]?] // Insertion: type, context, new text, author, images - | ['G', string, string | null, string[]?]; // Global Comment: type, comment, author, images + | ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images + | ['R', string, string, string | null, ShareableImage[]?] // Replacement: type, original, replacement, author, images + | ['C', string, string, string | null, ShareableImage[]?] // Comment: type, original, comment, author, images + | ['I', string, string, string | null, ShareableImage[]?] // Insertion: type, context, new text, author, images + | ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images export interface SharePayload { p: string; // plan markdown a: ShareableAnnotation[]; - g?: string[]; // global attachments (image paths) + g?: ShareableImage[]; // global attachments (path strings or [path, name] tuples) +} + +/** + * Convert ShareableImage[] to ImageAttachment[] (handles old plain-string format) + */ +function parseShareableImages(raw: ShareableImage[] | undefined): ImageAttachment[] | undefined { + if (!raw?.length) return undefined; + return raw.map(img => { + if (typeof img === 'string') { + // Old format: plain path string — derive name from filename + const name = img.split('/').pop()?.replace(/\.[^.]+$/, '') || 'image'; + return { path: img, name }; + } + return { path: img[0], name: img[1] }; + }); +} + +/** + * Convert ImageAttachment[] to ShareableImage[] for compact serialization + */ +function toShareableImages(images: ImageAttachment[] | undefined): ShareableImage[] | undefined { + if (!images?.length) return undefined; + return images.map(img => [img.path, img.name]); } /** @@ -76,7 +102,7 @@ export async function decompress(b64: string): Promise { export function toShareable(annotations: Annotation[]): ShareableAnnotation[] { return annotations.map(ann => { const author = ann.author || null; - const images = ann.imagePaths?.length ? ann.imagePaths : undefined; + const images = toShareableImages(ann.images); // Handle GLOBAL_COMMENT specially - it starts with 'G' (from GLOBAL_COMMENT) if (ann.type === AnnotationType.GLOBAL_COMMENT) { @@ -115,7 +141,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { if (type === 'G') { const text = item[1] as string; const author = item[2] as string | null; - const imagePaths = item[3] as string[] | undefined; + const rawImages = item[3] as ShareableImage[] | undefined; return { id: `shared-${index}-${Date.now()}`, @@ -127,7 +153,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { originalText: '', createdA: Date.now() + index, author: author || undefined, - imagePaths: imagePaths?.length ? imagePaths : undefined, + images: parseShareableImages(rawImages), }; } @@ -136,7 +162,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { // For others: [type, original, text, author, images?] const text = type === 'D' ? undefined : item[2] as string; const author = type === 'D' ? item[2] as string | null : item[3] as string | null; - const imagePaths = type === 'D' ? item[3] as string[] | undefined : item[4] as string[] | undefined; + const rawImages = type === 'D' ? item[3] as ShareableImage[] | undefined : item[4] as ShareableImage[] | undefined; return { id: `shared-${index}-${Date.now()}`, @@ -148,7 +174,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { originalText, createdA: Date.now() + index, // Preserve order author: author || undefined, - imagePaths: imagePaths?.length ? imagePaths : undefined, + images: parseShareableImages(rawImages), // startMeta/endMeta will be set by web-highlighter }; }); @@ -160,13 +186,13 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { export async function generateShareUrl( markdown: string, annotations: Annotation[], - globalAttachments?: string[], + globalAttachments?: ImageAttachment[], baseUrl: string = 'https://share.plannotator.ai' ): Promise { const payload: SharePayload = { p: markdown, a: toShareable(annotations), - g: globalAttachments?.length ? globalAttachments : undefined, + g: globalAttachments?.length ? toShareableImages(globalAttachments) : undefined, }; const hash = await compress(payload);