diff --git a/apps/pi-extension/server/external-annotations.ts b/apps/pi-extension/server/external-annotations.ts new file mode 100644 index 00000000..3b462bf7 --- /dev/null +++ b/apps/pi-extension/server/external-annotations.ts @@ -0,0 +1,160 @@ +/** + * External Annotations — Pi (node:http) server handler. + * + * Thin HTTP adapter over the shared annotation store. Mirrors the Bun + * handler at packages/server/external-annotations.ts but uses node:http + * IncomingMessage/ServerResponse + res.write() for SSE. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + createAnnotationStore, + transformPlanInput, + transformReviewInput, + serializeSSEEvent, + HEARTBEAT_COMMENT, + HEARTBEAT_INTERVAL_MS, + type StorableAnnotation, + type ExternalAnnotationEvent, +} from "../generated/external-annotation.js"; +import { json, parseBody } from "./helpers.js"; + +// --------------------------------------------------------------------------- +// Route prefix +// --------------------------------------------------------------------------- + +const BASE = "/api/external-annotations"; +const STREAM = `${BASE}/stream`; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createExternalAnnotationHandler(mode: "plan" | "review") { + const store = createAnnotationStore(); + const subscribers = new Set(); + const transform = mode === "plan" ? transformPlanInput : transformReviewInput; + + // Wire store mutations → SSE broadcast + store.onMutation((event: ExternalAnnotationEvent) => { + const data = serializeSSEEvent(event); + for (const res of subscribers) { + try { + res.write(data); + } catch { + // Response closed — clean up + subscribers.delete(res); + } + } + }); + + return { + async handle( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ): Promise { + // --- SSE stream --- + if (url.pathname === STREAM && req.method === "GET") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + // Disable idle timeout for SSE connections + res.setTimeout(0); + + // Send current state as snapshot + const snapshot: ExternalAnnotationEvent = { + type: "snapshot", + annotations: store.getAll(), + }; + res.write(serializeSSEEvent(snapshot)); + + subscribers.add(res); + + // Heartbeat to keep connection alive + const heartbeatTimer = setInterval(() => { + try { + res.write(HEARTBEAT_COMMENT); + } catch { + clearInterval(heartbeatTimer); + subscribers.delete(res); + } + }, HEARTBEAT_INTERVAL_MS); + + // Clean up on disconnect + res.on("close", () => { + clearInterval(heartbeatTimer); + subscribers.delete(res); + }); + + // Don't end the response — SSE stays open + return true; + } + + // --- GET snapshot (polling fallback) --- + if (url.pathname === BASE && req.method === "GET") { + const since = url.searchParams.get("since"); + if (since !== null) { + const sinceVersion = parseInt(since, 10); + if (!isNaN(sinceVersion) && sinceVersion === store.version) { + res.writeHead(304); + res.end(); + return true; + } + } + json(res, { + annotations: store.getAll(), + version: store.version, + }); + return true; + } + + // --- POST (add single or batch) --- + if (url.pathname === BASE && req.method === "POST") { + try { + const body = await parseBody(req); + const parsed = transform(body); + + if ("error" in parsed) { + json(res, { error: parsed.error }, 400); + return true; + } + + const created = store.add(parsed.annotations); + json(res, { ids: created.map((a: StorableAnnotation) => a.id) }, 201); + } catch { + json(res, { error: "Invalid JSON" }, 400); + } + return true; + } + + // --- DELETE (by id, by source, or clear all) --- + if (url.pathname === BASE && req.method === "DELETE") { + const id = url.searchParams.get("id"); + const source = url.searchParams.get("source"); + + if (id) { + store.remove(id); + json(res, { ok: true }); + return true; + } + + if (source) { + const count = store.clearBySource(source); + json(res, { ok: true, removed: count }); + return true; + } + + const count = store.clearAll(); + json(res, { ok: true, removed: count }); + return true; + } + + // Not handled — pass through + return false; + }, + }; +} diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 9a11d0f7..a305256a 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -16,6 +16,7 @@ import { listenOnPort } from "./network.js"; import { getRepoInfo } from "./project.js"; import { handleDocRequest, handleFileBrowserRequest } from "./reference.js"; +import { createExternalAnnotationHandler } from "./external-annotations.js"; export interface AnnotateServerResult { port: number; @@ -61,9 +62,13 @@ export async function startAnnotateServer(options: { // Detect repo info (cached for this session) const repoInfo = getRepoInfo(); + const externalAnnotations = createExternalAnnotationHandler("plan"); + const server = createServer(async (req, res) => { const url = requestUrl(req); + if (await externalAnnotations.handle(req, res, url)) return; + if (url.pathname === "/api/plan" && req.method === "GET") { json(res, { plan: options.markdown, diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 89154a29..786158a8 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -15,6 +15,7 @@ import { saveToHistory, } from "../generated/storage.js"; import { createEditorAnnotationHandler } from "./annotations.js"; +import { createExternalAnnotationHandler } from "./external-annotations.js"; import { handleDraftRequest, handleFavicon, @@ -135,6 +136,7 @@ export async function startPlanReviewServer(options: { // Editor annotations (in-memory, VS Code integration — skip in archive mode) const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null; + const externalAnnotations = options.mode !== "archive" ? createExternalAnnotationHandler("plan") : null; // Lazy cache for in-session archive tab let cachedArchivePlans: ArchivedPlan[] | null = null; @@ -226,6 +228,8 @@ export async function startPlanReviewServer(options: { await handleDraftRequest(req, res, draftKey); } else if (editorAnnotations && (await editorAnnotations.handle(req, res, url))) { return; + } else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) { + return; } else if (url.pathname === "/api/doc" && req.method === "GET") { handleDocRequest(res, url); } else if (url.pathname === "/api/obsidian/vaults") { diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index a665e763..b151929d 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -36,6 +36,7 @@ import { } from "../generated/review-core.js"; import { createEditorAnnotationHandler } from "./annotations.js"; +import { createExternalAnnotationHandler } from "./external-annotations.js"; import { handleDraftRequest, handleFavicon, @@ -143,6 +144,7 @@ export async function startReviewServer(options: { } : getRepoInfo(); const editorAnnotations = createEditorAnnotationHandler(); + const externalAnnotations = createExternalAnnotationHandler("review"); let currentPatch = options.rawPatch; let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; @@ -514,6 +516,8 @@ export async function startReviewServer(options: { handleFavicon(res); } else if (await editorAnnotations.handle(req, res, url)) { return; + } else if (await externalAnnotations.handle(req, res, url)) { + return; } else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { const handler = aiEndpoints[url.pathname]; if (handler) { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 727b0749..9f2626c9 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -6,7 +6,7 @@ cd "$(dirname "$0")" mkdir -p generated generated/ai/providers -for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config; do +for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index db0ab30f..e577b9c5 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -47,6 +47,7 @@ import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser'; import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; import { useArchive } from '@plannotator/ui/hooks/useArchive'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; +import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser'; @@ -329,6 +330,12 @@ const App: React.FC = () => { }); const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); + const { externalAnnotations, deleteExternalAnnotation } = useExternalAnnotations(); + + const allAnnotations = useMemo( + () => [...annotations, ...externalAnnotations], + [annotations, externalAnnotations] + ); const handleRestoreDraft = React.useCallback(() => { const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft(); @@ -677,7 +684,7 @@ const App: React.FC = () => { const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); - if (annotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) { + if (allAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) { body.feedback = annotationsOutput; } @@ -767,7 +774,7 @@ const App: React.FC = () => { const hasDocAnnotations = Array.from(docAnnotations.values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); - if (annotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { // Check if agent exists for OpenCode users if (origin === 'opencode') { const warning = getAgentWarning(); @@ -805,6 +812,12 @@ const App: React.FC = () => { }, []); const handleDeleteAnnotation = (id: string) => { + const ann = allAnnotations.find(a => a.id === id); + if (ann?.source) { + deleteExternalAnnotation(id); + if (selectedAnnotationId === id) setSelectedAnnotationId(null); + return; + } viewerRef.current?.removeHighlight(id); setAnnotations(prev => prev.filter(a => a.id !== id)); if (selectedAnnotationId === id) setSelectedAnnotationId(null); @@ -841,7 +854,7 @@ const App: React.FC = () => { const hasDocAnnotations = Array.from(docAnnotations.values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); - const hasPlanAnnotations = annotations.length > 0 || globalAttachments.length > 0; + const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0; const hasEditorAnnotations = editorAnnotations.length > 0; if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations) { @@ -849,7 +862,7 @@ const App: React.FC = () => { } let output = hasPlanAnnotations - ? exportAnnotations(blocks, annotations, globalAttachments, annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', annotateSource ?? 'plan') + ? exportAnnotations(blocks, allAnnotations, globalAttachments, annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', annotateSource ?? 'plan') : ''; if (hasDocAnnotations) { @@ -861,7 +874,7 @@ const App: React.FC = () => { } return output; - }, [blocks, annotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations]); + }, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations]); // Quick-save handlers for export dropdown and keyboard shortcut const handleDownloadAnnotations = () => { @@ -1070,7 +1083,7 @@ const App: React.FC = () => { const hasDocAnnotations = Array.from(docAnnotations.values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); - if (annotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { setShowFeedbackPrompt(true); } else { handleDeny(); @@ -1082,12 +1095,12 @@ const App: React.FC = () => { ? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground' : 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30' }`} - title={annotateMode ? (annotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'} + title={annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'} > - {isSubmitting ? 'Sending...' : annotateMode ? (annotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'} + {isSubmitting ? 'Sending...' : annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'} {!annotateMode &&
@@ -1268,7 +1281,7 @@ const App: React.FC = () => { className="md:hidden" isPanelOpen={isPanelOpen} onTogglePanel={() => setIsPanelOpen(!isPanelOpen)} - annotationCount={annotations.length + editorAnnotations.length} + annotationCount={allAnnotations.length + editorAnnotations.length} onOpenExport={() => { setInitialExportTab(undefined); setShowExport(true); }} onOpenSettings={() => setMobileSettingsOpen(true)} onDownloadAnnotations={handleDownloadAnnotations} @@ -1462,7 +1475,7 @@ const App: React.FC = () => { { // VS Code editor annotations (only polls when inside VS Code webview) const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); + // External annotations (SSE-based, for any external tool) + const { externalAnnotations, deleteExternalAnnotation } = useExternalAnnotations(); + + const allAnnotations = useMemo( + () => [...annotations, ...externalAnnotations], + [annotations, externalAnnotations] + ); + const allAnnotationsRef = useRef(allAnnotations); + allAnnotationsRef.current = allAnnotations; + // AI Chat const [aiAvailable, setAiAvailable] = useState(false); const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); @@ -368,8 +379,8 @@ const ReviewApp: React.FC = () => { const activeFileAnnotations = useMemo(() => { const activeFile = files[activeFileIndex]; if (!activeFile) return []; - return annotations.filter(a => a.filePath === activeFile.path); - }, [annotations, files, activeFileIndex]); + return allAnnotations.filter(a => a.filePath === activeFile.path); + }, [allAnnotations, files, activeFileIndex]); // Load diff content - try API first, fall back to demo useEffect(() => { @@ -516,13 +527,18 @@ const ReviewApp: React.FC = () => { )); }, []); - // Delete annotation const handleDeleteAnnotation = useCallback((id: string) => { + const ann = allAnnotationsRef.current.find(a => a.id === id); + if (ann?.source) { + deleteExternalAnnotation(id); + if (selectedAnnotationId === id) setSelectedAnnotationId(null); + return; + } setAnnotations(prev => prev.filter(a => a.id !== id)); if (selectedAnnotationId === id) { setSelectedAnnotationId(null); } - }, [selectedAnnotationId]); + }, [selectedAnnotationId, deleteExternalAnnotation]); // Handle identity change - update author on existing annotations const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { @@ -648,7 +664,7 @@ const ReviewApp: React.FC = () => { } // Find the annotation - const annotation = annotations.find(a => a.id === id); + const annotation = allAnnotations.find(a => a.id === id); if (!annotation) { setSelectedAnnotationId(id); return; @@ -661,7 +677,7 @@ const ReviewApp: React.FC = () => { } setSelectedAnnotationId(id); - }, [annotations, files, activeFileIndex, handleFileSwitch]); + }, [allAnnotations, files, activeFileIndex, handleFileSwitch]); // Copy raw diff to clipboard const handleCopyDiff = useCallback(async () => { @@ -679,12 +695,12 @@ const ReviewApp: React.FC = () => { // Copy feedback markdown to clipboard const handleCopyFeedback = useCallback(async () => { - if (annotations.length === 0) { + if (allAnnotations.length === 0) { setShowNoAnnotationsDialog(true); return; } try { - const feedback = exportReviewFeedback(annotations, prMetadata); + const feedback = exportReviewFeedback(allAnnotations, prMetadata); await navigator.clipboard.writeText(feedback); setCopyFeedback('Feedback copied!'); setTimeout(() => setCopyFeedback(null), 2000); @@ -693,18 +709,18 @@ const ReviewApp: React.FC = () => { setCopyFeedback('Failed to copy'); setTimeout(() => setCopyFeedback(null), 2000); } - }, [annotations, prMetadata]); + }, [allAnnotations, prMetadata]); const activeFile = files[activeFileIndex]; const feedbackMarkdown = useMemo(() => { - let output = exportReviewFeedback(annotations, prMetadata); + let output = exportReviewFeedback(allAnnotations, prMetadata); if (editorAnnotations.length > 0) { output += exportEditorAnnotations(editorAnnotations); } return output; - }, [annotations, prMetadata, editorAnnotations]); + }, [allAnnotations, prMetadata, editorAnnotations]); - const totalAnnotationCount = annotations.length + editorAnnotations.length; + const totalAnnotationCount = allAnnotations.length + editorAnnotations.length; // Send feedback to OpenCode via API const handleSendFeedback = useCallback(async () => { @@ -723,7 +739,7 @@ const ReviewApp: React.FC = () => { body: JSON.stringify({ approved: false, feedback: feedbackMarkdown, - annotations, + annotations: allAnnotations, ...(effectiveAgent && { agentSwitch: effectiveAgent }), }), }); @@ -738,7 +754,7 @@ const ReviewApp: React.FC = () => { setTimeout(() => setCopyFeedback(null), 2000); setIsSendingFeedback(false); } - }, [totalAnnotationCount, feedbackMarkdown, annotations]); + }, [totalAnnotationCount, feedbackMarkdown, allAnnotations]); // Approve without feedback (LGTM) const handleApprove = useCallback(async () => { @@ -768,8 +784,8 @@ const ReviewApp: React.FC = () => { // Build the payload for /api/pr-action from current annotations const buildPRReviewPayload = useCallback((action: 'approve' | 'comment', generalComment?: string) => { - const fileAnnotations = annotations.filter(a => (a.scope ?? 'line') === 'line'); - const fileScoped = annotations.filter(a => a.scope === 'file'); + const fileAnnotations = allAnnotations.filter(a => (a.scope ?? 'line') === 'line'); + const fileScoped = allAnnotations.filter(a => a.scope === 'file'); // Top-level body: file-scoped comments const bodyParts: string[] = []; @@ -823,7 +839,7 @@ const ReviewApp: React.FC = () => { } return { action, body, fileComments }; - }, [annotations, editorAnnotations, files]); + }, [allAnnotations, editorAnnotations, files]); // Submit a review directly to GitHub const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', generalComment?: string) => { @@ -1320,7 +1336,7 @@ const ReviewApp: React.FC = () => { files={files} activeFileIndex={activeFileIndex} onSelectFile={handleFileSwitch} - annotations={annotations} + annotations={allAnnotations} viewedFiles={viewedFiles} onToggleViewed={handleToggleViewed} hideViewedFiles={hideViewedFiles} @@ -1451,7 +1467,7 @@ const ReviewApp: React.FC = () => { setIsPanelOpen(!isPanelOpen)} - annotations={annotations} + annotations={allAnnotations} files={files} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} @@ -1497,7 +1513,7 @@ const ReviewApp: React.FC = () => {
- {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} + {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''}
                   {feedbackMarkdown}
diff --git a/packages/review-editor/components/ReviewPanel.tsx b/packages/review-editor/components/ReviewPanel.tsx
index 718369c6..90746e47 100644
--- a/packages/review-editor/components/ReviewPanel.tsx
+++ b/packages/review-editor/components/ReviewPanel.tsx
@@ -392,6 +392,7 @@ export const ReviewPanel: React.FC = ({
                   ))}
                 
               )}
+
             
)} diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index c95a2698..dfe78dff 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -16,6 +16,7 @@ import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers"; import { handleDoc, handleFileBrowserFiles } from "./reference-handlers"; import { contentHash, deleteDraft } from "./draft"; +import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; import { dirname } from "path"; import { isWSL } from "./browser"; @@ -100,6 +101,7 @@ export async function startAnnotateServer( const wslFlag = await isWSL(); const gitUser = detectGitUser(); const draftKey = contentHash(markdown); + const externalAnnotations = createExternalAnnotationHandler("plan"); // Detect repo info (cached for this session) const repoInfo = await getRepoInfo(); @@ -190,6 +192,10 @@ export async function startAnnotateServer( return handleDraftLoad(draftKey); } + // API: External annotations (SSE-based, for any external tool) + const externalResponse = await externalAnnotations.handle(req, url); + if (externalResponse) return externalResponse; + // API: Submit annotation feedback if (url.pathname === "/api/feedback" && req.method === "POST") { try { diff --git a/packages/server/external-annotations.ts b/packages/server/external-annotations.ts new file mode 100644 index 00000000..3ad396d0 --- /dev/null +++ b/packages/server/external-annotations.ts @@ -0,0 +1,170 @@ +/** + * External Annotations — Bun server handler. + * + * Thin HTTP adapter over the shared annotation store. Handles routing, + * request parsing, and SSE broadcasting using Bun's Request/Response + + * ReadableStream APIs. + * + * The Pi extension has a mirror handler using node:http primitives at + * apps/pi-extension/server/external-annotations.ts. + */ + +import { + createAnnotationStore, + transformPlanInput, + transformReviewInput, + serializeSSEEvent, + HEARTBEAT_COMMENT, + HEARTBEAT_INTERVAL_MS, + type AnnotationStore, + type StorableAnnotation, + type ExternalAnnotationEvent, +} from "@plannotator/shared/external-annotation"; + +export type { ExternalAnnotationEvent } from "@plannotator/shared/external-annotation"; + +// --------------------------------------------------------------------------- +// Handler interface (matches existing EditorAnnotationHandler pattern) +// --------------------------------------------------------------------------- + +export interface ExternalAnnotationHandler { + handle: (req: Request, url: URL) => Promise; +} + +// --------------------------------------------------------------------------- +// Route prefix +// --------------------------------------------------------------------------- + +const BASE = "/api/external-annotations"; +const STREAM = `${BASE}/stream`; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createExternalAnnotationHandler( + mode: "plan" | "review", +): ExternalAnnotationHandler { + const store: AnnotationStore = createAnnotationStore(); + const subscribers = new Set(); + const encoder = new TextEncoder(); + const transform = mode === "plan" ? transformPlanInput : transformReviewInput; + + // Wire store mutations → SSE broadcast + store.onMutation((event: ExternalAnnotationEvent) => { + const data = encoder.encode(serializeSSEEvent(event)); + for (const controller of subscribers) { + try { + controller.enqueue(data); + } catch { + // Controller closed — clean up on next iteration + subscribers.delete(controller); + } + } + }); + + return { + async handle(req: Request, url: URL): Promise { + // --- SSE stream --- + if (url.pathname === STREAM && req.method === "GET") { + let heartbeatTimer: ReturnType | null = null; + let ctrl: ReadableStreamDefaultController; + + const stream = new ReadableStream({ + start(controller) { + ctrl = controller; + + // Send current state as snapshot + const snapshot: ExternalAnnotationEvent = { + type: "snapshot", + annotations: store.getAll(), + }; + controller.enqueue(encoder.encode(serializeSSEEvent(snapshot))); + + subscribers.add(controller); + + // Heartbeat to keep connection alive + heartbeatTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(HEARTBEAT_COMMENT)); + } catch { + // Stream closed + if (heartbeatTimer) clearInterval(heartbeatTimer); + subscribers.delete(controller); + } + }, HEARTBEAT_INTERVAL_MS); + }, + cancel() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + subscribers.delete(ctrl); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // --- GET snapshot (polling fallback) --- + if (url.pathname === BASE && req.method === "GET") { + const since = url.searchParams.get("since"); + if (since !== null) { + const sinceVersion = parseInt(since, 10); + if (!isNaN(sinceVersion) && sinceVersion === store.version) { + return new Response(null, { status: 304 }); + } + } + return Response.json({ + annotations: store.getAll(), + version: store.version, + }); + } + + // --- POST (add single or batch) --- + if (url.pathname === BASE && req.method === "POST") { + try { + const body = await req.json(); + const parsed = transform(body); + + if ("error" in parsed) { + return Response.json({ error: parsed.error }, { status: 400 }); + } + + const created = store.add(parsed.annotations); + return Response.json( + { ids: created.map((a) => a.id) }, + { status: 201 }, + ); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + } + + // --- DELETE (by id, by source, or clear all) --- + if (url.pathname === BASE && req.method === "DELETE") { + const id = url.searchParams.get("id"); + const source = url.searchParams.get("source"); + + if (id) { + store.remove(id); + return Response.json({ ok: true }); + } + + if (source) { + const count = store.clearBySource(source); + return Response.json({ ok: true, removed: count }); + } + + const count = store.clearAll(); + return Response.json({ ok: true, removed: count }); + } + + // Not handled — pass through + return null; + }, + }; +} diff --git a/packages/server/index.ts b/packages/server/index.ts index bebac83c..17372134 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -42,6 +42,7 @@ import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraft import { contentHash, deleteDraft } from "./draft"; import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc, handleFileBrowserFiles } from "./reference-handlers"; import { createEditorAnnotationHandler } from "./editor-annotations"; +import { createExternalAnnotationHandler } from "./external-annotations"; import { isWSL } from "./browser"; // Re-export utilities @@ -141,6 +142,7 @@ export async function startPlannotatorServer( // --- Plan review mode setup (skip in archive mode) --- const draftKey = mode !== "archive" ? contentHash(plan) : ""; const editorAnnotations = mode !== "archive" ? createEditorAnnotationHandler() : null; + const externalAnnotations = mode !== "archive" ? createExternalAnnotationHandler("plan") : null; const slug = mode !== "archive" ? generateSlug(plan) : ""; // Lazy cache for in-session archive browsing (plan review sidebar tab) @@ -362,6 +364,10 @@ export async function startPlannotatorServer( const editorResponse = await editorAnnotations?.handle(req, url); if (editorResponse) return editorResponse; + // API: External annotations (SSE-based, for any external tool) + const externalResponse = await externalAnnotations?.handle(req, url); + if (externalResponse) return externalResponse; + // API: Save to notes (decoupled from approve/deny) if (url.pathname === "/api/save-notes" && req.method === "POST") { const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult } = {}; diff --git a/packages/server/review.ts b/packages/server/review.ts index e0e901e7..73ffb67b 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -15,6 +15,7 @@ import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; import { createEditorAnnotationHandler } from "./editor-annotations"; +import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr"; import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; @@ -95,6 +96,7 @@ export async function startReviewServer( const isPRMode = !!prMetadata; const draftKey = contentHash(options.rawPatch); const editorAnnotations = createEditorAnnotationHandler(); + const externalAnnotations = createExternalAnnotationHandler("review"); // Mutable state for diff switching let currentPatch = options.rawPatch; @@ -442,6 +444,10 @@ export async function startReviewServer( const editorResponse = await editorAnnotations.handle(req, url); if (editorResponse) return editorResponse; + // API: External annotations (SSE-based, for any external tool) + const externalResponse = await externalAnnotations.handle(req, url); + if (externalResponse) return externalResponse; + // API: Submit review feedback if (url.pathname === "/api/feedback" && req.method === "POST") { try { diff --git a/packages/shared/external-annotation.ts b/packages/shared/external-annotation.ts new file mode 100644 index 00000000..b3f215dc --- /dev/null +++ b/packages/shared/external-annotation.ts @@ -0,0 +1,363 @@ +/** + * External Annotations — shared types, store logic, and SSE helpers. + * + * Runtime-agnostic: no node:fs, no node:http, no Bun APIs. + * Both the Bun server handler and Pi server handler import this module + * and wrap it with their respective HTTP transport layers. + * + * The store is generic — plan servers store Annotation objects, + * review servers store CodeAnnotation objects. The mode-specific + * input transformers handle validation and field assignment. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Constraint for any annotation type the store can hold. */ +export type StorableAnnotation = { id: string; source?: string }; + +export type ExternalAnnotationEvent = + | { type: "snapshot"; annotations: T[] } + | { type: "add"; annotations: T[] } + | { type: "remove"; ids: string[] } + | { type: "clear"; source?: string }; + +// --------------------------------------------------------------------------- +// SSE helpers +// --------------------------------------------------------------------------- + +/** Heartbeat comment to keep SSE connections alive (sent every 30s). */ +export const HEARTBEAT_COMMENT = ":\n\n"; + +/** Interval in ms between heartbeat comments. */ +export const HEARTBEAT_INTERVAL_MS = 30_000; + +/** Encode an event as an SSE `data:` line. */ +export function serializeSSEEvent(event: ExternalAnnotationEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// --------------------------------------------------------------------------- +// Input validation — shared helpers +// --------------------------------------------------------------------------- + +export interface ParseError { + error: string; +} + +/** + * Unwrap a POST body into an array of raw input objects. + * + * Accepts either: + * - A single annotation object: `{ source: "...", ... }` + * - A batch wrapper: `{ annotations: [{ source: "...", ... }, ...] }` + */ +function unwrapBody(body: unknown): Record[] | ParseError { + if (!body || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record; + + // Batch format: { annotations: [...] } + if (Array.isArray(obj.annotations)) { + if (obj.annotations.length === 0) { + return { error: "annotations array must not be empty" }; + } + const items: Record[] = []; + for (let i = 0; i < obj.annotations.length; i++) { + const item = obj.annotations[i]; + if (!item || typeof item !== "object") { + return { error: `annotations[${i}] must be an object` }; + } + items.push(item as Record); + } + return items; + } + + // Single format: { source: "...", ... } + if (typeof obj.source === "string") { + return [obj as Record]; + } + + return { error: 'Missing required "source" field or "annotations" array' }; +} + +function requireString(obj: Record, field: string, index: number): string | ParseError { + const val = obj[field]; + if (typeof val !== "string" || val.length === 0) { + return { error: `annotations[${index}] missing required "${field}" field` }; + } + return val; +} + +// --------------------------------------------------------------------------- +// Plan mode transformer — produces Annotation objects +// --------------------------------------------------------------------------- + +/** The Annotation type shape for plan mode (mirrors packages/ui/types.ts). */ +interface PlanAnnotation { + id: string; + blockId: string; + startOffset: number; + endOffset: number; + type: string; // AnnotationType value + text?: string; + originalText: string; + createdA: number; + author?: string; + source?: string; +} + +const VALID_PLAN_TYPES = ["DELETION", "INSERTION", "REPLACEMENT", "COMMENT", "GLOBAL_COMMENT"]; + +export function transformPlanInput( + body: unknown, +): { annotations: PlanAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: PlanAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + // Must have text content + if (typeof obj.text !== "string" || obj.text.length === 0) { + return { error: `annotations[${i}] missing required "text" field` }; + } + + // Validate type if provided, default to GLOBAL_COMMENT + const type = typeof obj.type === "string" ? obj.type : "GLOBAL_COMMENT"; + if (!VALID_PLAN_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_PLAN_TYPES.join(", ")}`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + blockId: "external", + startOffset: 0, + endOffset: 0, + type, + text: String(obj.text), + originalText: typeof obj.originalText === "string" ? obj.originalText : "", + createdA: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Review mode transformer — produces CodeAnnotation objects +// --------------------------------------------------------------------------- + +/** The CodeAnnotation type shape for review mode (mirrors packages/ui/types.ts). */ +interface ReviewAnnotation { + id: string; + type: string; // CodeAnnotationType value + scope?: string; + filePath: string; + lineStart: number; + lineEnd: number; + side: string; + text?: string; + suggestedCode?: string; + originalCode?: string; + createdAt: number; + author?: string; + source?: string; +} + +const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"]; +const VALID_SIDES = ["old", "new"]; +const VALID_SCOPES = ["line", "file"]; + +export function transformReviewInput( + body: unknown, +): { annotations: ReviewAnnotation[] } | ParseError { + const items = unwrapBody(body); + if ("error" in items) return items; + + const annotations: ReviewAnnotation[] = []; + for (let i = 0; i < items.length; i++) { + const obj = items[i]; + + const source = requireString(obj, "source", i); + if (typeof source !== "string") return source; + + const filePath = requireString(obj, "filePath", i); + if (typeof filePath !== "string") return filePath; + + if (typeof obj.lineStart !== "number") { + return { error: `annotations[${i}] missing required "lineStart" field` }; + } + if (typeof obj.lineEnd !== "number") { + return { error: `annotations[${i}] missing required "lineEnd" field` }; + } + + // side: optional, defaults to "new" + const side = typeof obj.side === "string" ? obj.side : "new"; + if (!VALID_SIDES.includes(side)) { + return { + error: `annotations[${i}] invalid side "${side}". Must be one of: ${VALID_SIDES.join(", ")}`, + }; + } + + // type: optional, defaults to "comment" + const type = typeof obj.type === "string" ? obj.type : "comment"; + if (!VALID_REVIEW_TYPES.includes(type)) { + return { + error: `annotations[${i}] invalid type "${type}". Must be one of: ${VALID_REVIEW_TYPES.join(", ")}`, + }; + } + + // scope: optional, defaults to "line" + const scope = typeof obj.scope === "string" ? obj.scope : "line"; + if (!VALID_SCOPES.includes(scope)) { + return { + error: `annotations[${i}] invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, + }; + } + + // Must have at least text or suggestedCode + if (typeof obj.text !== "string" && typeof obj.suggestedCode !== "string") { + return { + error: `annotations[${i}] must have at least one of: text, suggestedCode`, + }; + } + + annotations.push({ + id: crypto.randomUUID(), + type, + scope, + filePath, + lineStart: obj.lineStart, + lineEnd: obj.lineEnd, + side, + text: typeof obj.text === "string" ? obj.text : undefined, + suggestedCode: typeof obj.suggestedCode === "string" ? obj.suggestedCode : undefined, + originalCode: typeof obj.originalCode === "string" ? obj.originalCode : undefined, + createdAt: Date.now(), + author: typeof obj.author === "string" ? obj.author : undefined, + source, + }); + } + + return { annotations }; +} + +// --------------------------------------------------------------------------- +// Annotation Store (generic) +// --------------------------------------------------------------------------- + +type MutationListener = (event: ExternalAnnotationEvent) => void; + +export interface AnnotationStore { + /** Add fully-formed annotations. Returns the added annotations. */ + add(items: T[]): T[]; + /** Remove an annotation by ID. Returns true if found. */ + remove(id: string): boolean; + /** Remove all annotations from a specific source. Returns count removed. */ + clearBySource(source: string): number; + /** Remove all annotations. Returns count removed. */ + clearAll(): number; + /** Get all annotations (snapshot). */ + getAll(): T[]; + /** Monotonic version counter — incremented on every mutation. */ + readonly version: number; + /** Register a listener for mutation events. Returns unsubscribe function. */ + onMutation(listener: MutationListener): () => void; +} + +/** + * Create an in-memory annotation store. + * + * The store is runtime-agnostic — it holds data and emits events. + * HTTP transport (SSE broadcasting, request parsing) is handled by + * the server-specific adapter (Bun or Pi). + */ +export function createAnnotationStore(): AnnotationStore { + const annotations: T[] = []; + const listeners = new Set>(); + let version = 0; + + function emit(event: ExternalAnnotationEvent): void { + for (const listener of listeners) { + try { + listener(event); + } catch { + // Don't let a failing listener break the store + } + } + } + + return { + add(items) { + if (items.length > 0) { + for (const item of items) { + annotations.push(item); + } + version++; + emit({ type: "add", annotations: items }); + } + return items; + }, + + remove(id) { + const idx = annotations.findIndex((a) => a.id === id); + if (idx === -1) return false; + annotations.splice(idx, 1); + version++; + emit({ type: "remove", ids: [id] }); + return true; + }, + + clearBySource(source) { + const before = annotations.length; + for (let i = annotations.length - 1; i >= 0; i--) { + if (annotations[i].source === source) { + annotations.splice(i, 1); + } + } + const removed = before - annotations.length; + if (removed > 0) { + version++; + emit({ type: "clear", source }); + } + return removed; + }, + + clearAll() { + const count = annotations.length; + if (count > 0) { + annotations.length = 0; + version++; + emit({ type: "clear" }); + } + return count; + }, + + getAll() { + return [...annotations]; + }, + + get version() { + return version; + }, + + onMutation(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 289f3dd8..4f07dbd2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -18,6 +18,7 @@ "./reference-common": "./reference-common.ts", "./favicon": "./favicon.ts", "./resolve-file": "./resolve-file.ts", + "./external-annotation": "./external-annotation.ts", "./config": "./config.ts" } } diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 4b0b5766..eae5a248 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -142,6 +142,7 @@ export const AnnotationPanel: React.FC = ({ ))} )} + )} diff --git a/packages/ui/hooks/useExternalAnnotations.ts b/packages/ui/hooks/useExternalAnnotations.ts new file mode 100644 index 00000000..4f5a8987 --- /dev/null +++ b/packages/ui/hooks/useExternalAnnotations.ts @@ -0,0 +1,157 @@ +/** + * Real-time external annotations via SSE with polling fallback. + * + * Primary transport: EventSource on /api/external-annotations/stream. + * Fallback: version-gated GET polling if SSE fails (e.g., proxy environments). + * + * Generic over the annotation type — plan editor uses Annotation, + * review editor uses CodeAnnotation. The hook is shape-agnostic; + * it just serializes/deserializes JSON. + * + * Always active — no VS Code gate. Any running Plannotator session can + * receive external annotations from any tool. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { ExternalAnnotationEvent } from '../types'; + +const POLL_INTERVAL_MS = 500; +const STREAM_URL = '/api/external-annotations/stream'; +const SNAPSHOT_URL = '/api/external-annotations'; + +interface UseExternalAnnotationsReturn { + externalAnnotations: T[]; + deleteExternalAnnotation: (id: string) => void; + clearExternalAnnotations: (source?: string) => void; +} + +export function useExternalAnnotations(): UseExternalAnnotationsReturn { + const [annotations, setAnnotations] = useState([]); + const versionRef = useRef(0); + const fallbackRef = useRef(false); + const pollTimerRef = useRef | null>(null); + const receivedSnapshotRef = useRef(false); + + useEffect(() => { + let cancelled = false; + + // --- SSE primary transport --- + const es = new EventSource(STREAM_URL); + + es.onmessage = (event) => { + if (cancelled) return; + + try { + const parsed: ExternalAnnotationEvent = JSON.parse(event.data); + + switch (parsed.type) { + case 'snapshot': + receivedSnapshotRef.current = true; + setAnnotations(parsed.annotations); + break; + case 'add': + setAnnotations((prev) => [...prev, ...parsed.annotations]); + break; + case 'remove': + setAnnotations((prev) => + prev.filter((a) => !parsed.ids.includes(a.id)), + ); + break; + case 'clear': + setAnnotations((prev) => + parsed.source + ? prev.filter((a) => a.source !== parsed.source) + : [], + ); + break; + } + } catch { + // Ignore malformed events (e.g., heartbeat comments) + } + }; + + es.onerror = () => { + // If we never received a snapshot, SSE isn't working — fall back to polling + if (!receivedSnapshotRef.current && !fallbackRef.current) { + fallbackRef.current = true; + es.close(); + startPolling(); + } + // Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot + }; + + // --- Polling fallback --- + function startPolling() { + if (cancelled) return; + + // Initial fetch + fetchSnapshot(); + + pollTimerRef.current = setInterval(() => { + if (cancelled) return; + fetchSnapshot(); + }, POLL_INTERVAL_MS); + } + + async function fetchSnapshot() { + try { + const url = + versionRef.current > 0 + ? `${SNAPSHOT_URL}?since=${versionRef.current}` + : SNAPSHOT_URL; + + const res = await fetch(url); + + if (res.status === 304) return; // No changes + if (!res.ok) return; + + const data = await res.json(); + if (Array.isArray(data.annotations)) { + setAnnotations(data.annotations); + } + if (typeof data.version === 'number') { + versionRef.current = data.version; + } + } catch { + // Silent — next poll will retry + } + } + + return () => { + cancelled = true; + es.close(); + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + }, []); + + const deleteExternalAnnotation = useCallback(async (id: string) => { + // Optimistic update + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + try { + await fetch( + `${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, + { method: 'DELETE' }, + ); + } catch { + // SSE will reconcile on next event + } + }, []); + + const clearExternalAnnotations = useCallback(async (source?: string) => { + // Optimistic update + setAnnotations((prev) => + source ? prev.filter((a) => a.source !== source) : [], + ); + try { + const qs = source ? `?source=${encodeURIComponent(source)}` : ''; + await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' }); + } catch { + // SSE will reconcile on next event + } + }, []); + + return { externalAnnotations: annotations, deleteExternalAnnotation, clearExternalAnnotations }; +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 626cbfc3..32b2da7e 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -25,6 +25,7 @@ export interface Annotation { originalText: string; // The text that was selected createdA: number; author?: string; // Tater identity for collaborative sharing + source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API images?: ImageAttachment[]; // Attached images with human-readable names isQuickLabel?: boolean; // true if created via quick label chip quickLabelTip?: string; // optional instruction tip from the label definition @@ -76,6 +77,7 @@ export interface CodeAnnotation { originalCode?: string; // Original selected lines for suggestion diff createdAt: number; author?: string; + source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API } // For @pierre/diffs integration @@ -134,3 +136,7 @@ export interface VaultNode { } export type { EditorAnnotation } from '@plannotator/shared/types'; + +export type { + ExternalAnnotationEvent, +} from '@plannotator/shared/external-annotation'; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 58ae051e..fc331e4f 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -453,4 +453,5 @@ export const exportEditorAnnotations = (editorAnnotations: EditorAnnotation[]): output += `---\n`; return output; -}; \ No newline at end of file +}; + diff --git a/tests/manual/local/test-external-annotations.sh b/tests/manual/local/test-external-annotations.sh new file mode 100755 index 00000000..dc3758ba --- /dev/null +++ b/tests/manual/local/test-external-annotations.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Test script for External Annotations API +# +# Usage: +# ./test-external-annotations.sh +# +# What it does: +# 1. Builds the review app (ensures latest code) +# 2. Starts a sandbox review server with sample diff data +# 3. Opens browser — watch annotations arrive in real-time +# 4. Sends 6 waves of annotations over ~17 seconds: +# - Wave 1 (2s): Single eslint warning +# - Wave 2 (5s): Batch of 3 (eslint error, typescript error, eslint suggestion) +# - Wave 3 (8s): Coverage info annotation +# - Wave 4 (10s): Depcheck warning +# - Wave 5 (13s): Delete first annotation +# - Wave 6 (17s): Clear all eslint annotations +# 5. Prints feedback result when you submit + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +echo "=== External Annotations API Test ===" +echo "" + +# Build first to ensure latest code +echo "Building review app..." +cd "$PROJECT_ROOT" +bun run --cwd apps/review build 2>&1 | tail -3 + +echo "" +echo "Building hook (copies review HTML)..." +bun run build:hook 2>&1 | tail -3 + +echo "" +echo "Starting sandbox review server..." +echo "Browser will open automatically." +echo "" +echo "Watch the annotation panel — annotations will appear in real-time." +echo "Timeline:" +echo " 2s → eslint warning on parser.ts:12" +echo " 5s → batch: eslint error + typescript error + eslint suggestion" +echo " 8s → coverage info annotation" +echo " 10s → depcheck warning on package.json" +echo " 13s → delete first annotation" +echo " 17s → clear all eslint annotations" +echo "" + +bun run "$PROJECT_ROOT/tests/manual/test-external-annotations.ts" + +echo "" +echo "=== Test Complete ===" diff --git a/tests/manual/test-external-annotations.ts b/tests/manual/test-external-annotations.ts new file mode 100644 index 00000000..f63ac88b --- /dev/null +++ b/tests/manual/test-external-annotations.ts @@ -0,0 +1,278 @@ +/** + * Test script for External Annotations API + * + * Usage: + * bun run tests/manual/test-external-annotations.ts + * + * What it does: + * 1. Starts the review server with a sample diff (sandbox mode) + * 2. Opens browser so you can see annotations arrive in real-time + * 3. Sends a batch of CodeAnnotation-shaped annotations over timed intervals + * 4. Demonstrates single add, batch add, delete, and clear operations + * 5. Prints server decision when you submit feedback + */ + +import { + startReviewServer, + handleReviewServerReady, +} from "@plannotator/server/review"; + +// @ts-ignore - Bun import attribute for text +import html from "../../apps/review/dist/index.html" with { type: "text" }; + +// --------------------------------------------------------------------------- +// Sample diff (same as test-review-server.ts) +// --------------------------------------------------------------------------- + +const sampleDiff = `diff --git a/src/utils/parser.ts b/src/utils/parser.ts +index 1234567..abcdefg 100644 +--- a/src/utils/parser.ts ++++ b/src/utils/parser.ts +@@ -10,6 +10,8 @@ export function parseMarkdown(input: string): Block[] { + const blocks: Block[] = []; + const lines = input.split('\\n'); + ++ // Handle empty input ++ if (lines.length === 0) return blocks; ++ + for (const line of lines) { + if (line.startsWith('#')) { + blocks.push({ type: 'heading', content: line }); +@@ -25,7 +27,7 @@ export function parseMarkdown(input: string): Block[] { + } + + export function formatBlock(block: Block): string { +- return block.content; ++ return block.content.trim(); + } + + // New helper function +diff --git a/src/components/App.tsx b/src/components/App.tsx +index 7654321..fedcba9 100644 +--- a/src/components/App.tsx ++++ b/src/components/App.tsx +@@ -1,5 +1,6 @@ + import React, { useState, useEffect } from 'react'; + import { parseMarkdown } from '../utils/parser'; ++import { formatBlock } from '../utils/parser'; + + export function App() { + const [blocks, setBlocks] = useState([]); +@@ -15,6 +16,10 @@ export function App() { + fetchData(); + }, []); + ++ const handleFormat = (block: Block) => { ++ return formatBlock(block); ++ }; ++ + return ( +
+

