fix(ui): preserve desktop annotation focus

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-11 02:58:06 +00:00
1 parent 4e6d81f711
commit 718e90557c
4 files changed
+91 -6

No files matched your search

@@ -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]);
@@ -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(
<QueryClientProvider client={queryClient}>
<Harness doc={doc} initialPanelOpen={false} />
</QueryClientProvider>,
);
});
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<HTMLButtonElement>('[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,
});
}
});
});
@@ -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();
+5 -4
View File
@@ -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 {