feat(ui): code file viewer with syntax highlighting and annotations (#634)

* feat(ui): extract reusable PopoutDialog, fix backdrop blur

The table popout lost its backdrop blur when we switched to modal={false}
to keep annotation toolbars interactive. Radix ignores Dialog.Overlay in
non-modal mode, so replace it with a plain div backdrop that works
regardless. Extract the dialog shell (backdrop, close button, portal,
annotation-aware dismiss) into a reusable PopoutDialog component for
upcoming use cases. Add a demo table to the dev plan content.

* Add read-only code file popout

* Add code file annotation support

* Fix code selection popover position

* fix(editor): restore global-attachment-only drafts

The save condition was broadened to persist drafts with only global
attachments, but the restore handler still skipped applying when both
annotation arrays were empty — silently dropping the attachments.

* fix(ui): import SelectedLineRange from @pierre/diffs base package

SelectedLineRange is not re-exported from @pierre/diffs/react —
import it from the base @pierre/diffs entry point instead.


* chore: add TODO for bot callback + code annotation limitation
This commit is contained in:
Michael Ramos
2026-04-30 10:42:36 -07:00
committed by GitHub
parent c007d1c6a2
commit a11bf802bd
33 changed files with 1480 additions and 222 deletions
+40 -1
View File
@@ -27,6 +27,10 @@
* a more structural diff (outline → full spec) within whichever mode is on.
*/
import type { Plugin } from 'vite';
import { existsSync, readFileSync, statSync } from 'fs';
import { resolve } from 'path';
import { isCodeFilePath } from '../../packages/shared/code-file';
import { preloadFile } from '@pierre/diffs/ssr';
// ─── Default plans (Real-time Collaboration) ─────────────────────────────
// What every dev sees when running `bun run dev:hook` without any flag.
@@ -569,7 +573,7 @@ export function devMockApi(): Plugin {
return {
name: 'plannotator-dev-mock-api',
configureServer(server) {
server.middlewares.use((req, res, next) => {
server.middlewares.use(async (req, res, next) => {
if (req.url === '/api/plan') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
@@ -606,6 +610,41 @@ export function devMockApi(): Plugin {
return;
}
if (req.url?.startsWith('/api/doc?')) {
const url = new URL(req.url, 'http://localhost');
const reqPath = url.searchParams.get('path');
if (!reqPath) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'Missing path parameter' }));
return;
}
const base = url.searchParams.get('base');
const repoRoot = resolve(import.meta.dirname, '../..');
const resolved = resolve(base || repoRoot, reqPath);
if (!existsSync(resolved) || statSync(resolved).isDirectory()) {
res.statusCode = 404;
res.end(JSON.stringify({ error: `File not found: ${reqPath}` }));
return;
}
const contents = readFileSync(resolved, 'utf-8');
res.setHeader('Content-Type', 'application/json');
if (isCodeFilePath(reqPath)) {
const displayName = resolved.split('/').pop() || resolved;
let prerenderedHTML: string | undefined;
try {
const result = await preloadFile({
file: { name: displayName, contents },
options: { disableFileHeader: true },
});
prerenderedHTML = result.prerenderedHTML;
} catch { /* fall back to client-side rendering */ }
res.end(JSON.stringify({ codeFile: true, contents, filepath: resolved, prerenderedHTML }));
} else {
res.end(JSON.stringify({ markdown: contents, filepath: resolved }));
}
return;
}
next();
});
},
+1
View File
@@ -39,6 +39,7 @@
"prepublishOnly": "cd ../.. && bun run build:pi"
},
"dependencies": {
"@pierre/diffs": "^1.1.12",
"turndown": "^7.2.4",
"@joplin/turndown-plugin-gfm": "^1.0.64"
},
+37 -1
View File
@@ -24,11 +24,13 @@ import {
import { detectObsidianVaults } from "../generated/integrations-common.js";
import {
isAbsoluteUserPath,
isCodeFilePath,
resolveMarkdownFile,
resolveUserPath,
isWithinProjectRoot,
} from "../generated/resolve-file.js";
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
import { preloadFile } from "@pierre/diffs/ssr";
type Res = ServerResponse;
@@ -54,7 +56,7 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi
}
/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
export function handleDocRequest(res: Res, url: URL): void {
export async function handleDocRequest(res: Res, url: URL): Promise<void> {
const requestedPath = url.searchParams.get("path");
if (!requestedPath) {
json(res, { error: "Missing path parameter" }, 400);
@@ -106,6 +108,40 @@ export function handleDocRequest(res: Res, url: URL): void {
return;
}
// Code files: resolve directly, return raw contents (no markdown conversion)
if (isCodeFilePath(requestedPath)) {
const resolvedCode = resolveUserPath(requestedPath, resolvedBase || projectRoot);
if (!resolvedBase && !isWithinProjectRoot(resolvedCode, projectRoot)) {
json(res, { error: "Access denied: path is outside project root" }, 403);
return;
}
try {
if (existsSync(resolvedCode)) {
const stat = statSync(resolvedCode);
if (stat.size > 2 * 1024 * 1024) {
json(res, { error: "File too large (max 2MB)" }, 413);
return;
}
const contents = readFileSync(resolvedCode, "utf-8");
const displayName = resolvedCode.split("/").pop() || resolvedCode;
let prerenderedHTML: string | undefined;
try {
const result = await preloadFile({
file: { name: displayName, contents },
options: { disableFileHeader: true },
});
prerenderedHTML = result.prerenderedHTML;
} catch {
// Fall back to client-side rendering
}
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
return;
}
} catch { /* fall through */ }
json(res, { error: `File not found: ${requestedPath}` }, 404);
return;
}
const result = resolveMarkdownFile(requestedPath, projectRoot);
if (result.kind === "ambiguous") {
+1 -1
View File
@@ -126,7 +126,7 @@ export async function startAnnotateServer(options: {
if (!url.searchParams.has("base") && options.filePath && !/^https?:\/\//i.test(options.filePath)) {
url.searchParams.set("base", dirname(resolvePath(options.filePath)));
}
handleDocRequest(res, url);
await handleDocRequest(res, url);
} else if (url.pathname === "/api/obsidian/vaults") {
handleObsidianVaultsRequest(res);
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
+1 -1
View File
@@ -246,7 +246,7 @@ export async function startPlanReviewServer(options: {
} else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) {
return;
} else if (url.pathname === "/api/doc" && req.method === "GET") {
handleDocRequest(res, url);
await handleDocRequest(res, url);
} else if (url.pathname === "/api/obsidian/vaults") {
handleObsidianVaultsRequest(res);
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
mkdir -p generated generated/ai/providers
for f in feedback-templates prompts review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
for f in feedback-templates prompts review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
+5
View File
@@ -26,6 +26,7 @@
"version": "0.0.1",
"dependencies": {
"@plannotator/editor": "workspace:*",
"@plannotator/review-editor": "workspace:*",
"@plannotator/server": "workspace:*",
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
@@ -89,6 +90,7 @@
"version": "0.19.3",
"dependencies": {
"@joplin/turndown-plugin-gfm": "^1.0.64",
"@pierre/diffs": "^1.1.12",
"turndown": "^7.2.4",
},
"devDependencies": {
@@ -191,6 +193,7 @@
"name": "@plannotator/server",
"version": "0.19.3",
"dependencies": {
"@pierre/diffs": "^1.1.12",
"@plannotator/ai": "workspace:*",
"@plannotator/shared": "workspace:*",
},
@@ -210,6 +213,8 @@
"name": "@plannotator/ui",
"version": "0.0.1",
"dependencies": {
"@pierre/diffs": "^1.1.12",
"@plannotator/review-editor": "workspace:*",
"@plannotator/shared": "workspace:*",
"@plannotator/web-highlighter": "^0.8.1",
"@radix-ui/react-dialog": "^1.1.15",
+131 -23
View File
@@ -1,12 +1,12 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { type Origin, getAgentName } from '@plannotator/shared/agents';
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
import { ExportModal } from '@plannotator/ui/components/ExportModal';
import { ImportModal } from '@plannotator/ui/components/ImportModal';
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types';
import { Annotation, Block, EditorMode, type CodeAnnotation, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types';
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
import { Tooltip, TooltipProvider } from '@plannotator/ui/components/Tooltip';
import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip';
@@ -52,6 +52,7 @@ import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar';
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
import { useCodeFilePopout } from '@plannotator/ui/hooks/useCodeFilePopout';
import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft';
import { useArchive } from '@plannotator/ui/hooks/useArchive';
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
@@ -61,10 +62,12 @@ import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstr
import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser';
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser';
import { generateId } from '@plannotator/ui/utils/generateId';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser';
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout';
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
// Demo content toggle. Default: the original Real-time Collaboration plan.
// Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps
@@ -90,7 +93,9 @@ type NoteAutoSaveResults = {
const App: React.FC = () => {
const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [codeAnnotations, setCodeAnnotations] = useState<CodeAnnotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState<string | null>(null);
const [blocks, setBlocks] = useState<Block[]>([]);
const [frontmatter, setFrontmatter] = useState<Frontmatter | null>(null);
const [showExport, setShowExport] = useState(false);
@@ -308,6 +313,18 @@ const App: React.FC = () => {
viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted,
});
// Code file popout (read-only syntax-highlighted overlay)
const codeFilePopout = useCodeFilePopout({
buildUrl: useCallback((codePath: string) => {
const baseDir = linkedDocHook.filepath
? linkedDocHook.filepath.replace(/\/[^/]+$/, '')
: imageBaseDir;
return baseDir
? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(baseDir)}`
: `/api/doc?path=${encodeURIComponent(codePath)}`;
}, [linkedDocHook.filepath, imageBaseDir]),
});
// Archive browser
const archive = useArchive({
markdown, viewerRef, linkedDocHook,
@@ -546,11 +563,21 @@ const App: React.FC = () => {
// four-term check that was inlined across the annotate-mode header + keyboard paths.
const hasAnyAnnotations = useMemo(
() => allAnnotations.length > 0
|| codeAnnotations.length > 0
|| editorAnnotations.length > 0
|| linkedDocHook.docAnnotationCount > 0
|| globalAttachments.length > 0,
[allAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length],
[allAnnotations.length, codeAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length],
);
const feedbackAnnotationCount =
allAnnotations.length +
codeAnnotations.length +
editorAnnotations.length +
linkedDocHook.docAnnotationCount +
globalAttachments.length;
// Code-file comments are intentionally not serialized into share URLs in v1.
// Hide share entry points once they exist so we do not silently drop feedback.
const canShareCurrentSession = sharingEnabled && codeAnnotations.length === 0;
// URL-based sharing
const {
@@ -586,6 +613,7 @@ const App: React.FC = () => {
// Auto-save annotation drafts
const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({
annotations: allAnnotations,
codeAnnotations,
globalAttachments,
isApiMode,
isSharedSession,
@@ -593,9 +621,10 @@ const App: React.FC = () => {
});
const handleRestoreDraft = React.useCallback(() => {
const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft();
if (restored.length > 0) {
const { annotations: restored, codeAnnotations: restoredCode, globalAttachments: restoredGlobal } = restoreDraft();
if (restored.length > 0 || restoredCode.length > 0 || restoredGlobal.length > 0) {
setAnnotations(restored);
setCodeAnnotations(restoredCode);
if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal);
// Apply highlights to DOM after a tick
setTimeout(() => {
@@ -948,7 +977,7 @@ const App: React.FC = () => {
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
if (allAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) {
if (allAnnotations.length > 0 || codeAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) {
body.feedback = annotationsOutput;
}
@@ -994,6 +1023,7 @@ const App: React.FC = () => {
body: JSON.stringify({
feedback: annotationsOutput,
annotations: allAnnotations,
codeAnnotations,
}),
});
setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay
@@ -1069,7 +1099,7 @@ const App: React.FC = () => {
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
// Check if agent exists for OpenCode users
if (origin === 'opencode') {
const warning = getAgentWarning();
@@ -1090,7 +1120,7 @@ const App: React.FC = () => {
}, [
showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning,
showPermissionModeSetup, pendingPasteImage,
submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode,
submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode,
gate, hasAnyAnnotations,
origin, getAgentWarning,
]);
@@ -1098,6 +1128,7 @@ const App: React.FC = () => {
const handleAddAnnotation = (ann: Annotation) => {
setAnnotations(prev => [...prev, ann]);
setSelectedAnnotationId(ann.id);
setSelectedCodeAnnotationId(null);
if (wideModeType === null) {
setIsPanelOpen(true);
}
@@ -1106,9 +1137,54 @@ const App: React.FC = () => {
// Keep selection behavior explicit across mobile/wide-mode transitions.
const handleSelectAnnotation = React.useCallback((id: string | null) => {
setSelectedAnnotationId(id);
if (id) setSelectedCodeAnnotationId(null);
if (id && isMobile && wideModeType === null) setIsPanelOpen(true);
}, [isMobile, wideModeType]);
const handleAddCodeAnnotation = React.useCallback((input: CodeFileAnnotationInput) => {
const annotation: CodeAnnotation = {
id: generateId('code-ann'),
type: 'comment',
scope: 'line',
filePath: input.filePath,
lineStart: input.lineStart,
lineEnd: input.lineEnd,
side: 'new',
text: input.text,
images: input.images,
originalCode: input.originalCode,
createdAt: Date.now(),
author: configStore.get('displayName') || undefined,
};
setCodeAnnotations(prev => [...prev, annotation]);
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(annotation.id);
if (wideModeType === null) {
setIsPanelOpen(true);
}
}, [wideModeType]);
// The code popout is full-viewport modal — the annotation panel is behind it.
// This handler only fires when the popout is closed (sidebar visible), so
// reopening the file via codeFilePopout.open() is the correct behavior.
const handleSelectCodeAnnotation = React.useCallback((id: string) => {
const annotation = codeAnnotations.find(a => a.id === id);
if (!annotation) return;
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(id);
codeFilePopout.open(annotation.filePath);
if (isMobile && wideModeType === null) setIsPanelOpen(true);
}, [codeAnnotations, codeFilePopout.open, isMobile, wideModeType]);
const handleDeleteCodeAnnotation = React.useCallback((id: string) => {
setCodeAnnotations(prev => prev.filter(a => a.id !== id));
if (selectedCodeAnnotationId === id) setSelectedCodeAnnotationId(null);
}, [selectedCodeAnnotationId]);
const handleEditCodeAnnotation = React.useCallback((id: string, updates: Partial<CodeAnnotation>) => {
setCodeAnnotations(prev => prev.map(a => a.id === id ? { ...a, ...updates } : a));
}, []);
// Core annotation removal — highlight cleanup + state filter + selection clear
const removeAnnotation = (id: string) => {
viewerRef.current?.removeHighlight(id);
@@ -1158,6 +1234,9 @@ const App: React.FC = () => {
setAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
setCodeAnnotations(prev => prev.map(ann =>
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
));
};
const handleAddGlobalAttachment = (image: ImageAttachment) => {
@@ -1181,8 +1260,9 @@ const App: React.FC = () => {
);
const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0;
const hasEditorAnnotations = editorAnnotations.length > 0;
const hasCodeAnnotations = codeAnnotations.length > 0;
if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations) {
if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations && !hasCodeAnnotations) {
return 'User reviewed the document and has no feedback.';
}
@@ -1219,10 +1299,17 @@ const App: React.FC = () => {
output += exportEditorAnnotations(editorAnnotations);
}
if (hasCodeAnnotations) {
output += exportCodeFileAnnotations(codeAnnotations);
}
return output;
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]);
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]);
// Bot callback config — read once from URL search params (?cb=&ct=)
// TODO: bot callbacks post shareUrl which doesn't include code-file annotations.
// If a user adds code comments and hits the callback button, those comments are silently dropped.
// Fix: either disable callbacks when codeAnnotations exist, or include annotationsOutput in the payload.
const callbackConfig = React.useMemo(() => getCallbackConfig(), []);
const callCallback = React.useCallback(async (action: CallbackAction) => {
@@ -1506,7 +1593,7 @@ const App: React.FC = () => {
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
);
if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
setShowFeedbackPrompt(true);
} else {
handleDeny();
@@ -1534,7 +1621,7 @@ const App: React.FC = () => {
return;
}
// Plan mode: existing Claude-Code / OpenCode guards.
if (origin === 'claude-code' && allAnnotations.length > 0) {
if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) {
setShowClaudeCodeWarning(true);
return;
}
@@ -1550,10 +1637,10 @@ const App: React.FC = () => {
}}
disabled={isSubmitting || (annotateMode && isExiting)}
isLoading={isSubmitting}
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0}
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0)}
title={annotateMode ? 'Approve — no changes requested' : undefined}
/>
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0 && (
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0) && (
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
@@ -1609,7 +1696,7 @@ const App: React.FC = () => {
onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')}
onSaveToBear={() => handleQuickSaveToNotes('bear')}
onSaveToOctarine={() => handleQuickSaveToNotes('octarine')}
sharingEnabled={sharingEnabled}
sharingEnabled={canShareCurrentSession}
isApiMode={isApiMode}
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode}
obsidianConfigured={isObsidianConfigured()}
@@ -1843,6 +1930,7 @@ const App: React.FC = () => {
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
maxWidth={annotateReaderMaxWidth}
onOpenLinkedDoc={handleOpenLinkedDoc}
onOpenCodeFile={codeFilePopout.open}
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null}
imageBaseDir={imageBaseDir}
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
@@ -1864,11 +1952,15 @@ const App: React.FC = () => {
isOpen={isPanelOpen && wideModeType === null}
blocks={blocks}
annotations={allAnnotations}
selectedId={selectedAnnotationId}
onSelect={setSelectedAnnotationId}
selectedId={selectedAnnotationId ?? selectedCodeAnnotationId}
onSelect={handleSelectAnnotation}
onDelete={handleDeleteAnnotation}
onEdit={handleEditAnnotation}
sharingEnabled={sharingEnabled}
codeAnnotations={codeAnnotations}
onSelectCodeAnnotation={handleSelectCodeAnnotation}
onDeleteCodeAnnotation={handleDeleteCodeAnnotation}
onEditCodeAnnotation={handleEditCodeAnnotation}
sharingEnabled={canShareCurrentSession}
width={panelResize.width}
editorAnnotations={editorAnnotations}
onDeleteEditorAnnotation={deleteEditorAnnotation}
@@ -1876,13 +1968,29 @@ const App: React.FC = () => {
onQuickCopy={async () => {
await navigator.clipboard.writeText(wrapFeedbackForAgent(annotationsOutput));
}}
onShare={shareUrl ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined}
onShare={canShareCurrentSession && shareUrl ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined}
otherFileAnnotations={otherFileAnnotations}
onOtherFileAnnotationsClick={handleFlashAnnotatedFiles}
/>
</div>
</ScrollViewportContext.Provider>
{/* Code File Popout */}
{codeFilePopout.popoutProps && (
<CodeFilePopout
{...codeFilePopout.popoutProps}
annotations={codeAnnotations.filter((ann) => ann.filePath === codeFilePopout.popoutProps?.filepath)}
selectedAnnotationId={selectedCodeAnnotationId}
onAddAnnotation={handleAddCodeAnnotation}
onEditAnnotation={handleEditCodeAnnotation}
onDeleteAnnotation={handleDeleteCodeAnnotation}
onSelectAnnotation={(id) => {
setSelectedAnnotationId(null);
setSelectedCodeAnnotationId(id);
}}
/>
)}
{/* Export Modal */}
<ExportModal
isOpen={showExport}
@@ -1894,9 +2002,9 @@ const App: React.FC = () => {
shortUrlError={shortUrlError}
onGenerateShortUrl={generateShortUrl}
annotationsOutput={annotationsOutput}
annotationCount={allAnnotations.length}
annotationCount={allAnnotations.length + codeAnnotations.length}
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
sharingEnabled={sharingEnabled}
sharingEnabled={canShareCurrentSession}
markdown={markdown}
isApiMode={isApiMode}
initialTab={initialExportTab}
@@ -1928,7 +2036,7 @@ const App: React.FC = () => {
handleApprove();
}}
title="Annotations Won't Be Sent"
message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''} will be lost.</>}
message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.</>}
subMessage={
<>
To send feedback, use <strong>Send Feedback</strong> instead.
@@ -1956,7 +2064,7 @@ const App: React.FC = () => {
else handleAnnotateExit();
}}
title="Annotations Won't Be Sent"
message={<>You have {allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length} annotation{(allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length) !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.</>}
message={<>You have {feedbackAnnotationCount} annotation{feedbackAnnotationCount !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.</>}
subMessage="To send your annotations, use Send Annotations instead."
confirmText={exitWarningAction === 'approve' ? 'Approve Anyway' : 'Close Anyway'}
cancelText="Cancel"
+19 -1
View File
@@ -2,7 +2,7 @@ export const DEMO_PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
## Context
This proposal introduces real-time collaborative editing to the Plannotator editor, letting reviewers annotate the same plan simultaneously with sub-second visibility of each other's cursors and edits. We are targeting **production-grade concurrency** for up to 50 active collaborators per document, with end-to-end edit-to-visible latency under 150ms at the 95th percentile. The implementation uses operational transforms running on a dedicated Node.js gateway that speaks \`WebSocket\` to clients and \`gRPC\` to the storage tier. See [the technical design doc](https://docs.example.com/realtime-v2) for the full rationale and rollout plan.
This proposal introduces real-time collaborative editing to the Plannotator editor, letting reviewers annotate the same plan simultaneously with sub-second visibility of each other's cursors and edits. We are targeting **production-grade concurrency** for up to 50 active collaborators per document, with end-to-end edit-to-visible latency under 150ms at the 95th percentile. The implementation uses operational transforms running on a dedicated Node.js gateway that speaks \`WebSocket\` to clients and \`gRPC\` to the storage tier. See [the technical design doc](https://docs.example.com/realtime-v2) for the full rationale and rollout plan. The editor entry point is [App.tsx](packages/editor/App.tsx) and the server config lives in [server/index.ts](packages/server/index.ts).
Runtime parameters for phase one:
@@ -15,6 +15,24 @@ export const COLLAB_CONFIG = {
} as const;
\`\`\`
### Configuration reference
| Parameter | Value | Description |
| --- | --- | --- |
| \`maxCollaborators\` | 50 | Hard ceiling per document before the gateway rejects new joins |
| \`heartbeatIntervalMs\` | 5 000 ms | Ping cadence; three missed heartbeats trigger a reconnect |
| \`operationBatchSize\` | 32 | Max ops coalesced into a single WebSocket frame |
| \`gateway\` | \`wss://collab.plannotator.ai\` | Regional edge endpoint; clients are routed by latency |
### Key files
| File | Role |
| --- | --- |
| [App.tsx](packages/editor/App.tsx) | Editor entry point |
| [resolve-file.ts](packages/shared/resolve-file.ts) | Path resolution |
| [nonexistent.ts](packages/fake/nope.ts) | Should fail silently |
| [package.json](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)*.
+1
View File
@@ -25,6 +25,7 @@
"*.ts"
],
"dependencies": {
"@pierre/diffs": "^1.1.12",
"@plannotator/shared": "workspace:*",
"@plannotator/ai": "workspace:*"
},
+32
View File
@@ -11,11 +11,13 @@ import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/refere
import { detectObsidianVaults } from "./integrations";
import {
isAbsoluteUserPath,
isCodeFilePath,
resolveMarkdownFile,
resolveUserPath,
isWithinProjectRoot,
} from "@plannotator/shared/resolve-file";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { preloadFile } from "@pierre/diffs/ssr";
// --- Route handlers ---
@@ -72,6 +74,36 @@ export async function handleDoc(req: Request): Promise<Response> {
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
// Code files: resolve directly, return raw contents (no markdown conversion)
if (isCodeFilePath(requestedPath)) {
const resolvedCode = resolveUserPath(requestedPath, resolvedBase || projectRoot);
if (!resolvedBase && !isWithinProjectRoot(resolvedCode, projectRoot)) {
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
}
try {
const file = Bun.file(resolvedCode);
if (await file.exists()) {
if (file.size > 2 * 1024 * 1024) {
return Response.json({ error: "File too large (max 2MB)" }, { status: 413 });
}
const contents = await file.text();
const displayName = resolvedCode.split("/").pop() || resolvedCode;
let prerenderedHTML: string | undefined;
try {
const result = await preloadFile({
file: { name: displayName, contents },
options: { disableFileHeader: true },
});
prerenderedHTML = result.prerenderedHTML;
} catch {
// Fall back to client-side rendering
}
return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
}
} catch { /* fall through */ }
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
}
const result = resolveMarkdownFile(requestedPath, projectRoot);
if (result.kind === "ambiguous") {
+6
View File
@@ -0,0 +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 function isCodeFilePath(input: string): boolean {
return CODE_FILE_REGEX.test(input.replace(/#.*$/, ''))
&& !input.startsWith('http://') && !input.startsWith('https://');
}
+1
View File
@@ -19,6 +19,7 @@
"./repo": "./repo.ts",
"./reference-common": "./reference-common.ts",
"./favicon": "./favicon.ts",
"./code-file": "./code-file.ts",
"./resolve-file": "./resolve-file.ts",
"./external-annotation": "./external-annotation.ts",
"./agent-jobs": "./agent-jobs.ts",
+3
View File
@@ -14,6 +14,9 @@ import { isAbsolute, join, resolve, win32 } from "path";
import { existsSync, readdirSync, type Dirent } from "fs";
const MARKDOWN_PATH_REGEX = /\.mdx?$/i;
export { CODE_FILE_REGEX, isCodeFilePath } from "./code-file";
const WINDOWS_DRIVE_PATH_PATTERNS = [
/^\/cygdrive\/([a-zA-Z])\/(.+)$/,
/^\/([a-zA-Z])\/(.+)$/,
+204 -13
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Annotation, AnnotationType, Block, type EditorAnnotation } from '../types';
import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types';
import { isCurrentUser } from '../utils/identity';
import { ImageThumbnail } from './ImageThumbnail';
import { EditorAnnotationCard } from './EditorAnnotationCard';
@@ -14,6 +14,10 @@ interface PanelProps {
onDelete: (id: string) => void;
onEdit?: (id: string, updates: Partial<Annotation>) => void;
selectedId: string | null;
codeAnnotations?: CodeAnnotation[];
onSelectCodeAnnotation?: (id: string) => void;
onDeleteCodeAnnotation?: (id: string) => void;
onEditCodeAnnotation?: (id: string, updates: Partial<CodeAnnotation>) => void;
sharingEnabled?: boolean;
width?: number;
editorAnnotations?: EditorAnnotation[];
@@ -33,6 +37,10 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
onDelete,
onEdit,
selectedId,
codeAnnotations = [],
onSelectCodeAnnotation,
onDeleteCodeAnnotation,
onEditCodeAnnotation,
sharingEnabled = true,
width,
editorAnnotations,
@@ -47,7 +55,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
const [copiedText, setCopiedText] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
const totalCount = annotations.length + (editorAnnotations?.length ?? 0);
const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
const timelineEntries = [
...sortedAnnotations.map(annotation => ({ kind: 'plan' as const, ts: annotation.createdA, annotation })),
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })),
].sort((a, b) => a.ts - b.ts);
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
// Scroll selected annotation card into view
useEffect(() => {
@@ -113,24 +126,35 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
</svg>
</div>
<p className="text-xs text-muted-foreground">
Select text to add annotations
Select text or code lines to add annotations
</p>
</div>
) : (
<>
{sortedAnnotations.map(ann => (
<AnnotationCard
key={ann.id}
annotation={ann}
isSelected={selectedId === ann.id}
onSelect={() => onSelect(ann.id)}
onDelete={() => onDelete(ann.id)}
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(ann.id, updates) : undefined}
/>
{timelineEntries.map(entry => (
entry.kind === 'plan' ? (
<AnnotationCard
key={entry.annotation.id}
annotation={entry.annotation}
isSelected={selectedId === entry.annotation.id}
onSelect={() => onSelect(entry.annotation.id)}
onDelete={() => onDelete(entry.annotation.id)}
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
/>
) : (
<CodeAnnotationCard
key={entry.annotation.id}
annotation={entry.annotation}
isSelected={selectedId === entry.annotation.id}
onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)}
onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)}
onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined}
/>
)
))}
{editorAnnotations && editorAnnotations.length > 0 && (
<>
{sortedAnnotations.length > 0 && (
{timelineEntries.length > 0 && (
<div className="flex items-center gap-2 pt-2 pb-1">
<div className="flex-1 border-t border-border/30" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Editor</span>
@@ -495,3 +519,170 @@ const AnnotationCard: React.FC<{
</div>
);
};
const CodeAnnotationCard: React.FC<{
annotation: CodeAnnotation;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onEdit?: (updates: Partial<CodeAnnotation>) => void;
}> = ({ annotation, isSelected, onSelect, onDelete, onEdit }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(annotation.text || '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isEditing) {
textareaRef.current?.focus();
textareaRef.current?.select();
}
}, [isEditing]);
useEffect(() => {
if (!isEditing) setEditText(annotation.text || '');
}, [annotation.text, isEditing]);
const handleSaveEdit = () => {
onEdit?.({ text: editText });
setIsEditing(false);
};
const lineRange = annotation.lineStart === annotation.lineEnd
? `line ${annotation.lineStart}`
: `lines ${annotation.lineStart}-${annotation.lineEnd}`;
const fileName = annotation.filePath.split('/').pop() || annotation.filePath;
return (
<div
data-annotation-id={annotation.id}
onClick={onSelect}
className={`group relative p-2.5 rounded-lg border cursor-pointer transition-all ${
isSelected
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'border-transparent hover:bg-muted/50 hover:border-border/50'
}`}
>
{annotation.author && (
<div className={`flex items-center gap-1.5 text-[10px] font-mono truncate mb-1.5 ${isCurrentUser(annotation.author) ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}>
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="truncate">{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}</span>
</div>
)}
<div className="flex items-center justify-between mb-2">
<div className="min-w-0 flex items-center gap-2">
<div className="flex items-center gap-1.5 text-primary">
<span className="p-1 rounded bg-primary/10">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</span>
<span className="text-[10px] font-semibold uppercase tracking-wide">Code</span>
</div>
<span className="text-[10px] text-muted-foreground/50">{formatTimestamp(annotation.createdAt)}</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition-all">
{onEdit && !isEditing && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-all"
title="Edit annotation"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
title="Delete annotation"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="text-[11px] font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1.5 truncate" title={annotation.filePath}>
{fileName} · {lineRange}
</div>
{annotation.originalCode && (
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground bg-muted/30 rounded px-2 py-1.5 whitespace-pre-wrap max-h-24 overflow-y-auto">
{annotation.originalCode}
</div>
)}
{isEditing ? (
<div className="mt-2 space-y-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
setIsEditing(false);
setEditText(annotation.text || '');
}
}}
onClick={(e) => e.stopPropagation()}
className="w-full text-xs text-foreground/90 pl-2 border-l-2 border-primary/50 bg-background border border-border rounded px-2 py-1.5 resize-none focus:outline-none focus:ring-2 focus:ring-primary/50"
rows={Math.min(editText.split('\n').length + 1, 8)}
/>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleSaveEdit();
}}
className="px-2 py-1 text-[10px] font-medium rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Save
</button>
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(false);
setEditText(annotation.text || '');
}}
className="px-2 py-1 text-[10px] font-medium rounded bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
annotation.text && (
<div className="mt-2 text-xs text-foreground/90 pl-2 border-l-2 border-primary/50 whitespace-pre-wrap">
{annotation.text}
</div>
)
)}
{annotation.images && annotation.images.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{annotation.images.map((img) => (
<div key={img.path} className="text-center">
<ImageThumbnail path={img.path} size="sm" showRemove={false} />
<div className="text-[9px] text-muted-foreground truncate max-w-[3rem]" title={img.name}>{img.name}</div>
</div>
))}
</div>
)}
</div>
);
};
+10 -6
View File
@@ -11,6 +11,7 @@ import { TableBlock } from "./blocks/TableBlock";
export const BlockRenderer: React.FC<{
block: Block;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
@@ -19,7 +20,7 @@ export const BlockRenderer: React.FC<{
githubRepo?: string;
headingAnchorId?: string;
onNavigateAnchor?: (hash: string) => void;
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
switch (block.type) {
case 'heading': {
const Tag = `h${block.level || 1}` as React.ElementType;
@@ -35,7 +36,7 @@ export const BlockRenderer: React.FC<{
data-block-id={block.id}
data-block-type="heading"
>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</Tag>
);
}
@@ -48,6 +49,7 @@ export const BlockRenderer: React.FC<{
kind={block.alertKind}
body={block.content}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
githubRepo={githubRepo}
@@ -65,7 +67,7 @@ export const BlockRenderer: React.FC<{
>
{paragraphs.map((para, i) => (
<p key={i} className={i > 0 ? 'mt-2' : ''}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</p>
))}
</blockquote>
@@ -94,7 +96,7 @@ export const BlockRenderer: React.FC<{
onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
/>
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</span>
</div>
);
@@ -110,6 +112,7 @@ export const BlockRenderer: React.FC<{
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
githubRepo={githubRepo}
onNavigateAnchor={onNavigateAnchor}
/>
@@ -119,7 +122,7 @@ export const BlockRenderer: React.FC<{
return <hr className="border-border/30 my-8" data-block-id={block.id} />;
case 'html':
return <HtmlBlock block={block} imageBaseDir={imageBaseDir} onOpenLinkedDoc={onOpenLinkedDoc} onNavigateAnchor={onNavigateAnchor} />;
return <HtmlBlock block={block} imageBaseDir={imageBaseDir} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={onNavigateAnchor} />;
case 'directive': {
const kind = block.directiveKind || 'note';
@@ -132,6 +135,7 @@ export const BlockRenderer: React.FC<{
blockType="directive"
kindAttribute={kind}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
imageBaseDir={imageBaseDir}
onImageClick={onImageClick}
githubRepo={githubRepo}
@@ -146,7 +150,7 @@ export const BlockRenderer: React.FC<{
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
data-block-id={block.id}
>
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
</p>
);
}
+568
View File
@@ -0,0 +1,568 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { File, type LineAnnotation } from '@pierre/diffs/react';
import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs';
import { PopoutDialog } from './PopoutDialog';
import { useTheme } from './ThemeProvider';
import { CommentPopover } from './CommentPopover';
import { ImageThumbnail } from './ImageThumbnail';
import type { CodeAnnotation, ImageAttachment } from '../types';
export interface CodeFileAnnotationInput {
filePath: string;
lineStart: number;
lineEnd: number;
text: string;
images?: ImageAttachment[];
originalCode: string;
}
interface CodeFilePopoutProps {
open: boolean;
onClose: () => void;
filepath: string;
contents: string;
prerenderedHTML?: string;
annotations?: CodeAnnotation[];
selectedAnnotationId?: string | null;
onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void;
onEditAnnotation?: (id: string, updates: Partial<CodeAnnotation>) => void;
onDeleteAnnotation?: (id: string) => void;
onSelectAnnotation?: (id: string | null) => void;
container?: HTMLElement | null;
}
interface PendingComment {
range: { start: number; end: number };
contextText: string;
originalCode: string;
anchorEl?: HTMLElement;
anchorRect?: DOMRect;
}
const gutterButtonStyle: React.CSSProperties = {
appearance: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '1lh',
height: '1lh',
fontSize: 'var(--diffs-font-size, 13px)',
lineHeight: 'var(--diffs-line-height, 20px)',
border: 'none',
borderRadius: 4,
backgroundColor: 'var(--diffs-modified-base)',
color: 'var(--diffs-bg)',
cursor: 'pointer',
position: 'relative',
zIndex: 4,
padding: 0,
marginRight: 'calc(1ch - 1lh)',
};
function getThemeColors(): { bg: string; fg: string } {
try {
const styles = getComputedStyle(document.documentElement);
return {
bg: styles.getPropertyValue('--background').trim(),
fg: styles.getPropertyValue('--foreground').trim(),
};
} catch {
return { bg: '', fg: '' };
}
}
function buildPierreCSS(mode: 'dark' | 'light', bg: string, fg: string): string {
if (!bg || !fg) return '';
return `
:host {
color-scheme: ${mode};
height: 100% !important;
}
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
--diffs-bg: ${bg} !important;
--diffs-fg: ${fg} !important;
--diffs-dark-bg: ${bg};
--diffs-light-bg: ${bg};
--diffs-dark: ${fg};
--diffs-light: ${fg};
}
pre, code { background-color: ${bg} !important; }
[data-column-number] { background-color: ${bg} !important; }
[data-file] { height: 100% !important; }
[data-code] { height: 100% !important; overflow-y: auto !important; }
[data-line] { cursor: pointer; }
`;
}
function getLineSlice(contents: string, start: number, end: number): string {
return contents
.split('\n')
.slice(Math.max(0, start - 1), Math.max(0, end))
.join('\n');
}
function lineLabel(start: number, end: number): string {
return start === end ? `line ${start}` : `lines ${start}-${end}`;
}
function getLineNumberFromSelectionNode(node: Node | null): number | null {
let current: Node | null = node;
if (current?.nodeType === Node.TEXT_NODE) current = current.parentNode;
while (current) {
if (current instanceof HTMLElement) {
const line = current.closest('[data-line]')?.getAttribute('data-line');
if (line) {
const parsed = Number(line);
return Number.isFinite(parsed) ? parsed : null;
}
}
current = current.parentNode;
}
return null;
}
function getPierreSelection(root: HTMLElement | null): Selection | null {
const shadowRoot = root?.querySelector('diffs-container')?.shadowRoot;
const shadowSelection = (shadowRoot as (ShadowRoot & { getSelection?: () => Selection | null }) | null)
?.getSelection?.();
return shadowSelection && !shadowSelection.isCollapsed
? shadowSelection
: window.getSelection();
}
const CodeInlineAnnotation: React.FC<{
annotation: CodeAnnotation;
isSelected: boolean;
onSelect?: (id: string | null) => void;
onEdit?: (id: string, updates: Partial<CodeAnnotation>) => void;
onDelete?: (id: string) => void;
}> = ({ annotation, isSelected, onSelect, onEdit, onDelete }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(annotation.text ?? '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!isEditing) setEditText(annotation.text ?? '');
}, [annotation.text, isEditing]);
useEffect(() => {
if (isEditing) {
textareaRef.current?.focus();
textareaRef.current?.select();
}
}, [isEditing]);
const save = () => {
onEdit?.(annotation.id, { text: editText });
setIsEditing(false);
};
return (
<div
data-code-annotation-id={annotation.id}
onClick={() => onSelect?.(annotation.id)}
className={`group my-2 mx-3 rounded-lg border px-3 py-2 text-xs shadow-sm cursor-pointer transition-colors ${
isSelected ? 'border-primary/50' : 'border-border hover:border-border/80'
}`}
style={{
backgroundColor: isSelected ? 'color-mix(in oklab, var(--primary) 12%, var(--popover))' : 'var(--popover)',
color: 'var(--foreground)',
opacity: 1,
}}
>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground">
<span className="font-semibold text-primary">Comment</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono normal-case text-foreground">
{lineLabel(annotation.lineStart, annotation.lineEnd)}
</span>
{annotation.author && <span className="truncate normal-case">by {annotation.author}</span>}
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{onEdit && !isEditing && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Edit comment"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(annotation.id);
}}
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title="Delete comment"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{isEditing ? (
<div className="mt-2 space-y-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setIsEditing(false);
setEditText(annotation.text ?? '');
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
e.preventDefault();
save();
}
}}
rows={Math.min(editText.split('\n').length + 1, 8)}
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
save();
}}
className="rounded bg-primary px-2 py-1 text-[10px] font-medium text-primary-foreground hover:opacity-90"
>
Save
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsEditing(false);
setEditText(annotation.text ?? '');
}}
className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground hover:bg-muted/80"
>
Cancel
</button>
</div>
</div>
) : (
annotation.text && (
<div className="mt-1.5 whitespace-pre-wrap border-l-2 border-primary/50 pl-2 text-foreground/90">
{annotation.text}
</div>
)
)}
{annotation.images && annotation.images.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{annotation.images.map((img) => (
<div key={img.path} className="text-center">
<ImageThumbnail path={img.path} size="sm" showRemove={false} />
<div className="max-w-[3rem] truncate text-[9px] text-muted-foreground" title={img.name}>
{img.name}
</div>
</div>
))}
</div>
)}
</div>
);
};
export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
open,
onClose,
filepath,
contents,
prerenderedHTML,
annotations = [],
selectedAnnotationId,
onAddAnnotation,
onEditAnnotation,
onDeleteAnnotation,
onSelectAnnotation,
container,
}) => {
const { resolvedMode } = useTheme();
const mode = resolvedMode ?? 'dark';
const colors = getThemeColors();
const [pierreTheme, setPierreTheme] = useState(() => ({
type: mode as 'dark' | 'light',
css: buildPierreCSS(mode, colors.bg, colors.fg),
}));
const [copied, setCopied] = useState(false);
const [pendingComment, setPendingComment] = useState<PendingComment | null>(null);
const fileAreaRef = useRef<HTMLDivElement>(null);
const lastPointerRectRef = useRef<DOMRect | null>(null);
const suppressLineClickUntilRef = useRef(0);
useEffect(() => {
requestAnimationFrame(() => {
const c = getThemeColors();
setPierreTheme({
type: mode,
css: buildPierreCSS(mode, c.bg, c.fg),
});
});
}, [mode]);
useEffect(() => {
setPendingComment(null);
}, [filepath]);
const displayName = filepath.split('/').pop() || filepath;
const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
const lineCount = useMemo(() => contents.split('\n').length, [contents]);
const selectedCodeAnnotation = useMemo(
() => annotations.find((ann) => ann.id === selectedAnnotationId),
[annotations, selectedAnnotationId],
);
// TODO: add token-level annotation support (charStart/charEnd) — for now only line-scope
const lineAnnotations = useMemo((): LineAnnotation<CodeAnnotation>[] => {
return annotations
.filter((ann) => (ann.scope ?? 'line') === 'line')
.map((ann) => ({
lineNumber: ann.lineEnd,
metadata: ann,
}));
}, [annotations]);
const selectedLines = useMemo((): PierreSelectedLineRange | null => {
if (pendingComment) {
return { start: pendingComment.range.start, end: pendingComment.range.end };
}
if (selectedCodeAnnotation) {
return { start: selectedCodeAnnotation.lineStart, end: selectedCodeAnnotation.lineEnd };
}
return null;
}, [pendingComment, selectedCodeAnnotation]);
const effectivePrerenderedHTML = lineAnnotations.length === 0 ? prerenderedHTML : undefined;
useEffect(() => {
if (!selectedAnnotationId || !fileAreaRef.current) return;
const timer = setTimeout(() => {
fileAreaRef.current
?.querySelector(`[data-code-annotation-id="${selectedAnnotationId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
return () => clearTimeout(timer);
}, [selectedAnnotationId, filepath]);
const openCommentForRange = useCallback((
range: { start: number; end: number },
anchorEl?: HTMLElement,
anchorRect?: DOMRect,
) => {
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
setPendingComment({
range: { start, end },
anchorEl,
anchorRect: anchorRect ?? anchorEl?.getBoundingClientRect() ?? lastPointerRectRef.current ?? undefined,
contextText: `${relativePath} ${lineLabel(start, end)}`,
originalCode: getLineSlice(contents, start, end),
});
}, [contents, relativePath]);
const openCommentForBrowserSelection = useCallback(() => {
if (!onAddAnnotation) return;
const selection = getPierreSelection(fileAreaRef.current);
const selectedText = selection?.toString();
if (!selection || selection.isCollapsed || !selectedText?.trim()) return;
const anchorLine = getLineNumberFromSelectionNode(selection.anchorNode);
const focusLine = getLineNumberFromSelectionNode(selection.focusNode);
if (anchorLine == null || focusLine == null) return;
openCommentForRange(
{ start: anchorLine, end: focusLine },
undefined,
lastPointerRectRef.current
?? (selection.rangeCount > 0 ? selection.getRangeAt(0).getBoundingClientRect() : undefined),
);
selection.removeAllRanges();
}, [onAddAnnotation, openCommentForRange]);
const renderAnnotation = useCallback((annotation: LineAnnotation<CodeAnnotation>) => {
if (!annotation.metadata) return null;
return (
<CodeInlineAnnotation
annotation={annotation.metadata}
isSelected={selectedAnnotationId === annotation.metadata.id}
onSelect={onSelectAnnotation}
onEdit={onEditAnnotation}
onDelete={onDeleteAnnotation}
/>
);
}, [onDeleteAnnotation, onEditAnnotation, onSelectAnnotation, selectedAnnotationId]);
const renderGutterUtility = useCallback((getHoveredLine: () => { lineNumber: number } | undefined) => {
return (
<button
type="button"
className="hover-add-comment"
style={gutterButtonStyle}
title="Add code comment"
onMouseEnter={(e) => {
e.currentTarget.style.filter = 'brightness(1.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.filter = '';
}}
onClick={(e) => {
e.stopPropagation();
const line = getHoveredLine();
if (!line) return;
openCommentForRange({ start: line.lineNumber, end: line.lineNumber }, e.currentTarget);
}}
>
+
</button>
);
}, [openCommentForRange]);
const handleLineSelectionEnd = useCallback((range: PierreSelectedLineRange | null) => {
if (!onAddAnnotation) return;
if (!range) return;
if (range.start !== range.end) {
suppressLineClickUntilRef.current = Date.now() + 300;
}
openCommentForRange({ start: range.start, end: range.end }, undefined, lastPointerRectRef.current ?? undefined);
}, [onAddAnnotation, openCommentForRange]);
const handleLineClick = useCallback((props: LineEventBaseProps & { event: PointerEvent }) => {
if (!onAddAnnotation) return;
if (Date.now() < suppressLineClickUntilRef.current) return;
openCommentForRange(
{ start: props.lineNumber, end: props.lineNumber },
undefined,
props.lineElement.getBoundingClientRect(),
);
}, [onAddAnnotation, openCommentForRange]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(contents);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<PopoutDialog
open={open}
onClose={onClose}
title={displayName}
container={container}
className="w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] h-[calc(100vh-4rem)]"
>
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
<div className="flex items-center gap-2 min-w-0">
<svg className="w-4 h-4 flex-shrink-0 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span className="text-sm font-medium text-foreground truncate" title={filepath}>
{relativePath}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground tabular-nums">
{lineCount} lines
</span>
<button
onClick={handleCopy}
title={copied ? 'Copied!' : 'Copy file contents'}
className={`p-1.5 rounded-md transition-colors ${
copied ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{copied ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</div>
<div className="border-t border-border/30" />
<div
ref={fileAreaRef}
className="flex-1 min-h-0"
onPointerMove={(e) => {
lastPointerRectRef.current = new DOMRect(e.clientX, e.clientY, 0, 0);
}}
onMouseUp={() => {
requestAnimationFrame(openCommentForBrowserSelection);
}}
>
<File
key={filepath}
file={{ name: displayName, contents }}
prerenderedHTML={effectivePrerenderedHTML}
className="h-full"
lineAnnotations={lineAnnotations}
selectedLines={selectedLines}
renderAnnotation={renderAnnotation}
renderGutterUtility={renderGutterUtility}
style={{
'--diffs-dark-bg': colors.bg,
'--diffs-light-bg': colors.bg,
'--diffs-dark': colors.fg,
'--diffs-light': colors.fg,
} as React.CSSProperties}
options={{
themeType: pierreTheme.type,
unsafeCSS: pierreTheme.css,
overflow: 'scroll',
disableFileHeader: true,
enableLineSelection: true,
enableGutterUtility: !!onAddAnnotation,
lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
onLineClick: handleLineClick,
onLineSelectionEnd: handleLineSelectionEnd,
}}
/>
</div>
{pendingComment && onAddAnnotation && (
<CommentPopover
anchorEl={pendingComment.anchorEl}
anchorRect={pendingComment.anchorRect}
contextText={pendingComment.contextText}
isGlobal={false}
onSubmit={(text, images) => {
onAddAnnotation({
filePath: filepath,
lineStart: pendingComment.range.start,
lineEnd: pendingComment.range.end,
text,
images,
originalCode: pendingComment.originalCode,
});
setPendingComment(null);
}}
onClose={() => setPendingComment(null)}
/>
)}
</PopoutDialog>
);
};
+8 -4
View File
@@ -7,7 +7,9 @@ import { useDraggable } from '../hooks/useDraggable';
interface CommentPopoverProps {
/** Element to anchor the popover near (re-reads position on scroll) */
anchorEl: HTMLElement;
anchorEl?: HTMLElement;
/** Static viewport rect to anchor near when no stable DOM element exists */
anchorRect?: DOMRect;
/** Truncated selected text shown in header, or empty for global */
contextText: string;
/** Whether this is a global comment */
@@ -40,6 +42,7 @@ function computePosition(anchorRect: DOMRect): { top: number; left: number; flip
export const CommentPopover: React.FC<CommentPopoverProps> = ({
anchorEl,
anchorRect,
contextText,
isGlobal,
initialText = '',
@@ -55,7 +58,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
// Reset drag when anchor changes (new annotation) or mode switches
useEffect(() => { resetDrag(); }, [anchorEl, resetDrag]);
useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
// Track anchor position on scroll/resize (popover mode only, not after user drag)
@@ -63,7 +66,8 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
if (mode !== 'popover' || wasDragged) return;
const update = () => {
setPosition(computePosition(anchorEl.getBoundingClientRect()));
const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
if (rect) setPosition(computePosition(rect));
};
update();
@@ -73,7 +77,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [anchorEl, mode, wasDragged]);
}, [anchorEl, anchorRect, mode, wasDragged]);
// Focus textarea on mount and mode changes
useEffect(() => {
+36 -1
View File
@@ -1,4 +1,5 @@
import React from "react";
import { isCodeFilePath } from "@plannotator/shared/code-file";
import { transformPlainText } from "../utils/inlineTransforms";
import { getImageSrc } from "./ImageThumbnail";
@@ -85,11 +86,12 @@ function emitPlainTextWithBareUrls(
export const InlineMarkdown: React.FC<{
text: string;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
}> = ({ text, onOpenLinkedDoc, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
}> = ({ text, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
@@ -180,6 +182,7 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -201,6 +204,7 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -222,6 +226,7 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -242,6 +247,7 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -263,6 +269,7 @@ export const InlineMarkdown: React.FC<{
onImageClick={onImageClick}
text={match[1]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -505,7 +512,9 @@ export const InlineMarkdown: React.FC<{
/\.(mdx?|html?)(#.*)?$/i.test(linkUrl) &&
!linkUrl.startsWith("http://") &&
!linkUrl.startsWith("https://");
const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);
const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl;
const codeFilePath = isCodeFile ? linkUrl.replace(/#.*$/, '') : linkUrl;
const isInPageAnchor = safeLinkUrl.startsWith('#');
if (isInPageAnchor) {
@@ -551,6 +560,31 @@ export const InlineMarkdown: React.FC<{
</svg>
</a>,
);
} else if (isCodeFile && onOpenCodeFile) {
parts.push(
<a
key={key++}
href={safeLinkUrl}
onClick={(e) => {
e.preventDefault();
onOpenCodeFile(codeFilePath);
}}
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
title={`View: ${linkUrl}`}
>
{linkText}
<svg
className="w-3 h-3 opacity-50 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</a>,
);
} else if (isLocalDoc) {
// No handler — render as plain link (e.g., in shared/portal views)
parts.push(
@@ -590,6 +624,7 @@ export const InlineMarkdown: React.FC<{
key={key++}
text={before}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
imageBaseDir={imageBaseDir}
+91
View File
@@ -0,0 +1,91 @@
import React, { useCallback } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
const ANNOTATION_SELECTORS = [
'.annotation-toolbar',
'[data-comment-popover="true"]',
'[data-floating-picker="true"]',
];
interface PopoutDialogProps {
open: boolean;
onClose: () => void;
title: string;
container?: HTMLElement | null;
className?: string;
children: React.ReactNode;
/** Extra data attributes to spread onto Dialog.Content */
dataAttributes?: Record<string, string>;
}
export const PopoutDialog: React.FC<PopoutDialogProps> = ({
open,
onClose,
title,
container,
className,
children,
dataAttributes,
}) => {
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as Element;
if (ANNOTATION_SELECTORS.some((sel) => target.closest(sel))) return;
onClose();
},
[onClose],
);
const handleInteractOutside = useCallback(
(e: Event) => {
const target = e.target as Element | null;
if (!target) return;
if (ANNOTATION_SELECTORS.some((sel) => target.closest(sel))) {
e.preventDefault();
}
},
[],
);
return (
<Dialog.Root
open={open}
onOpenChange={(next) => { if (!next) onClose(); }}
modal={false}
>
<Dialog.Portal container={container ?? undefined}>
{/* Radix ignores Dialog.Overlay when modal={false}, so we use a plain
div for the backdrop. This gives us the dark scrim + blur while
keeping annotation toolbars (which portal outside the dialog)
interactive. */}
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px]"
onClick={handleBackdropClick}
aria-hidden="true"
/>
<Dialog.Content
className={`fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden ${className ?? 'w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] max-h-[calc(100vh-4rem)]'}`}
data-popout="true"
aria-describedby={undefined}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={handleInteractOutside}
{...dataAttributes}
>
<Dialog.Title className="sr-only">{title}</Dialog.Title>
<Dialog.Close asChild>
<button
className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Dialog.Close>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
+6 -1
View File
@@ -64,6 +64,7 @@ interface ViewerProps {
repoInfo?: { display: string; branch?: string; host?: string } | null;
stickyActions?: boolean;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
imageBaseDir?: string;
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string; backLabel?: string } | null;
// Plan diff props
@@ -153,6 +154,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
showDemoBadge,
maxWidth,
onOpenLinkedDoc,
onOpenCodeFile,
linkedDocInfo,
imageBaseDir,
copyLabel,
@@ -611,6 +613,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
block={block}
orderedIndex={indices[i]}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onToggleCheckbox={onToggleCheckbox}
checkboxOverrides={checkboxOverrides}
githubRepo={repoInfo?.display}
@@ -632,6 +635,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
imageBaseDir={imageBaseDir}
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
githubRepo={repoInfo?.display}
onNavigateAnchor={scrollToAnchor}
onHover={(element) => {
@@ -685,7 +689,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
/>
) : (
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
)
)}
@@ -781,6 +785,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
imageBaseDir={imageBaseDir}
onImageClick={(src, alt) => setLightbox({ src, alt })}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
githubRepo={repoInfo?.display}
onNavigateAnchor={scrollToAnchor}
/>
+3 -2
View File
@@ -7,6 +7,7 @@ interface AlertBlockProps {
kind: AlertKind;
body: string;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
@@ -38,7 +39,7 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
};
export const AlertBlock: React.FC<AlertBlockProps> = ({
blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
}) => {
return (
<div
@@ -51,7 +52,7 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
<Icon kind={kind} />
<span>{TITLE[kind]}</span>
</div>
{renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo })}
{renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo })}
</div>
);
};
@@ -9,6 +9,7 @@ interface CalloutProps {
blockType: 'alert' | 'directive';
kindAttribute: string;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
@@ -23,6 +24,7 @@ export const Callout: React.FC<CalloutProps> = ({
blockType,
kindAttribute,
onOpenLinkedDoc,
onOpenCodeFile,
imageBaseDir,
onImageClick,
githubRepo,
@@ -49,6 +51,7 @@ export const Callout: React.FC<CalloutProps> = ({
imageBaseDir,
onImageClick,
onOpenLinkedDoc,
onOpenCodeFile,
onNavigateAnchor,
githubRepo,
})}
+16 -3
View File
@@ -1,4 +1,5 @@
import React, { useRef, useEffect } from "react";
import { isCodeFilePath } from "@plannotator/shared/code-file";
import { Block } from "../../types";
import { sanitizeBlockHtml } from "../../utils/sanitizeHtml";
import { getImageSrc } from "../ImageThumbnail";
@@ -7,6 +8,7 @@ interface HtmlBlockProps {
block: Block;
imageBaseDir?: string;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
}
@@ -21,6 +23,7 @@ function rewriteRelativeRefs(
root: HTMLElement,
imageBaseDir?: string,
onOpenLinkedDoc?: (path: string) => void,
onOpenCodeFile?: (path: string) => void,
onNavigateAnchor?: (hash: string) => void,
): (() => void) {
const cleanups: (() => void)[] = [];
@@ -56,6 +59,15 @@ function rewriteRelativeRefs(
cleanups.push(() => a.removeEventListener('click', handler));
return;
}
if (onOpenCodeFile && isCodeFilePath(href)) {
const handler = (e: Event) => {
e.preventDefault();
onOpenCodeFile(href.replace(/#.*$/, ''));
};
a.addEventListener('click', handler);
cleanups.push(() => a.removeEventListener('click', handler));
return;
}
if (onOpenLinkedDoc && /\.(mdx?|html?)(#.*)?$/i.test(href)) {
const handler = (e: Event) => {
e.preventDefault();
@@ -75,7 +87,7 @@ function rewriteRelativeRefs(
// re-set on every parent re-render would collapse any open <details> the
// user just opened. Paired with React.memo below so the component itself
// stops re-rendering unless the block content actually changes.
const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onNavigateAnchor }) => {
const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor }) => {
const ref = useRef<HTMLDivElement>(null);
const sanitized = React.useMemo(
() => sanitizeBlockHtml(block.content),
@@ -86,9 +98,9 @@ const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLi
if (ref.current.innerHTML !== sanitized) {
ref.current.innerHTML = sanitized;
}
const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onNavigateAnchor);
const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor);
return cleanup;
}, [sanitized, imageBaseDir, onOpenLinkedDoc, onNavigateAnchor]);
}, [sanitized, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor]);
return (
<div
ref={ref}
@@ -105,5 +117,6 @@ export const HtmlBlock = React.memo(
prev.block.content === next.block.content &&
prev.imageBaseDir === next.imageBaseDir &&
prev.onOpenLinkedDoc === next.onOpenLinkedDoc &&
prev.onOpenCodeFile === next.onOpenCodeFile &&
prev.onNavigateAnchor === next.onNavigateAnchor,
);
@@ -7,6 +7,7 @@ interface TableBlockProps {
onHover?: (element: HTMLElement) => void;
onLeave?: () => void;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
@@ -81,6 +82,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
onHover,
onLeave,
onOpenLinkedDoc,
onOpenCodeFile,
onNavigateAnchor,
imageBaseDir,
onImageClick,
@@ -111,6 +113,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
onImageClick={onImageClick}
text={header}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -128,6 +131,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
onImageClick={onImageClick}
text={cell}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
+113 -152
View File
@@ -1,5 +1,4 @@
import React, { useMemo, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import {
createColumnHelper,
flexRender,
@@ -12,25 +11,24 @@ import {
} from '@tanstack/react-table';
import { Block } from '../../types';
import { InlineMarkdown } from '../InlineMarkdown';
import { PopoutDialog } from '../PopoutDialog';
import { parseTableContent, buildCsvFromRows, buildMarkdownTable } from './TableBlock';
interface TablePopoutProps {
block: Block;
open: boolean;
onClose: () => void;
/** Portal target — pass Viewer's annotation containerRef so annotation */
/** hooks can walk into the popout's text nodes. Null falls back to body. */
/** Portal target — pass Viewer's annotation containerRef so annotation
* hooks can walk into the popout's text nodes. Null falls back to body. */
container?: HTMLElement | null;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
githubRepo?: string;
}
// A row is a dict of columnId → cell text. Column ids are derived from the
// header label (or index fallback) so the data shape matches what TanStack
// Table expects for an accessor-based column definition.
type Row = Record<string, string>;
const TablePopoutImpl: React.FC<TablePopoutProps> = ({
@@ -39,6 +37,7 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
onClose,
container,
onOpenLinkedDoc,
onOpenCodeFile,
onNavigateAnchor,
imageBaseDir,
onImageClick,
@@ -46,8 +45,6 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
}) => {
const { headers, rows } = useMemo(() => parseTableContent(block.content), [block.content]);
// Build stable column ids from headers — duplicates get suffixed so
// TanStack Table doesn't collide on accessor keys.
const columnIds = useMemo(() => {
const seen = new Map<string, number>();
return headers.map((h, i) => {
@@ -82,6 +79,7 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
onImageClick={onImageClick}
text={info.getValue()}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
@@ -95,8 +93,6 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
const [copiedMd, setCopiedMd] = useState(false);
const [copiedCsv, setCopiedCsv] = useState(false);
// Copy reflects what the user currently sees — filter + sort applied.
// Read is one-shot on click; no subscription, no derived state to sync.
const getVisibleRowsData = (): string[][] =>
table.getRowModel().rows.map((row) =>
columnIds.map((id) => row.getValue<string>(id) ?? ''),
@@ -138,151 +134,118 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
const totalRows = data.length;
return (
<Dialog.Root open={open} onOpenChange={(next) => { if (!next) onClose(); }} modal={false}>
<Dialog.Portal container={container ?? undefined}>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] max-h-[calc(100vh-4rem)] -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden"
data-block-id={block.id}
data-popout="true"
aria-describedby={undefined}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
// With modal={false}, Radix treats any click outside Dialog.Content
// as dismissal. Our annotation stack (toolbar, comment popover,
// quick-label picker) portals to document.body — clicks on them
// are outside the dialog DOM but logically part of this session.
// Keep the dialog open when the interaction lands on any of them.
const target = e.target as Node | null;
if (!target || !(target instanceof Element)) return;
if (
target.closest('.annotation-toolbar') ||
target.closest('[data-comment-popover="true"]') ||
target.closest('[data-floating-picker="true"]')
) {
e.preventDefault();
<PopoutDialog
open={open}
onClose={onClose}
title="Table"
container={container}
dataAttributes={{ 'data-block-id': block.id }}
>
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
<div className="relative max-w-sm flex-1">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M10 18a8 8 0 100-16 8 8 0 000 16z" />
</svg>
<input
type="text"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Filter rows…"
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/40 border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/50"
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{visibleRows.length === totalRows
? `${totalRows} row${totalRows === 1 ? '' : 's'}`
: `${visibleRows.length} of ${totalRows}`}
</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={handleCopyMarkdown}
title={
copiedMd
? 'Copied!'
: visibleRows.length === totalRows
? 'Copy as markdown'
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as markdown`
}
}}
>
<Dialog.Title className="sr-only">Table</Dialog.Title>
<Dialog.Close asChild>
<button
className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
aria-label="Close"
>
className={`p-1.5 rounded-md transition-colors ${
copiedMd ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{copiedMd ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</Dialog.Close>
)}
</button>
<button
onClick={handleCopyCsv}
title={
copiedCsv
? 'Copied as CSV!'
: visibleRows.length === totalRows
? 'Copy as CSV'
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as CSV`
}
className={`px-2 py-1 rounded-md text-[10px] font-bold tracking-tight uppercase leading-none transition-colors ${
copiedCsv ? 'text-success' : 'text-primary hover:bg-primary/10'
}`}
>
{copiedCsv ? '✓' : 'CSV'}
</button>
</div>
</div>
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
<div className="relative max-w-sm flex-1">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M10 18a8 8 0 100-16 8 8 0 000 16z" />
</svg>
<input
type="text"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Filter rows…"
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/40 border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/50"
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{visibleRows.length === totalRows
? `${totalRows} row${totalRows === 1 ? '' : 's'}`
: `${visibleRows.length} of ${totalRows}`}
</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={handleCopyMarkdown}
title={
copiedMd
? 'Copied!'
: visibleRows.length === totalRows
? 'Copy as markdown'
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as markdown`
}
className={`p-1.5 rounded-md transition-colors ${
copiedMd ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{copiedMd ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<button
onClick={handleCopyCsv}
title={
copiedCsv
? 'Copied as CSV!'
: visibleRows.length === totalRows
? 'Copy as CSV'
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as CSV`
}
className={`px-2 py-1 rounded-md text-[10px] font-bold tracking-tight uppercase leading-none transition-colors ${
copiedCsv ? 'text-success' : 'text-primary hover:bg-primary/10'
}`}
>
{copiedCsv ? '✓' : 'CSV'}
</button>
</div>
</div>
<div className="overflow-auto flex-1 px-5 pb-5">
<table className="min-w-full border-collapse text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-border">
{headerGroup.headers.map((header) => {
const sort = header.column.getIsSorted();
return (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30 sticky top-0 z-10 select-none cursor-pointer hover:bg-muted/50 transition-colors"
>
<span className="inline-flex items-center gap-1.5">
{flexRender(header.column.columnDef.header, header.getContext())}
<SortIndicator dir={sort} />
</span>
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{visibleRows.length === 0 ? (
<tr>
<td colSpan={columnIds.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
No rows match the filter.
<div className="overflow-auto flex-1 px-5 pb-5">
<table className="min-w-full border-collapse text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-border">
{headerGroup.headers.map((header) => {
const sort = header.column.getIsSorted();
return (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30 sticky top-0 z-10 select-none cursor-pointer hover:bg-muted/50 transition-colors"
>
<span className="inline-flex items-center gap-1.5">
{flexRender(header.column.columnDef.header, header.getContext())}
<SortIndicator dir={sort} />
</span>
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{visibleRows.length === 0 ? (
<tr>
<td colSpan={columnIds.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
No rows match the filter.
</td>
</tr>
) : (
visibleRows.map((row) => (
<tr key={row.id} className="border-b border-border/50 hover:bg-muted/20">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 text-foreground/80">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
</tr>
) : (
visibleRows.map((row) => (
<tr key={row.id} className="border-b border-border/50 hover:bg-muted/20">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2 text-foreground/80">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</PopoutDialog>
);
};
@@ -293,8 +256,6 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
// re-render, which conflicts with web-highlighter's DOM mutations (the
// library inserts <mark> tags into the live DOM) and React's reconciler
// throws NotFoundError trying to remove nodes it doesn't own.
// Callback identity is intentionally ignored — the behavior is stable even
// if the parent hands us new function references.
export const TablePopout = React.memo(
TablePopoutImpl,
(prev, next) =>
@@ -16,6 +16,7 @@ export function renderProseBody(args: {
imageBaseDir?: string;
onImageClick?: (src: string, alt: string) => void;
onOpenLinkedDoc?: (path: string) => void;
onOpenCodeFile?: (path: string) => void;
onNavigateAnchor?: (hash: string) => void;
githubRepo?: string;
}): React.ReactNode {
@@ -26,6 +27,7 @@ export function renderProseBody(args: {
imageBaseDir,
onImageClick,
onOpenLinkedDoc,
onOpenCodeFile,
onNavigateAnchor,
githubRepo,
} = args;
@@ -36,6 +38,7 @@ export function renderProseBody(args: {
onImageClick={onImageClick}
text={text}
onOpenLinkedDoc={onOpenLinkedDoc}
onOpenCodeFile={onOpenCodeFile}
onNavigateAnchor={onNavigateAnchor}
githubRepo={githubRepo}
/>
+21 -10
View File
@@ -9,7 +9,7 @@
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Annotation, ImageAttachment } from '../types';
import type { Annotation, CodeAnnotation, ImageAttachment } from '../types';
import { fromShareable, parseShareableImages } from '../utils/sharing';
import type { ShareableAnnotation } from '../utils/sharing';
@@ -18,6 +18,7 @@ const DEBOUNCE_MS = 500;
/** New format: full objects. */
interface DraftData {
annotations: Annotation[];
codeAnnotations?: CodeAnnotation[];
globalAttachments: ImageAttachment[];
ts: number;
}
@@ -47,6 +48,7 @@ function formatTimeAgo(ts: number): string {
interface UseAnnotationDraftOptions {
annotations: Annotation[];
codeAnnotations?: CodeAnnotation[];
globalAttachments: ImageAttachment[];
isApiMode: boolean;
isSharedSession: boolean;
@@ -55,19 +57,20 @@ interface UseAnnotationDraftOptions {
interface UseAnnotationDraftResult {
draftBanner: { count: number; timeAgo: string } | null;
restoreDraft: () => { annotations: Annotation[]; globalAttachments: ImageAttachment[] };
restoreDraft: () => { annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] };
dismissDraft: () => void;
}
export function useAnnotationDraft({
annotations,
codeAnnotations = [],
globalAttachments,
isApiMode,
isSharedSession,
submitted,
}: UseAnnotationDraftOptions): UseAnnotationDraftResult {
const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null);
const draftDataRef = useRef<{ annotations: Annotation[]; globalAttachments: ImageAttachment[] } | null>(null);
const draftDataRef = useRef<{ annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] } | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasMountedRef = useRef(false);
@@ -87,25 +90,32 @@ export function useAnnotationDraft({
}
let restoredAnnotations: Annotation[];
let restoredCodeAnnotations: CodeAnnotation[] = [];
let restoredGlobal: ImageAttachment[];
if (isLegacyDraft(data)) {
// Old tuple format — deserialize via fromShareable
restoredAnnotations = data.a.length > 0 ? fromShareable(data.a, data.d) : [];
restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters<typeof parseShareableImages>[0]) ?? []) : [];
} else if (Array.isArray(data.annotations) && data.annotations.length > 0) {
} else if (Array.isArray(data.annotations)) {
// New direct-object format
restoredAnnotations = data.annotations;
restoredCodeAnnotations = Array.isArray(data.codeAnnotations) ? data.codeAnnotations : [];
restoredGlobal = Array.isArray(data.globalAttachments) ? data.globalAttachments : [];
} else if (Array.isArray((data as DraftData).codeAnnotations) && (data as DraftData).codeAnnotations!.length > 0) {
restoredAnnotations = [];
restoredCodeAnnotations = (data as DraftData).codeAnnotations!;
restoredGlobal = Array.isArray((data as DraftData).globalAttachments) ? (data as DraftData).globalAttachments : [];
} else {
hasMountedRef.current = true;
return;
}
if (restoredAnnotations.length > 0) {
draftDataRef.current = { annotations: restoredAnnotations, globalAttachments: restoredGlobal };
const totalCount = restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length;
if (totalCount > 0) {
draftDataRef.current = { annotations: restoredAnnotations, codeAnnotations: restoredCodeAnnotations, globalAttachments: restoredGlobal };
setDraftBanner({
count: restoredAnnotations.length,
count: totalCount,
timeAgo: formatTimeAgo(data.ts || 0),
});
}
@@ -120,13 +130,14 @@ export function useAnnotationDraft({
useEffect(() => {
if (!isApiMode || isSharedSession || submitted) return;
if (!hasMountedRef.current) return;
if (annotations.length === 0) return;
if (annotations.length === 0 && codeAnnotations.length === 0 && globalAttachments.length === 0) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const payload: DraftData = {
annotations,
codeAnnotations,
globalAttachments,
ts: Date.now(),
};
@@ -143,14 +154,14 @@ export function useAnnotationDraft({
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [annotations, globalAttachments, isApiMode, isSharedSession, submitted]);
}, [annotations, codeAnnotations, globalAttachments, isApiMode, isSharedSession, submitted]);
const restoreDraft = useCallback(() => {
const data = draftDataRef.current;
setDraftBanner(null);
draftDataRef.current = null;
if (!data) return { annotations: [], globalAttachments: [] };
if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [] };
return data;
}, []);
+71
View File
@@ -0,0 +1,71 @@
import { useState, useCallback } from "react";
interface CodeFileState {
filepath: string;
contents: string;
prerenderedHTML?: 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;
} | null;
}
export function useCodeFilePopout(
options: UseCodeFilePopoutOptions
): UseCodeFilePopoutReturn {
const { buildUrl } = options;
const [state, setState] = useState<CodeFileState | null>(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) {
setIsLoading(false);
return;
}
setState({ filepath: data.filepath, contents: data.contents, prerenderedHTML: data.prerenderedHTML });
setIsLoading(false);
} catch {
setIsLoading(false);
}
},
[buildUrl]
);
return {
open,
close,
isLoading,
popoutProps: state
? { open: true, onClose: close, filepath: state.filepath, contents: state.contents, prerenderedHTML: state.prerenderedHTML }
: null,
};
}
+1
View File
@@ -14,6 +14,7 @@
"./theme": "./theme.css"
},
"dependencies": {
"@pierre/diffs": "^1.1.12",
"@plannotator/shared": "workspace:*",
"@plannotator/web-highlighter": "^0.8.1",
"@radix-ui/react-dialog": "^1.1.15",
+1
View File
@@ -107,6 +107,7 @@ export interface CodeAnnotation {
lineEnd: number;
side: 'old' | 'new'; // Maps to 'deletions' | 'additions' in @pierre/diffs
text?: string;
images?: ImageAttachment[];
suggestedCode?: string;
originalCode?: string; // Original selected lines for suggestion diff
charStart?: number; // Character offset within lineStart (token-level selection)
+7
View File
@@ -0,0 +1,7 @@
export function generateId(prefix?: string): string {
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
return prefix ? `${prefix}-${id}` : id;
}
+35 -1
View File
@@ -1,4 +1,4 @@
import { Block, type Annotation, type EditorAnnotation, type ImageAttachment } from '../types';
import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types';
import { planDenyFeedback } from '@plannotator/shared/feedback-templates';
/**
@@ -689,3 +689,37 @@ export const exportEditorAnnotations = (editorAnnotations: EditorAnnotation[]):
return output;
};
export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string => {
if (annotations.length === 0) return '';
let output = `\n# Code File Feedback\n\nThe following feedback is on code files referenced from the reviewed document.\n\n`;
const sorted = [...annotations].sort((a, b) => {
if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath);
if (a.lineStart !== b.lineStart) return a.lineStart - b.lineStart;
return a.createdAt - b.createdAt;
});
sorted.forEach((ann, index) => {
const lineRange = ann.lineStart === ann.lineEnd
? `line ${ann.lineStart}`
: `lines ${ann.lineStart}-${ann.lineEnd}`;
output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`;
if (ann.originalCode) {
output += `\`\`\`\n${ann.originalCode}\n\`\`\`\n`;
}
if (ann.text) {
output += `> ${ann.text}\n`;
}
if (ann.images && ann.images.length > 0) {
output += `**Attached images:**\n`;
ann.images.forEach((img) => {
output += `- [${img.name}] \`${img.path}\`\n`;
});
}
output += '\n';
});
output += `---\n`;
return output;
};