diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index c92684b2659..97fd3edefe2 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -26,7 +26,7 @@ import { cn, relativeTime } from "@/lib/utils"; import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; -import { hasActiveNativeSelection, isCoarsePointerDevice } from "@/lib/document-annotation-selection"; +import { isCoarsePointerDevice } from "@/lib/document-annotation-selection"; import { AgentIcon } from "./AgentIconPicker"; import { deriveInitials } from "./Identity"; import { MarkdownBody } from "./MarkdownBody"; @@ -312,7 +312,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { useEffect(() => { if (!props.pendingAnchor || !props.open || props.isMobile) return; - if (isCoarsePointerDevice() || hasActiveNativeSelection()) return; + if (isCoarsePointerDevice()) return; composerRef.current?.focus(); }, [props.isMobile, props.open, props.pendingAnchor]); diff --git a/ui/src/components/IssueDocumentAnnotations.test.tsx b/ui/src/components/IssueDocumentAnnotations.test.tsx index 4998e87badb..176899f7d18 100644 --- a/ui/src/components/IssueDocumentAnnotations.test.tsx +++ b/ui/src/components/IssueDocumentAnnotations.test.tsx @@ -1058,4 +1058,59 @@ describe("IssueDocumentAnnotations", () => { }); } }); + + it("auto-focuses the composer for desktop selection requests", async () => { + const originalMatchMedia = window.matchMedia; + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => {}); + mockAnnotationsApi.list.mockResolvedValue([]); + const root = createRoot(container); + const queryClient = makeQueryClient(); + const doc = makeDoc(); + + try { + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const body = container.querySelector("p"); + const range = document.createRange(); + range.selectNodeContents(body!); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + focusSpy.mockClear(); + + const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]'); + await act(async () => selectButton?.click()); + await flush(); + + expect(container.querySelector('[data-testid="document-annotation-composer"]')).not.toBeNull(); + expect(focusSpy).toHaveBeenCalled(); + } finally { + window.getSelection()?.removeAllRanges(); + focusSpy.mockRestore(); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: originalMatchMedia, + }); + } + }); }); diff --git a/ui/src/lib/document-annotation-selection.test.ts b/ui/src/lib/document-annotation-selection.test.ts index 6dcbc78a9a1..b144bdc6fff 100644 --- a/ui/src/lib/document-annotation-selection.test.ts +++ b/ui/src/lib/document-annotation-selection.test.ts @@ -4,6 +4,7 @@ import { verifyDocumentAnchorSelector } from "@paperclipai/shared"; import { buildAnchorFromContainerSelection, getContainerTextOffset, + isCoarsePointerDevice, rangesForNormalizedSpan, } from "./document-annotation-selection"; @@ -55,6 +56,34 @@ function selectText(container: HTMLElement, needle: string): Range { throw new Error(`Could not find "${needle}" in container`); } +describe("isCoarsePointerDevice", () => { + it("uses the primary pointer media query on hybrid touch laptops", () => { + const targetWindow = { + navigator: { maxTouchPoints: 5 }, + matchMedia: (query: string) => ({ matches: query === "(pointer: fine)" }), + } as unknown as Window; + + expect(isCoarsePointerDevice(targetWindow)).toBe(false); + }); + + it("detects coarse primary pointers when media queries are available", () => { + const targetWindow = { + navigator: { maxTouchPoints: 0 }, + matchMedia: (query: string) => ({ matches: query === "(pointer: coarse)" }), + } as unknown as Window; + + expect(isCoarsePointerDevice(targetWindow)).toBe(true); + }); + + it("falls back to legacy touch points without matchMedia", () => { + const targetWindow = { + navigator: { maxTouchPoints: 1 }, + } as unknown as Window; + + expect(isCoarsePointerDevice(targetWindow)).toBe(true); + }); +}); + describe("buildAnchorFromContainerSelection", () => { it("produces a selector that verifies against the same markdown", () => { const container = makeContainer(); diff --git a/ui/src/lib/document-annotation-selection.ts b/ui/src/lib/document-annotation-selection.ts index 44349f469d9..a1e37d89bf9 100644 --- a/ui/src/lib/document-annotation-selection.ts +++ b/ui/src/lib/document-annotation-selection.ts @@ -23,10 +23,11 @@ export function isCoarsePointerDevice(targetWindow: Window = window): boolean { const navigatorWithLegacyTouchPoints = targetWindow.navigator as Navigator & { msMaxTouchPoints?: number; }; - if ((navigatorWithLegacyTouchPoints.maxTouchPoints ?? 0) > 0) return true; - if ((navigatorWithLegacyTouchPoints.msMaxTouchPoints ?? 0) > 0) return true; - return typeof targetWindow.matchMedia === "function" - && targetWindow.matchMedia("(pointer: coarse)").matches; + if (typeof targetWindow.matchMedia === "function") { + return targetWindow.matchMedia("(pointer: coarse)").matches; + } + return (navigatorWithLegacyTouchPoints.maxTouchPoints ?? 0) > 0 + || (navigatorWithLegacyTouchPoints.msMaxTouchPoints ?? 0) > 0; } export function hasActiveNativeSelection(targetWindow: Window = window): boolean {