mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
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:
1 parent
7fd321d622
commit
02e2dd271b
4 files changed
+259
-6
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user