import React, { useState, useRef, useEffect, useId } from 'react'; import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types'; import type { MentionSource } from '../utils/mentions'; import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete'; import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete'; import { isCurrentUser } from '../utils/identity'; import { ImageThumbnail } from './ImageThumbnail'; import { EditorAnnotationCard } from './EditorAnnotationCard'; import { useIsMobile } from '../hooks/useIsMobile'; import { OverlayScrollArea } from './OverlayScrollArea'; import { Button } from './ui/button'; import { cn } from '../lib/utils'; import { resolveReplyParents, resolveThreadRootTimestamps } from '@plannotator/core/annotation-threads'; import { ROOT_DOCUMENT_GROUP_KEY } from '../utils/annotationScope'; import type { AnnotationScope, AnnotationDocumentGroup } from '../utils/annotationScope'; import type { QuestionPanelRow } from '../utils/questionAnswers'; import { QuestionsPanelSection } from './QuestionsPanelSection'; import { HostTitle } from './HostTitle'; // Card type-word colors. Deletion uses `destructive` (reliably red on every // theme, matching the in-document .deletion highlight). Comment uses the // `annotation-comment` token, which defaults to each theme's --accent (so it // stays consistent with the .comment highlight) but is overridden to a legible // blue in neutral themes whose accent is a low-contrast gray (e.g. "simple"). // Global has no in-document highlight, so it uses a fixed legible purple. const TYPE_COLOR: Record = { [AnnotationType.DELETION]: 'text-destructive', [AnnotationType.COMMENT]: 'text-annotation-comment', [AnnotationType.GLOBAL_COMMENT]: 'text-purple-500', }; const TYPE_LABEL: Record = { [AnnotationType.DELETION]: 'Deletion', [AnnotationType.COMMENT]: 'Comment', [AnnotationType.GLOBAL_COMMENT]: 'Global', }; const PencilIcon = () => ( ); const TrashCardIcon = () => ( ); /** * Order annotations so every reply follows its parent (replies among * themselves stay in creation order). The threading rule is the shared one * (resolveReplyParents, also what the export applies): a reply whose parent * is absent, a self-reference, and every member of an `inReplyTo` cycle * render as top-level cards in input order, so nothing is ever dropped. * Without any `inReplyTo` the input order is returned unchanged, so * annotations without replies render exactly as before. */ export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> { if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false })); const parents = resolveReplyParents(sorted); const byParent = new Map(); for (const a of sorted) { const parent = parents.get(a.id); if (!parent) continue; const list = byParent.get(parent) ?? []; list.push(a); byParent.set(parent, list); } // Depth-first, iteratively: a 5,000-deep chain must not recurse 5,000 // frames deep. The stack holds each node's replies in reverse so they pop // in creation order. const out: Array<{ annotation: Annotation; isReply: boolean }> = []; const stack: Array<{ annotation: Annotation; isReply: boolean }> = []; const pushReplies = (a: Annotation) => { const replies = byParent.get(a.id); if (!replies) return; for (let i = replies.length - 1; i >= 0; i--) stack.push({ annotation: replies[i], isReply: true }); }; for (const a of sorted) { if (parents.get(a.id)) continue; out.push({ annotation: a, isReply: false }); pushReplies(a); while (stack.length > 0) { const next = stack.pop()!; out.push(next); pushReplies(next.annotation); } } return out; } interface DirectEditsPanelItem { id: string; title?: string; label?: string; added: number; removed: number; diffText: string; description?: string; onDiscard?: () => void; } interface PanelProps { /** The second line of the empty state. Default "Select text to annotate"; a host whose document is an image or code says how to comment there. */ emptyHint?: string; isOpen: boolean; annotations: Annotation[]; blocks: Block[]; onSelect: (id: string) => void; 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 | string; editorAnnotations?: EditorAnnotation[]; onDeleteEditorAnnotation?: (id: string) => void; onClose?: () => void; /** Copy the full feedback payload. May resolve a success boolean; resolving * `false` suppresses the "Copied" flash. A void resolution (existing hosts) * is treated as success, preserving the original behavior. */ onQuickCopy?: () => Promise; onShare?: () => void; otherFileAnnotations?: { count: number; files: number }; onOtherFileAnnotationsClick?: () => void; /** Committed direct edits to one or more documents. Rendered as pinned cards * above the annotation timeline with expandable unified diffs. */ directEdits?: DirectEditsPanelItem[] | null; /** Host slot rendered at the foot of each plan-annotation card (e.g. reply/ * resolve UI). The panel stays presentation-only; clicks inside the slot * do not select the card. Default: nothing rendered. */ renderCardFooter?: (annotation: Annotation) => React.ReactNode; /** Host slot rendered inside the header row of each plan-annotation card, * after `author · time` and before the built-in edit/delete actions — the * place for a status stamp (resolved, needs reply, a reviewer badge). Twin * of `renderCardFooter`: the panel stays presentation-only, clicks inside * the slot do not select the card, and it renders under `readOnly` too * (a stamp is a read affordance). Default: nothing rendered, and no * wrapper element exists at all. */ renderCardHeader?: (annotation: Annotation) => React.ReactNode; /** Opt-in host capability: an `@` mention source for the EDIT box of each * plan-annotation card, the same `MentionSource` `Viewer` and `HtmlViewer` * take for their creation composers. Supplied → the edit textarea grows * the `@` picker and a save that follows a pick carries the surviving ids * as `Annotation.mentions`. Absent → the edit box is exactly what it was: * no listener, no picker, no `mentions` key. */ mentionSource?: MentionSource; /** Hide every built-in mutation affordance (delete/edit, direct-edit * discard). The host footer slot still renders: its contents are * host-owned and may be read affordances (replies, links), so the host * gates what belongs in it. Selection and scrolling still work. * Default false — today's behavior. */ readOnly?: boolean; /** Embed only the timeline body in a host-owned stage. The host owns the * title, close control, visible-viewport geometry, and focus boundary. */ presentation?: 'panel' | 'embedded'; /** Ids of annotations with no live location in the document (e.g. the * HTML viewer's onUnanchoredChange report after a refresh). Matching * cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */ unanchoredIds?: ReadonlySet; /** * Multi-document sessions (annotate folder sessions, and any session whose * linked documents carry feedback): which documents the timeline shows. * Supplying this together with `onAnnotationScopeChange` turns on the * `This file | All files` toggle and the grouped cross-file view. Omitting * them — as every host that has not opted in does — leaves the panel exactly * as it was, including the legacy `+N in M other files` affordance. */ annotationScope?: AnnotationScope; onAnnotationScopeChange?: (scope: AnnotationScope) => void; /** One group per document carrying feedback, open document first (see * `groupAnnotationsByDocument`). Only read while the scope is `all`. */ documentGroups?: readonly AnnotationDocumentGroup[]; /** Select/jump to an annotation by its owning document. A card in another * document is the host's cue to navigate there first. */ onSelectInDocument?: (path: string, id: string) => void; /** Delete an annotation from its owning document's store. */ onDeleteInDocument?: (path: string, id: string) => void; /** Edit an annotation in its owning document's store. */ onEditInDocument?: (path: string, id: string, updates: Partial) => void; /** * `:::question` rows for the open document (`buildQuestionPanelRows`). * Supplied, a Questions section renders above a Comments section and the * answer annotations (rows carrying `questionAnswer`) leave the comment * timeline; a row whose question is gone offers Remove through `onDelete`. * Absent, the panel is unchanged and answers list as comment cards. */ questionRows?: readonly QuestionPanelRow[]; /** A Questions row was clicked (the host scrolls to the card). */ onSelectQuestion?: (row: QuestionPanelRow) => void; /** * The order of the comment timeline (and of each document group), for a * host whose comments have an order of their own (the Inbox's video * comments, by the moment they are at). Ties keep creation order. Absent: * creation order, exactly as before. */ compareAnnotations?: (a: Annotation, b: Annotation) => number; /** * The panel's title (default "Annotations") and a quiet note at the right * of its header (the Inbox's "By time" for a video's comments). Absent: * the header is exactly as before. */ title?: string; titleNote?: string; /** * Which built-in parts a plan-annotation card draws: its quoted text * (`quote`) and its attached images under the comment (`images`). A host * that draws them itself (the Inbox's video comments show the moment and * the frame in `renderCardHeader`) turns them off per card. Absent: both * drawn, exactly as before. */ cardParts?: (annotation: Annotation) => { quote?: boolean; images?: boolean }; } export const AnnotationPanel: React.FC = ({ emptyHint = 'Select text to annotate', isOpen, annotations, onSelect, onDelete, onEdit, selectedId, codeAnnotations = [], onSelectCodeAnnotation, onDeleteCodeAnnotation, onEditCodeAnnotation, sharingEnabled = true, width, editorAnnotations, onDeleteEditorAnnotation, onClose, onQuickCopy, onShare, otherFileAnnotations, onOtherFileAnnotationsClick, directEdits = null, renderCardFooter, renderCardHeader, mentionSource, readOnly = false, presentation = 'panel', unanchoredIds, annotationScope, onAnnotationScopeChange, documentGroups, onSelectInDocument, onDeleteInDocument, onEditInDocument, questionRows, onSelectQuestion, compareAnnotations, title = 'Annotations', titleNote, cardParts, }) => { const isMobile = useIsMobile(); const embedded = presentation === 'embedded'; const mobilePanel = isMobile && !embedded; const [copiedText, setCopiedText] = useState(false); const listRef = useRef(null); // With a Questions section, answers are listed there, not as comments. const showQuestions = questionRows !== undefined && questionRows.length > 0; const timelineAnnotations = questionRows !== undefined ? annotations.filter((a) => a.questionAnswer == null) : annotations; const byCreation = (a: Annotation, b: Annotation) => a.createdA - b.createdA; const byHost = compareAnnotations ? (a: Annotation, b: Annotation) => compareAnnotations(a, b) || byCreation(a, b) : byCreation; const sortedAnnotations = [...timelineAnnotations].sort(byHost); const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt); // Replies (`inReplyTo`) thread under their parent: each reply is lifted to // sit right after its parent (and the parent's earlier replies) at the // parent's timeline position. With no replies the order is untouched. const threadedAnnotations = threadReplies(sortedAnnotations); // Thread timestamps are resolved once per render, linearly (shared helper), // and the comparator only reads the map: resolving each chain inside the // comparator with a linear parent lookup was O(n^2 log n) and froze the // tab on a few thousand threaded comments. const threadRootTs = resolveThreadRootTimestamps(sortedAnnotations); const planEntries = threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, threadTs: threadRootTs.get(annotation.id) ?? annotation.createdA, annotation, isReply })); const codeEntries = sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, threadTs: annotation.createdAt, annotation, isReply: false })); // A host order keeps the plan comments as the host sorted them (code comments after). const timelineEntries = compareAnnotations ? [...planEntries, ...codeEntries] : [...planEntries, ...codeEntries].sort((a, b) => { if (a.threadTs !== b.threadTs) return a.threadTs - b.threadTs; return a.ts - b.ts; }); const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0); const timelineCount = timelineAnnotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0); // --- Cross-file scope (opt-in: a host that passes neither prop is unchanged) --- const scopeEnabled = annotationScope !== undefined && onAnnotationScopeChange !== undefined; const scope: AnnotationScope = scopeEnabled ? annotationScope! : 'current'; const groups = documentGroups ?? []; const otherGroups = groups.filter((group) => !group.isCurrent); const otherGroupCount = otherGroups.reduce((n, group) => n + group.annotations.length, 0); const groupedTotal = groups.reduce((n, group) => n + group.annotations.length, 0); const showGroups = scope === 'all' && groups.length > 0; const headerCount = showGroups ? groupedTotal + codeAnnotations.length + (editorAnnotations?.length ?? 0) : totalCount; const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); const toggleGroup = (path: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; const scopeToggle = scopeEnabled ? (
{(['current', 'all'] as const).map((value) => ( ))}
) : null; // Scroll selected annotation card into view useEffect(() => { if (!selectedId || !listRef.current) return; const card = listRef.current.querySelector(`[data-annotation-id="${selectedId}"]`); if (card) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [selectedId]); if (!isOpen) return null; const panel = ( ); if (mobilePanel) { return ( <>
{panel} ); } return panel; }; function formatTimestamp(ts: number): string { const now = Date.now(); const diff = now - ts; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 60) return 'now'; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; if (days < 7) return `${days}d`; // An older date names its year: "Oct 20" alone reads as this October. const date = new Date(ts); const thisYear = date.getFullYear() === new Date(now).getFullYear(); return date.toLocaleDateString( undefined, thisYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' }, ); } /** * A card's "author · time" line. The author truncates (its full name in the * title) while the time never shrinks, so a long name cannot push the time * out; `` keeps a right-to-left name from reordering the time around it. * The text reads exactly as before: "Alice (me) · 5m". */ const CardByline: React.FC<{ author?: string; isMe: boolean; timestamp: number }> = ({ author, isMe, timestamp }) => ( {author ? ( <> {author} {isMe ? (me) : null} · ) : null} {formatTimestamp(timestamp)} ); /** Pinned card for committed direct edits: +N/−M summary, expandable unified * diff, and a two-step discard. Not part of the annotation timeline — edits * are document state, not a selection-anchored note. */ const DirectEditsCard: React.FC<{ title?: string; label?: string; added: number; removed: number; diffText: string; description?: string; onDiscard?: () => void; }> = ({ title = 'Edits', label, added, removed, diffText, description, onDiscard }) => { const [expanded, setExpanded] = useState(false); const [confirmDiscard, setConfirmDiscard] = useState(false); // Auto-cancel the discard confirmation after a beat. useEffect(() => { if (!confirmDiscard) return; const t = setTimeout(() => setConfirmDiscard(false), 3000); return () => clearTimeout(t); }, [confirmDiscard]); // Show from the first hunk header; the ---/+++ preamble is noise here. const diffLines = React.useMemo(() => { const lines = diffText.split('\n'); const fileSeparators = lines.filter((l) => l.startsWith('===')).length; if (fileSeparators > 1) return lines; const firstHunk = lines.findIndex((l) => l.startsWith('@@')); return firstHunk === -1 ? lines : lines.slice(firstHunk); }, [diffText]); return (
{title} {label && ( {label} )} +{added} / -{removed}
{onDiscard && ( )}

