From 02e2dd271b6e3ea31d9610cd612c183746f88083 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:20:32 -0500 Subject: [PATCH] feat(ui): add selection debug instrumentation (#9397) Adds debug-gated selection instrumentation and focused tests for issue document annotations. Co-Authored-By: Paperclip --- ui/src/components/DocumentAnnotationLayer.tsx | 40 ++++- .../components/IssueDocumentAnnotations.tsx | 17 +- ui/src/lib/document-annotation-debug.test.ts | 52 ++++++ ui/src/lib/document-annotation-debug.ts | 156 ++++++++++++++++++ 4 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 ui/src/lib/document-annotation-debug.test.ts create mode 100644 ui/src/lib/document-annotation-debug.ts diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 16134168548..d9418aae7fe 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Profiler, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, MessageSquarePlus } from "lucide-react"; import type { DocumentAnnotationAnchorState, @@ -11,6 +11,14 @@ import { getContainerTextOffset, rangesForNormalizedSpan, } from "@/lib/document-annotation-selection"; +import { + initializeSelectionDebug, + isSelectionDebugEnabled, + recordAnnotationCommit, + recordCaptureSelection, + recordMarkdownMutations, + recordSelectionChange, +} from "@/lib/document-annotation-debug"; import type { DocumentAnnotationAnchorSelector } from "@paperclipai/shared"; export interface AnnotationOverlayThread { @@ -245,6 +253,8 @@ export function DocumentAnnotationLayer({ [reactId], ); const nativeHighlightsSupported = getNativeHighlightApi() !== null; + const selectionDebugEnabled = isSelectionDebugEnabled(); + if (selectionDebugEnabled) initializeSelectionDebug(); const visibleThreads = useMemo(() => { if (!hideResolved) return threads; @@ -365,6 +375,12 @@ export function DocumentAnnotationLayer({ const mutationObserver = typeof window.MutationObserver === "function" && container ? new window.MutationObserver((mutations) => { + if (selectionDebugEnabled) { + const markdownMutations = mutations.filter((mutation) => + Boolean(elementFromNode(mutation.target)?.closest(".paperclip-markdown")), + ); + if (markdownMutations.length > 0) recordMarkdownMutations(markdownMutations.length); + } const onlyLayerMutations = mutations.every((mutation) => { const target = elementFromNode(mutation.target); return !!target?.closest(".paperclip-doc-annotation-layer, .paperclip-doc-annotation-visual-layer"); @@ -392,7 +408,7 @@ export function DocumentAnnotationLayer({ window.removeEventListener("resize", handleResizeOrScroll); window.removeEventListener("scroll", handleResizeOrScroll, true); }; - }, [computeHighlightRects, containerRef]); + }, [computeHighlightRects, containerRef, selectionDebugEnabled]); const captureSelection = useCallback((): PendingAnchor | null => { const container = containerRef.current; @@ -421,7 +437,17 @@ export function DocumentAnnotationLayer({ useEffect(() => { if (typeof document === "undefined") return; const handleSelectionChange = () => { + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const selectionIsActive = Boolean( + selection && !selection.isCollapsed && range && containerRef.current?.contains(range.commonAncestorContainer), + ); + if (selectionDebugEnabled) recordSelectionChange(selectionIsActive); + const captureStartedAt = selectionDebugEnabled ? performance.now() : 0; const anchor = captureSelection(); + if (selectionDebugEnabled) { + recordCaptureSelection(performance.now() - captureStartedAt, Boolean(anchor)); + } if (!anchor) { onPendingAnchorChange(null); setToolbarPosition(null); @@ -431,7 +457,7 @@ export function DocumentAnnotationLayer({ }; document.addEventListener("selectionchange", handleSelectionChange); return () => document.removeEventListener("selectionchange", handleSelectionChange); - }, [captureSelection, onPendingAnchorChange]); + }, [captureSelection, containerRef, onPendingAnchorChange, selectionDebugEnabled]); useEffect(() => { if (captureSelectionRequestId === undefined) return; @@ -449,7 +475,7 @@ export function DocumentAnnotationLayer({ if (pendingAnchor) onRequestComment(pendingAnchor); }; - return ( + const content = ( <> {!nativeHighlightsSupported ? ( ); + + return selectionDebugEnabled ? ( + + {content} + + ) : content; } diff --git a/ui/src/components/IssueDocumentAnnotations.tsx b/ui/src/components/IssueDocumentAnnotations.tsx index cff00f8f550..dcc7bb5d627 100644 --- a/ui/src/components/IssueDocumentAnnotations.tsx +++ b/ui/src/components/IssueDocumentAnnotations.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +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"; @@ -7,6 +7,11 @@ 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 PendingAnchor } from "./DocumentAnnotationLayer"; import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel"; import type { CompanyUserProfile } from "@/lib/company-members"; @@ -67,6 +72,8 @@ export function IssueDocumentAnnotations({ initialComposerAnchor, onInitialComposerAnchorConsumed, }: IssueDocumentAnnotationsProps) { + const selectionDebugEnabled = isSelectionDebugEnabled(); + if (selectionDebugEnabled) initializeSelectionDebug(); const containerRef = useRef(null); const [focusedThreadId, setFocusedThreadId] = useState(defaultFocusedThreadId ?? null); const [focusedCommentId, setFocusedCommentId] = useState(null); @@ -337,7 +344,7 @@ export function IssueDocumentAnnotations({ /> ) : null; - return ( + const content = (
{ @@ -384,6 +391,12 @@ export function IssueDocumentAnnotations({ {panelOpen && isMobile ? annotationPanel : null}
); + + return selectionDebugEnabled ? ( + + {content} + + ) : content; } export interface DocumentAnnotationsCountChipProps { diff --git a/ui/src/lib/document-annotation-debug.test.ts b/ui/src/lib/document-annotation-debug.test.ts new file mode 100644 index 00000000000..56426e8cc55 --- /dev/null +++ b/ui/src/lib/document-annotation-debug.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + SELECTION_DEBUG_STORAGE_KEY, + initializeSelectionDebug, + isSelectionDebugEnabled, + recordAnnotationCommit, + recordCaptureSelection, + recordMarkdownMutations, + recordSelectionChange, +} from "./document-annotation-debug"; + +describe("document annotation selection diagnostics", () => { + afterEach(() => { + window.localStorage.removeItem(SELECTION_DEBUG_STORAGE_KEY); + delete window.__paperclipSelectionDebug; + vi.restoreAllMocks(); + }); + + it("stays disabled until the dev localStorage flag is enabled", () => { + expect(isSelectionDebugEnabled()).toBe(false); + + window.localStorage.setItem(SELECTION_DEBUG_STORAGE_KEY, "true"); + + expect(isSelectionDebugEnabled()).toBe(true); + }); + + it("records selection cadence, capture cost, commits, and idle markdown mutations", () => { + window.localStorage.setItem(SELECTION_DEBUG_STORAGE_KEY, "1"); + vi.spyOn(console, "debug").mockImplementation(() => {}); + const state = initializeSelectionDebug(); + + recordSelectionChange(true); + recordSelectionChange(false); + recordCaptureSelection(12.5, true); + recordAnnotationCommit("DocumentAnnotationLayer", "update", 4.25); + expect(state.lastSelectionChangeAt).not.toBeNull(); + state.lastSelectionChangeAt = performance.now() - 200; + recordMarkdownMutations(3); + + const snapshot = state.snapshot(); + expect(snapshot.selectionChanges).toBe(2); + expect(snapshot.activeSelectionChanges).toBe(1); + expect(snapshot.captureCount).toBe(1); + expect(snapshot.captureDurationMs).toBe(12.5); + expect(snapshot.maxCaptureDurationMs).toBe(12.5); + expect(snapshot.reactCommits.DocumentAnnotationLayer).toBe(1); + expect(snapshot.idleMutationCallbacks).toBe(1); + expect(snapshot.idleMutationRecords).toBe(3); + }); +}); diff --git a/ui/src/lib/document-annotation-debug.ts b/ui/src/lib/document-annotation-debug.ts new file mode 100644 index 00000000000..b9c87b21923 --- /dev/null +++ b/ui/src/lib/document-annotation-debug.ts @@ -0,0 +1,156 @@ +export const SELECTION_DEBUG_STORAGE_KEY = "paperclipDebugSelection"; + +type SelectionDebugEvent = { + at: number; + type: string; + details: Record; +}; + +type SelectionDebugState = { + enabledAt: number; + selectionChanges: number; + activeSelectionChanges: number; + captureCount: number; + captureDurationMs: number; + maxCaptureDurationMs: number; + mutationCallbacks: number; + mutationRecords: number; + idleMutationCallbacks: number; + idleMutationRecords: number; + reactCommits: Record; + events: SelectionDebugEvent[]; + lastSelectionChangeAt: number | null; + reset: () => void; + snapshot: () => Omit; +}; + +declare global { + interface Window { + __paperclipSelectionDebug?: SelectionDebugState; + } +} + +const MAX_DEBUG_EVENTS = 250; +const IDLE_SELECTION_WINDOW_MS = 150; + +function now(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} + +function createState(): SelectionDebugState { + const state: SelectionDebugState = { + enabledAt: now(), + selectionChanges: 0, + activeSelectionChanges: 0, + captureCount: 0, + captureDurationMs: 0, + maxCaptureDurationMs: 0, + mutationCallbacks: 0, + mutationRecords: 0, + idleMutationCallbacks: 0, + idleMutationRecords: 0, + reactCommits: {}, + events: [], + lastSelectionChangeAt: null, + reset: () => { + state.enabledAt = now(); + state.selectionChanges = 0; + state.activeSelectionChanges = 0; + state.captureCount = 0; + state.captureDurationMs = 0; + state.maxCaptureDurationMs = 0; + state.mutationCallbacks = 0; + state.mutationRecords = 0; + state.idleMutationCallbacks = 0; + state.idleMutationRecords = 0; + state.reactCommits = {}; + state.events = []; + state.lastSelectionChangeAt = null; + }, + snapshot: () => ({ + enabledAt: state.enabledAt, + selectionChanges: state.selectionChanges, + activeSelectionChanges: state.activeSelectionChanges, + captureCount: state.captureCount, + captureDurationMs: state.captureDurationMs, + maxCaptureDurationMs: state.maxCaptureDurationMs, + mutationCallbacks: state.mutationCallbacks, + mutationRecords: state.mutationRecords, + idleMutationCallbacks: state.idleMutationCallbacks, + idleMutationRecords: state.idleMutationRecords, + reactCommits: { ...state.reactCommits }, + events: [...state.events], + lastSelectionChangeAt: state.lastSelectionChangeAt, + }), + }; + return state; +} + +export function isSelectionDebugEnabled(): boolean { + if (import.meta.env.MODE === "production" || typeof window === "undefined") return false; + try { + const value = window.localStorage.getItem(SELECTION_DEBUG_STORAGE_KEY); + return value === "1" || value === "true"; + } catch { + return false; + } +} + +export function initializeSelectionDebug(): SelectionDebugState { + if (!window.__paperclipSelectionDebug) { + window.__paperclipSelectionDebug = createState(); + console.info(`[paperclip selection debug] enabled; set localStorage.${SELECTION_DEBUG_STORAGE_KEY} = "0" and reload to disable`); + } + return window.__paperclipSelectionDebug; +} + +function record(type: string, details: SelectionDebugEvent["details"]): void { + const state = initializeSelectionDebug(); + const event = { at: now(), type, details }; + state.events.push(event); + if (state.events.length > MAX_DEBUG_EVENTS) state.events.shift(); + console.debug("[paperclip selection debug]", event); +} + +export function recordSelectionChange(active: boolean): void { + const state = initializeSelectionDebug(); + const timestamp = now(); + const elapsedSincePreviousMs = state.lastSelectionChangeAt === null + ? null + : timestamp - state.lastSelectionChangeAt; + state.selectionChanges += 1; + if (active) { + state.activeSelectionChanges += 1; + } + state.lastSelectionChangeAt = timestamp; + record("selectionchange", { + active, + elapsedSincePreviousMs: elapsedSincePreviousMs ?? -1, + }); +} + +export function recordCaptureSelection(durationMs: number, captured: boolean): void { + const state = initializeSelectionDebug(); + state.captureCount += 1; + state.captureDurationMs += durationMs; + state.maxCaptureDurationMs = Math.max(state.maxCaptureDurationMs, durationMs); + record("captureSelection", { durationMs, captured }); +} + +export function recordAnnotationCommit(id: string, phase: string, actualDuration: number): void { + const state = initializeSelectionDebug(); + state.reactCommits[id] = (state.reactCommits[id] ?? 0) + 1; + record("react-commit", { id, phase, actualDuration }); +} + +export function recordMarkdownMutations(recordCount: number): void { + const state = initializeSelectionDebug(); + const idle = state.lastSelectionChangeAt !== null && now() - state.lastSelectionChangeAt >= IDLE_SELECTION_WINDOW_MS; + state.mutationCallbacks += 1; + state.mutationRecords += recordCount; + if (idle) { + state.idleMutationCallbacks += 1; + state.idleMutationRecords += recordCount; + } + record("markdown-mutation", { recordCount, idle }); +}