mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
be2c81fa3b
* 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>
397 lines
14 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|