From be8b47f8fbe7d176466eb696ab0321b78f3fe8ef Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Fri, 20 Mar 2026 11:11:30 +1000 Subject: [PATCH] :sparkles: document-level notes --- .../components/AIChat/ChatMessageItem.tsx | 15 +- .../components/Editor/MarkdownEditor.tsx | 27 +-- .../components/Feedback/FeedbackPanel.css | 104 ++++++++++++ .../components/Feedback/FeedbackPanel.tsx | 156 ++++++++++++++++-- src/renderer/store/editorStore.ts | 31 ++++ src/renderer/types/editor.ts | 10 +- src/renderer/utils/annotationParser.ts | 9 +- tsconfig.node.tsbuildinfo | 2 +- tsconfig.web.tsbuildinfo | 2 +- 9 files changed, 312 insertions(+), 44 deletions(-) diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index f1f2233..994ac33 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -21,7 +21,8 @@ function badgeColor(type: TextAnnotation['type']): string { case 'show_tell': return 'rgba(255, 140, 30, 0.75)' case 'critique': return 'rgba(160, 80, 220, 0.75)' case 'custom': return 'rgba(30, 200, 150, 0.8)' - case 'user_comment': return 'rgba(240, 100, 180, 0.85)' + case 'user_comment': return 'rgba(240, 100, 180, 0.85)' + case 'document_note': return 'rgba(80, 180, 240, 0.85)' } } @@ -95,11 +96,13 @@ function ChatMessageItemInner({ message, linkedAnnotations }: Props): JSX.Elemen > {ann.type.replace(/_/g, ' ')} - - "{ann.matchedText.length > 40 - ? ann.matchedText.slice(0, 38) + '…' - : ann.matchedText}" - + {ann.matchedText && ( + + "{ann.matchedText.length > 40 + ? ann.matchedText.slice(0, 38) + '…' + : ann.matchedText}" + + )} {ann.applied && ( Applied )} diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index b9ecacd..713e035 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -35,11 +35,14 @@ const rawAnnotationsField = StateField.define({ } if (tr.docChanged && annotations.length > 0) { const oldLen = tr.changes.length - return annotations.map(a => ({ - ...a, - from: tr.changes.mapPos(Math.min(a.from, oldLen), -1), - to: tr.changes.mapPos(Math.min(a.to, oldLen), 1) - })) + return annotations.map(a => { + if (a.from === undefined || a.to === undefined) return a + return { + ...a, + from: tr.changes.mapPos(Math.min(a.from, oldLen), -1), + to: tr.changes.mapPos(Math.min(a.to, oldLen), 1) + } + }) } return annotations } @@ -159,6 +162,7 @@ export function scrollToAnnotation(ann: TextAnnotation): void { const view = currentEditorView if (!view) return const tracked = view.state.field(rawAnnotationsField).find(a => a.id === ann.id) ?? ann + if (tracked.from === undefined) return view.dispatch({ selection: { anchor: tracked.from }, effects: EditorView.scrollIntoView(tracked.from, { y: 'center' }) @@ -176,14 +180,14 @@ export function applyAnnotation(ann: TextAnnotation, suggestion: string): void { const { markAnnotationApplied } = useEditorStore.getState() const cmAnnotations = view.state.field(rawAnnotationsField) const tracked = cmAnnotations.find(a => a.id === ann.id) - if (!tracked) return + if (!tracked || tracked.from === undefined || tracked.to === undefined) return const changeSpec = { from: tracked.from, to: tracked.to, insert: suggestion } // Map surviving annotation positions through the text change so // their from/to reflect the new document offsets. const changeSet = view.state.changes(changeSpec) const remaining = cmAnnotations .filter(a => a.id !== ann.id) - .map(a => ({ ...a, from: changeSet.mapPos(a.from), to: changeSet.mapPos(a.to) })) + .map(a => a.from === undefined || a.to === undefined ? a : { ...a, from: changeSet.mapPos(a.from), to: changeSet.mapPos(a.to) }) // Combine text replacement + annotation update in one transaction // so CM history treats them as a single undoable unit. view.dispatch({ @@ -200,7 +204,7 @@ export function applyAnnotation(ann: TextAnnotation, suggestion: string): void { const annotationHoverTooltip = hoverTooltip( (view, pos) => { const annotations = view.state.field(rawAnnotationsField) - const found = annotations.find(a => pos >= a.from && pos <= a.to) + const found = annotations.find(a => a.from !== undefined && a.to !== undefined && pos >= a.from && pos <= a.to) if (!found) return null // Capture in a new const so TypeScript preserves the non-undefined type // across the nested create() closure without requiring non-null assertions. @@ -337,8 +341,9 @@ const annotationHoverTooltip = hoverTooltip( // Build a DecorationSet from an annotation list, clamped to docLen. function buildDecoSet(annotations: TextAnnotation[], docLen: number): DecorationSet { const builder = new RangeSetBuilder() - const sorted = [...annotations].sort((a, b) => a.from - b.from) + const sorted = [...annotations].sort((a, b) => (a.from ?? -1) - (b.from ?? -1)) for (const ann of sorted) { + if (ann.from === undefined || ann.to === undefined) continue const from = Math.max(0, Math.min(ann.from, docLen)) const to = Math.max(from, Math.min(ann.to, docLen)) if (from < to) { @@ -721,7 +726,7 @@ export function MarkdownEditor(): JSX.Element { if (tr.annotation(Transaction.addToHistory) === false) continue tr.changes.iterChangedRanges((fromA, toA) => { for (const ann of preAnnotations) { - if (fromA < ann.to && toA > ann.from) { + if (ann.from !== undefined && ann.to !== undefined && fromA < ann.to && toA > ann.from) { schedulePendingDismiss(ann.id) } } @@ -806,7 +811,7 @@ export function MarkdownEditor(): JSX.Element { const trackedById = new Map(view.state.field(rawAnnotationsField).map(a => [a.id, a])) const toDispatch = reanchored.map(a => { const tracked = trackedById.get(a.id) - return (tracked && tracked.from >= 0 && tracked.to <= docLen) ? tracked : a + return (tracked && tracked.from !== undefined && tracked.from >= 0 && tracked.to !== undefined && tracked.to <= docLen) ? tracked : a }) view.dispatch({ effects: setAnnotationsEffect.of(toDispatch), diff --git a/src/renderer/components/Feedback/FeedbackPanel.css b/src/renderer/components/Feedback/FeedbackPanel.css index ba09201..2a266ac 100644 --- a/src/renderer/components/Feedback/FeedbackPanel.css +++ b/src/renderer/components/Feedback/FeedbackPanel.css @@ -427,3 +427,107 @@ white-space: pre-wrap; word-break: break-word; } + +/* ── Add document note ───────────────────────────────── */ +.fb-add-note-section { + flex-shrink: 0; + padding: 8px 12px; + border-top: 1px solid var(--border); +} + +.fb-add-note-btn { + background: none; + border: 1px dashed var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + padding: 5px 10px; + width: 100%; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.fb-add-note-btn:hover { + color: rgba(80, 180, 240, 0.9); + border-color: rgba(80, 180, 240, 0.4); +} + +.fb-note-form { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fb-note-textarea { + width: 100%; + background: var(--message-bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12.5px; + line-height: 1.6; + padding: 6px 8px; + resize: none; + box-sizing: border-box; + outline: none; + transition: border-color 0.15s; +} + +.fb-note-textarea:focus { + border-color: rgba(80, 180, 240, 0.5); +} + +.fb-note-textarea::placeholder { + color: var(--text-muted); + font-style: italic; +} + +.fb-note-form-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.fb-note-save-btn { + background: rgba(80, 180, 240, 0.15); + border: 1px solid rgba(80, 180, 240, 0.4); + border-radius: 4px; + color: rgba(80, 180, 240, 0.9); + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + cursor: pointer; + transition: background 0.15s; +} + +.fb-note-save-btn:hover { + background: rgba(80, 180, 240, 0.25); +} + +.fb-note-cancel-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + cursor: pointer; + transition: color 0.15s; +} + +.fb-note-cancel-btn:hover { + color: var(--text-primary); +} + +/* ── Document note card: no hover jump affordance ───── */ +.fb-card-header--no-jump { + cursor: default; +} + +.fb-card-header--no-jump:hover { + background: transparent; +} diff --git a/src/renderer/components/Feedback/FeedbackPanel.tsx b/src/renderer/components/Feedback/FeedbackPanel.tsx index 65b1469..1506799 100644 --- a/src/renderer/components/Feedback/FeedbackPanel.tsx +++ b/src/renderer/components/Feedback/FeedbackPanel.tsx @@ -19,13 +19,14 @@ type AnalysisState = function badgeColor(type: TextAnnotation['type']): string { switch (type) { - case 'passive_voice': return 'rgba(255, 200, 0, 0.75)' - case 'consistency': return 'rgba(220, 80, 80, 0.75)' - case 'style': return 'rgba(80, 160, 255, 0.75)' - case 'show_tell': return 'rgba(255, 140, 30, 0.75)' - case 'critique': return 'rgba(160, 80, 220, 0.75)' - case 'custom': return 'rgba(30, 200, 150, 0.8)' - case 'user_comment': return 'rgba(240, 100, 180, 0.85)' + case 'passive_voice': return 'rgba(255, 200, 0, 0.75)' + case 'consistency': return 'rgba(220, 80, 80, 0.75)' + case 'style': return 'rgba(80, 160, 255, 0.75)' + case 'show_tell': return 'rgba(255, 140, 30, 0.75)' + case 'critique': return 'rgba(160, 80, 220, 0.75)' + case 'custom': return 'rgba(30, 200, 150, 0.8)' + case 'user_comment': return 'rgba(240, 100, 180, 0.85)' + case 'document_note': return 'rgba(80, 180, 240, 0.85)' } } @@ -197,6 +198,36 @@ function UserCommentCard({ ann, onDismiss }: UserCommentCardProps): JSX.Element ) } +interface DocumentNoteCardProps { + ann: TextAnnotation + onDismiss: () => void +} + +function DocumentNoteCard({ ann, onDismiss }: DocumentNoteCardProps): JSX.Element { + return ( +
+
+
+
+ Document note +
+ +
+
+
{ann.comment}
+
+ ) +} + interface ArchiveCardProps { ann: TextAnnotation onRemove?: () => void @@ -222,7 +253,9 @@ function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element { )} - "{ann.matchedText}" + {ann.matchedText && ( + "{ann.matchedText}" + )} {ann.comment && ( {ann.comment} )} @@ -242,8 +275,34 @@ export function FeedbackPanel(): JSX.Element { removeAnnotation, clearArchivedAnnotations, removeArchivedAnnotation, + addDocumentNote, } = useEditorStore() const [analyseAll, setAnalyseAll] = useState(false) + const [addingNote, setAddingNote] = useState(false) + const [noteText, setNoteText] = useState('') + const noteTextareaRef = useRef(null) + + useEffect(() => { + if (addingNote) noteTextareaRef.current?.focus() + }, [addingNote]) + + function handleSaveNote(): void { + const trimmed = noteText.trim() + if (trimmed) addDocumentNote(trimmed) + setNoteText('') + setAddingNote(false) + } + + function handleNoteKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleSaveNote() + } + if (e.key === 'Escape') { + setNoteText('') + setAddingNote(false) + } + } const archivedAnnotations = activeFilePath ? (annotationsByFile[activeFilePath]?.annotations ?? []).filter(a => a.applied || a.dismissed) @@ -275,6 +334,29 @@ export function FeedbackPanel(): JSX.Element {

No feedback yet.

Run a critique from the toolbar to highlight issues in your text.

+
+ {addingNote ? ( +
+