feat(ui): add selection debug instrumentation (#9397)

Adds debug-gated selection instrumentation and focused tests for issue document annotations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-07-10 21:20:32 -05:00
1 parent 7fd321d622
commit 02e2dd271b
4 files changed
+259 -6

No files matched your search

+36 -4
View File
@@ -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 ? (
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
@@ -578,4 +604,10 @@ export function DocumentAnnotationLayer({
</div>
</>
);
return selectionDebugEnabled ? (
<Profiler id="DocumentAnnotationLayer" onRender={recordAnnotationCommit}>
{content}
</Profiler>
) : content;
}
+15 -2
View File
@@ -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<HTMLElement | null>(null);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? null);
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
@@ -337,7 +344,7 @@ export function IssueDocumentAnnotations({
/>
) : null;
return (
const content = (
<div className="paperclip-doc-annotation-host relative">
<section
ref={(element) => {
@@ -384,6 +391,12 @@ export function IssueDocumentAnnotations({
{panelOpen && isMobile ? annotationPanel : null}
</div>
);
return selectionDebugEnabled ? (
<Profiler id="IssueDocumentAnnotations" onRender={recordAnnotationCommit}>
{content}
</Profiler>
) : content;
}
export interface DocumentAnnotationsCountChipProps {
@@ -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);
});
});
+156
View File
@@ -0,0 +1,156 @@
export const SELECTION_DEBUG_STORAGE_KEY = "paperclipDebugSelection";
type SelectionDebugEvent = {
at: number;
type: string;
details: Record<string, number | string | boolean>;
};
type SelectionDebugState = {
enabledAt: number;
selectionChanges: number;
activeSelectionChanges: number;
captureCount: number;
captureDurationMs: number;
maxCaptureDurationMs: number;
mutationCallbacks: number;
mutationRecords: number;
idleMutationCallbacks: number;
idleMutationRecords: number;
reactCommits: Record<string, number>;
events: SelectionDebugEvent[];
lastSelectionChangeAt: number | null;
reset: () => void;
snapshot: () => Omit<SelectionDebugState, "reset" | "snapshot">;
};
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 });
}