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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions apps/pi-extension/server/external-annotations.ts
Original file line number Diff line number Diff line change
@@ -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<StorableAnnotation>();
const subscribers = new Set<ServerResponse>();
const transform = mode === "plan" ? transformPlanInput : transformReviewInput;

// Wire store mutations → SSE broadcast
store.onMutation((event: ExternalAnnotationEvent<StorableAnnotation>) => {
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<boolean> {
// --- 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<StorableAnnotation> = {
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;
},
};
}
5 changes: 5 additions & 0 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
saveToHistory,
} from "../generated/storage.js";
import { createEditorAnnotationHandler } from "./annotations.js";
import { createExternalAnnotationHandler } from "./external-annotations.js";
import {
handleDraftRequest,
handleFavicon,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") {
Expand Down
4 changes: 4 additions & 0 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from "../generated/review-core.js";

import { createEditorAnnotationHandler } from "./annotations.js";
import { createExternalAnnotationHandler } from "./external-annotations.js";
import {
handleDraftRequest,
handleFavicon,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion apps/pi-extension/vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 23 additions & 10 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -329,6 +330,12 @@ const App: React.FC = () => {
});

const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations();
const { externalAnnotations, deleteExternalAnnotation } = useExternalAnnotations<Annotation>();

const allAnnotations = useMemo(
() => [...annotations, ...externalAnnotations],
[annotations, externalAnnotations]
);

const handleRestoreDraft = React.useCallback(() => {
const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -841,15 +854,15 @@ 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) {
return 'User reviewed the document and has no feedback.';
}

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) {
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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'}
>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : annotateMode ? (annotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}</span>
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : annotateMode ? (allAnnotations.length > 0 || editorAnnotations.length > 0 || linkedDocHook.docAnnotationCount > 0 ? 'Send Annotations' : 'Done') : 'Send Feedback'}</span>
</button>

{!annotateMode && <div className="relative group/approve">
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1462,7 +1475,7 @@ const App: React.FC = () => {
<AnnotationPanel
isOpen={isPanelOpen}
blocks={blocks}
annotations={annotations}
annotations={allAnnotations}
selectedId={selectedAnnotationId}
onSelect={setSelectedAnnotationId}
onDelete={handleDeleteAnnotation}
Expand Down
Loading