authorgravatar for 113233555+caezium@users.noreply.github.comHenry Zhang <113233555+caezium@users.noreply.github.com> 2026-07-22 22:35:38+08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 16:35:38+02:00
loge5fba263e6820350e4edc7e5d93955aabef53df1
tree3a20e8c627fd7b0928b50e2062f0914c9e34b238
parent18b468871e6a3bfad207109898011ea330afeadb
signature Signed by PGP key B5690EEEBB952194

Restore Copy Link in chat link context menu (#4161)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 230 insertions(+), 37 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+24-37
...@@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm";...@@ -42,6 +42,10 @@ import remarkGfm from "remark-gfm";
42import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";42import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
43import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";43import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
44import { PierreEntryIcon } from "./chat/PierreEntryIcon";44import { PierreEntryIcon } from "./chat/PierreEntryIcon";
45import {
46 resolveExternalWebLinkHost,
47 showExternalLinkContextMenu,
48} from "./chat/externalLinkContextMenu";
45import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";49import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons";
46import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";50import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
47import { Button } from "./ui/button";51import { Button } from "./ui/button";
...@@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session";...@@ -76,6 +80,7 @@ import { usePreparedConnection } from "../state/session";
76import { previewEnvironment } from "../state/preview";80import { previewEnvironment } from "../state/preview";
77import { useAtomCommand } from "../state/use-atom-command";81import { useAtomCommand } from "../state/use-atom-command";
78import { useAtomQueryRunner } from "../state/use-atom-query-runner";82import { useAtomQueryRunner } from "../state/use-atom-query-runner";
83import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
79import { isPreviewSupportedInRuntime } from "../previewStateStore";84import { isPreviewSupportedInRuntime } from "../previewStateStore";
80import {85import {
81 isBrowserPreviewFile,86 isBrowserPreviewFile,
...@@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";...@@ -831,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
831/** Hosts whose favicon request already failed this session — skip straight to the globe. */836/** Hosts whose favicon request already failed this session — skip straight to the globe. */
832const failedFaviconHosts = new Set<string>();837const failedFaviconHosts = new Set<string>();
833838
834function resolveExternalLinkHost(href: string | undefined): string | null {
835 if (!href) return null;
836 try {
837 const url = new URL(href);
838 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
839 return url.hostname || null;
840 } catch {
841 return null;
842 }
843}
844
845const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {839const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
846 const [failedHost, setFailedHost] = useState<string | null>(null);840 const [failedHost, setFailedHost] = useState<string | null>(null);
847 return (841 return (
...@@ -1393,7 +1387,7 @@ function ChatMarkdown({...@@ -1393,7 +1387,7 @@ function ChatMarkdown({
1393 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";1387 const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
1394 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;1388 const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
1395 if (!fileLinkMeta) {1389 if (!fileLinkMeta) {
1396 const faviconHost = resolveExternalLinkHost(href);1390 const faviconHost = resolveExternalWebLinkHost(href);
1397 const isSameDocumentLink = href?.startsWith("#") ?? false;1391 const isSameDocumentLink = href?.startsWith("#") ?? false;
1398 const onClick = props.onClick;1392 const onClick = props.onClick;
1399 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();1393 const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
...@@ -1410,37 +1404,30 @@ function ChatMarkdown({...@@ -1410,37 +1404,30 @@ function ChatMarkdown({
1410 }1404 }
1411 }}1405 }}
1412 onContextMenu={(event) => {1406 onContextMenu={(event) => {
1413 if (!canOpenInPreview || !href) return;1407 if (!canOpenInPreview || !href || !faviconHost) return;
1414 event.preventDefault();1408 event.preventDefault();
1415 event.stopPropagation();1409 event.stopPropagation();
1416 const api = readLocalApi();1410 const api = readLocalApi();
1417 if (!api) return;1411 if (!api) return;
1418 void (async () => {1412 void showExternalLinkContextMenu({
1419 let operation = "show-link-context-menu";1413 href,
1420 try {1414 position: { x: event.clientX, y: event.clientY },
1421 const clicked = await api.contextMenu.show(1415 showContextMenu: (items, position) => api.contextMenu.show(items, position),
1422 [1416 openInPreview: async (target) => {
1423 { id: "open-in-browser", label: "Open in integrated browser" },1417 const result = await openExternalLinkInPreview(target);
1424 { id: "open-external", label: "Open in system browser" },1418 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1425 ] as const,1419 reportMarkdownActionFailure(
1426 { x: event.clientX, y: event.clientY },1420 { operation: "open-link-in-preview", target },
1427 );1421 result.cause,
1428 if (clicked === "open-in-browser") {1422 );
1429 operation = "open-link-in-preview";
1430 const result = await openExternalLinkInPreview(href);
1431 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1432 reportMarkdownActionFailure({ operation, target: href }, result.cause);
1433 }
1434 return;
1435 }1423 }
1436 if (clicked === "open-external") {1424 },
1437 operation = "open-link-external";1425 openExternal: (target) => api.shell.openExternal(target),
1438 await api.shell.openExternal(href);1426 copyLink: (target) => writeTextToClipboard(target, "link"),
1439 }1427 reportFailure: (operation, cause) => {
1440 } catch (cause) {
1441 reportMarkdownActionFailure({ operation, target: href }, cause);1428 reportMarkdownActionFailure({ operation, target: href }, cause);
1442 }1429 },
1443 })();1430 });
1444 }}1431 }}
1445 >1432 >
1446 {faviconHost ? (1433 {faviconHost ? (
apps/web/src/components/chat/externalLinkContextMenu.test.ts created+128
...@@ -0,0 +1,128 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu";
4
5function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) {
6 const showContextMenu = vi.fn().mockResolvedValue(selection);
7 const openInPreview = vi.fn().mockResolvedValue(undefined);
8 const openExternal = vi.fn().mockResolvedValue(undefined);
9 const copyLink = vi.fn().mockResolvedValue(undefined);
10 const reportFailure = vi.fn();
11
12 return {
13 showContextMenu,
14 openInPreview,
15 openExternal,
16 copyLink,
17 reportFailure,
18 };
19}
20
21describe("external chat link context menu", () => {
22 it("offers both open actions and Copy Link", async () => {
23 const harness = createHarness(null);
24
25 await showExternalLinkContextMenu({
26 href: "https://example.com/docs?topic=menus#copy",
27 position: { x: 12, y: 24 },
28 ...harness,
29 });
30
31 expect(harness.showContextMenu).toHaveBeenCalledWith(
32 [
33 { id: "open-in-preview", label: "Open in integrated browser" },
34 { id: "open-external", label: "Open in system browser" },
35 { id: "copy-link", label: "Copy Link" },
36 ],
37 { x: 12, y: 24 },
38 );
39 expect(harness.openInPreview).not.toHaveBeenCalled();
40 expect(harness.openExternal).not.toHaveBeenCalled();
41 expect(harness.copyLink).not.toHaveBeenCalled();
42 });
43
44 it("copies the exact destination without opening it", async () => {
45 const harness = createHarness("copy-link");
46 const href = "https://example.com/docs?topic=menus#copy";
47
48 await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness });
49
50 expect(harness.copyLink).toHaveBeenCalledWith(href);
51 expect(harness.openInPreview).not.toHaveBeenCalled();
52 expect(harness.openExternal).not.toHaveBeenCalled();
53 });
54
55 it.each([
56 ["open-in-preview" as const, "openInPreview" as const],
57 ["open-external" as const, "openExternal" as const],
58 ])("preserves the %s action", async (selection, expectedCallback) => {
59 const harness = createHarness(selection);
60 const href = "https://example.com/docs";
61
62 await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness });
63
64 expect(harness[expectedCallback]).toHaveBeenCalledWith(href);
65 expect(harness.copyLink).not.toHaveBeenCalled();
66 });
67
68 it("reports the selected action when it fails", async () => {
69 const harness = createHarness("copy-link");
70 const cause = new Error("clipboard denied");
71 harness.copyLink.mockRejectedValue(cause);
72
73 await showExternalLinkContextMenu({
74 href: "https://example.com/docs",
75 position: { x: 1, y: 2 },
76 ...harness,
77 });
78
79 expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause);
80 });
81
82 it("reports the menu operation when the native menu cannot be shown", async () => {
83 const harness = createHarness(null);
84 const cause = new Error("menu unavailable");
85 harness.showContextMenu.mockRejectedValue(cause);
86
87 await showExternalLinkContextMenu({
88 href: "https://example.com/docs",
89 position: { x: 1, y: 2 },
90 ...harness,
91 });
92
93 expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause);
94 expect(harness.openInPreview).not.toHaveBeenCalled();
95 expect(harness.openExternal).not.toHaveBeenCalled();
96 expect(harness.copyLink).not.toHaveBeenCalled();
97 });
98
99 it.each([
100 ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"],
101 ["open-external" as const, "openExternal" as const, "open-link-external"],
102 ])("reports a failed %s action", async (selection, callback, operation) => {
103 const harness = createHarness(selection);
104 const cause = new Error("open failed");
105 harness[callback].mockRejectedValue(cause);
106
107 await showExternalLinkContextMenu({
108 href: "https://example.com/docs",
109 position: { x: 1, y: 2 },
110 ...harness,
111 });
112
113 expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause);
114 });
115
116 it.each([
117 ["https://example.com", "example.com"],
118 ["http://localhost:3000/path", "localhost"],
119 ["#details", null],
120 ["mailto:hello@example.com", null],
121 ["file:///tmp/example.txt", null],
122 ["javascript:void(0)", null],
123 ["not a URL", null],
124 [undefined, null],
125 ])("resolves the external web-link host for %s as %s", (href, expected) => {
126 expect(resolveExternalWebLinkHost(href)).toBe(expected);
127 });
128});
apps/web/src/components/chat/externalLinkContextMenu.ts created+78
...@@ -0,0 +1,78 @@
1import type { ContextMenuItem } from "@t3tools/contracts";
2
3export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link";
4
5export type ExternalLinkContextMenuFailureOperation =
6 | "show-link-context-menu"
7 | "open-link-in-preview"
8 | "open-link-external"
9 | "copy-link";
10
11const FAILURE_OPERATION_BY_ACTION = {
12 "open-in-preview": "open-link-in-preview",
13 "open-external": "open-link-external",
14 "copy-link": "copy-link",
15} as const satisfies Record<ExternalLinkContextMenuAction, ExternalLinkContextMenuFailureOperation>;
16
17const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
18 { id: "open-in-preview", label: "Open in integrated browser" },
19 { id: "open-external", label: "Open in system browser" },
20 { id: "copy-link", label: "Copy Link" },
21] as const satisfies readonly ContextMenuItem<ExternalLinkContextMenuAction>[];
22
23interface ShowExternalLinkContextMenuOptions {
24 readonly href: string;
25 readonly position: { readonly x: number; readonly y: number };
26 readonly showContextMenu: (
27 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],
28 position: { readonly x: number; readonly y: number },
29 ) => Promise<ExternalLinkContextMenuAction | null>;
30 readonly openInPreview: (href: string) => Promise<void>;
31 readonly openExternal: (href: string) => Promise<void>;
32 readonly copyLink: (href: string) => Promise<unknown>;
33 readonly reportFailure: (
34 operation: ExternalLinkContextMenuFailureOperation,
35 cause: unknown,
36 ) => void;
37}
38
39export function resolveExternalWebLinkHost(href: string | undefined): string | null {
40 if (!href) return null;
41 try {
42 const url = new URL(href);
43 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
44 return url.hostname || null;
45 } catch {
46 return null;
47 }
48}
49
50export async function showExternalLinkContextMenu({
51 href,
52 position,
53 showContextMenu,
54 openInPreview,
55 openExternal,
56 copyLink,
57 reportFailure,
58}: ShowExternalLinkContextMenuOptions): Promise<void> {
59 let action: ExternalLinkContextMenuAction | null;
60 try {
61 action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position);
62 } catch (cause) {
63 reportFailure("show-link-context-menu", cause);
64 return;
65 }
66
67 try {
68 if (action === "open-in-preview") {
69 await openInPreview(href);
70 } else if (action === "open-external") {
71 await openExternal(href);
72 } else if (action === "copy-link") {
73 await copyLink(href);
74 }
75 } catch (cause) {
76 if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause);
77 }
78}