Plannotator

+@@ -22,7 +27,7 @@ export function App() { + {blocks.map((block, i) => ( +
+ {block.type} +- {block.content} ++ {handleFormat(block)} +
+ ))} +
+diff --git a/package.json b/package.json +index 1111111..2222222 100644 +--- a/package.json ++++ b/package.json +@@ -5,7 +5,8 @@ + "scripts": { + "dev": "vite", + "build": "vite build", +- "test": "vitest" ++ "test": "vitest", ++ "lint": "eslint src/" + }, + "dependencies": { + "react": "^18.2.0" +`; + +// --------------------------------------------------------------------------- +// Annotation sequences — CodeAnnotation shape for review mode +// --------------------------------------------------------------------------- + +const ANNOTATIONS = { + // Wave 1: Single comment annotation + wave1: { + source: "eslint", + type: "concern", + filePath: "src/utils/parser.ts", + lineStart: 12, + lineEnd: 12, + side: "new", + text: "Unexpected empty return. Consider returning an explicit empty array for clarity.", + author: "eslint", + }, + + // Wave 2: Batch of 3 annotations + wave2: [ + { + source: "eslint", + type: "concern", + filePath: "src/components/App.tsx", + lineStart: 3, + lineEnd: 3, + side: "new", + text: "Duplicate import from '../utils/parser'. Merge with line 2.", + author: "eslint", + }, + { + source: "typescript", + type: "concern", + filePath: "src/components/App.tsx", + lineStart: 19, + lineEnd: 21, + side: "new", + text: "Parameter 'block' implicitly has an 'any' type. Add explicit type annotation.", + author: "typescript", + }, + { + source: "eslint", + type: "suggestion", + filePath: "src/utils/parser.ts", + lineStart: 28, + lineEnd: 28, + side: "new", + text: "Consider using optional chaining for safer access.", + suggestedCode: "return block.content?.trim() ?? '';", + originalCode: "return block.content.trim();", + author: "eslint", + }, + ], + + // Wave 3: Coverage comment + wave3: { + source: "coverage", + type: "comment", + filePath: "src/utils/parser.ts", + lineStart: 10, + lineEnd: 15, + side: "new", + text: "Branch coverage: 67% (2/3 branches). Missing: empty input path.", + author: "coverage", + }, + + // Wave 4: Package.json comment + wave4: { + source: "depcheck", + type: "concern", + filePath: "package.json", + lineStart: 9, + lineEnd: 9, + side: "new", + text: "eslint is referenced in scripts but not listed in devDependencies.", + author: "depcheck", + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const BASE = "/api/external-annotations"; + +async function post(port: number, body: object) { + const res = await fetch(`http://localhost:${port}${BASE}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.json(); +} + +async function del(port: number, params: string) { + const res = await fetch(`http://localhost:${port}${BASE}?${params}`, { + method: "DELETE", + }); + return res.json(); +} + +function log(msg: string) { + const ts = new Date().toLocaleTimeString("en-US", { hour12: false }); + console.error(`[${ts}] ${msg}`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +log("Starting Code Review server with external annotations demo..."); + +const server = await startReviewServer({ + rawPatch: sampleDiff, + gitRef: "demo (external annotations)", + origin: "claude-code", + htmlContent: html as unknown as string, + onReady: (url, isRemote, port) => { + handleReviewServerReady(url, isRemote, port); + log(`Server running at ${url}`); + log(""); + log("=== External Annotations Demo ==="); + log("Watch the browser — annotations will arrive in real-time."); + log(""); + + // Schedule annotation waves + scheduleWaves(port); + }, +}); + +async function scheduleWaves(port: number) { + // Wave 1: Single annotation after 2s + await Bun.sleep(2000); + log("Wave 1: Sending single eslint concern..."); + const r1 = await post(port, ANNOTATIONS.wave1); + log(` → Created: ${JSON.stringify(r1.ids)}`); + + // Wave 2: Batch of 3 after 3s + await Bun.sleep(3000); + log("Wave 2: Sending batch of 3 annotations (eslint + typescript)..."); + const r2 = await post(port, { annotations: ANNOTATIONS.wave2 }); + log(` → Created: ${JSON.stringify(r2.ids)}`); + + // Wave 3: Coverage comment after 3s + await Bun.sleep(3000); + log("Wave 3: Sending coverage comment..."); + const r3 = await post(port, ANNOTATIONS.wave3); + log(` → Created: ${JSON.stringify(r3.ids)}`); + + // Wave 4: One more after 2s + await Bun.sleep(2000); + log("Wave 4: Sending depcheck concern..."); + const r4 = await post(port, ANNOTATIONS.wave4); + log(` → Created: ${JSON.stringify(r4.ids)}`); + + // Wave 5: Delete the first annotation after 3s + await Bun.sleep(3000); + const firstId = r1.ids[0]; + log(`Wave 5: Deleting first annotation (${firstId})...`); + await del(port, `id=${firstId}`); + log(` → Deleted`); + + // Wave 6: Clear all eslint annotations after 4s + await Bun.sleep(4000); + log("Wave 6: Clearing all eslint annotations..."); + const r6 = await del(port, "source=eslint"); + log(` → Cleared ${r6.removed} eslint annotations`); + + log(""); + log("=== Demo complete ==="); + log("Remaining annotations should be: coverage + depcheck + typescript"); + log("Submit feedback or close the browser when done."); +} + +// Wait for user to submit +const result = await server.waitForDecision(); +await Bun.sleep(1500); +server.stop(); + +log(""); +log("Result:"); +console.log(JSON.stringify(result, null, 2)); +process.exit(0);