diff --git a/apps/vscode-extension/images/annotation-gutter.svg b/apps/vscode-extension/images/annotation-gutter.svg new file mode 100644 index 00000000..47dd2964 --- /dev/null +++ b/apps/vscode-extension/images/annotation-gutter.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 599ecd63..c2983230 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -31,8 +31,53 @@ { "command": "plannotator-webview.openUrl", "title": "Plannotator: Open URL in Editor" + }, + { + "command": "plannotator-webview.addEditorAnnotation", + "title": "Plannotator: Add Annotation" + }, + { + "command": "plannotator-webview.submitComment", + "title": "Submit Annotation", + "icon": "$(check)" + }, + { + "command": "plannotator-webview.deleteEditorAnnotation", + "title": "Delete Annotation", + "icon": "$(trash)" } ], + "keybindings": [ + { + "command": "plannotator-webview.addEditorAnnotation", + "key": "ctrl+shift+.", + "mac": "cmd+shift+.", + "when": "editorHasSelection" + } + ], + "menus": { + "editor/context": [ + { + "command": "plannotator-webview.addEditorAnnotation", + "when": "editorHasSelection", + "group": "plannotator" + } + ], + "comments/commentThread/title": [ + { + "command": "plannotator-webview.deleteEditorAnnotation", + "group": "inline", + "when": "commentController == plannotator && commentThread == plannotator-thread" + } + ], + "comments/commentThread/context": [ + { + "command": "plannotator-webview.submitComment", + "group": "inline", + "when": "commentController == plannotator" + } + ] + }, "configuration": { "title": "Plannotator", "properties": { diff --git a/apps/vscode-extension/src/cookie-proxy.ts b/apps/vscode-extension/src/cookie-proxy.ts index 82c7df89..77f09488 100644 --- a/apps/vscode-extension/src/cookie-proxy.ts +++ b/apps/vscode-extension/src/cookie-proxy.ts @@ -1,5 +1,6 @@ import * as http from "http"; import { EventEmitter } from "events"; +import { buildThemeListenerScript } from "./vscode-theme"; export interface CookieProxyOptions { loadCookies: () => string; @@ -144,10 +145,11 @@ function parseCookieString(str: string): Record { function injectScript(html: string, savedCookies: string): string { const initial = JSON.stringify(parseCookieString(savedCookies)); + const themeListener = buildThemeListenerScript(); // Virtual cookie jar: overrides document.cookie so plannotator works even // when the browser blocks third-party cookies inside the iframe. - const script = ``; +} + +/** + * Returns inline JS injected into the iframe HTML (via cookie proxy). + * Listens for theme messages from the wrapper and applies CSS overrides. + */ +export function buildThemeListenerScript(): string { + const pairsJson = JSON.stringify(TOKEN_PAIRS); + return ``; +} diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index a4407ee0..676a0763 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, extractFrontmatter, Frontmatter } from '@plannotator/ui/utils/parser'; +import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, Frontmatter } 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'; @@ -47,6 +47,7 @@ import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc'; import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser'; import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; +import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; @@ -563,6 +564,8 @@ const App: React.FC = () => { submitted: !!submitted, }); + const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); + const handleRestoreDraft = React.useCallback(() => { const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft(); if (restored.length > 0) { @@ -822,7 +825,7 @@ const App: React.FC = () => { const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some( (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); - if (annotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations) { + if (annotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) { body.feedback = annotationsOutput; } @@ -912,7 +915,7 @@ const App: React.FC = () => { } // No annotations → Approve, otherwise → Send Feedback - if (annotations.length === 0) { + if (annotations.length === 0 && editorAnnotations.length === 0) { // Check if agent exists for OpenCode users if (origin === 'opencode') { const warning = getAgentWarning(); @@ -981,8 +984,9 @@ const App: React.FC = () => { (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 ); const hasPlanAnnotations = annotations.length > 0 || globalAttachments.length > 0; + const hasEditorAnnotations = editorAnnotations.length > 0; - if (!hasPlanAnnotations && !hasDocAnnotations) { + if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations) { return 'No changes detected.'; } @@ -994,8 +998,12 @@ const App: React.FC = () => { output += exportLinkedDocAnnotations(docAnnotations); } + if (hasEditorAnnotations) { + output += exportEditorAnnotations(editorAnnotations); + } + return output; - }, [blocks, annotations, globalAttachments, linkedDocHook.getDocAnnotations]); + }, [blocks, annotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations]); // Quick-save handlers for export dropdown and keyboard shortcut const handleDownloadAnnotations = () => { @@ -1152,7 +1160,7 @@ const App: React.FC = () => { <> - {annotations.length > 0 && ( + {totalAnnotationCount > 0 && (
- Your {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} won't be sent if you approve. + Your {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} won't be sent if you approve.
)}
@@ -878,6 +887,8 @@ const ReviewApp: React.FC = () => { onDeleteAnnotation={handleDeleteAnnotation} feedbackMarkdown={feedbackMarkdown} width={panelResize.width} + editorAnnotations={editorAnnotations} + onDeleteEditorAnnotation={deleteEditorAnnotation} />
@@ -936,7 +947,7 @@ const ReviewApp: React.FC = () => { handleApprove(); }} title="Annotations Won't Be Sent" - message={<>You have {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} that will be lost if you approve.} + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you approve.} subMessage="To send your feedback, use Send Feedback instead." confirmText="Approve Anyway" cancelText="Cancel" diff --git a/packages/review-editor/components/ReviewPanel.tsx b/packages/review-editor/components/ReviewPanel.tsx index a7128f5d..9c199cba 100644 --- a/packages/review-editor/components/ReviewPanel.tsx +++ b/packages/review-editor/components/ReviewPanel.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; -import { CodeAnnotation } from '@plannotator/ui/types'; +import { CodeAnnotation, type EditorAnnotation } from '@plannotator/ui/types'; import { isCurrentUser } from '@plannotator/ui/utils/identity'; +import { EditorAnnotationCard } from '@plannotator/ui/components/EditorAnnotationCard'; import { HighlightedCode } from './HighlightedCode'; import { detectLanguage } from '../utils/detectLanguage'; import { renderInlineMarkdown } from '../utils/renderInlineMarkdown'; @@ -21,6 +22,8 @@ interface ReviewPanelProps { onDeleteAnnotation: (id: string) => void; feedbackMarkdown?: string; width?: number; + editorAnnotations?: EditorAnnotation[]; + onDeleteEditorAnnotation?: (id: string) => void; } const SuggestionPreview: React.FC<{ code: string; originalCode?: string; language?: string }> = ({ code, originalCode, language }) => { @@ -75,7 +78,10 @@ export const ReviewPanel: React.FC = ({ onDeleteAnnotation, feedbackMarkdown, width, + editorAnnotations, + onDeleteEditorAnnotation, }) => { + const totalCount = annotations.length + (editorAnnotations?.length ?? 0); const [copied, setCopied] = useState(false); const handleQuickCopy = async () => { @@ -114,14 +120,14 @@ export const ReviewPanel: React.FC = ({ Annotations - {annotations.length} + {totalCount} {/* Content */}
- {annotations.length === 0 ? ( + {totalCount === 0 ? (
@@ -208,10 +214,30 @@ export const ReviewPanel: React.FC = ({ ))}
)} + + {/* Editor annotations (VS Code) */} + {editorAnnotations && editorAnnotations.length > 0 && ( + <> + {annotations.length > 0 && ( +
+
+ Editor +
+
+ )} + {editorAnnotations.map(ann => ( + onDeleteEditorAnnotation?.(ann.id)} + /> + ))} + + )}
{/* Quick Copy Footer */} - {feedbackMarkdown && annotations.length > 0 && ( + {feedbackMarkdown && totalCount > 0 && (
{/* List */}
- {sortedAnnotations.length === 0 ? ( + {totalCount === 0 ? (
@@ -72,21 +78,41 @@ export const AnnotationPanel: React.FC = ({

) : ( - sortedAnnotations.map(ann => ( - onSelect(ann.id)} - onDelete={() => onDelete(ann.id)} - onEdit={onEdit ? (updates: Partial) => onEdit(ann.id, updates) : undefined} - /> - )) + <> + {sortedAnnotations.map(ann => ( + onSelect(ann.id)} + onDelete={() => onDelete(ann.id)} + onEdit={onEdit ? (updates: Partial) => onEdit(ann.id, updates) : undefined} + /> + ))} + {editorAnnotations && editorAnnotations.length > 0 && ( + <> + {sortedAnnotations.length > 0 && ( +
+
+ Editor +
+
+ )} + {editorAnnotations.map(ann => ( + onDeleteEditorAnnotation?.(ann.id)} + /> + ))} + + )} + )}
{/* Quick Share Footer */} - {sharingEnabled && shareUrl && annotations.length > 0 && ( + {sharingEnabled && shareUrl && totalCount > 0 && (
+
+ + {/* Selected text */} +
+ {annotation.selectedText} +
+ + {/* Comment */} + {annotation.comment && ( +
+ {annotation.comment} +
+ )} +
+ ); +}; diff --git a/packages/ui/hooks/useEditorAnnotations.ts b/packages/ui/hooks/useEditorAnnotations.ts new file mode 100644 index 00000000..0265be3c --- /dev/null +++ b/packages/ui/hooks/useEditorAnnotations.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { EditorAnnotation } from '../types'; + +const POLL_INTERVAL = 500; +const IS_VSCODE = typeof window !== 'undefined' && (window as any).__PLANNOTATOR_VSCODE === true; + +interface UseEditorAnnotationsReturn { + editorAnnotations: EditorAnnotation[]; + deleteEditorAnnotation: (id: string) => void; +} + +/** + * Polls the server for editor annotations created by the VS Code extension. + * + * Only activates when running inside a VS Code webview (detected via + * window.__PLANNOTATOR_VSCODE set by the theme bridge). In browser/shared URL + * contexts, returns an empty array with zero network cost. + */ +export function useEditorAnnotations(): UseEditorAnnotationsReturn { + const [annotations, setAnnotations] = useState([]); + const intervalRef = useRef | null>(null); + + const fetchAnnotations = useCallback(async () => { + try { + const res = await fetch('/api/editor-annotations'); + if (!res.ok) return; + const data = await res.json(); + const incoming: EditorAnnotation[] = data.annotations ?? []; + setAnnotations((prev) => { + if (prev.length === incoming.length && prev.every((a, i) => a.id === incoming[i].id)) return prev; + return incoming; + }); + } catch { + // Silently fail — next poll will retry + } + }, []); + + useEffect(() => { + if (!IS_VSCODE) return; + + // Initial fetch + start polling + fetchAnnotations(); + intervalRef.current = setInterval(fetchAnnotations, POLL_INTERVAL); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [fetchAnnotations]); + + const deleteEditorAnnotation = useCallback(async (id: string) => { + if (!IS_VSCODE) return; + try { + await fetch(`/api/editor-annotation?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + } catch { + // Silently fail — next poll will reconcile + } + }, []); + + return { editorAnnotations: annotations, deleteEditorAnnotation }; +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 28408bb9..1329efec 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -96,3 +96,5 @@ export interface VaultNode { type: "file" | "folder"; children?: VaultNode[]; } + +export type { EditorAnnotation } from '@plannotator/shared/types'; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 655bedb1..cc77b50a 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -1,4 +1,4 @@ -import { Block, type Annotation, type ImageAttachment } from '../types'; +import { Block, type Annotation, type EditorAnnotation, type ImageAttachment } from '../types'; /** * Parsed YAML frontmatter as key-value pairs. @@ -392,6 +392,30 @@ export const exportLinkedDocAnnotations = ( }); } + output += `---\n`; + return output; +}; + +export const exportEditorAnnotations = (editorAnnotations: EditorAnnotation[]): string => { + if (editorAnnotations.length === 0) return ''; + + let output = `\n# Editor File Annotations\n\nThe following annotations reference code files in the project.\n\n`; + + editorAnnotations.forEach((ann, index) => { + const lineRange = ann.lineStart === ann.lineEnd + ? `line ${ann.lineStart}` + : `lines ${ann.lineStart}-${ann.lineEnd}`; + + output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`; + output += `\`\`\`\n${ann.selectedText}\n\`\`\`\n`; + + if (ann.comment) { + output += `> ${ann.comment}\n`; + } + + output += '\n'; + }); + output += `---\n`; return output; }; \ No newline at end of file