Skip to content

Commit fdc4bc4

Browse files
authored
feat(plan,annotate): include source line numbers in exported feedback (#623)
Each annotation in exported plan/annotate feedback now carries source line numbers — single-line blocks show `(line N)`, multi-line blocks show `(lines N–M)`. Diff-context and global comments stay lineless. When the document was produced by Turndown/Jina (HTML file or URL), the export carries a caveat that line numbers refer to the converted markdown rather than the original source. Key implementation details: - extractFrontmatter() returns contentStartLine so block line numbers account for stripped YAML headers - blockEndLine() computes end lines per block type, with code blocks, directives, and alerts accounting for stripped wrapper lines - isConvertedSource() helper in url-to-markdown.ts centralizes the source-type check across all entry points - sourceConverted threaded from all CLIs through annotate servers to the /api/plan payload; isConverted added to /api/doc responses - Per-document conversion tracking in useLinkedDoc ensures the correct flag is used when viewing linked HTML docs Supersedes #621. For provenance purposes, this commit was AI assisted.
1 parent 7ab2d8f commit fdc4bc4

14 files changed

Lines changed: 269 additions & 36 deletions

File tree

apps/hook/server/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotat
6767
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
6868
import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference";
6969
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
70-
import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
70+
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
7171
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree";
7272
import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool";
7373
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
@@ -580,6 +580,7 @@ if (args[0] === "sessions") {
580580
let folderPath: string | undefined;
581581
let annotateMode: "annotate" | "annotate-folder" = "annotate";
582582
let sourceInfo: string | undefined;
583+
let sourceConverted = false;
583584

584585
// --- URL annotation ---
585586
const isUrl = /^https?:\/\//i.test(filePath);
@@ -590,6 +591,7 @@ if (args[0] === "sessions") {
590591
try {
591592
const result = await urlToMarkdown(filePath, { useJina });
592593
markdown = result.markdown;
594+
sourceConverted = isConvertedSource(result.source);
593595
if (process.env.PLANNOTATOR_DEBUG) {
594596
console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`);
595597
}
@@ -636,6 +638,7 @@ if (args[0] === "sessions") {
636638
markdown = htmlToMarkdown(html);
637639
absolutePath = resolvedArg;
638640
sourceInfo = path.basename(resolvedArg);
641+
sourceConverted = true;
639642
console.error(`Converted: ${absolutePath}`);
640643
} else {
641644
// Single markdown file annotation mode
@@ -674,6 +677,7 @@ if (args[0] === "sessions") {
674677
mode: annotateMode,
675678
folderPath,
676679
sourceInfo,
680+
sourceConverted,
677681
sharingEnabled,
678682
shareBaseUrl,
679683
pasteApiUrl,

apps/opencode-plugin/commands.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannot
2525
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
2626
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
2727
import { parseAnnotateArgs } from "@plannotator/shared/annotate-args";
28-
import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
28+
import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown";
2929
import { statSync } from "fs";
3030
import path from "path";
3131

@@ -170,6 +170,7 @@ export async function handleAnnotateCommand(
170170
let folderPath: string | undefined;
171171
let annotateMode: "annotate" | "annotate-folder" = "annotate";
172172
let sourceInfo: string | undefined;
173+
let sourceConverted = false;
173174

174175
// --- URL annotation ---
175176
const isUrl = /^https?:\/\//i.test(filePath);
@@ -180,6 +181,7 @@ export async function handleAnnotateCommand(
180181
try {
181182
const result = await urlToMarkdown(filePath, { useJina });
182183
markdown = result.markdown;
184+
sourceConverted = isConvertedSource(result.source);
183185
} catch (err) {
184186
client.app.log({ level: "error", message: `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}` });
185187
return;
@@ -223,6 +225,7 @@ export async function handleAnnotateCommand(
223225
markdown = htmlToMarkdown(html);
224226
absolutePath = resolvedArg;
225227
sourceInfo = path.basename(resolvedArg);
228+
sourceConverted = true;
226229
client.app.log({ level: "info", message: `Converted: ${absolutePath}` });
227230
} else {
228231
// Markdown file annotation
@@ -258,6 +261,7 @@ export async function handleAnnotateCommand(
258261
mode: annotateMode,
259262
folderPath,
260263
sourceInfo,
264+
sourceConverted,
261265
sharingEnabled: await getSharingEnabled(),
262266
shareBaseUrl: getShareBaseUrl(),
263267
pasteApiUrl: getPasteApiUrl(),

apps/pi-extension/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { planDenyFeedback } from "./generated/feedback-templates.js";
3535
import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
3636
import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
3737
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
38-
import { urlToMarkdown } from "./generated/url-to-markdown.js";
38+
import { urlToMarkdown, isConvertedSource } from "./generated/url-to-markdown.js";
3939
import { loadConfig, resolveUseJina } from "./generated/config.js";
4040
import { parseAnnotateArgs } from "./generated/annotate-args.js";
4141
import { resolveAtReference } from "./generated/at-reference.js";
@@ -361,6 +361,7 @@ export default function plannotator(pi: ExtensionAPI): void {
361361
let folderPath: string | undefined;
362362
let mode: "annotate" | "annotate-folder" | undefined;
363363
let sourceInfo: string | undefined;
364+
let sourceConverted = false;
364365
let isFolder = false;
365366

366367
// --- URL annotation ---
@@ -372,6 +373,7 @@ export default function plannotator(pi: ExtensionAPI): void {
372373
try {
373374
const result = await urlToMarkdown(filePath, { useJina });
374375
markdown = result.markdown;
376+
sourceConverted = isConvertedSource(result.source);
375377
} catch (err) {
376378
ctx.ui.notify(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, "error");
377379
return;
@@ -420,6 +422,7 @@ export default function plannotator(pi: ExtensionAPI): void {
420422
const html = readFileSync(absolutePath, "utf-8");
421423
markdown = htmlToMarkdown(html);
422424
sourceInfo = basename(absolutePath);
425+
sourceConverted = true;
423426
ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info");
424427
} else {
425428
markdown = readFileSync(absolutePath, "utf-8");
@@ -428,7 +431,7 @@ export default function plannotator(pi: ExtensionAPI): void {
428431
}
429432

430433
try {
431-
const result = await openMarkdownAnnotation(ctx, absolutePath, markdown, mode ?? "annotate", folderPath, sourceInfo, gate);
434+
const result = await openMarkdownAnnotation(ctx, absolutePath, markdown, mode ?? "annotate", folderPath, sourceInfo, sourceConverted, gate);
432435
if (result.approved) {
433436
ctx.ui.notify("Annotation approved.", "info");
434437
} else if (result.exit) {

apps/pi-extension/plannotator-browser.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export async function openMarkdownAnnotation(
391391
mode: AnnotateMode,
392392
folderPath?: string,
393393
sourceInfo?: string,
394+
sourceConverted?: boolean,
394395
gate?: boolean,
395396
): Promise<{ feedback: string; exit?: boolean; approved?: boolean }> {
396397
if (!ctx.hasUI || !planHtmlContent) {
@@ -416,6 +417,7 @@ export async function openMarkdownAnnotation(
416417
mode,
417418
folderPath,
418419
sourceInfo,
420+
sourceConverted,
419421
gate,
420422
htmlContent: planHtmlContent,
421423
sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
@@ -431,7 +433,7 @@ export async function openLastMessageAnnotation(
431433
lastText: string,
432434
gate?: boolean,
433435
): Promise<{ feedback: string; exit?: boolean; approved?: boolean }> {
434-
return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last", undefined, undefined, gate);
436+
return openMarkdownAnnotation(ctx, "last-message", lastText, "annotate-last", undefined, undefined, undefined, gate);
435437
}
436438

437439
export async function openArchiveBrowserAction(

apps/pi-extension/plannotator-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,15 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
272272
request.respond({ status: "error", error: "Missing filePath for annotate request." });
273273
return;
274274
}
275+
const sourceConverted = /\.html?$/i.test(payload.filePath) || /^https?:\/\//i.test(payload.filePath);
275276
const result = await openMarkdownAnnotation(
276277
ctx,
277278
payload.filePath,
278279
payload.markdown ?? "",
279280
payload.mode ?? "annotate",
280281
payload.folderPath,
281282
undefined,
283+
sourceConverted,
282284
payload.gate,
283285
);
284286
request.respond({ status: "handled", result });

apps/pi-extension/server/reference.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ export function handleDocRequest(res: Res, url: URL): void {
7777
try {
7878
if (existsSync(fromBase)) {
7979
const raw = readFileSync(fromBase, "utf-8");
80-
const markdown = /\.html?$/i.test(requestedPath) ? htmlToMarkdown(raw) : raw;
81-
json(res, { markdown, filepath: fromBase });
80+
const isHtml = /\.html?$/i.test(requestedPath);
81+
const markdown = isHtml ? htmlToMarkdown(raw) : raw;
82+
json(res, { markdown, filepath: fromBase, isConverted: isHtml });
8283
return;
8384
}
8485
} catch {
@@ -97,7 +98,7 @@ export function handleDocRequest(res: Res, url: URL): void {
9798
try {
9899
if (existsSync(resolvedHtml)) {
99100
const html = readFileSync(resolvedHtml, "utf-8");
100-
json(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml });
101+
json(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml, isConverted: true });
101102
return;
102103
}
103104
} catch { /* fall through to 404 */ }

apps/pi-extension/server/serverAnnotate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export async function startAnnotateServer(options: {
4343
shareBaseUrl?: string;
4444
pasteApiUrl?: string;
4545
sourceInfo?: string;
46+
sourceConverted?: boolean;
4647
gate?: boolean;
4748
}): Promise<AnnotateServerResult> {
4849
const gitUser = detectGitUser();
@@ -92,6 +93,7 @@ export async function startAnnotateServer(options: {
9293
mode: options.mode || "annotate",
9394
filePath: options.filePath,
9495
sourceInfo: options.sourceInfo,
96+
sourceConverted: options.sourceConverted ?? false,
9597
gate: options.gate ?? false,
9698
sharingEnabled,
9799
shareBaseUrl,

packages/editor/App.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
22
import { type Origin, getAgentName } from '@plannotator/shared/agents';
3-
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter } from '@plannotator/ui/utils/parser';
3+
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
44
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
55
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
66
import { ExportModal } from '@plannotator/ui/components/ExportModal';
@@ -150,6 +150,7 @@ const App: React.FC = () => {
150150
const [gate, setGate] = useState(false);
151151
const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null);
152152
const [sourceInfo, setSourceInfo] = useState<string | undefined>();
153+
const [sourceConverted, setSourceConverted] = useState(false);
153154
const [sourceFilePath, setSourceFilePath] = useState<string | undefined>();
154155
const [imageBaseDir, setImageBaseDir] = useState<string | undefined>(undefined);
155156
const [isLoading, setIsLoading] = useState(true);
@@ -304,7 +305,7 @@ const App: React.FC = () => {
304305
const linkedDocHook = useLinkedDoc({
305306
markdown, annotations, selectedAnnotationId, globalAttachments,
306307
setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments,
307-
viewerRef, sidebar: linkedDocSidebar, sourceFilePath,
308+
viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted,
308309
});
309310

310311
// Archive browser
@@ -652,7 +653,7 @@ const App: React.FC = () => {
652653
if (!res.ok) throw new Error('Not in API mode');
653654
return res.json();
654655
})
655-
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; gate?: boolean; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => {
656+
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => {
656657
// Initialize config store with server-provided values (config file > cookie > default)
657658
configStore.init(data.serverConfig);
658659
// gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable
@@ -682,6 +683,7 @@ const App: React.FC = () => {
682683
setAnnotateSource(data.mode === 'annotate-last' ? 'message' : data.mode === 'annotate-folder' ? 'folder' : 'file');
683684
}
684685
setSourceInfo(data.sourceInfo ?? undefined);
686+
setSourceConverted(!!data.sourceConverted);
685687
if (data.filePath) {
686688
setImageBaseDir(data.mode === 'annotate-folder' ? data.filePath : data.filePath.replace(/\/[^/]+$/, ''));
687689
if (data.mode === 'annotate') {
@@ -1184,20 +1186,41 @@ const App: React.FC = () => {
11841186
return 'User reviewed the document and has no feedback.';
11851187
}
11861188

1189+
// Derive the conversion flag for the currently-displayed document:
1190+
// when viewing a linked doc, use that doc's isConverted; otherwise use the root flag.
1191+
const activeConverted = linkedDocHook.isActive
1192+
? (docAnnotations.get(linkedDocHook.filepath ?? '')?.isConverted ?? false)
1193+
: sourceConverted;
1194+
11871195
let output = hasPlanAnnotations
1188-
? exportAnnotations(blocks, allAnnotations, globalAttachments, annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', annotateSource ?? 'plan')
1196+
? exportAnnotations(
1197+
blocks,
1198+
allAnnotations,
1199+
globalAttachments,
1200+
annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback',
1201+
annotateSource ?? 'plan',
1202+
{ sourceConverted: activeConverted },
1203+
)
11891204
: '';
11901205

11911206
if (hasDocAnnotations) {
1192-
output += exportLinkedDocAnnotations(docAnnotations);
1207+
// Parse blocks for each linked doc's cached markdown so the exporter
1208+
// can attach source line numbers per annotation.
1209+
const enriched: Map<string, LinkedDocAnnotationEntry> = new Map(docAnnotations);
1210+
for (const [filepath, entry] of enriched) {
1211+
if (entry.markdown) {
1212+
enriched.set(filepath, { ...entry, blocks: parseMarkdownToBlocks(entry.markdown) });
1213+
}
1214+
}
1215+
output += exportLinkedDocAnnotations(enriched);
11931216
}
11941217

11951218
if (hasEditorAnnotations) {
11961219
output += exportEditorAnnotations(editorAnnotations);
11971220
}
11981221

11991222
return output;
1200-
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations]);
1223+
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]);
12011224

12021225
// Bot callback config — read once from URL search params (?cb=&ct=)
12031226
const callbackConfig = React.useMemo(() => getCallbackConfig(), []);

packages/server/annotate.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export interface AnnotateServerOptions {
5050
pasteApiUrl?: string;
5151
/** Source attribution: original URL or filename (e.g. "https://..." or "index.html") */
5252
sourceInfo?: string;
53+
/** True when `markdown` was produced by Turndown/Jina (HTML or URL) —
54+
* feedback line numbers won't match the original source. */
55+
sourceConverted?: boolean;
5356
/** Enable review-gate UX: adds an Approve button alongside Close/Send Annotations (#570) */
5457
gate?: boolean;
5558
/** Called when server starts with the URL, remote status, and port */
@@ -98,6 +101,7 @@ export async function startAnnotateServer(
98101
mode = "annotate",
99102
folderPath,
100103
sourceInfo,
104+
sourceConverted,
101105
sharingEnabled = true,
102106
shareBaseUrl,
103107
pasteApiUrl,
@@ -155,6 +159,7 @@ export async function startAnnotateServer(
155159
mode,
156160
filePath,
157161
sourceInfo,
162+
sourceConverted: sourceConverted ?? false,
158163
gate,
159164
sharingEnabled,
160165
shareBaseUrl,

packages/server/reference-handlers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ export async function handleDoc(req: Request): Promise<Response> {
4545
const file = Bun.file(fromBase);
4646
if (await file.exists()) {
4747
const raw = await file.text();
48-
const markdown = /\.html?$/i.test(requestedPath) ? htmlToMarkdown(raw) : raw;
49-
return Response.json({ markdown, filepath: fromBase });
48+
const isHtml = /\.html?$/i.test(requestedPath);
49+
const markdown = isHtml ? htmlToMarkdown(raw) : raw;
50+
return Response.json({ markdown, filepath: fromBase, isConverted: isHtml });
5051
}
5152
} catch {
5253
/* fall through to standard resolution */
@@ -65,7 +66,7 @@ export async function handleDoc(req: Request): Promise<Response> {
6566
if (await file.exists()) {
6667
const html = await file.text();
6768
const markdown = htmlToMarkdown(html);
68-
return Response.json({ markdown, filepath: resolvedHtml });
69+
return Response.json({ markdown, filepath: resolvedHtml, isConverted: true });
6970
}
7071
} catch { /* fall through */ }
7172
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });

0 commit comments

Comments
 (0)