Files
backnotprop__plannotator/packages/server/annotate.ts
T
Graham Lipsman be2c81fa3b annotate-last: pick which message to annotate (fixes #800) (#809)
* feat: message picker for annotate-last (#800)

When running /plannotator-last after /rewind, the newest transcript
entry is no longer the message the user intended to annotate, and there
was no affordance to pick a different one.

Adds a picker UI that surfaces the recent assistant messages so the
user can choose which one to annotate:

- A "Message N of M" button in the Viewer's sticky-top action bar
  (alongside Copy / Global comment / Attachments), so it stays
  accessible while scrolling.
- A "Messages" tab in the left sidebar with the full list
  (newest-first, preview + timestamp, default ★), mirroring the
  existing Files / Versions / Archive tab pattern.

Wired for Claude Code, Codex, and Droid (all share apps/hook/server).
OpenCode, Pi, and Copilot still get the original single-message
behavior — they don't emit recentMessages, so the picker affordances
hide cleanly.

Default selection (index 0) matches today's "last message" behavior,
so users who don't interact with the picker see no change.

* feat: extend annotate-last picker to Copilot and OpenCode

The picker UI from #800 was wired for Claude / Codex / Droid only. Pull
Copilot and OpenCode onto the same shape so users on those harnesses
also get the recent-messages picker when annotating the last assistant
message.

- Copilot: replace getLastCopilotMessage with getRecentCopilotMessages,
  walking events.jsonl newest-first up to 25 assistant.message events.
- OpenCode: rewrite the session walk to collect up to 25 messages
  (newest first) instead of bailing on the first hit; normalize the SDK
  time.created (ms epoch) to ISO to match the shared picker contract.
- Both pass recentMessages to startAnnotateServer only when length > 1,
  matching the existing Claude/Codex/Droid behavior.

Also trims a leftover narrating comment in MessagesBrowser and refreshes
the stale Copilot session-parser header.

Pi parity follows in the next commit (needs round-trip of the picker
selection through /api/feedback so its post-submit anchoring quotes the
right message).

* feat(pi): wire annotate-last picker with feedback round-trip

Extends the picker UI (#800) to Pi and fixes a Pi-specific anchoring bug
the picker would otherwise introduce.

Picker plumbing
- assistant-message: getRecentAssistantMessages walks the active branch
  newest-first, returning { messageId, text, timestamp? } in the same
  shape the other harnesses produce.
- Plumbed through plannotator-browser / plannotator-events so the Bun
  server's recentMessages option is populated when the branch has more
  than one assistant message.

Anchoring fix
- Pi quotes the targeted assistant message back to the agent because its
  UX is async — the conversation may have moved on by feedback time.
  With the picker, that target is no longer guaranteed to be the
  snapshot taken when the UI opened. The editor now sends the user's
  selectedMessageId with /api/feedback; Pi looks it up in the current
  branch via findAssistantMessageByEntryId and quotes that message
  instead. Falls back to the original snapshot if the entry is gone.
- The round-trip field is optional and only meaningful in annotate-last
  mode; other harnesses (and other modes) ignore it.

Timestamp safety
- Pi's SDK currently types SessionEntryBase.timestamp as string, but the
  picker contract everywhere else is ISO. Treat the value as unknown and
  normalize string/number(ms)/Date to ISO; drop anything else, rather
  than blind-casting and risking silent drift if the SDK changes.

* chore: strip issue-number references from comments

Comments shouldn't rely on external references — issue numbers age out
of context, link rot is a thing, and a reader shouldn't need to open
GitHub to understand why a line exists. Strip the `(#800)` and `(#570)`
parentheticals from comments and doc strings across the picker and
review-gate code; the surrounding "why" content is preserved.

* fix: prevent removeChild crash when switching annotate-last messages

Switching the picked message remounted nothing, so React reconciled new
content against DOM that web-highlighter had mutated with <mark> nodes,
throwing removeChild. Drive the Viewer key (and StickyHeaderLane's
remount token) off a shared viewerContentKey so a message switch fully
remounts the Viewer and re-anchors the sticky-header observer.

Also cap MessagesBrowser row previews via previewText() and drop the
redundant 'block' class that was overriding line-clamp-2.

* feat: persist annotate-last feedback across messages

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-03 13:01:36 -07:00

397 lines
14 KiB
TypeScript

/**
* 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"/"true" for remote, "0"/"false" for local
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
*/
import { isRemoteSession, getServerHostname, getServerPort } from "./remote";
import { getRepoInfo } from "./repo";
import type { Origin } from "@plannotator/shared/agents";
import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers";
import { handleDoc, handleDocExists, handleFileBrowserFiles, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers";
import { warmFileListCache } from "@plannotator/shared/resolve-file";
import { contentHash, deleteDraft } from "./draft";
import { createExternalAnnotationHandler } from "./external-annotations";
import { saveConfig, detectGitUser, getServerConfig } from "./config";
import { dirname, resolve as resolvePath } from "path";
import { isWSL } from "./browser";
import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime";
import type { AIEndpoints } from "@plannotator/ai";
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export { handleServerReady as handleAnnotateServerReady } from "./shared-handlers";
// --- 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?: Origin;
/** UI mode: "annotate" for files, "annotate-last" for last agent message, "annotate-folder" for folders */
mode?: "annotate" | "annotate-last" | "annotate-folder";
/** Folder path when annotating a directory (used as projectRoot for file browser) */
folderPath?: string;
/**
* Recent assistant messages for `annotate-last` mode (newest-first). When
* provided with more than one entry, the editor renders a picker so users
* can choose which message to annotate; index 0 is the default selection
* and matches the legacy "last message" behavior.
*/
recentMessages?: { messageId: string; text: string; timestamp?: string }[];
/** Whether URL sharing is enabled (default: true) */
sharingEnabled?: boolean;
/** Custom base URL for share links */
shareBaseUrl?: string;
/** Base URL of the paste service API for short URL sharing */
pasteApiUrl?: string;
/** Source attribution: original URL or filename (e.g. "https://..." or "index.html") */
sourceInfo?: string;
/** True when `markdown` was produced by Turndown/Jina (HTML or URL) —
* feedback line numbers won't match the original source. */
sourceConverted?: boolean;
/** Enable review-gate UX: adds an Approve button alongside Close/Send Annotations */
gate?: boolean;
/** Raw HTML content for direct iframe rendering (--render-html mode) */
rawHtml?: string;
/** Render HTML as-is in an iframe instead of converting to markdown */
renderHtml?: boolean;
/** 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[];
exit?: boolean;
approved?: boolean;
selectedMessageId?: string;
feedbackScope?: "message" | "messages";
}>;
/** 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<AnnotateServerResult> {
// Side-channel pre-warm so /api/doc/exists POSTs land on warm cache.
void warmFileListCache(process.cwd(), "code");
const {
markdown,
filePath,
htmlContent,
origin,
mode = "annotate",
folderPath,
recentMessages,
sourceInfo,
sourceConverted,
sharingEnabled = true,
shareBaseUrl,
pasteApiUrl,
gate = false,
rawHtml,
renderHtml = false,
onReady,
} = options;
const isRemote = isRemoteSession();
const configuredPort = getServerPort();
const wslFlag = await isWSL();
const gitUser = detectGitUser();
const draftSource =
mode === "annotate-folder" && folderPath
? `folder:${resolvePath(folderPath)}`
: renderHtml && rawHtml ? rawHtml : markdown;
const draftKey = contentHash(draftSource);
const externalAnnotations = createExternalAnnotationHandler("plan");
const aiRuntime = await createAIRuntime();
// Detect repo info (cached for this session)
const repoInfo = await getRepoInfo();
// Decision promise
let resolveDecision: (result: {
feedback: string;
annotations: unknown[];
exit?: boolean;
approved?: boolean;
selectedMessageId?: string;
feedbackScope?: "message" | "messages";
}) => void;
const decisionPromise = new Promise<{
feedback: string;
annotations: unknown[];
exit?: boolean;
approved?: boolean;
selectedMessageId?: string;
feedbackScope?: "message" | "messages";
}>((resolve) => {
resolveDecision = resolve;
});
// Start server with retry logic
let server: ReturnType<typeof Bun.serve> | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
server = Bun.serve({
hostname: getServerHostname(),
port: configuredPort,
async fetch(req, server) {
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,
filePath,
sourceInfo,
sourceConverted: sourceConverted ?? false,
gate,
renderAs: renderHtml && rawHtml ? 'html' as const : 'markdown' as const,
...(renderHtml && rawHtml ? { rawHtml } : {}),
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
repoInfo,
projectRoot: folderPath || process.cwd(),
isWSL: wslFlag,
serverConfig: getServerConfig(gitUser),
...(recentMessages ? { recentMessages } : {}),
});
}
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
}
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
return handleImage(req);
}
// API: Serve a linked markdown document
// Inject source file's directory as base for relative path resolution.
// Skip base injection for URL annotations — there's no local directory to resolve against.
if (url.pathname === "/api/doc" && req.method === "GET") {
if (!url.searchParams.has("base") && !/^https?:\/\//i.test(filePath)) {
const docUrl = new URL(req.url);
docUrl.searchParams.set("base", dirname(filePath));
return handleDoc(new Request(docUrl.toString()));
}
return handleDoc(req);
}
// API: Batch existence check for code-file paths the renderer detected
if (url.pathname === "/api/doc/exists" && req.method === "POST") {
return handleDocExists(req);
}
// API: Detect Obsidian vaults
if (url.pathname === "/api/obsidian/vaults") {
return handleObsidianVaults();
}
// API: List Obsidian vault files as a tree
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
return handleObsidianFiles(req);
}
// API: Read an Obsidian vault document
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
return handleObsidianDoc(req);
}
// API: List markdown files in a directory as a tree
if (url.pathname === "/api/reference/files" && req.method === "GET") {
return handleFileBrowserFiles(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req);
}
// API: Annotation draft persistence
if (url.pathname === "/api/draft") {
if (req.method === "POST") return handleDraftSave(req, draftKey);
if (req.method === "DELETE") return handleDraftDelete(draftKey);
return handleDraftLoad(draftKey);
}
// API: External annotations (SSE-based, for any external tool)
const externalResponse = await externalAnnotations.handle(req, url, {
disableIdleTimeout: () => server.timeout(req, 0),
});
if (externalResponse) return externalResponse;
if (url.pathname.startsWith("/api/ai/")) {
const handler = aiRuntime.endpoints[url.pathname as keyof AIEndpoints];
if (handler) {
if (url.pathname === AI_QUERY_ENDPOINT) {
server.timeout(req, 0);
}
return handler(req);
}
return Response.json({ error: "Not found" }, { status: 404 });
}
// API: Exit annotation session without feedback
if (url.pathname === "/api/exit" && req.method === "POST") {
deleteDraft(draftKey);
resolveDecision({ feedback: "", annotations: [], exit: true });
return Response.json({ ok: true });
}
// API: Approve the annotation session (review-gate UX)
if (url.pathname === "/api/approve" && req.method === "POST") {
deleteDraft(draftKey);
resolveDecision({ feedback: "", annotations: [], approved: true });
return Response.json({ ok: true });
}
// API: Submit annotation feedback
if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
const body = (await req.json()) as {
feedback: string;
annotations: unknown[];
selectedMessageId?: string;
feedbackScope?: "message" | "messages";
};
deleteDraft(draftKey);
resolveDecision({
feedback: body.feedback || "",
annotations: body.annotations || [],
selectedMessageId: body.selectedMessageId,
feedbackScope: body.feedbackScope,
});
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 });
}
}
// Favicon
if (url.pathname === "/favicon.svg") return handleFavicon();
// Serve embedded HTML for all other routes (SPA)
return new Response(htmlContent, {
headers: { "Content-Type": "text/html" },
});
},
error(err) {
console.error("[plannotator] Server error:", err);
return new Response(
`Internal Server Error: ${err instanceof Error ? err.message : String(err)}`,
{ status: 500, headers: { "Content-Type": "text/plain" } },
);
},
});
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 port = server.port!;
const serverUrl = `http://localhost:${port}`;
// Notify caller that server is ready
if (onReady) {
onReady(serverUrl, isRemote, port);
}
return {
port,
url: serverUrl,
isRemote,
waitForDecision: () => decisionPromise,
stop: () => {
aiRuntime.dispose();
server.stop();
},
};
}