import { Profiler, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared"; import { MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations"; import { queryKeys } from "@/lib/queryKeys"; import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash"; import { initializeSelectionDebug, isSelectionDebugEnabled, recordAnnotationCommit, } from "@/lib/document-annotation-debug"; import { DocumentAnnotationLayer, type AnnotationAnchorRect, type PendingAnchor } from "./DocumentAnnotationLayer"; import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel"; import { DocumentAnnotationPopover } from "./DocumentAnnotationPopover"; import type { CompanyUserProfile } from "@/lib/company-members"; // Width of the right-hand comment gutter on desktop (lg+). The gutter is an // in-flow flex column beside the document, so it scrolls with the doc instead // of floating over the viewport (PAP-504). const DESKTOP_ANNOTATION_PANEL_WIDTH = 360; type AnnotationDocument = Pick; export interface IssueDocumentAnnotationsProps { issueId: string; doc: AnnotationDocument; target?: DocumentAnnotationTarget; /** The body that is being rendered/edited (current or historical revision). */ bodyMarkdown: string; /** True when a draft has unsaved changes or is currently saving. */ draftDirty: boolean; /** True when there is a remote conflict that requires user resolution. */ draftConflicted: boolean; /** True when the document is being viewed in historical revision preview. */ historicalPreview: boolean; /** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */ children: ReactNode; /** Current location hash so we can resolve deep-link targets. */ locationHash: string; /** Controlled panel state. Caller owns this so the count chip can live in the doc header. */ panelOpen: boolean; onPanelOpenChange: (open: boolean) => void; /** Keep the panel in document flow for narrow hosts such as the task properties pane. */ panelPlacement?: "floating" | "inline" | "popover"; agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; /** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */ defaultFocusedThreadId?: string; /** * Seed the composer with a pending anchor and open the panel once. Used when * a host captures a selection before the annotated document wrapper exists. */ initialComposerAnchor?: PendingAnchor | null; onInitialComposerAnchorConsumed?: () => void; } export function IssueDocumentAnnotations({ issueId, doc, target, bodyMarkdown, draftDirty, draftConflicted, historicalPreview, children, locationHash, panelOpen, onPanelOpenChange, panelPlacement = "floating", agentMap, userProfileMap, defaultFocusedThreadId, initialComposerAnchor, onInitialComposerAnchorConsumed, }: IssueDocumentAnnotationsProps) { const selectionDebugEnabled = isSelectionDebugEnabled(); if (selectionDebugEnabled) initializeSelectionDebug(); const containerRef = useRef(null); const [focusedThreadId, setFocusedThreadId] = useState(defaultFocusedThreadId ?? null); const [focusedCommentId, setFocusedCommentId] = useState(null); const [selectionAnchor, setSelectionAnchor] = useState(null); const [composerAnchor, setComposerAnchor] = useState(null); const [popoverAnchorRect, setPopoverAnchorRect] = useState(null); const [isMobile, setIsMobile] = useState(false); const hashHandledRef = useRef(null); // Bus token to ask the body layer to capture the current selection into a pendingAnchor. const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0); const consumedInitialAnchorRef = useRef(null); useEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; const mediaQuery = window.matchMedia("(max-width: 1023px)"); const handler = () => setIsMobile(mediaQuery.matches); handler(); if (typeof mediaQuery.addEventListener === "function") { mediaQuery.addEventListener("change", handler); return () => mediaQuery.removeEventListener("change", handler); } return undefined; }, []); const annotationsQuery = useQuery({ queryKey: target?.kind === "routine" ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") : target?.kind === "case" ? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all") : queryKeys.issues.documentAnnotations(issueId, doc.key, "all"), queryFn: () => target ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) : documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }), staleTime: 30_000, }); const allThreads = annotationsQuery.data ?? []; // Resolve deep link `#document-&thread=...&comment=...` once per change. useEffect(() => { if (!locationHash) return; if (hashHandledRef.current === locationHash) return; const target = parseDocumentAnnotationHash(locationHash); if (!target || target.documentKey !== doc.key) return; if (!target.threadId) return; hashHandledRef.current = locationHash; onPanelOpenChange(true); setFocusedThreadId(target.threadId); setFocusedCommentId(target.commentId); }, [doc.key, locationHash, onPanelOpenChange]); const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId; const newCommentDisabledReason = historicalPreview ? "New comments are disabled while previewing a historical revision." : draftConflicted ? "Resolve the document conflict before adding new comments." : draftDirty ? "Save the draft to anchor new comments." : !doc.latestRevisionId ? "Document has no saved revision yet." : null; const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => { setSelectionAnchor(anchor); if (anchor && panelPlacement === "popover") { setComposerAnchor(null); setFocusedThreadId(null); setPopoverAnchorRect(null); onPanelOpenChange(false); } }, [onPanelOpenChange, panelPlacement]); const handleClearComposerAnchor = useCallback(() => { setSelectionAnchor(null); setComposerAnchor(null); setPopoverAnchorRect(null); }, []); const handleRequestComment = useCallback((anchor: PendingAnchor, rect?: AnnotationAnchorRect) => { if (newCommentDisabled) return; setSelectionAnchor(null); setComposerAnchor(anchor); setFocusedThreadId(null); if (rect) setPopoverAnchorRect(rect); onPanelOpenChange(true); }, [newCommentDisabled, onPanelOpenChange]); useEffect(() => { if (!initialComposerAnchor) return; if (consumedInitialAnchorRef.current === initialComposerAnchor) return; if (newCommentDisabled) return; consumedInitialAnchorRef.current = initialComposerAnchor; setComposerAnchor(initialComposerAnchor); onPanelOpenChange(true); onInitialComposerAnchorConsumed?.(); }, [initialComposerAnchor, newCommentDisabled, onInitialComposerAnchorConsumed, onPanelOpenChange]); const handleThreadFocus = useCallback((threadId: string | null, rect?: AnnotationAnchorRect) => { setFocusedThreadId(threadId); if (threadId) { setComposerAnchor(null); if (rect) setPopoverAnchorRect(rect); onPanelOpenChange(true); setFocusedCommentId(null); } }, [onPanelOpenChange]); const handleAnchorRectChange = useCallback((rect: AnnotationAnchorRect | null) => { setPopoverAnchorRect((current) => isSameAnchorRect(current, rect) ? current : rect); }, []); const handleRequestCommentFromSelection = useCallback(() => { if (newCommentDisabled) return; if (selectionAnchor) { handleRequestComment(selectionAnchor); return; } // Trigger the layer to re-read the current selection and emit a pendingAnchor. setCaptureSelectionRequestId((current) => current + 1); }, [handleRequestComment, newCommentDisabled, selectionAnchor]); // ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open. useEffect(() => { if (!panelOpen) return; if (typeof window === "undefined") return; const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; const isMeta = event.metaKey || event.ctrlKey; if (!isMeta || !event.shiftKey) return; if (event.key.toLowerCase() !== "m") return; event.preventDefault(); handleRequestCommentFromSelection(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [panelOpen, handleRequestCommentFromSelection]); const focusedThread = useMemo(() => { if (!focusedThreadId) return null; return allThreads.find((thread) => thread.id === focusedThreadId) ?? null; }, [allThreads, focusedThreadId]); const overlayThreads = useMemo( () => allThreads.map((thread) => ({ id: thread.id, selectedText: thread.selectedText, status: thread.status, anchorState: thread.anchorState, })), [allThreads], ); const isInlinePlacement = panelPlacement === "inline"; const isPopoverPlacement = panelPlacement === "popover"; const showPopover = panelOpen && isPopoverPlacement && !isMobile && Boolean(popoverAnchorRect && (composerAnchor || focusedThread)); // On desktop (lg+) the panel docks into an in-flow gutter column beside the // document so it scrolls with the doc rather than floating over the viewport. const showDesktopGutter = panelOpen && !isInlinePlacement && !isPopoverPlacement && !isMobile; const annotationPanel = panelOpen ? ( { onPanelOpenChange(open); if (!open) { setSelectionAnchor(null); setComposerAnchor(null); setFocusedThreadId(null); setFocusedCommentId(null); } }} issueId={issueId} target={target} documentKey={doc.key} documentRevisionNumber={doc.latestRevisionNumber} baseRevisionId={doc.latestRevisionId} baseRevisionNumber={doc.latestRevisionNumber} threads={allThreads as DocumentAnnotationThreadWithComments[]} focusedThreadId={focusedThreadId} focusedCommentId={focusedCommentId} onFocusThread={(id) => { setFocusedThreadId(id); if (!id) setFocusedCommentId(null); }} pendingAnchor={composerAnchor} onClearPendingAnchor={handleClearComposerAnchor} onRequestCommentFromSelection={handleRequestCommentFromSelection} newCommentDisabled={newCommentDisabled} newCommentDisabledReason={newCommentDisabledReason} isMobile={isMobile} inline={isInlinePlacement || isPopoverPlacement} desktopWidth={showDesktopGutter ? DESKTOP_ANNOTATION_PANEL_WIDTH : undefined} agentMap={agentMap} userProfileMap={userProfileMap} /> ) : null; const content = (
{ containerRef.current = element; }} className={cn("relative min-w-0", showDesktopGutter && "lg:flex-1")} data-testid={`document-annotation-body-${doc.key}`} >
{children}
{!historicalPreview && doc.latestRevisionId ? ( ) : null} {showPopover && popoverAnchorRect ? ( { setComposerAnchor(null); setFocusedThreadId(null); setFocusedCommentId(null); setPopoverAnchorRect(null); onPanelOpenChange(false); }} onThreadCreated={() => { setComposerAnchor(null); setPopoverAnchorRect(null); onPanelOpenChange(false); }} newCommentDisabled={newCommentDisabled} agentMap={agentMap} userProfileMap={userProfileMap} /> ) : null}
{panelOpen && (isInlinePlacement || (isPopoverPlacement && !showPopover)) && !isMobile ? (
{annotationPanel}
) : null} {showDesktopGutter ? (
{/* Sticky within the gutter: stays beside the highlighted range while the document is on screen, then scrolls away with the doc. */}
{annotationPanel}
) : null} {panelOpen && isMobile ? annotationPanel : null}
); return selectionDebugEnabled ? ( {content} ) : content; } function isSameAnchorRect(a: AnnotationAnchorRect | null, b: AnnotationAnchorRect | null): boolean { return a === b || Boolean(a && b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height); } export interface DocumentAnnotationsCountChipProps { issueId: string; docKey: string; target?: DocumentAnnotationTarget; panelOpen: boolean; onToggle: () => void; } /** * Renders the unresolved-count chip for a document. Lives in the document header row * (next to `rev N ▾`) so it stays visible when the document is folded. */ export function DocumentAnnotationsCountChip({ issueId, docKey, target, panelOpen, onToggle, }: DocumentAnnotationsCountChipProps) { const annotationsQuery = useQuery({ queryKey: target?.kind === "routine" ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") : target?.kind === "case" ? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all") : queryKeys.issues.documentAnnotations(issueId, docKey, "all"), queryFn: () => target ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) : documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }), staleTime: 30_000, }); const threads = annotationsQuery.data ?? []; const openCount = useMemo( () => threads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length, [threads], ); return ( ); }