From 22f5dffc96fb51a61276ad340a4f6d6c88176114 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 14:27:14 -0700 Subject: [PATCH 01/16] feat(pfm): code file line range references with hover preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for line ranges in code file references: - `file.ts:42` — single line - `file.ts:10-20` — line range New parseCodePath() in packages/shared/code-file.ts extracts line info. Server strips line suffix before file resolution, returns line info in response. CodeFilePopout scrolls to the referenced line on open. Hovering a code file badge with line info shows a small code snippet popover after 300ms delay. Moved useCodeFilePopout to packages/ui/hooks/pfm/ as the start of organizing PFM-related hooks. Old path re-exports for compatibility. --- packages/editor/demoPlan.ts | 2 +- packages/server/reference-handlers.ts | 9 +- packages/shared/code-file.ts | 25 ++++- packages/shared/pfm-reminder.ts | 2 + packages/ui/components/CodeFilePopout.tsx | 13 +++ packages/ui/components/InlineMarkdown.tsx | 76 +++++++++++++- packages/ui/hooks/pfm/useCodeFilePopout.ts | 111 +++++++++++++++++++++ packages/ui/hooks/useCodeFilePopout.ts | 98 +----------------- 8 files changed, 229 insertions(+), 107 deletions(-) create mode 100644 packages/ui/hooks/pfm/useCodeFilePopout.ts diff --git a/packages/editor/demoPlan.ts b/packages/editor/demoPlan.ts index 8fe0a94ff..9558be651 100644 --- a/packages/editor/demoPlan.ts +++ b/packages/editor/demoPlan.ts @@ -34,7 +34,7 @@ export const COLLAB_CONFIG = { | \`package.json\` | Root config | ## Overview -Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. +Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. The core rendering logic is in \`packages/ui/components/Viewer.tsx:140-180\` and the block parser at \`packages/ui/utils/parser.ts:261-286\`. ## Phase 1: Infrastructure diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 947aee0b1..b18bf0771 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -8,6 +8,7 @@ import { existsSync, statSync } from "fs"; import { resolve } from "path"; import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { parseCodePath } from "@plannotator/shared/code-file"; import { detectObsidianVaults } from "./integrations"; import { isAbsoluteUserPath, @@ -83,7 +84,9 @@ export async function handleDoc(req: Request): Promise { // Code files: try literal resolve first; on miss, fall back to the smart // resolver which walks the project for case-insensitive / suffix matches. if (isCodeFilePath(requestedPath)) { - const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot); + const parsed = parseCodePath(requestedPath); + const cleanPath = parsed.filePath; + const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot); let resolvedCode: string | null = null; @@ -95,7 +98,7 @@ export async function handleDoc(req: Request): Promise { } if (!resolvedCode) { - const result = await resolveCodeFile(requestedPath, projectRoot); + const result = await resolveCodeFile(cleanPath, projectRoot); if (result.kind === "found") { resolvedCode = result.path; } else if (result.kind === "ambiguous") { @@ -134,7 +137,7 @@ export async function handleDoc(req: Request): Promise { } catch { // Fall back to client-side rendering } - return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML }); + return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); } catch { return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); } diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts index fc9d95b72..92492037b 100644 --- a/packages/shared/code-file.ts +++ b/packages/shared/code-file.ts @@ -1,6 +1,6 @@ export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i; -export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+/g; +export const CODE_PATH_BARE_REGEX = /(?:\.{0,2}\/)?(?:[a-zA-Z0-9_@.\-\[\]]+\/)+[a-zA-Z0-9_.\-\[\]]+\.[a-zA-Z0-9]+(?::\d+(?:-\d+)?)?/g; const IMPLAUSIBLE_CHARS = /[{},*?\s]/; @@ -8,9 +8,30 @@ export function isPlausibleCodeFilePath(input: string): boolean { return !IMPLAUSIBLE_CHARS.test(input); } +export interface ParsedCodePath { + filePath: string; + line?: number; + lineEnd?: number; +} + +const LINE_SUFFIX_RE = /:(\d+)(?:-(\d+))?$/; + +export function parseCodePath(input: string): ParsedCodePath { + const clean = input.replace(/#.*$/, ''); + const m = clean.match(LINE_SUFFIX_RE); + if (!m) return { filePath: clean }; + const line = Number.parseInt(m[1], 10); + const lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; +} + +export function stripLineRef(input: string): string { + return input.replace(/#.*$/, '').replace(LINE_SUFFIX_RE, ''); +} + export function isCodeFilePath(input: string): boolean { if (!isPlausibleCodeFilePath(input)) return false; - return CODE_FILE_REGEX.test(input.replace(/#.*$/, '')) + return CODE_FILE_REGEX.test(stripLineRef(input)) && !input.startsWith('http://') && !input.startsWith('https://'); } diff --git a/packages/shared/pfm-reminder.ts b/packages/shared/pfm-reminder.ts index 3430d899e..0b2e0711c 100644 --- a/packages/shared/pfm-reminder.ts +++ b/packages/shared/pfm-reminder.ts @@ -46,8 +46,10 @@ Code-file links (highest leverage) Reference real source files inline. Plannotator validates the path and renders a clickable badge that opens the file in the reviewer's editor — prefer this over pasting code when you just need to point at something. \`packages/server/index.ts\` backticked path \`packages/server/index.ts:42\` path with line number + \`packages/server/index.ts:10-20\` line range — hover shows a code snippet preview [the handler](packages/server/index.ts:42) markdown link form Ambiguous paths (e.g. \`index.ts\`) still render and open a picker. +Line references (\`:42\` or \`:10-20\`) open the file scrolled to that region. Callouts and alerts GitHub-style alerts highlight critical context: diff --git a/packages/ui/components/CodeFilePopout.tsx b/packages/ui/components/CodeFilePopout.tsx index b45404183..95f6a5cce 100644 --- a/packages/ui/components/CodeFilePopout.tsx +++ b/packages/ui/components/CodeFilePopout.tsx @@ -24,6 +24,8 @@ interface CodeFilePopoutProps { prerenderedHTML?: string; error?: string; requestedPath?: string; + line?: number; + lineEnd?: number; annotations?: CodeAnnotation[]; selectedAnnotationId?: string | null; onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void; @@ -290,6 +292,8 @@ export const CodeFilePopout: React.FC = ({ prerenderedHTML, error, requestedPath, + line: initialLine, + lineEnd: initialLineEnd, annotations = [], selectedAnnotationId, onAddAnnotation, @@ -364,6 +368,15 @@ export const CodeFilePopout: React.FC = ({ return () => clearTimeout(timer); }, [selectedAnnotationId, filepath]); + useEffect(() => { + if (!initialLine || !fileAreaRef.current) return; + const timer = setTimeout(() => { + const lineEl = fileAreaRef.current?.querySelector(`[data-line="${initialLine}"]`); + lineEl?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 150); + return () => clearTimeout(timer); + }, [initialLine, filepath]); + const openCommentForRange = useCallback(( range: { start: number; end: number }, anchorEl?: HTMLElement, diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index eb654df69..29fe275e2 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX } from "@plannotator/shared/code-file"; +import React, { useState, useRef, useCallback } from "react"; +import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; @@ -33,6 +33,42 @@ function gateCodePath( } } +const CodeSnippetPreview: React.FC<{ + anchorEl: HTMLElement | null; + contents: string; + line: number; + lineEnd?: number; +}> = ({ anchorEl, contents, line, lineEnd }) => { + if (!anchorEl) return null; + const lines = contents.split('\n'); + const start = Math.max(0, line - 1); + const end = Math.min(lines.length, (lineEnd ?? line)); + const snippet = lines.slice(start, end); + + const rect = anchorEl.getBoundingClientRect(); + const top = rect.bottom + 4; + const left = Math.max(8, rect.left); + + return ( +
+
+ {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`} +
+
+        {snippet.map((l, i) => (
+          
+ {start + i + 1} + {l || ' '} +
+ ))} +
+
+ ); +}; + const CodeFileLink: React.FC<{ candidate: string; display: string; @@ -40,8 +76,29 @@ const CodeFileLink: React.FC<{ }> = ({ candidate, display, onOpenCodeFile }) => { const validation = useCodePathValidation(); const gate = gateCodePath(candidate, validation); - const [pickerOpen, setPickerOpen] = React.useState(false); - const anchorRef = React.useRef(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [hoverPreview, setHoverPreview] = useState<{ contents: string } | null>(null); + const anchorRef = useRef(null); + const hoverTimerRef = useRef | null>(null); + const parsed = parseCodePath(candidate); + const hasLineRef = parsed.line != null; + + const handleMouseEnter = useCallback(() => { + if (!hasLineRef || gate.render === 'plain') return; + hoverTimerRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/doc?path=${encodeURIComponent(candidate)}`); + const data = await res.json(); + if (data.contents) setHoverPreview({ contents: data.contents }); + } catch {} + }, 300); + }, [candidate, hasLineRef, gate.render]); + + const handleMouseLeave = useCallback(() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + setHoverPreview(null); + }, []); if (gate.render === 'plain') { return ( @@ -53,6 +110,7 @@ const CodeFileLink: React.FC<{ const isAmbiguous = gate.render === 'ambiguous-link'; const handleClick = () => { + handleMouseLeave(); if (isAmbiguous) { setPickerOpen(true); return; @@ -69,6 +127,8 @@ const CodeFileLink: React.FC<{ data-ambiguous={isAmbiguous ? "true" : undefined} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} className="code-file-link px-1.5 py-0.5 rounded bg-muted text-sm font-mono cursor-pointer hover:text-primary inline-flex items-center gap-1 transition-colors" title={isAmbiguous ? `${display} — multiple matches` : `View: ${display}`} > @@ -78,6 +138,14 @@ const CodeFileLink: React.FC<{ {(gate as { matches: string[] }).matches.length} )} + {hoverPreview && hasLineRef && ( + + )} {pickerOpen && isAmbiguous && ( string; +} + +export interface UseCodeFilePopoutReturn { + open: (codePath: string) => void; + close: () => void; + isLoading: boolean; + popoutProps: { + open: boolean; + onClose: () => void; + filepath: string; + contents: string; + prerenderedHTML?: string; + error?: string; + requestedPath?: string; + line?: number; + lineEnd?: number; + } | null; +} + +export function useCodeFilePopout( + options: UseCodeFilePopoutOptions +): UseCodeFilePopoutReturn { + const { buildUrl } = options; + const [state, setState] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const close = useCallback(() => { + setState(null); + setIsLoading(false); + }, []); + + const open = useCallback( + async (codePath: string) => { + setIsLoading(true); + const parsed = parseCodePath(codePath); + try { + const res = await fetch(buildUrl(codePath)); + const data = (await res.json()) as { + codeFile?: boolean; + contents?: string; + filepath?: string; + prerenderedHTML?: string; + error?: string; + line?: number; + lineEnd?: number; + }; + if (!res.ok || data.error || !data.codeFile || typeof data.contents !== 'string' || !data.filepath) { + setState({ + filepath: codePath, + contents: "", + error: data.error ?? `File not found in repo: ${codePath}`, + requestedPath: codePath, + }); + setIsLoading(false); + return; + } + setState({ + filepath: data.filepath, + contents: data.contents, + prerenderedHTML: data.prerenderedHTML, + line: data.line ?? parsed.line, + lineEnd: data.lineEnd ?? parsed.lineEnd, + }); + setIsLoading(false); + } catch { + setState({ + filepath: codePath, + contents: "", + error: `Failed to load: ${codePath}`, + requestedPath: codePath, + }); + setIsLoading(false); + } + }, + [buildUrl] + ); + + return { + open, + close, + isLoading, + popoutProps: state + ? { + open: true, + onClose: close, + filepath: state.filepath, + contents: state.contents, + prerenderedHTML: state.prerenderedHTML, + error: state.error, + requestedPath: state.requestedPath, + line: state.line, + lineEnd: state.lineEnd, + } + : null, + }; +} diff --git a/packages/ui/hooks/useCodeFilePopout.ts b/packages/ui/hooks/useCodeFilePopout.ts index e5e0c3de4..21a3256b5 100644 --- a/packages/ui/hooks/useCodeFilePopout.ts +++ b/packages/ui/hooks/useCodeFilePopout.ts @@ -1,97 +1 @@ -import { useState, useCallback } from "react"; - -interface CodeFileState { - filepath: string; - contents: string; - prerenderedHTML?: string; - error?: string; - requestedPath?: string; -} - -interface UseCodeFilePopoutOptions { - buildUrl: (codePath: string) => string; -} - -export interface UseCodeFilePopoutReturn { - open: (codePath: string) => void; - close: () => void; - isLoading: boolean; - popoutProps: { - open: boolean; - onClose: () => void; - filepath: string; - contents: string; - prerenderedHTML?: string; - error?: string; - requestedPath?: string; - } | null; -} - -export function useCodeFilePopout( - options: UseCodeFilePopoutOptions -): UseCodeFilePopoutReturn { - const { buildUrl } = options; - const [state, setState] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const close = useCallback(() => { - setState(null); - setIsLoading(false); - }, []); - - const open = useCallback( - async (codePath: string) => { - setIsLoading(true); - try { - const res = await fetch(buildUrl(codePath)); - const data = (await res.json()) as { - codeFile?: boolean; - contents?: string; - filepath?: string; - prerenderedHTML?: string; - error?: string; - }; - if (!res.ok || data.error || !data.codeFile || typeof data.contents !== 'string' || !data.filepath) { - // Surface the not-found state so the popout can show a clear message - // instead of silently doing nothing. - setState({ - filepath: codePath, - contents: "", - error: data.error ?? `File not found in repo: ${codePath}`, - requestedPath: codePath, - }); - setIsLoading(false); - return; - } - setState({ filepath: data.filepath, contents: data.contents, prerenderedHTML: data.prerenderedHTML }); - setIsLoading(false); - } catch { - setState({ - filepath: codePath, - contents: "", - error: `Failed to load: ${codePath}`, - requestedPath: codePath, - }); - setIsLoading(false); - } - }, - [buildUrl] - ); - - return { - open, - close, - isLoading, - popoutProps: state - ? { - open: true, - onClose: close, - filepath: state.filepath, - contents: state.contents, - prerenderedHTML: state.prerenderedHTML, - error: state.error, - requestedPath: state.requestedPath, - } - : null, - }; -} +export { useCodeFilePopout, type UseCodeFilePopoutReturn } from "./pfm/useCodeFilePopout"; From e17b0ef527c376f276c421c990eda336da6788d4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 14:31:27 -0700 Subject: [PATCH 02/16] fix: strip line refs in batch validator + cleanup hover timer on unmount handleDocExists was passing paths with :line suffixes to resolveCodeFile, causing code file badges with line refs to show as "missing". Also add unmount cleanup for the hover preview timer. --- packages/server/reference-handlers.ts | 3 ++- packages/ui/components/InlineMarkdown.tsx | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index b18bf0771..af18848d0 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -218,7 +218,8 @@ export async function handleDocExists(req: Request): Promise { await Promise.all( (paths as string[]).map(async (p) => { - const r = await resolveCodeFile(p, projectRoot, baseDir); + const cleanP = parseCodePath(p).filePath; + const r = await resolveCodeFile(cleanP, projectRoot, baseDir); if (r.kind === "found") { results[p] = { status: "found", resolved: r.path }; } else if (r.kind === "ambiguous") { diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 29fe275e2..4696918a0 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useCallback } from "react"; +import React, { useState, useRef, useCallback, useEffect } from "react"; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; @@ -100,6 +100,10 @@ const CodeFileLink: React.FC<{ setHoverPreview(null); }, []); + useEffect(() => { + return () => { if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); }; + }, []); + if (gate.render === 'plain') { return ( From 26ab3922d6c9f810cfc09b4dbd4e0bd7810a947a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 14:44:49 -0700 Subject: [PATCH 03/16] fix(pfm): syntax-highlighted hover preview + faster delay Use hljs.highlight() for syntax coloring in the code snippet popover, with language auto-detected from file extension. Reduced hover delay from 300ms to 150ms. Added smart positioning (show above when near viewport bottom). Preview header shows filename and line range. --- packages/ui/components/InlineMarkdown.tsx | 78 +++++++++++++++++------ 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 4696918a0..35d2ef900 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,4 +1,5 @@ -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import hljs from "highlight.js"; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; @@ -33,38 +34,72 @@ function gateCodePath( } } +function extToLanguage(filepath: string): string | undefined { + const ext = filepath.split('.').pop()?.toLowerCase(); + const map: Record = { + ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml', + sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown', + html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin', + }; + return ext ? map[ext] : undefined; +} + const CodeSnippetPreview: React.FC<{ anchorEl: HTMLElement | null; contents: string; + filepath: string; line: number; lineEnd?: number; -}> = ({ anchorEl, contents, line, lineEnd }) => { +}> = ({ anchorEl, contents, filepath, line, lineEnd }) => { if (!anchorEl) return null; - const lines = contents.split('\n'); + const allLines = contents.split('\n'); const start = Math.max(0, line - 1); - const end = Math.min(lines.length, (lineEnd ?? line)); - const snippet = lines.slice(start, end); + const end = Math.min(allLines.length, (lineEnd ?? line)); + const snippet = allLines.slice(start, end).join('\n'); + + const highlighted = useMemo(() => { + const lang = extToLanguage(filepath); + try { + if (lang) return hljs.highlight(snippet, { language: lang }).value; + return hljs.highlightAuto(snippet).value; + } catch { + return snippet.replace(/&/g, '&').replace(//g, '>'); + } + }, [snippet, filepath]); const rect = anchorEl.getBoundingClientRect(); - const top = rect.bottom + 4; - const left = Math.max(8, rect.left); + const spaceBelow = window.innerHeight - rect.bottom; + const showAbove = spaceBelow < 200 && rect.top > spaceBelow; + const top = showAbove ? undefined : rect.bottom + 4; + const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined; + const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520)); return (
-
- {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`} +
+ {filepath.split('/').pop()} + {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`} +
+
+ + + {snippet.split('\n').map((_, i) => ( + + + + ))} + +
{start + i + 1} +
-
-        {snippet.map((l, i) => (
-          
- {start + i + 1} - {l || ' '} -
- ))} -
); }; @@ -77,7 +112,7 @@ const CodeFileLink: React.FC<{ const validation = useCodePathValidation(); const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); - const [hoverPreview, setHoverPreview] = useState<{ contents: string } | null>(null); + const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null); const anchorRef = useRef(null); const hoverTimerRef = useRef | null>(null); const parsed = parseCodePath(candidate); @@ -89,9 +124,9 @@ const CodeFileLink: React.FC<{ try { const res = await fetch(`/api/doc?path=${encodeURIComponent(candidate)}`); const data = await res.json(); - if (data.contents) setHoverPreview({ contents: data.contents }); + if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} - }, 300); + }, 150); }, [candidate, hasLineRef, gate.render]); const handleMouseLeave = useCallback(() => { @@ -146,6 +181,7 @@ const CodeFileLink: React.FC<{ From 9dd634cceb39d708ee03026dca9d3fe3de0904e2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 17:09:47 -0700 Subject: [PATCH 04/16] fix(pfm): keep hover preview open when mouse enters popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a 200ms dismiss delay with cancel-on-enter pattern — hovering from the badge to the popover (or back) keeps it alive. The popover only closes when the mouse leaves both the badge and popover for 200ms. Allows scrolling through longer code snippets. --- packages/ui/components/InlineMarkdown.tsx | 52 +++++++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 35d2ef900..dfa47dcdd 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -52,7 +52,9 @@ const CodeSnippetPreview: React.FC<{ filepath: string; line: number; lineEnd?: number; -}> = ({ anchorEl, contents, filepath, line, lineEnd }) => { + onMouseEnter?: () => void; + onMouseLeave?: () => void; +}> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => { if (!anchorEl) return null; const allLines = contents.split('\n'); const start = Math.max(0, line - 1); @@ -80,6 +82,8 @@ const CodeSnippetPreview: React.FC<{
{filepath.split('/').pop()} @@ -113,30 +117,58 @@ const CodeFileLink: React.FC<{ const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null); + const [previewHovered, setPreviewHovered] = useState(false); const anchorRef = useRef(null); - const hoverTimerRef = useRef | null>(null); + const showTimerRef = useRef | null>(null); + const hideTimerRef = useRef | null>(null); const parsed = parseCodePath(candidate); const hasLineRef = parsed.line != null; + const cancelHide = useCallback(() => { + if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } + }, []); + + const scheduleHide = useCallback(() => { + cancelHide(); + hideTimerRef.current = setTimeout(() => { + setHoverPreview(null); + setPreviewHovered(false); + }, 200); + }, [cancelHide]); + const handleMouseEnter = useCallback(() => { if (!hasLineRef || gate.render === 'plain') return; - hoverTimerRef.current = setTimeout(async () => { + cancelHide(); + if (hoverPreview) return; + showTimerRef.current = setTimeout(async () => { try { const res = await fetch(`/api/doc?path=${encodeURIComponent(candidate)}`); const data = await res.json(); if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); - }, [candidate, hasLineRef, gate.render]); + }, [candidate, hasLineRef, gate.render, cancelHide, hoverPreview]); const handleMouseLeave = useCallback(() => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - setHoverPreview(null); - }, []); + if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; } + scheduleHide(); + }, [scheduleHide]); + + const handlePreviewEnter = useCallback(() => { + cancelHide(); + setPreviewHovered(true); + }, [cancelHide]); + + const handlePreviewLeave = useCallback(() => { + setPreviewHovered(false); + scheduleHide(); + }, [scheduleHide]); useEffect(() => { - return () => { if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); }; + return () => { + if (showTimerRef.current) clearTimeout(showTimerRef.current); + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }; }, []); if (gate.render === 'plain') { @@ -184,6 +216,8 @@ const CodeFileLink: React.FC<{ filepath={hoverPreview.filepath} line={parsed.line!} lineEnd={parsed.lineEnd} + onMouseEnter={handlePreviewEnter} + onMouseLeave={handlePreviewLeave} /> )} {pickerOpen && isAmbiguous && ( From 09790875d24151487755d6572a8c097d4409f9aa Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 17:20:36 -0700 Subject: [PATCH 05/16] fix(ui): responsive Graphviz container height + remove white background The Graphviz container used a fixed min-height of 20rem regardless of diagram dimensions, causing tiny horizontal diagrams to float in huge empty space. Now computes height from the SVG's aspect ratio, capped at 36rem. Also strips Graphviz's default white background polygon so diagrams are transparent and work with dark mode. --- packages/ui/components/GraphvizBlock.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index ca6d394c7..e84503ba5 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -164,7 +164,8 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { const cleaned = renderedSvg .replace(/ width="[^"]*"/, ' width="100%"') .replace(/ height="[^"]*"/, ' height="100%"') - .replace(/ style="[^"]*"/, ''); + .replace(/ style="[^"]*"/, '') + .replace(/]*fill="white"[^>]*\/>/, ''); if (!cancelled) { naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); setSvg(cleaned); @@ -454,10 +455,15 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { ); + const naturalHeight = naturalBoundsRef.current + ? `min(65vh, ${Math.min(36 * 16, Math.max(4 * 16, Math.round(naturalBoundsRef.current.height * (800 / naturalBoundsRef.current.width))))}px)` + : 'min(65vh, 36rem)'; + const diagramBody = (
= ({ block }) => { <>
{!isExpanded && controls} - {showSource || !svg ? inlineSource : !isExpanded ? diagramBody :
} + {showSource || !svg ? inlineSource : !isExpanded ? diagramBody :
}
{!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal( From dd6d43be83cd653116761338aa393a8b3c163e9f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 17:23:05 -0700 Subject: [PATCH 06/16] fix(ui): theme-aware Graphviz diagrams via CSS overrides Plain Graphviz diagrams (without user-specified colors) now inherit Plannotator theme tokens: node fills use --card, strokes use --border, text uses --foreground, edges use --muted-foreground. User-specified fill/stroke/fontcolor in DOT source takes precedence via specificity. --- packages/ui/components/GraphvizBlock.tsx | 2 +- packages/ui/theme.css | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index e84503ba5..069a7f03c 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -462,7 +462,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { const diagramBody = (
B }) render correctly in any theme. + User-specified fill/stroke/fontcolor in the DOT source take precedence. */ +.graphviz-themed svg text { fill: var(--foreground); } +.graphviz-themed svg .node polygon, +.graphviz-themed svg .node ellipse, +.graphviz-themed svg .node path { fill: var(--card); stroke: var(--border); } +.graphviz-themed svg .edge path { stroke: var(--muted-foreground); } +.graphviz-themed svg .edge polygon { fill: var(--muted-foreground); stroke: var(--muted-foreground); } +.graphviz-themed svg .edge text { fill: var(--muted-foreground); } +.graphviz-themed svg .cluster polygon { fill: color-mix(in srgb, var(--muted) 30%, transparent); stroke: var(--border); } +.graphviz-themed svg .cluster text { fill: var(--muted-foreground); } .directive-danger .directive-title, .directive-caution .directive-title { color: #ef4444; } From 4dffd51b2419ac59375d91f0e6f93bf20f83b366 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 17:28:21 -0700 Subject: [PATCH 07/16] fix(ui): theme Graphviz via SVG post-processing instead of CSS CSS overrides clobber user-specified colors from DOT source because CSS fill properties take precedence over SVG fill attributes. Switch to JS-based replacement: only swap known Graphviz defaults (black, #000000, lightgrey) with theme tokens, preserving any user-specified colors like transparency hex values. --- packages/ui/components/GraphvizBlock.tsx | 11 +++++++++-- packages/ui/theme.css | 13 ------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index 069a7f03c..7e3849884 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -165,7 +165,14 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { .replace(/ width="[^"]*"/, ' width="100%"') .replace(/ height="[^"]*"/, ' height="100%"') .replace(/ style="[^"]*"/, '') - .replace(/]*fill="white"[^>]*\/>/, ''); + .replace(/]*fill="white"[^>]*\/>/, '') + .replace(/fill="black"/g, 'fill="var(--foreground)"') + .replace(/fill="#000000"/g, 'fill="var(--foreground)"') + .replace(/stroke="black"/g, 'stroke="var(--muted-foreground)"') + .replace(/stroke="#000000"/g, 'stroke="var(--muted-foreground)"') + .replace(/fill="none"(.*?)stroke="black"/g, 'fill="none"$1stroke="var(--muted-foreground)"') + .replace(/fill="lightgrey"/g, 'fill="var(--muted)"') + .replace(/fill="lightgray"/g, 'fill="var(--muted)"'); if (!cancelled) { naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); setSvg(cleaned); @@ -462,7 +469,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { const diagramBody = (
B }) render correctly in any theme. - User-specified fill/stroke/fontcolor in the DOT source take precedence. */ -.graphviz-themed svg text { fill: var(--foreground); } -.graphviz-themed svg .node polygon, -.graphviz-themed svg .node ellipse, -.graphviz-themed svg .node path { fill: var(--card); stroke: var(--border); } -.graphviz-themed svg .edge path { stroke: var(--muted-foreground); } -.graphviz-themed svg .edge polygon { fill: var(--muted-foreground); stroke: var(--muted-foreground); } -.graphviz-themed svg .edge text { fill: var(--muted-foreground); } -.graphviz-themed svg .cluster polygon { fill: color-mix(in srgb, var(--muted) 30%, transparent); stroke: var(--border); } -.graphviz-themed svg .cluster text { fill: var(--muted-foreground); } .directive-danger .directive-title, .directive-caution .directive-title { color: #ef4444; } From 71cb1ca557846cfce5d177c014136e035de19e0d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 18:08:55 -0700 Subject: [PATCH 08/16] feat(ui): make sketch diagrams a config setting (default on) Replace hardcoded SKETCH_MODE flag with sketchDiagrams config option in ~/.plannotator/config.json (default: true). Registered in the ConfigStore settings system so it syncs via serverConfig and can be toggled from the Settings UI. Also loads Google Fonts referenced in Graphviz DOT source via tags in the page head. --- packages/server/index.ts | 3 +- packages/shared/config.ts | 6 + packages/ui/components/GraphvizBlock.tsx | 157 ++++++++++++++++++++++- packages/ui/config/settings.ts | 14 ++ 4 files changed, 175 insertions(+), 5 deletions(-) diff --git a/packages/server/index.ts b/packages/server/index.ts index 038f28fce..81977f9c1 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -321,13 +321,14 @@ export async function startPlannotatorServer( // 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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean; sketchDiagrams?: boolean }; const toSave: Record = {}; 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 (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder; + if (body.sketchDiagrams !== undefined) toSave.sketchDiagrams = body.sketchDiagrams; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); return Response.json({ ok: true }); } catch { diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 73876ab4d..1e6e63d7c 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -122,6 +122,11 @@ export interface PlannotatorConfig { * Read by the `improve-context` PreToolUse handler. Default: false. */ pfmReminder?: boolean; + /** + * Render Graphviz diagrams with a hand-drawn sketch style using rough.js. + * Default: true. + */ + sketchDiagrams?: boolean; } const CONFIG_DIR = join(homedir(), ".plannotator"); @@ -198,6 +203,7 @@ export function getServerConfig(gitUser: string | null): { gitUser: gitUser ?? undefined, ...(cfg.conventionalComments !== undefined && { conventionalComments: cfg.conventionalComments }), ...(cfg.conventionalLabels !== undefined && { conventionalLabels: cfg.conventionalLabels }), + sketchDiagrams: cfg.sketchDiagrams ?? true, }; } diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index 7e3849884..ce28dc6eb 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -2,6 +2,126 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { instance } from '@viz-js/viz'; import type { Block } from '../types'; +import { useConfig } from '../config/useConfig'; + +let roughPromise: Promise | null = null; +function loadRoughJs(): Promise { + if (roughPromise) return roughPromise; + roughPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/roughjs@4.6.6/bundled/rough.js'; + script.onload = () => resolve((window as any).rough); + script.onerror = reject; + document.head.appendChild(script); + }); + return roughPromise; +} + +function parsePoints(pointsStr: string): [number, number][] { + const nums = pointsStr.trim().split(/[\s,]+/).map(Number); + const result: [number, number][] = []; + for (let i = 0; i < nums.length - 1; i += 2) { + result.push([nums[i], nums[i + 1]]); + } + return result; +} + +async function sketchifySvg(svgMarkup: string): Promise { + const rough = await loadRoughJs(); + const parser = new DOMParser(); + const doc = parser.parseFromString(svgMarkup, 'image/svg+xml'); + const svgEl = doc.querySelector('svg'); + if (!svgEl) return svgMarkup; + + const rc = rough.svg(svgEl); + + const elements = svgEl.querySelectorAll('path, rect, ellipse, circle, line, polygon, polyline'); + for (const el of elements) { + const parent = el.parentElement; + if (!parent) continue; + + const fill = el.getAttribute('fill') || 'none'; + const stroke = el.getAttribute('stroke') || 'none'; + const isArrowHead = el.tagName === 'polygon' && parent.classList.contains('edge'); + + if (fill === 'none' && stroke === 'none') continue; + + const opts: any = { + stroke: stroke === 'none' ? 'transparent' : stroke, + fill: fill === 'none' || fill === 'transparent' ? undefined : fill, + fillStyle: 'solid', + roughness: 1.2, + bowing: 1.5, + seed: Math.floor(Math.random() * 2 ** 31), + }; + + if (isArrowHead) { + opts.roughness = 0.5; + opts.bowing = 0.5; + } + + let node: SVGGElement | null = null; + try { + switch (el.tagName) { + case 'path': { + const d = el.getAttribute('d'); + if (d) node = rc.path(d, opts); + break; + } + case 'rect': { + const x = +(el.getAttribute('x') || 0); + const y = +(el.getAttribute('y') || 0); + const w = +(el.getAttribute('width') || 0); + const h = +(el.getAttribute('height') || 0); + if (w > 0 && h > 0) node = rc.rectangle(x, y, w, h, opts); + break; + } + case 'circle': { + const cx = +(el.getAttribute('cx') || 0); + const cy = +(el.getAttribute('cy') || 0); + const r = +(el.getAttribute('r') || 0); + if (r > 0) node = rc.circle(cx, cy, r * 2, opts); + break; + } + case 'ellipse': { + const cx = +(el.getAttribute('cx') || 0); + const cy = +(el.getAttribute('cy') || 0); + const rx = +(el.getAttribute('rx') || 0); + const ry = +(el.getAttribute('ry') || 0); + if (rx > 0 && ry > 0) node = rc.ellipse(cx, cy, rx * 2, ry * 2, opts); + break; + } + case 'line': { + const x1 = +(el.getAttribute('x1') || 0); + const y1 = +(el.getAttribute('y1') || 0); + const x2 = +(el.getAttribute('x2') || 0); + const y2 = +(el.getAttribute('y2') || 0); + node = rc.line(x1, y1, x2, y2, opts); + break; + } + case 'polygon': { + const pts = el.getAttribute('points'); + if (pts) node = rc.polygon(parsePoints(pts), opts); + break; + } + case 'polyline': { + const pts = el.getAttribute('points'); + if (pts) node = rc.linearPath(parsePoints(pts), opts); + break; + } + } + } catch { + continue; + } + + if (node) { + parent.insertBefore(node, el); + parent.removeChild(el); + } + } + + return svgEl.outerHTML; +} interface ViewBox { x: number; @@ -104,6 +224,7 @@ function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox } export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { + const sketchMode = useConfig('sketchDiagrams'); const containerRef = useRef(null); const [svg, setSvg] = useState(''); const [error, setError] = useState(null); @@ -173,10 +294,38 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { .replace(/fill="none"(.*?)stroke="black"/g, 'fill="none"$1stroke="var(--muted-foreground)"') .replace(/fill="lightgrey"/g, 'fill="var(--muted)"') .replace(/fill="lightgray"/g, 'fill="var(--muted)"'); + + const SYSTEM_FONTS = new Set(['times', 'times new roman', 'arial', 'helvetica', 'courier', 'courier new', 'sans-serif', 'serif', 'monospace', 'inter', 'system-ui']); + const fontMatches = cleaned.matchAll(/font-family="([^"]+)"/g); + const customFonts = new Set(); + for (const m of fontMatches) { + const family = m[1].split(',')[0].trim(); + if (family && !SYSTEM_FONTS.has(family.toLowerCase())) customFonts.add(family); + } + for (const font of customFonts) { + const id = `graphviz-font-${font.replace(/\s+/g, '-').toLowerCase()}`; + if (!document.getElementById(id)) { + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}&display=swap`; + document.head.appendChild(link); + } + } + const withFonts = cleaned; if (!cancelled) { - naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); - setSvg(cleaned); - setError(null); + naturalBoundsRef.current = parseViewBoxFromMarkup(withFonts); + if (sketchMode) { + try { + const sketched = await sketchifySvg(withFonts); + if (!cancelled) { setSvg(sketched); setError(null); } + } catch { + if (!cancelled) { setSvg(withFonts); setError(null); } + } + } else { + setSvg(withFonts); + setError(null); + } } } catch (err) { if (!cancelled) { @@ -191,7 +340,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { return () => { cancelled = true; }; - }, [block.content]); + }, [block.content, sketchMode]); useEffect(() => { zoomLevelRef.current = 1; diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 18d1a8d3e..70df74074 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -234,6 +234,20 @@ export const SETTINGS = { } }, }, + sketchDiagrams: { + defaultValue: true as boolean, + fromCookie: () => { + const v = storage.getItem('plannotator-sketch-diagrams'); + return v === 'true' ? true : v === 'false' ? false : undefined; + }, + toCookie: (v: boolean) => storage.setItem('plannotator-sketch-diagrams', String(v)), + serverKey: 'sketchDiagrams', + fromServer: (sc: Record) => { + const v = sc.sketchDiagrams; + return typeof v === 'boolean' ? v : undefined; + }, + toServer: (v: boolean) => ({ sketchDiagrams: v }), + }, } satisfies Record>; export type SettingsMap = typeof SETTINGS; From df21a23061104c9407ac5b05380d28dc05325ea0 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 18:12:03 -0700 Subject: [PATCH 09/16] cleanup: remove dead previewHovered state and withFonts alias --- packages/ui/components/GraphvizBlock.tsx | 13 ++++++------- packages/ui/components/InlineMarkdown.tsx | 12 ++++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index ce28dc6eb..16647ef51 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { instance } from '@viz-js/viz'; import type { Block } from '../types'; -import { useConfig } from '../config/useConfig'; +import { useConfigValue } from '../config/useConfig'; let roughPromise: Promise | null = null; function loadRoughJs(): Promise { @@ -224,7 +224,7 @@ function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox } export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { - const sketchMode = useConfig('sketchDiagrams'); + const sketchMode = useConfigValue('sketchDiagrams'); const containerRef = useRef(null); const [svg, setSvg] = useState(''); const [error, setError] = useState(null); @@ -312,18 +312,17 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { document.head.appendChild(link); } } - const withFonts = cleaned; if (!cancelled) { - naturalBoundsRef.current = parseViewBoxFromMarkup(withFonts); + naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); if (sketchMode) { try { - const sketched = await sketchifySvg(withFonts); + const sketched = await sketchifySvg(cleaned); if (!cancelled) { setSvg(sketched); setError(null); } } catch { - if (!cancelled) { setSvg(withFonts); setError(null); } + if (!cancelled) { setSvg(cleaned); setError(null); } } } else { - setSvg(withFonts); + setSvg(cleaned); setError(null); } } diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index dfa47dcdd..5c4c87c33 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -89,14 +89,14 @@ const CodeSnippetPreview: React.FC<{ {filepath.split('/').pop()} {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}
-
+
{snippet.split('\n').map((_, i) => ( - - + + @@ -117,7 +117,6 @@ const CodeFileLink: React.FC<{ const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null); - const [previewHovered, setPreviewHovered] = useState(false); const anchorRef = useRef(null); const showTimerRef = useRef | null>(null); const hideTimerRef = useRef | null>(null); @@ -132,7 +131,6 @@ const CodeFileLink: React.FC<{ cancelHide(); hideTimerRef.current = setTimeout(() => { setHoverPreview(null); - setPreviewHovered(false); }, 200); }, [cancelHide]); @@ -156,11 +154,9 @@ const CodeFileLink: React.FC<{ const handlePreviewEnter = useCallback(() => { cancelHide(); - setPreviewHovered(true); }, [cancelHide]); const handlePreviewLeave = useCallback(() => { - setPreviewHovered(false); scheduleHide(); }, [scheduleHide]); From fecd435d31c8ebf4fc1d1794d1c20e36e0fc121e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 10 May 2026 19:23:28 -0700 Subject: [PATCH 10/16] fix(pfm): enable scrolling in hover preview popover The outer container had overflow-hidden which clipped content without allowing scroll. Switch to flex column layout with min-h-0 on the code area so the scroll container respects the max-height constraint. --- packages/ui/components/InlineMarkdown.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 5c4c87c33..223ff4587 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -80,16 +80,16 @@ const CodeSnippetPreview: React.FC<{ return (
-
+
{filepath.split('/').pop()} {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}
-
+
{start + i + 1}
{start + i + 1}
{snippet.split('\n').map((_, i) => ( From e3848a944d6c76b5dc989f4c3d2df33c778165dc Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 04:57:40 -0700 Subject: [PATCH 11/16] fix(review): address PR #692 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Bundle rough.js as npm dep instead of CDN script tag — eliminates supply chain risk from same-origin third-party code execution 2. Fix Rules of Hooks violation in CodeSnippetPreview — move useMemo above conditional return 3. Add sketchDiagrams to getServerConfig return type (TypeScript fix) 4. Preserve :line suffix when using gate.resolved on click — resolved paths were dropping line info so popout opened at file top 5. Pi server parity — add parseCodePath to Pi's /api/doc and /api/doc/exists handlers for line-ref support 6. Pass baseDir to hover preview fetch for relative path resolution in linked doc / annotate flows 7. Remove dead SVG color replacement regex (line 294 was unreachable) 8. Pre-split highlighted lines to avoid O(n²) in preview render --- apps/pi-extension/server/reference.ts | 12 +++++++---- packages/shared/config.ts | 1 + packages/ui/components/GraphvizBlock.tsx | 20 +++--------------- packages/ui/components/InlineMarkdown.tsx | 25 ++++++++++++++++------- packages/ui/package.json | 1 + 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index 5bb6cd5bb..f71dfe6fa 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -32,6 +32,7 @@ import { isWithinProjectRoot, warmFileListCache, } from "../generated/resolve-file.js"; +import { parseCodePath } from "../generated/code-file.js"; import { htmlToMarkdown } from "../generated/html-to-markdown.js"; import { preloadFile } from "@pierre/diffs/ssr"; @@ -116,7 +117,9 @@ export async function handleDocRequest(res: Res, url: URL): Promise { // Code files: try literal resolve first; on miss, fall back to smart resolver. if (isCodeFilePath(requestedPath)) { - const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot); + const parsed = parseCodePath(requestedPath); + const cleanPath = parsed.filePath; + const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot); let resolvedCode: string | null = null; @@ -125,7 +128,7 @@ export async function handleDocRequest(res: Res, url: URL): Promise { } if (!resolvedCode) { - const result = await resolveCodeFile(requestedPath, projectRoot); + const result = await resolveCodeFile(cleanPath, projectRoot); if (result.kind === "found") { resolvedCode = result.path; } else if (result.kind === "ambiguous") { @@ -163,7 +166,7 @@ export async function handleDocRequest(res: Res, url: URL): Promise { } catch { // Fall back to client-side rendering } - json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML }); + json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); return; } catch { json(res, { error: `File not found: ${requestedPath}` }, 404); @@ -234,7 +237,8 @@ export async function handleDocExistsRequest(res: Res, req: IncomingMessage): Pr await Promise.all( (paths as string[]).map(async (p) => { - const r = await resolveCodeFile(p, projectRoot, baseDir); + const cleanP = parseCodePath(p).filePath; + const r = await resolveCodeFile(cleanP, projectRoot, baseDir); if (r.kind === "found") { results[p] = { status: "found", resolved: r.path }; } else if (r.kind === "ambiguous") { diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 1e6e63d7c..b6e0bb2f4 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -195,6 +195,7 @@ export function getServerConfig(gitUser: string | null): { gitUser?: string; conventionalComments?: boolean; conventionalLabels?: CCLabelConfig[] | null; + sketchDiagrams?: boolean; } { const cfg = loadConfig(); return { diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index 16647ef51..adef75e7a 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -1,22 +1,10 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { instance } from '@viz-js/viz'; +import rough from 'roughjs'; import type { Block } from '../types'; import { useConfigValue } from '../config/useConfig'; -let roughPromise: Promise | null = null; -function loadRoughJs(): Promise { - if (roughPromise) return roughPromise; - roughPromise = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://unpkg.com/roughjs@4.6.6/bundled/rough.js'; - script.onload = () => resolve((window as any).rough); - script.onerror = reject; - document.head.appendChild(script); - }); - return roughPromise; -} - function parsePoints(pointsStr: string): [number, number][] { const nums = pointsStr.trim().split(/[\s,]+/).map(Number); const result: [number, number][] = []; @@ -26,8 +14,7 @@ function parsePoints(pointsStr: string): [number, number][] { return result; } -async function sketchifySvg(svgMarkup: string): Promise { - const rough = await loadRoughJs(); +function sketchifySvg(svgMarkup: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(svgMarkup, 'image/svg+xml'); const svgEl = doc.querySelector('svg'); @@ -291,7 +278,6 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { .replace(/fill="#000000"/g, 'fill="var(--foreground)"') .replace(/stroke="black"/g, 'stroke="var(--muted-foreground)"') .replace(/stroke="#000000"/g, 'stroke="var(--muted-foreground)"') - .replace(/fill="none"(.*?)stroke="black"/g, 'fill="none"$1stroke="var(--muted-foreground)"') .replace(/fill="lightgrey"/g, 'fill="var(--muted)"') .replace(/fill="lightgray"/g, 'fill="var(--muted)"'); @@ -316,7 +302,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); if (sketchMode) { try { - const sketched = await sketchifySvg(cleaned); + const sketched = sketchifySvg(cleaned); if (!cancelled) { setSvg(sketched); setError(null); } } catch { if (!cancelled) { setSvg(cleaned); setError(null); } diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 223ff4587..ccd0c3ebf 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -55,7 +55,6 @@ const CodeSnippetPreview: React.FC<{ onMouseEnter?: () => void; onMouseLeave?: () => void; }> = ({ anchorEl, contents, filepath, line, lineEnd, onMouseEnter, onMouseLeave }) => { - if (!anchorEl) return null; const allLines = contents.split('\n'); const start = Math.max(0, line - 1); const end = Math.min(allLines.length, (lineEnd ?? line)); @@ -71,6 +70,10 @@ const CodeSnippetPreview: React.FC<{ } }, [snippet, filepath]); + const highlightedLines = useMemo(() => highlighted.split('\n'), [highlighted]); + + if (!anchorEl) return null; + const rect = anchorEl.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const showAbove = spaceBelow < 200 && rect.top > spaceBelow; @@ -97,7 +100,7 @@ const CodeSnippetPreview: React.FC<{ ))} @@ -112,7 +115,8 @@ const CodeFileLink: React.FC<{ candidate: string; display: string; onOpenCodeFile: (path: string) => void; -}> = ({ candidate, display, onOpenCodeFile }) => { + baseDir?: string; +}> = ({ candidate, display, onOpenCodeFile, baseDir }) => { const validation = useCodePathValidation(); const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); @@ -140,12 +144,14 @@ const CodeFileLink: React.FC<{ if (hoverPreview) return; showTimerRef.current = setTimeout(async () => { try { - const res = await fetch(`/api/doc?path=${encodeURIComponent(candidate)}`); + const params = new URLSearchParams({ path: candidate }); + if (baseDir) params.set('base', baseDir); + const res = await fetch(`/api/doc?${params}`); const data = await res.json(); if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); - }, [candidate, hasLineRef, gate.render, cancelHide, hoverPreview]); + }, [candidate, hasLineRef, gate.render, cancelHide, hoverPreview, baseDir]); const handleMouseLeave = useCallback(() => { if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; } @@ -182,7 +188,9 @@ const CodeFileLink: React.FC<{ setPickerOpen(true); return; } - onOpenCodeFile(gate.render === 'link' && gate.resolved ? gate.resolved : candidate); + const resolvedPath = gate.render === 'link' && gate.resolved ? gate.resolved : candidate; + const lineSuffix = parsed.line != null ? `:${parsed.line}${parsed.lineEnd != null ? `-${parsed.lineEnd}` : ''}` : ''; + onOpenCodeFile(gate.render === 'link' && gate.resolved ? resolvedPath + lineSuffix : candidate); }; return ( @@ -280,6 +288,7 @@ function emitPlainTextWithBareUrls( nextKey: () => number, onOpenCodeFile?: (path: string) => void, validation?: CodePathValidationContextValue | null, + baseDir?: string, ): void { if (text.length === 0) return; @@ -351,6 +360,7 @@ function emitPlainTextWithBareUrls( candidate={cleanPath} display={span.value} onOpenCodeFile={onOpenCodeFile!} + baseDir={baseDir} />, ); } @@ -591,6 +601,7 @@ export const InlineMarkdown: React.FC<{ candidate={cleanPath} display={codeContent} onOpenCodeFile={onOpenCodeFile} + baseDir={imageBaseDir} />, ); } else { @@ -948,7 +959,7 @@ export const InlineMarkdown: React.FC<{ // detected inline via emitPlainTextWithBareUrls() below. const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#@]/); const plainText = nextSpecial === -1 ? remaining : remaining.slice(0, nextSpecial + 1); - emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation); + emitPlainTextWithBareUrls(plainText, previousChar, parts, () => key++, onOpenCodeFile, validation, imageBaseDir); previousChar = plainText[plainText.length - 1] || previousChar; if (nextSpecial === -1) { break; diff --git a/packages/ui/package.json b/packages/ui/package.json index 14d6dce97..a51d03cd6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "perfect-freehand": "^1.2.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "roughjs": "^4.6.6", "unique-username-generator": "^1.5.1" }, "devDependencies": { From bca148326eb3634e3ec1fa317dbd40af4671c766 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 05:02:10 -0700 Subject: [PATCH 12/16] chore: update bun.lock for roughjs dependency --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index 8fd3e21c3..bf97ea553 100644 --- a/bun.lock +++ b/bun.lock @@ -231,6 +231,7 @@ "perfect-freehand": "^1.2.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "roughjs": "^4.6.6", "unique-username-generator": "^1.5.1", }, "devDependencies": { From eb4c8503dd960c392454061890e7a29b2e7eef8a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 05:38:14 -0700 Subject: [PATCH 13/16] fix(review): address PR #692 re-review findings - Remove rough.js sketch mode (experimental, not worth the overhead) - Remove sketchDiagrams config setting, Google Font loading - Remove scroll-to-line from CodeFilePopout (shadow DOM issue, deferring) - Fix ambiguous picker dropping line suffix on pick - Fix hoverPreview in useCallback deps causing redundant fetches - Validate lineEnd >= line in parseCodePath (swap if inverted) --- bun.lock | 1 - packages/server/index.ts | 3 +- packages/shared/code-file.ts | 5 +- packages/shared/config.ts | 7 -- packages/ui/components/CodeFilePopout.tsx | 13 -- packages/ui/components/GraphvizBlock.tsx | 140 +--------------------- packages/ui/components/InlineMarkdown.tsx | 10 +- packages/ui/config/settings.ts | 14 --- packages/ui/package.json | 1 - 9 files changed, 13 insertions(+), 181 deletions(-) diff --git a/bun.lock b/bun.lock index bf97ea553..8fd3e21c3 100644 --- a/bun.lock +++ b/bun.lock @@ -231,7 +231,6 @@ "perfect-freehand": "^1.2.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "roughjs": "^4.6.6", "unique-username-generator": "^1.5.1", }, "devDependencies": { diff --git a/packages/server/index.ts b/packages/server/index.ts index 81977f9c1..038f28fce 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -321,14 +321,13 @@ export async function startPlannotatorServer( // 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; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean; sketchDiagrams?: boolean }; + const body = (await req.json()) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean }; const toSave: Record = {}; 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 (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder; - if (body.sketchDiagrams !== undefined) toSave.sketchDiagrams = body.sketchDiagrams; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); return Response.json({ ok: true }); } catch { diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts index 92492037b..43633a766 100644 --- a/packages/shared/code-file.ts +++ b/packages/shared/code-file.ts @@ -20,8 +20,9 @@ export function parseCodePath(input: string): ParsedCodePath { const clean = input.replace(/#.*$/, ''); const m = clean.match(LINE_SUFFIX_RE); if (!m) return { filePath: clean }; - const line = Number.parseInt(m[1], 10); - const lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + let line = Number.parseInt(m[1], 10); + let lineEnd = m[2] ? Number.parseInt(m[2], 10) : undefined; + if (lineEnd != null && lineEnd < line) { const tmp = line; line = lineEnd; lineEnd = tmp; } return { filePath: clean.replace(LINE_SUFFIX_RE, ''), line, lineEnd }; } diff --git a/packages/shared/config.ts b/packages/shared/config.ts index b6e0bb2f4..73876ab4d 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -122,11 +122,6 @@ export interface PlannotatorConfig { * Read by the `improve-context` PreToolUse handler. Default: false. */ pfmReminder?: boolean; - /** - * Render Graphviz diagrams with a hand-drawn sketch style using rough.js. - * Default: true. - */ - sketchDiagrams?: boolean; } const CONFIG_DIR = join(homedir(), ".plannotator"); @@ -195,7 +190,6 @@ export function getServerConfig(gitUser: string | null): { gitUser?: string; conventionalComments?: boolean; conventionalLabels?: CCLabelConfig[] | null; - sketchDiagrams?: boolean; } { const cfg = loadConfig(); return { @@ -204,7 +198,6 @@ export function getServerConfig(gitUser: string | null): { gitUser: gitUser ?? undefined, ...(cfg.conventionalComments !== undefined && { conventionalComments: cfg.conventionalComments }), ...(cfg.conventionalLabels !== undefined && { conventionalLabels: cfg.conventionalLabels }), - sketchDiagrams: cfg.sketchDiagrams ?? true, }; } diff --git a/packages/ui/components/CodeFilePopout.tsx b/packages/ui/components/CodeFilePopout.tsx index 95f6a5cce..b45404183 100644 --- a/packages/ui/components/CodeFilePopout.tsx +++ b/packages/ui/components/CodeFilePopout.tsx @@ -24,8 +24,6 @@ interface CodeFilePopoutProps { prerenderedHTML?: string; error?: string; requestedPath?: string; - line?: number; - lineEnd?: number; annotations?: CodeAnnotation[]; selectedAnnotationId?: string | null; onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void; @@ -292,8 +290,6 @@ export const CodeFilePopout: React.FC = ({ prerenderedHTML, error, requestedPath, - line: initialLine, - lineEnd: initialLineEnd, annotations = [], selectedAnnotationId, onAddAnnotation, @@ -368,15 +364,6 @@ export const CodeFilePopout: React.FC = ({ return () => clearTimeout(timer); }, [selectedAnnotationId, filepath]); - useEffect(() => { - if (!initialLine || !fileAreaRef.current) return; - const timer = setTimeout(() => { - const lineEl = fileAreaRef.current?.querySelector(`[data-line="${initialLine}"]`); - lineEl?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 150); - return () => clearTimeout(timer); - }, [initialLine, filepath]); - const openCommentForRange = useCallback(( range: { start: number; end: number }, anchorEl?: HTMLElement, diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index adef75e7a..b84de1202 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -1,114 +1,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { instance } from '@viz-js/viz'; -import rough from 'roughjs'; import type { Block } from '../types'; -import { useConfigValue } from '../config/useConfig'; - -function parsePoints(pointsStr: string): [number, number][] { - const nums = pointsStr.trim().split(/[\s,]+/).map(Number); - const result: [number, number][] = []; - for (let i = 0; i < nums.length - 1; i += 2) { - result.push([nums[i], nums[i + 1]]); - } - return result; -} - -function sketchifySvg(svgMarkup: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(svgMarkup, 'image/svg+xml'); - const svgEl = doc.querySelector('svg'); - if (!svgEl) return svgMarkup; - - const rc = rough.svg(svgEl); - - const elements = svgEl.querySelectorAll('path, rect, ellipse, circle, line, polygon, polyline'); - for (const el of elements) { - const parent = el.parentElement; - if (!parent) continue; - - const fill = el.getAttribute('fill') || 'none'; - const stroke = el.getAttribute('stroke') || 'none'; - const isArrowHead = el.tagName === 'polygon' && parent.classList.contains('edge'); - - if (fill === 'none' && stroke === 'none') continue; - - const opts: any = { - stroke: stroke === 'none' ? 'transparent' : stroke, - fill: fill === 'none' || fill === 'transparent' ? undefined : fill, - fillStyle: 'solid', - roughness: 1.2, - bowing: 1.5, - seed: Math.floor(Math.random() * 2 ** 31), - }; - - if (isArrowHead) { - opts.roughness = 0.5; - opts.bowing = 0.5; - } - - let node: SVGGElement | null = null; - try { - switch (el.tagName) { - case 'path': { - const d = el.getAttribute('d'); - if (d) node = rc.path(d, opts); - break; - } - case 'rect': { - const x = +(el.getAttribute('x') || 0); - const y = +(el.getAttribute('y') || 0); - const w = +(el.getAttribute('width') || 0); - const h = +(el.getAttribute('height') || 0); - if (w > 0 && h > 0) node = rc.rectangle(x, y, w, h, opts); - break; - } - case 'circle': { - const cx = +(el.getAttribute('cx') || 0); - const cy = +(el.getAttribute('cy') || 0); - const r = +(el.getAttribute('r') || 0); - if (r > 0) node = rc.circle(cx, cy, r * 2, opts); - break; - } - case 'ellipse': { - const cx = +(el.getAttribute('cx') || 0); - const cy = +(el.getAttribute('cy') || 0); - const rx = +(el.getAttribute('rx') || 0); - const ry = +(el.getAttribute('ry') || 0); - if (rx > 0 && ry > 0) node = rc.ellipse(cx, cy, rx * 2, ry * 2, opts); - break; - } - case 'line': { - const x1 = +(el.getAttribute('x1') || 0); - const y1 = +(el.getAttribute('y1') || 0); - const x2 = +(el.getAttribute('x2') || 0); - const y2 = +(el.getAttribute('y2') || 0); - node = rc.line(x1, y1, x2, y2, opts); - break; - } - case 'polygon': { - const pts = el.getAttribute('points'); - if (pts) node = rc.polygon(parsePoints(pts), opts); - break; - } - case 'polyline': { - const pts = el.getAttribute('points'); - if (pts) node = rc.linearPath(parsePoints(pts), opts); - break; - } - } - } catch { - continue; - } - - if (node) { - parent.insertBefore(node, el); - parent.removeChild(el); - } - } - - return svgEl.outerHTML; -} interface ViewBox { x: number; @@ -211,7 +104,6 @@ function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox } export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { - const sketchMode = useConfigValue('sketchDiagrams'); const containerRef = useRef(null); const [svg, setSvg] = useState(''); const [error, setError] = useState(null); @@ -281,36 +173,10 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { .replace(/fill="lightgrey"/g, 'fill="var(--muted)"') .replace(/fill="lightgray"/g, 'fill="var(--muted)"'); - const SYSTEM_FONTS = new Set(['times', 'times new roman', 'arial', 'helvetica', 'courier', 'courier new', 'sans-serif', 'serif', 'monospace', 'inter', 'system-ui']); - const fontMatches = cleaned.matchAll(/font-family="([^"]+)"/g); - const customFonts = new Set(); - for (const m of fontMatches) { - const family = m[1].split(',')[0].trim(); - if (family && !SYSTEM_FONTS.has(family.toLowerCase())) customFonts.add(family); - } - for (const font of customFonts) { - const id = `graphviz-font-${font.replace(/\s+/g, '-').toLowerCase()}`; - if (!document.getElementById(id)) { - const link = document.createElement('link'); - link.id = id; - link.rel = 'stylesheet'; - link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}&display=swap`; - document.head.appendChild(link); - } - } if (!cancelled) { naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); - if (sketchMode) { - try { - const sketched = sketchifySvg(cleaned); - if (!cancelled) { setSvg(sketched); setError(null); } - } catch { - if (!cancelled) { setSvg(cleaned); setError(null); } - } - } else { - setSvg(cleaned); - setError(null); - } + setSvg(cleaned); + setError(null); } } catch (err) { if (!cancelled) { @@ -325,7 +191,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { return () => { cancelled = true; }; - }, [block.content, sketchMode]); + }, [block.content]); useEffect(() => { zoomLevelRef.current = 1; diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index ccd0c3ebf..08da67616 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -121,6 +121,8 @@ const CodeFileLink: React.FC<{ const gate = gateCodePath(candidate, validation); const [pickerOpen, setPickerOpen] = useState(false); const [hoverPreview, setHoverPreview] = useState<{ contents: string; filepath: string } | null>(null); + const hoverPreviewRef = useRef(hoverPreview); + hoverPreviewRef.current = hoverPreview; const anchorRef = useRef(null); const showTimerRef = useRef | null>(null); const hideTimerRef = useRef | null>(null); @@ -141,7 +143,7 @@ const CodeFileLink: React.FC<{ const handleMouseEnter = useCallback(() => { if (!hasLineRef || gate.render === 'plain') return; cancelHide(); - if (hoverPreview) return; + if (hoverPreviewRef.current) return; showTimerRef.current = setTimeout(async () => { try { const params = new URLSearchParams({ path: candidate }); @@ -151,7 +153,7 @@ const CodeFileLink: React.FC<{ if (data.contents) setHoverPreview({ contents: data.contents, filepath: data.filepath ?? candidate }); } catch {} }, 150); - }, [candidate, hasLineRef, gate.render, cancelHide, hoverPreview, baseDir]); + }, [candidate, hasLineRef, gate.render, cancelHide, baseDir]); const handleMouseLeave = useCallback(() => { if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; } @@ -182,6 +184,7 @@ const CodeFileLink: React.FC<{ } const isAmbiguous = gate.render === 'ambiguous-link'; + const lineSuffix = parsed.line != null ? `:${parsed.line}${parsed.lineEnd != null ? `-${parsed.lineEnd}` : ''}` : ''; const handleClick = () => { handleMouseLeave(); if (isAmbiguous) { @@ -189,7 +192,6 @@ const CodeFileLink: React.FC<{ return; } const resolvedPath = gate.render === 'link' && gate.resolved ? gate.resolved : candidate; - const lineSuffix = parsed.line != null ? `:${parsed.line}${parsed.lineEnd != null ? `-${parsed.lineEnd}` : ''}` : ''; onOpenCodeFile(gate.render === 'link' && gate.resolved ? resolvedPath + lineSuffix : candidate); }; @@ -228,7 +230,7 @@ const CodeFileLink: React.FC<{ { setPickerOpen(false); onOpenCodeFile(path); }} + onPick={(path) => { setPickerOpen(false); onOpenCodeFile(path + lineSuffix); }} onDismiss={() => setPickerOpen(false)} /> )} diff --git a/packages/ui/config/settings.ts b/packages/ui/config/settings.ts index 70df74074..18d1a8d3e 100644 --- a/packages/ui/config/settings.ts +++ b/packages/ui/config/settings.ts @@ -234,20 +234,6 @@ export const SETTINGS = { } }, }, - sketchDiagrams: { - defaultValue: true as boolean, - fromCookie: () => { - const v = storage.getItem('plannotator-sketch-diagrams'); - return v === 'true' ? true : v === 'false' ? false : undefined; - }, - toCookie: (v: boolean) => storage.setItem('plannotator-sketch-diagrams', String(v)), - serverKey: 'sketchDiagrams', - fromServer: (sc: Record) => { - const v = sc.sketchDiagrams; - return typeof v === 'boolean' ? v : undefined; - }, - toServer: (v: boolean) => ({ sketchDiagrams: v }), - }, } satisfies Record>; export type SettingsMap = typeof SETTINGS; diff --git a/packages/ui/package.json b/packages/ui/package.json index a51d03cd6..14d6dce97 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,7 +32,6 @@ "perfect-freehand": "^1.2.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "roughjs": "^4.6.6", "unique-username-generator": "^1.5.1" }, "devDependencies": { From ab7f496d690d03596117e872b501965794fadb9f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 05:53:08 -0700 Subject: [PATCH 14/16] cleanup: remove unused appendFileSync and homedir imports --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index b437b197a..674f94e63 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -75,7 +75,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -108,7 +108,7 @@ import { isTopLevelHelpInvocation, } from "./cli"; import path from "path"; -import { tmpdir, homedir } from "os"; +import { tmpdir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From f7d76d5dfb953f80a3bd505cd04ef89510f35601 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 06:26:46 -0700 Subject: [PATCH 15/16] fix(pfm): highlight lines individually, add createPortal TODO, fix PFM text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlight each line separately instead of splitting a full-snippet highlight — avoids broken spans on multi-line tokens. Added TODO for createPortal on the hover popover. Removed stale "scrolled to that region" claim from PFM reminder since scroll-to-line was deferred. --- packages/shared/pfm-reminder.ts | 1 - packages/ui/components/InlineMarkdown.tsx | 21 ++++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/shared/pfm-reminder.ts b/packages/shared/pfm-reminder.ts index 0b2e0711c..9edf68f65 100644 --- a/packages/shared/pfm-reminder.ts +++ b/packages/shared/pfm-reminder.ts @@ -49,7 +49,6 @@ Reference real source files inline. Plannotator validates the path and renders a \`packages/server/index.ts:10-20\` line range — hover shows a code snippet preview [the handler](packages/server/index.ts:42) markdown link form Ambiguous paths (e.g. \`index.ts\`) still render and open a picker. -Line references (\`:42\` or \`:10-20\`) open the file scrolled to that region. Callouts and alerts GitHub-style alerts highlight critical context: diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 08da67616..616da3ba6 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -60,18 +60,19 @@ const CodeSnippetPreview: React.FC<{ const end = Math.min(allLines.length, (lineEnd ?? line)); const snippet = allLines.slice(start, end).join('\n'); - const highlighted = useMemo(() => { + const highlightedLines = useMemo(() => { const lang = extToLanguage(filepath); - try { - if (lang) return hljs.highlight(snippet, { language: lang }).value; - return hljs.highlightAuto(snippet).value; - } catch { - return snippet.replace(/&/g, '&').replace(//g, '>'); - } + const lines = snippet.split('\n'); + return lines.map(line => { + try { + if (lang) return hljs.highlight(line, { language: lang }).value; + return hljs.highlightAuto(line).value; + } catch { + return line.replace(/&/g, '&').replace(//g, '>'); + } + }); }, [snippet, filepath]); - const highlightedLines = useMemo(() => highlighted.split('\n'), [highlighted]); - if (!anchorEl) return null; const rect = anchorEl.getBoundingClientRect(); @@ -81,6 +82,8 @@ const CodeSnippetPreview: React.FC<{ const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined; const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520)); + // TODO: render via createPortal(... , document.body) to avoid position: fixed + // breaking when an ancestor has transform/backdrop-filter (same fix as CodeFilePicker) return (
Date: Mon, 11 May 2026 07:06:55 -0700 Subject: [PATCH 16/16] fix(pfm): render hover preview via createPortal to fix z-index stacking The popover rendered inline was trapped behind the annotation sidebar's stacking context. Portal to document.body so it layers correctly. --- packages/ui/components/InlineMarkdown.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index 616da3ba6..1871a86c7 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; import hljs from "highlight.js"; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/shared/code-file"; import { transformPlainText } from "../utils/inlineTransforms"; @@ -82,9 +83,7 @@ const CodeSnippetPreview: React.FC<{ const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined; const left = Math.max(8, Math.min(rect.left, window.innerWidth - 520)); - // TODO: render via createPortal(... , document.body) to avoid position: fixed - // breaking when an ancestor has transform/backdrop-filter (same fix as CodeFilePicker) - return ( + return createPortal(
{start + i + 1}
-
+
, + document.body, ); };