import { useState, useEffect, useRef } from 'react' import { marked } from 'marked' import DOMPurify from 'dompurify' import { useEditorStore } from '../../store/editorStore' import type { TextAnnotation } from '../../types/editor' import { tooltipAnalysisCache, cancelPendingDismiss, analyseAnnotation, scrollToAnnotation, applyAnnotation } from '../Editor/MarkdownEditor' import './FeedbackPanel.css' type AnalysisState = | { status: 'idle' } | { status: 'streaming'; text: string } | { status: 'done'; text: string; suggestion: string | null } 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)' } } function renderMarkdown(text: string, streaming: boolean): string { const raw = marked.parse(streaming ? text + ' ▋' : text) as string return DOMPurify.sanitize(raw) } interface FeedbackCardProps { ann: TextAnnotation autoAnalyse: boolean onDismiss: () => void } function FeedbackCard({ ann, autoAnalyse, onDismiss }: FeedbackCardProps): JSX.Element { const [state, setState] = useState(() => { const cached = tooltipAnalysisCache.get(ann.id) ?? ann.analysisCache ?? null if (cached) return { status: 'done', text: cached.text, suggestion: cached.suggestion } // Custom (attachment-driven) annotations already carry their analysis — show // the problem description and suggestion immediately without an extra AI call. // Context-menu annotations (autoAnalyse: true) need AI analysis, so start idle. if (ann.type === 'custom' && !ann.autoAnalyse) { return { status: 'done', text: ann.message, suggestion: ann.suggestion ?? null } } return { status: 'idle' } }) const cleanupRef = useRef<(() => void) | null>(null) function startAnalysis(): void { // Prevent double-start if (state.status === 'streaming') return cleanupRef.current?.() setState({ status: 'streaming', text: '' }) cleanupRef.current = analyseAnnotation(ann, (text, streaming, suggestion) => { if (streaming) { setState({ status: 'streaming', text }) } else { setState({ status: 'done', text, suggestion }) } }) } // Trigger analysis when parent requests "Analyse all" useEffect(() => { if (autoAnalyse && state.status === 'idle') { startAnalysis() } }, [autoAnalyse]) // eslint-disable-line react-hooks/exhaustive-deps // Cleanup on unmount useEffect(() => { return () => { cleanupRef.current?.() } }, []) // When a hover tooltip completes analysis for this annotation, hydrate the card // from the cache so the sidebar reflects the result without requiring a manual click. useEffect(() => { const handler = (e: Event): void => { const { id } = (e as CustomEvent<{ id: string }>).detail if (id !== ann.id) return const cached = tooltipAnalysisCache.get(ann.id) if (!cached) return setState(prev => prev.status === 'idle' ? { status: 'done', text: cached.text, suggestion: cached.suggestion } : prev ) } window.addEventListener('annotation-cached', handler) return () => window.removeEventListener('annotation-cached', handler) }, [ann.id]) const typeName = ann.type.replace(/_/g, ' ') const isSpinning = state.status === 'streaming' && state.text === '' const hasText = (state.status === 'streaming' || state.status === 'done') && state.text !== '' const suggestion = state.status === 'done' ? state.suggestion : null return (
{/* Header — click to jump to passage in editor */}
scrollToAnnotation(ann)} title="Jump to passage">
{typeName} {ann.type === 'custom' && !ann.autoAnalyse && ( from attachment )}
"{ann.matchedText}"
{/* Idle: show Analyse button */} {state.status === 'idle' && ( )} {/* Streaming with no text yet: show bouncing dots */} {isSpinning && (
)} {/* Streaming or done with text: show markdown body */} {hasText && (
)} {/* Done with a suggestion: show Apply button */} {suggestion != null && ( )}
) } interface UserCommentCardProps { ann: TextAnnotation onDismiss: () => void } function UserCommentCard({ ann, onDismiss }: UserCommentCardProps): JSX.Element { return (
scrollToAnnotation(ann)} title="Jump to passage">
Your comment
"{ann.matchedText}"
{ann.comment}
) } interface ArchiveCardProps { ann: TextAnnotation onRemove?: () => void } function ArchiveCard({ ann, onRemove }: ArchiveCardProps): JSX.Element { const typeName = ann.type.replace(/_/g, ' ') const status = ann.applied ? 'applied' : 'dismissed' return (
{typeName} {status}
{onRemove && ( )}
"{ann.matchedText}" {ann.comment && ( {ann.comment} )} {ann.suggestion && ( → {ann.suggestion} )}
) } export function FeedbackPanel(): JSX.Element { const { annotations, annotationsByFile, activeFilePath, clearAnnotations, removeAnnotation, clearArchivedAnnotations, removeArchivedAnnotation, } = useEditorStore() const [analyseAll, setAnalyseAll] = useState(false) const archivedAnnotations = activeFilePath ? (annotationsByFile[activeFilePath]?.annotations ?? []).filter(a => a.applied || a.dismissed) : [] const dismissedCount = archivedAnnotations.filter(a => a.dismissed).length // Reset "Analyse all" whenever the annotation set changes (new critique run), // so auto-analysis doesn't carry over to fresh results unexpectedly. const prevAnnotationsRef = useRef(annotations) useEffect(() => { if (prevAnnotationsRef.current !== annotations) { setAnalyseAll(false) prevAnnotationsRef.current = annotations } }, [annotations]) function handleClearAll(): void { clearAnnotations() tooltipAnalysisCache.clear() } const hasActive = annotations.length > 0 const hasArchive = archivedAnnotations.length > 0 if (!hasActive && !hasArchive) { return (

No feedback yet.

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

) } return (
{hasActive && ( <>
{[...annotations].sort((a, b) => a.from - b.from).map(ann => ann.type === 'user_comment' ? { cancelPendingDismiss(ann.id); removeAnnotation(ann.id) }} /> : { cancelPendingDismiss(ann.id); removeAnnotation(ann.id); tooltipAnalysisCache.delete(ann.id) }} /> )}
)} {hasArchive && (
Archive {archivedAnnotations.length} {dismissedCount > 0 && ( )}
{archivedAnnotations.map(ann => ( removeArchivedAnnotation(ann.id) : undefined} /> ))}
)}
) }