{description ?? 'Your text changes — sent with the feedback as a diff.'}

{expanded && (
          {diffLines.map((line, i) => (
            
{line.length === 0 ? ' ' : line}
))}
)}
); }; /** * The plan-annotation card's edit box. * * Mounted only while a card is in edit mode, which is also what scopes an * `@` pick to ONE edit session: `useMentionAutocomplete`'s tagged-people * state lives and dies with this component, so reopening the editor starts * with nobody picked and a pick-less save is `{ text }` again. * * Without a `mentionSource` every mention path here is inert — no listener, * no menu state, no picker DOM, and the textarea's attributes are exactly * the ones it rendered before the prop existed (all five mention-related * ARIA attributes resolve to `undefined`). * * KNOWN DIFFERENCE from `CommentPopover`: no chip overlay. The token stays * plain text here. Chips live in `ComposerTextarea` (mirrored overlay behind * a transparent-text textarea) and duplicating that layer is not the way to * get them — the follow-up is to make this box use `ComposerTextarea`. */ const AnnotationEditComposer: React.FC<{ value: string; onChange: (text: string) => void; /** Save with the mention ids the body still tags (empty without a source). */ onSave: (mentions: readonly string[]) => void; onCancel: () => void; mentionSource?: MentionSource; }> = ({ value, onChange, onSave, onCancel, mentionSource }) => { const textareaRef = useRef(null); const mention = useMentionAutocomplete({ text: value, setText: onChange, textareaRef, source: mentionSource }); const listboxId = `annotation-card-mentions-${useId().replace(/:/g, '')}`; const menuOpen = mention.menu !== null; // Focus-on-open, unchanged: this component mounts exactly when editing starts. useEffect(() => { if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.select(); } }, []); const handleKeyDown = (e: React.KeyboardEvent) => { // The menu is offered the key FIRST: while it is open Escape closes it // (and only a second Escape cancels the edit), and an arrowed-to row // takes Enter. Mod+Enter is never consumed by the menu. if (mention.onKeyDown(e)) return; if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { e.preventDefault(); onSave(mention.mentionIds); } else if (e.key === 'Escape') { e.preventDefault(); onCancel(); } }; return (
e.stopPropagation()}>