diff --git a/apps/hook/dev-mock-api.ts b/apps/hook/dev-mock-api.ts index 40adac4d..7c919dbc 100644 --- a/apps/hook/dev-mock-api.ts +++ b/apps/hook/dev-mock-api.ts @@ -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(); }); }, diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index dbda3b5a..20c80add 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -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" }, diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index d4563ec7..ab377688 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -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 { 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") { diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 2407d048..408dba14 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -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") { diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 78448877..90c771c9 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -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") { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 9a362355..c5f3f8cd 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -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 diff --git a/bun.lock b/bun.lock index a47608b3..cfeea9d2 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 971d4e2d..37051e20 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -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([]); + const [codeAnnotations, setCodeAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState(null); const [blocks, setBlocks] = useState([]); const [frontmatter, setFrontmatter] = useState(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) => { + 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) && (
@@ -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} />
+ {/* Code File Popout */} + {codeFilePopout.popoutProps && ( + ann.filePath === codeFilePopout.popoutProps?.filepath)} + selectedAnnotationId={selectedCodeAnnotationId} + onAddAnnotation={handleAddCodeAnnotation} + onEditAnnotation={handleEditCodeAnnotation} + onDeleteAnnotation={handleDeleteCodeAnnotation} + onSelectAnnotation={(id) => { + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(id); + }} + /> + )} + {/* Export Modal */} { shortUrlError={shortUrlError} onGenerateShortUrl={generateShortUrl} annotationsOutput={annotationsOutput} - annotationCount={allAnnotations.length} + annotationCount={allAnnotations.length + codeAnnotations.length} taterSprite={taterMode ? : 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 Send Feedback 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" diff --git a/packages/editor/demoPlan.ts b/packages/editor/demoPlan.ts index 2118b308..bec1f27a 100644 --- a/packages/editor/demoPlan.ts +++ b/packages/editor/demoPlan.ts @@ -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)*. diff --git a/packages/server/package.json b/packages/server/package.json index 0d75e039..bf6517df 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -25,6 +25,7 @@ "*.ts" ], "dependencies": { + "@pierre/diffs": "^1.1.12", "@plannotator/shared": "workspace:*", "@plannotator/ai": "workspace:*" }, diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index a81d7689..ab02a43f 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -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 { 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") { diff --git a/packages/shared/code-file.ts b/packages/shared/code-file.ts new file mode 100644 index 00000000..db1cad4a --- /dev/null +++ b/packages/shared/code-file.ts @@ -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://'); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 51b58053..53be3e43 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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", diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts index c56c3930..31ff2670 100644 --- a/packages/shared/resolve-file.ts +++ b/packages/shared/resolve-file.ts @@ -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])\/(.+)$/, diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 96155650..2d8922c8 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -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) => void; selectedId: string | null; + codeAnnotations?: CodeAnnotation[]; + onSelectCodeAnnotation?: (id: string) => void; + onDeleteCodeAnnotation?: (id: string) => void; + onEditCodeAnnotation?: (id: string, updates: Partial) => void; sharingEnabled?: boolean; width?: number; editorAnnotations?: EditorAnnotation[]; @@ -33,6 +37,10 @@ export const AnnotationPanel: React.FC = ({ onDelete, onEdit, selectedId, + codeAnnotations = [], + onSelectCodeAnnotation, + onDeleteCodeAnnotation, + onEditCodeAnnotation, sharingEnabled = true, width, editorAnnotations, @@ -47,7 +55,12 @@ export const AnnotationPanel: React.FC = ({ const [copiedText, setCopiedText] = useState(false); const listRef = useRef(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 = ({

- Select text to add annotations + Select text or code lines to add annotations

) : ( <> - {sortedAnnotations.map(ann => ( - onSelect(ann.id)} - onDelete={() => onDelete(ann.id)} - onEdit={onEdit ? (updates: Partial) => onEdit(ann.id, updates) : undefined} - /> + {timelineEntries.map(entry => ( + entry.kind === 'plan' ? ( + onSelect(entry.annotation.id)} + onDelete={() => onDelete(entry.annotation.id)} + onEdit={onEdit ? (updates: Partial) => onEdit(entry.annotation.id, updates) : undefined} + /> + ) : ( + onSelectCodeAnnotation?.(entry.annotation.id)} + onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)} + onEdit={onEditCodeAnnotation ? (updates: Partial) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined} + /> + ) ))} {editorAnnotations && editorAnnotations.length > 0 && ( <> - {sortedAnnotations.length > 0 && ( + {timelineEntries.length > 0 && (
Editor @@ -495,3 +519,170 @@ const AnnotationCard: React.FC<{
); }; + +const CodeAnnotationCard: React.FC<{ + annotation: CodeAnnotation; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; + onEdit?: (updates: Partial) => void; +}> = ({ annotation, isSelected, onSelect, onDelete, onEdit }) => { + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(annotation.text || ''); + const textareaRef = useRef(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 ( +
+ {annotation.author && ( +
+ + + + {annotation.author}{isCurrentUser(annotation.author) && ' (me)'} +
+ )} + +
+
+
+ + + + + + Code +
+ {formatTimestamp(annotation.createdAt)} +
+
+ {onEdit && !isEditing && ( + + )} + +
+
+ +
+ {fileName} · {lineRange} +
+ + {annotation.originalCode && ( +
+ {annotation.originalCode} +
+ )} + + {isEditing ? ( +
+