Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 92 additions & 87 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1944,6 +1945,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
onReveal,
revealLabel,
}: MarkdownFileLinkProps) {
const { show: showLinkMenu, menu: linkMenu } = useMessageLinkMenu();
const handleOpenInEditor = useCallback(() => {
if (!onOpen) {
return;
Expand Down Expand Up @@ -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) : []),
Expand All @@ -2127,6 +2126,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
{ id: "copy-full", label: "Copy full path" },
] as const,
position,
trigger,
);

if (clicked === "preview-media") {
Expand Down Expand Up @@ -2160,6 +2160,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
}
},
[
showLinkMenu,
displayPath,
handleCopy,
handleOpenInBrowser,
Expand All @@ -2179,14 +2180,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
(event: ReactMouseEvent<HTMLElement>) => {
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],
);
Expand All @@ -2208,56 +2202,59 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
});

return (
<Tooltip>
<TooltipTrigger
render={
hasPrimaryAction ? (
<ContextChip
kind="mention"
render={<a href={href} />}
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}
>
<FileTagChipContent path={iconPath} label={label} theme={theme} />
</ContextChip>
) : (
<ContextChip
kind="mention"
render={<button type="button" />}
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}
>
<FileTagChipContent path={iconPath} label={label} theme={theme} />
</ContextChip>
)
}
/>
<TooltipPopup side="top" variant="code">
{/* The full path: the chip already shows the shortened form, and a link
<>
{linkMenu}
<Tooltip>
<TooltipTrigger
render={
hasPrimaryAction ? (
<ContextChip
kind="mention"
render={<a href={href} />}
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}
>
<FileTagChipContent path={iconPath} label={label} theme={theme} />
</ContextChip>
) : (
<ContextChip
kind="mention"
render={<button type="button" />}
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}
>
<FileTagChipContent path={iconPath} label={label} theme={theme} />
</ContextChip>
)
}
/>
<TooltipPopup side="top" variant="code">
{/* 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. */}
<div className="overflow-x-auto whitespace-nowrap scrollbar-thumb-border/78 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/78 [&::-webkit-scrollbar-track]:bg-transparent">
{targetPath}
</div>
</TooltipPopup>
</Tooltip>
<div className="overflow-x-auto whitespace-nowrap scrollbar-thumb-border/78 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/78 [&::-webkit-scrollbar-track]:bg-transparent">
{targetPath}
</div>
</TooltipPopup>
</Tooltip>
</>
);
}, areMarkdownFileLinkPropsEqual);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
openInPreview: async (target) => {
const result = await openExternalLinkInPreview(target);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
Expand Down Expand Up @@ -3071,33 +3070,39 @@ const CHAT_MARKDOWN_COMPONENTS = {
}
if (pullRequestPreviewTarget !== null) {
return (
<PullRequestLinkPreview
link={link}
originalUrl={href}
target={pullRequestPreviewTarget}
confirmBeforeOpen={confirmBeforeOpen}
onOpenPullRequest={(targetUrl) =>
openChangeRequestLink(
{
metaKey: false,
ctrlKey: false,
preventDefault: () => undefined,
stopPropagation: () => undefined,
},
targetUrl,
undefined,
environmentId ?? undefined,
)
}
onOpenFallback={openDeferredMarkdownLink}
/>
<>
{linkMenu}
<PullRequestLinkPreview
link={link}
originalUrl={href}
target={pullRequestPreviewTarget}
confirmBeforeOpen={confirmBeforeOpen}
onOpenPullRequest={(targetUrl) =>
openChangeRequestLink(
{
metaKey: false,
ctrlKey: false,
preventDefault: () => undefined,
stopPropagation: () => undefined,
},
targetUrl,
undefined,
environmentId ?? undefined,
)
}
onOpenFallback={openDeferredMarkdownLink}
/>
</>
);
}
return (
<Tooltip>
<TooltipTrigger render={link} />
<TooltipPopup side="top">{href}</TooltipPopup>
</Tooltip>
<>
{linkMenu}
<Tooltip>
<TooltipTrigger render={link} />
<TooltipPopup side="top">{href}</TooltipPopup>
</Tooltip>
</>
);
}

Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/components/chat/useMessageLinkMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useMessageLinkMenu>>();
function TestLinkMenu() {
const linkMenu = useMessageLinkMenu();
useImperativeHandle(linkMenuRef, () => linkMenu, [linkMenu]);
return null;
}
const trigger = {
getBoundingClientRect: () => ({ left: 120, bottom: 260 }),
} as HTMLElement;

await act(() => {
renderer = create(<TestLinkMenu />);
});
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,
});
});
});
Loading
Loading