diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b..da67d7da2eb6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -159,6 +159,7 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { useMessageLinkMenu } from "./chat/useMessageLinkMenu"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; @@ -1944,6 +1945,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onReveal, revealLabel, }: MarkdownFileLinkProps) { + const { show: showLinkMenu, menu: linkMenu } = useMessageLinkMenu(); const handleOpenInEditor = useCallback(() => { if (!onOpen) { return; @@ -2110,12 +2112,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); const showFileContextMenu = useCallback( - async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - + async (position: { x: number; y: number }, trigger: HTMLElement) => { try { - const clicked = await api.contextMenu.show( + const clicked = await showLinkMenu( [ ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), @@ -2127,6 +2126,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ { id: "copy-full", label: "Copy full path" }, ] as const, position, + trigger, ); if (clicked === "preview-media") { @@ -2160,6 +2160,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } }, [ + showLinkMenu, displayPath, handleCopy, handleOpenInBrowser, @@ -2179,14 +2180,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); - const position = - event.clientX === 0 && event.clientY === 0 - ? (() => { - const bounds = event.currentTarget.getBoundingClientRect(); - return { x: bounds.left, y: bounds.bottom }; - })() - : { x: event.clientX, y: event.clientY }; - void showFileContextMenu(position); + void showFileContextMenu({ x: event.clientX, y: event.clientY }, event.currentTarget); }, [showFileContextMenu], ); @@ -2208,56 +2202,59 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }); return ( - - } - className={MARKDOWN_FILE_LINK_CLASS_NAME} - data-markdown-copy={copyMarkdown} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (useBrowserPrimaryAction) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - ) : ( - } - aria-label={`File options for ${label}`} - aria-haspopup="menu" - className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} - onContextMenu={handleContextMenu} - > - - - ) - } - /> - - {/* The full path: the chip already shows the shortened form, and a link + <> + {linkMenu} + + } + className={MARKDOWN_FILE_LINK_CLASS_NAME} + data-markdown-copy={copyMarkdown} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + } + aria-label={`File options for ${label}`} + aria-haspopup="menu" + className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} + data-markdown-copy={copyMarkdown} + onClick={handleContextMenu} + onContextMenu={handleContextMenu} + > + + + ) + } + /> + + {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */} -
- {targetPath} -
-
-
+
+ {targetPath} +
+
+
+ ); }, areMarkdownFileLinkPropsEqual); @@ -2872,6 +2869,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { + const { show: showLinkMenu, menu: linkMenu } = useMessageLinkMenu(); const { cwd, environmentId, @@ -3012,6 +3010,7 @@ const CHAT_MARKDOWN_COMPONENTS = { event.stopPropagation(); const api = readLocalApi(); if (!api) return; + const trigger = event.currentTarget; const threadLinkAction = linkedThreadPullRequestFor(href) !== null ? "unlink-from-thread" @@ -3023,7 +3022,7 @@ const CHAT_MARKDOWN_COMPONENTS = { canOpenInPreview, threadLinkAction, position: { x: event.clientX, y: event.clientY }, - showContextMenu: (items, position) => api.contextMenu.show(items, position), + showContextMenu: (items, position) => showLinkMenu(items, position, trigger), openInPreview: async (target) => { const result = await openExternalLinkInPreview(target); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { @@ -3071,33 +3070,39 @@ const CHAT_MARKDOWN_COMPONENTS = { } if (pullRequestPreviewTarget !== null) { return ( - - openChangeRequestLink( - { - metaKey: false, - ctrlKey: false, - preventDefault: () => undefined, - stopPropagation: () => undefined, - }, - targetUrl, - undefined, - environmentId ?? undefined, - ) - } - onOpenFallback={openDeferredMarkdownLink} - /> + <> + {linkMenu} + + openChangeRequestLink( + { + metaKey: false, + ctrlKey: false, + preventDefault: () => undefined, + stopPropagation: () => undefined, + }, + targetUrl, + undefined, + environmentId ?? undefined, + ) + } + onOpenFallback={openDeferredMarkdownLink} + /> + ); } return ( - - - {href} - + <> + {linkMenu} + + + {href} + + ); } diff --git a/apps/web/src/components/chat/useMessageLinkMenu.test.tsx b/apps/web/src/components/chat/useMessageLinkMenu.test.tsx new file mode 100644 index 000000000000..a0192ffa5038 --- /dev/null +++ b/apps/web/src/components/chat/useMessageLinkMenu.test.tsx @@ -0,0 +1,59 @@ +import { act, createRef, useImperativeHandle } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useMessageLinkMenu } from "./useMessageLinkMenu"; + +let renderer: ReactTestRenderer | undefined; + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("message link menu positioning", () => { + it.each([ + { position: { x: 0, y: 0 }, expected: { x: 120, y: 260 } }, + { position: { x: 145, y: 245 }, expected: { x: 145, y: 245 } }, + { position: { x: 0, y: 245 }, expected: { x: 0, y: 245 } }, + { position: { x: 145, y: 0 }, expected: { x: 145, y: 0 } }, + ])("anchors a menu opened at $position to $expected", async ({ position, expected }) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "DOMRect", + class { + constructor( + readonly x: number, + readonly y: number, + readonly width: number, + readonly height: number, + ) {} + }, + ); + const linkMenuRef = createRef>(); + function TestLinkMenu() { + const linkMenu = useMessageLinkMenu(); + useImperativeHandle(linkMenuRef, () => linkMenu, [linkMenu]); + return null; + } + const trigger = { + getBoundingClientRect: () => ({ left: 120, bottom: 260 }), + } as HTMLElement; + + await act(() => { + renderer = create(); + }); + await act(() => { + void linkMenuRef.current!.show([{ id: "copy-link", label: "Copy Link" }], position, trigger); + }); + + // Evaluate the virtual anchor consumed by the popup's positioning engine. + const popup = linkMenuRef.current!.menu?.props.children; + expect(popup.props.anchor.getBoundingClientRect()).toMatchObject({ + ...expected, + width: 0, + height: 0, + }); + }); +}); diff --git a/apps/web/src/components/chat/useMessageLinkMenu.tsx b/apps/web/src/components/chat/useMessageLinkMenu.tsx new file mode 100644 index 000000000000..32e1c1f01593 --- /dev/null +++ b/apps/web/src/components/chat/useMessageLinkMenu.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Menu, MenuItem, MenuPopup } from "../ui/menu"; + +interface LinkMenuItem { + id: string; + label: string; +} + +interface LinkMenuState { + items: readonly LinkMenuItem[]; + trigger: HTMLElement; + position: { x: number; y: number }; +} + +/** Keeps message link focus and menu focus in the same accessibility tree. */ +export function useMessageLinkMenu() { + const [state, setState] = useState(null); + const pending = useRef<((value: string | null) => void) | null>(null); + const complete = useCallback((value: string | null) => { + const resolve = pending.current; + pending.current = null; + setState(null); + resolve?.(value); + }, []); + + useEffect(() => () => pending.current?.(null), []); + + const show = useCallback( + ( + items: readonly { id: T; label: string }[], + position: { x: number; y: number }, + trigger: HTMLElement, + ): Promise => { + pending.current?.(null); + pending.current = null; + if (items.length === 0) { + setState(null); + return Promise.resolve(null); + } + if (position.x === 0 && position.y === 0) { + const bounds = trigger.getBoundingClientRect(); + position = { x: bounds.left, y: bounds.bottom }; + } + return new Promise((resolve) => { + pending.current = (value) => resolve(value as T | null); + setState({ items, position, trigger }); + }); + }, + [], + ); + + const menu = state && ( + { + if (!open) complete(null); + }} + > + new DOMRect(state.position.x, state.position.y, 0, 0), + }} + aria-label="Link options" + finalFocus={() => state.trigger} + onFocus={(event) => { + if (event.target === event.currentTarget) { + event.currentTarget.querySelector('[role="menuitem"]')?.focus(); + } + }} + > + {state.items.map((item) => ( + complete(item.id)}> + {item.label} + + ))} + + + ); + + return { show, menu }; +}