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 ? (
@@ -578,4 +604,10 @@ export function DocumentAnnotationLayer({
>
);
+
+ 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 });
+}