diff --git a/packages/ui/src/features/editor/components/GithubRefChip.test.tsx b/packages/ui/src/features/editor/components/GithubRefChip.test.tsx new file mode 100644 index 0000000000..0175de3073 --- /dev/null +++ b/packages/ui/src/features/editor/components/GithubRefChip.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { GITHUB_REF_URL_ATTR, GithubRefChip } from "./GithubRefChip"; + +describe("GithubRefChip", () => { + it("exposes its URL as a DOM attribute so the context menu can copy it", () => { + const href = "https://github.com/PostHog/posthog/pull/23985"; + const { container } = render( + + PostHog/posthog#23985 + , + ); + + const carrier = container.querySelector(`[${GITHUB_REF_URL_ATTR}]`); + expect(carrier).not.toBeNull(); + expect(carrier?.getAttribute(GITHUB_REF_URL_ATTR)).toBe(href); + }); + + it("lets a nested right-click target resolve the URL via closest()", () => { + const href = "https://github.com/PostHog/posthog/issues/42"; + render( + + PostHog/posthog#42 + , + ); + + const label = screen.getByText("PostHog/posthog#42"); + expect( + label + .closest(`[${GITHUB_REF_URL_ATTR}]`) + ?.getAttribute(GITHUB_REF_URL_ATTR), + ).toBe(href); + }); +}); diff --git a/packages/ui/src/features/editor/components/GithubRefChip.tsx b/packages/ui/src/features/editor/components/GithubRefChip.tsx index ccd97bb677..843d629c43 100644 --- a/packages/ui/src/features/editor/components/GithubRefChip.tsx +++ b/packages/ui/src/features/editor/components/GithubRefChip.tsx @@ -2,6 +2,13 @@ import { GithubLogoIcon, GitPullRequestIcon } from "@phosphor-icons/react"; import { Chip } from "@posthog/quill"; import type { ReactNode } from "react"; +/** + * DOM attribute carrying the chip's GitHub URL. The conversation context menu + * reads it (via `closest()`) so "Copy" can copy the link of a right-clicked + * chip, which is otherwise unreachable from a text selection. + */ +export const GITHUB_REF_URL_ATTR = "data-github-ref-url"; + export function GithubRefChip({ href, kind, @@ -12,14 +19,18 @@ export function GithubRefChip({ children: ReactNode; }) { const Icon = kind === "pr" ? GitPullRequestIcon : GithubLogoIcon; + // `display: contents` wrapper keeps inline flow unchanged while exposing the + // URL on an ancestor of every part of the chip (icon, label, chip root). return ( - window.open(href, "_blank")} - className="cli-file-mention mx-0.5 max-w-full cursor-pointer! whitespace-nowrap pl-1 align-middle active:translate-y-0" - > - - {children} - + + window.open(href, "_blank")} + className="cli-file-mention mx-0.5 max-w-full cursor-pointer! whitespace-nowrap pl-1 align-middle active:translate-y-0" + > + + {children} + + ); } diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index fccb3f179a..78cd831953 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -15,6 +15,11 @@ import { resolveAndAttachDroppedFiles } from "@posthog/ui/features/message-edito import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector"; import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView"; import { ConversationView } from "@posthog/ui/features/sessions/components/ConversationView"; +import { + copyFromContextMenu, + getGithubRefUrlFromEventTarget, + resolveCopyText, +} from "@posthog/ui/features/sessions/components/copyContextTarget"; import { DropZoneOverlay } from "@posthog/ui/features/sessions/components/DropZoneOverlay"; import { ModelSelector } from "@posthog/ui/features/sessions/components/ModelSelector"; import { PendingChatView } from "@posthog/ui/features/sessions/components/PendingChatView"; @@ -250,6 +255,9 @@ export function SessionView({ const [isDraggingFile, setIsDraggingFile] = useState(false); const editorRef = useRef(null); const dragCounterRef = useRef(0); + // URL of the GitHub chip the context menu was opened on, captured on + // right-click so the "Copy" item can copy the link (selections can't reach it). + const copyTargetUrlRef = useRef(null); const firstPendingPermission = useMemo(() => { const entries = Array.from(pendingPermissions.entries()); @@ -363,6 +371,7 @@ export function SessionView({ useAutoFocusOnTyping(editorRef, !isActiveSession); const handleContextMenu = useCallback((e: React.MouseEvent) => { + copyTargetUrlRef.current = getGithubRefUrlFromEventTarget(e.target); const target = e.target as HTMLElement; if ( target.closest('input, textarea, [contenteditable="true"], .ProseMirror') @@ -642,10 +651,18 @@ export function SessionView({ { - const text = window.getSelection()?.toString(); - if (text) { - navigator.clipboard.writeText(text); + const url = copyTargetUrlRef.current; + const text = resolveCopyText( + url, + window.getSelection()?.toString(), + ); + if (!text) { + return; } + copyFromContextMenu(text, { + onSuccess: () => toast.success(url ? "Link copied" : "Copied"), + onError: () => toast.error("Couldn't copy"), + }); }} > Copy diff --git a/packages/ui/src/features/sessions/components/copyContextMenu.integration.test.tsx b/packages/ui/src/features/sessions/components/copyContextMenu.integration.test.tsx new file mode 100644 index 0000000000..a334294c8c --- /dev/null +++ b/packages/ui/src/features/sessions/components/copyContextMenu.integration.test.tsx @@ -0,0 +1,85 @@ +import { GithubRefChip } from "@posthog/ui/features/editor/components/GithubRefChip"; +import { + copyFromContextMenu, + getGithubRefUrlFromEventTarget, + resolveCopyText, +} from "@posthog/ui/features/sessions/components/copyContextTarget"; +import { ContextMenu, Theme } from "@radix-ui/themes"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef } from "react"; +import { describe, expect, it, vi } from "vitest"; + +const PR_URL = "https://github.com/PostHog/posthog/pull/63995"; + +// Radix's menu content mounts a scroll-area that observes resizes; jsdom lacks it. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +/** + * Mirrors the exact context-menu wiring in SessionView: a ContextMenu.Trigger + * whose child captures the right-clicked URL in a ref, and a "Copy" item that + * copies the captured URL (falling back to the text selection). + */ +function Harness() { + const copyTargetUrlRef = useRef(null); + const handleContextMenu = (e: React.MouseEvent) => { + copyTargetUrlRef.current = getGithubRefUrlFromEventTarget(e.target); + }; + return ( + + + + {/** biome-ignore lint/a11y/noStaticElementInteractions: test harness */} + + The draft PR is up: + + PostHog/posthog#63995 + + + + + { + const url = copyTargetUrlRef.current; + const text = resolveCopyText( + url, + window.getSelection()?.toString(), + ); + if (!text) { + return; + } + copyFromContextMenu(text); + }} + > + Copy + + + + + ); +} + +describe("conversation context-menu copy (integration)", () => { + it("copies the PR URL when right-clicking the chip and choosing Copy", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + // Right-click the chip label, exactly as a user would. + const label = screen.getByText("PostHog/posthog#63995"); + fireEvent.contextMenu(label); + + const copyItem = await screen.findByText("Copy"); + await userEvent.click(copyItem); + + // The write is deferred until after the menu closes (focus race), so wait. + await waitFor(() => expect(writeText).toHaveBeenCalledWith(PR_URL)); + }); +}); diff --git a/packages/ui/src/features/sessions/components/copyContextTarget.test.ts b/packages/ui/src/features/sessions/components/copyContextTarget.test.ts new file mode 100644 index 0000000000..d2e9cfb086 --- /dev/null +++ b/packages/ui/src/features/sessions/components/copyContextTarget.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { + copyFromContextMenu, + getGithubRefUrlFromEventTarget, + resolveCopyText, +} from "./copyContextTarget"; + +function buildDom(): { + icon: HTMLElement; + label: HTMLElement; + chip: HTMLElement; + outside: HTMLElement; +} { + document.body.innerHTML = ` + + + PostHog/posthog#23985 + + just some prose + `; + return { + icon: document.getElementById("icon") as HTMLElement, + label: document.getElementById("label") as HTMLElement, + chip: document.getElementById("chip") as HTMLElement, + outside: document.getElementById("outside") as HTMLElement, + }; +} + +const CHIP_URL = "https://github.com/PostHog/posthog/pull/23985"; + +describe("getGithubRefUrlFromEventTarget", () => { + it.each<{ + name: string; + pick: (dom: ReturnType) => EventTarget | null; + expected: string | null; + }>([ + { name: "a nested icon", pick: (dom) => dom.icon, expected: CHIP_URL }, + { name: "the label", pick: (dom) => dom.label, expected: CHIP_URL }, + { name: "non-chip prose", pick: (dom) => dom.outside, expected: null }, + { name: "a non-element target", pick: () => null, expected: null }, + ])("resolves $expected when the target is $name", ({ pick, expected }) => { + expect(getGithubRefUrlFromEventTarget(pick(buildDom()))).toBe(expected); + }); +}); + +describe("resolveCopyText", () => { + it.each<{ + name: string; + url: string | null; + selection: string | null | undefined; + expected: string | null; + }>([ + { + name: "prefers a captured chip URL over the text selection", + url: CHIP_URL, + selection: "selected", + expected: CHIP_URL, + }, + { + name: "falls back to the text selection when there is no chip URL", + url: null, + selection: "selected words", + expected: "selected words", + }, + { + name: "returns null for an empty selection and no chip URL", + url: null, + selection: "", + expected: null, + }, + { + name: "returns null for an undefined selection and no chip URL", + url: null, + selection: undefined, + expected: null, + }, + ])("$name", ({ url, selection, expected }) => { + expect(resolveCopyText(url, selection)).toBe(expected); + }); +}); + +describe("copyFromContextMenu", () => { + it("defers the clipboard write until after the current task (focus race)", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + copyFromContextMenu("https://github.com/PostHog/posthog/pull/1"); + + // Not written synchronously while the menu is still dismissing. + expect(writeText).not.toHaveBeenCalled(); + await vi.waitFor(() => + expect(writeText).toHaveBeenCalledWith( + "https://github.com/PostHog/posthog/pull/1", + ), + ); + }); + + it("invokes onSuccess after the deferred write resolves", async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + const onSuccess = vi.fn(); + const onError = vi.fn(); + + copyFromContextMenu("text", { onSuccess, onError }); + + await vi.waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(onError).not.toHaveBeenCalled(); + }); + + it("invokes onError when the deferred write rejects", async () => { + Object.assign(navigator, { + clipboard: { + writeText: vi + .fn() + .mockRejectedValue(new Error("Document is not focused")), + }, + }); + const onSuccess = vi.fn(); + const onError = vi.fn(); + + copyFromContextMenu("text", { onSuccess, onError }); + + await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/copyContextTarget.ts b/packages/ui/src/features/sessions/components/copyContextTarget.ts new file mode 100644 index 0000000000..632171975f --- /dev/null +++ b/packages/ui/src/features/sessions/components/copyContextTarget.ts @@ -0,0 +1,52 @@ +import { GITHUB_REF_URL_ATTR } from "@posthog/ui/features/editor/components/GithubRefChip"; + +/** + * Resolve the GitHub PR/issue URL the context menu was opened on, if the + * right-click landed inside a {@link GithubRefChip}. Returns `null` for any + * other target (prose, file chips, empty space, non-elements). + */ +export function getGithubRefUrlFromEventTarget( + target: EventTarget | null, +): string | null { + // `Element`, not `HTMLElement`: the chip icon renders as an , whose + // right-click target is an SVGElement that still supports `closest()`. + if (!(target instanceof Element)) return null; + return ( + target.closest(`[${GITHUB_REF_URL_ATTR}]`)?.dataset + .githubRefUrl ?? null + ); +} + +/** + * Decide what the conversation "Copy" action should put on the clipboard: a + * captured chip URL wins, otherwise fall back to the current text selection. + * Returns `null` when there is nothing to copy. + */ +export function resolveCopyText( + capturedUrl: string | null, + selection: string | null | undefined, +): string | null { + return capturedUrl ?? (selection ? selection : null); +} + +/** + * Copy text to the clipboard from a context-menu selection. + * + * The write is deferred to a later task on purpose. When a Radix + * `ContextMenu.Item` is selected, the menu's focus scope is being torn down and + * the document is momentarily not focused — calling `navigator.clipboard.writeText` + * synchronously there rejects with "Document is not focused" in Electron/Chromium, + * so the clipboard is left unchanged. Deferring lets the menu finish closing and + * focus return to the document before we write. + */ +export function copyFromContextMenu( + text: string, + callbacks: { onSuccess?: () => void; onError?: () => void } = {}, +): void { + setTimeout(() => { + navigator.clipboard + .writeText(text) + .then(() => callbacks.onSuccess?.()) + .catch(() => callbacks.onError?.()); + }, 0); +}
just some prose