From 296ce02e7a6871044c1a766c73caad3dbb5364d9 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:54:35 +0800 Subject: [PATCH 1/3] fix(web): restore Copy Link for chat links --- apps/web/src/components/ChatMarkdown.tsx | 45 +++---- .../chat/externalLinkContextMenu.test.ts | 127 ++++++++++++++++++ .../chat/externalLinkContextMenu.ts | 76 +++++++++++ 3 files changed, 223 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/chat/externalLinkContextMenu.test.ts create mode 100644 apps/web/src/components/chat/externalLinkContextMenu.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c85340c715b..10c9df175a3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,7 @@ import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { isExternalWebLink, showExternalLinkContextMenu } from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -76,6 +77,7 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, @@ -1410,37 +1412,30 @@ function ChatMarkdown({ } }} onContextMenu={(event) => { - if (!canOpenInPreview || !href) return; + if (!canOpenInPreview || !href || !isExternalWebLink(href)) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; - void (async () => { - let operation = "show-link-context-menu"; - try { - const clicked = await api.contextMenu.show( - [ - { id: "open-in-browser", label: "Open in integrated browser" }, - { id: "open-external", label: "Open in system browser" }, - ] as const, - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "open-in-browser") { - operation = "open-link-in-preview"; - const result = await openExternalLinkInPreview(href); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - reportMarkdownActionFailure({ operation, target: href }, result.cause); - } - return; + void showExternalLinkContextMenu({ + href, + position: { x: event.clientX, y: event.clientY }, + showContextMenu: (items, position) => api.contextMenu.show(items, position), + openInBrowser: async (target) => { + const result = await openExternalLinkInPreview(target); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportMarkdownActionFailure( + { operation: "open-link-in-preview", target }, + result.cause, + ); } - if (clicked === "open-external") { - operation = "open-link-external"; - await api.shell.openExternal(href); - } - } catch (cause) { + }, + openExternal: (target) => api.shell.openExternal(target), + copyLink: (target) => writeTextToClipboard(target, "link"), + reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); - } - })(); + }, + }); }} > {faviconHost ? ( diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts new file mode 100644 index 00000000000..67cd4ae015f --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { isExternalWebLink, showExternalLinkContextMenu } from "./externalLinkContextMenu"; + +function createHarness(selection: "open-in-browser" | "open-external" | "copy-link" | null) { + const showContextMenu = vi.fn().mockResolvedValue(selection); + const openInBrowser = vi.fn().mockResolvedValue(undefined); + const openExternal = vi.fn().mockResolvedValue(undefined); + const copyLink = vi.fn().mockResolvedValue(undefined); + const reportFailure = vi.fn(); + + return { + showContextMenu, + openInBrowser, + openExternal, + copyLink, + reportFailure, + }; +} + +describe("external chat link context menu", () => { + it("offers both open actions and Copy Link", async () => { + const harness = createHarness(null); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs?topic=menus#copy", + position: { x: 12, y: 24 }, + ...harness, + }); + + expect(harness.showContextMenu).toHaveBeenCalledWith( + [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, + ], + { x: 12, y: 24 }, + ); + expect(harness.openInBrowser).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("copies the exact destination without opening it", async () => { + const harness = createHarness("copy-link"); + const href = "https://example.com/docs?topic=menus#copy"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness.copyLink).toHaveBeenCalledWith(href); + expect(harness.openInBrowser).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-browser" as const, "openInBrowser" as const], + ["open-external" as const, "openExternal" as const], + ])("preserves the %s action", async (selection, expectedCallback) => { + const harness = createHarness(selection); + const href = "https://example.com/docs"; + + await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); + + expect(harness[expectedCallback]).toHaveBeenCalledWith(href); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it("reports the selected action when it fails", async () => { + const harness = createHarness("copy-link"); + const cause = new Error("clipboard denied"); + harness.copyLink.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("copy-link", cause); + }); + + it("reports the menu operation when the native menu cannot be shown", async () => { + const harness = createHarness(null); + const cause = new Error("menu unavailable"); + harness.showContextMenu.mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause); + expect(harness.openInBrowser).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + expect(harness.copyLink).not.toHaveBeenCalled(); + }); + + it.each([ + ["open-in-browser" as const, "openInBrowser" as const, "open-link-in-preview"], + ["open-external" as const, "openExternal" as const, "open-link-external"], + ])("reports a failed %s action", async (selection, callback, operation) => { + const harness = createHarness(selection); + const cause = new Error("open failed"); + harness[callback].mockRejectedValue(cause); + + await showExternalLinkContextMenu({ + href: "https://example.com/docs", + position: { x: 1, y: 2 }, + ...harness, + }); + + expect(harness.reportFailure).toHaveBeenCalledWith(operation, cause); + }); + + it.each([ + ["https://example.com", true], + ["http://localhost:3000/path", true], + ["#details", false], + ["mailto:hello@example.com", false], + ["file:///tmp/example.txt", false], + ["javascript:void(0)", false], + ["not a URL", false], + ])("classifies %s as an external web link: %s", (href, expected) => { + expect(isExternalWebLink(href)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts new file mode 100644 index 00000000000..408945cec9e --- /dev/null +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -0,0 +1,76 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; + +export type ExternalLinkContextMenuAction = "open-in-browser" | "open-external" | "copy-link"; + +export type ExternalLinkContextMenuFailureOperation = + | "show-link-context-menu" + | "open-link-in-preview" + | "open-link-external" + | "copy-link"; + +const FAILURE_OPERATION_BY_ACTION = { + "open-in-browser": "open-link-in-preview", + "open-external": "open-link-external", + "copy-link": "copy-link", +} as const satisfies Record; + +const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, +] as const satisfies readonly ContextMenuItem[]; + +interface ShowExternalLinkContextMenuOptions { + readonly href: string; + readonly position: { readonly x: number; readonly y: number }; + readonly showContextMenu: ( + items: readonly ContextMenuItem[], + position: { readonly x: number; readonly y: number }, + ) => Promise; + readonly openInBrowser: (href: string) => Promise; + readonly openExternal: (href: string) => Promise; + readonly copyLink: (href: string) => Promise; + readonly reportFailure: ( + operation: ExternalLinkContextMenuFailureOperation, + cause: unknown, + ) => void; +} + +export function isExternalWebLink(href: string): boolean { + try { + const url = new URL(href); + return (url.protocol === "http:" || url.protocol === "https:") && url.hostname.length > 0; + } catch { + return false; + } +} + +export async function showExternalLinkContextMenu({ + href, + position, + showContextMenu, + openInBrowser, + openExternal, + copyLink, + reportFailure, +}: ShowExternalLinkContextMenuOptions): Promise { + let action: ExternalLinkContextMenuAction | null; + try { + action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position); + } catch (cause) { + reportFailure("show-link-context-menu", cause); + return; + } + + try { + if (action === "open-in-browser") { + await openInBrowser(href); + } else if (action === "open-external") { + await openExternal(href); + } else if (action === "copy-link") { + await copyLink(href); + } + } catch (cause) { + if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause); + } +} From 5eb52911115abe6a0691b13a937e62918f283658 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:07:44 +0800 Subject: [PATCH 2/3] refactor(web): clarify preview link action --- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../chat/externalLinkContextMenu.test.ts | 18 +++++++++--------- .../components/chat/externalLinkContextMenu.ts | 14 +++++++------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 10c9df175a3..36fab2d5da7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1421,7 +1421,7 @@ function ChatMarkdown({ href, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), - openInBrowser: async (target) => { + openInPreview: async (target) => { const result = await openExternalLinkInPreview(target); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { reportMarkdownActionFailure( diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts index 67cd4ae015f..1f2b6b5ae0f 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -2,16 +2,16 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { isExternalWebLink, showExternalLinkContextMenu } from "./externalLinkContextMenu"; -function createHarness(selection: "open-in-browser" | "open-external" | "copy-link" | null) { +function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) { const showContextMenu = vi.fn().mockResolvedValue(selection); - const openInBrowser = vi.fn().mockResolvedValue(undefined); + const openInPreview = vi.fn().mockResolvedValue(undefined); const openExternal = vi.fn().mockResolvedValue(undefined); const copyLink = vi.fn().mockResolvedValue(undefined); const reportFailure = vi.fn(); return { showContextMenu, - openInBrowser, + openInPreview, openExternal, copyLink, reportFailure, @@ -30,13 +30,13 @@ describe("external chat link context menu", () => { expect(harness.showContextMenu).toHaveBeenCalledWith( [ - { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-in-preview", label: "Open in integrated browser" }, { id: "open-external", label: "Open in system browser" }, { id: "copy-link", label: "Copy Link" }, ], { x: 12, y: 24 }, ); - expect(harness.openInBrowser).not.toHaveBeenCalled(); + expect(harness.openInPreview).not.toHaveBeenCalled(); expect(harness.openExternal).not.toHaveBeenCalled(); expect(harness.copyLink).not.toHaveBeenCalled(); }); @@ -48,12 +48,12 @@ describe("external chat link context menu", () => { await showExternalLinkContextMenu({ href, position: { x: 1, y: 2 }, ...harness }); expect(harness.copyLink).toHaveBeenCalledWith(href); - expect(harness.openInBrowser).not.toHaveBeenCalled(); + expect(harness.openInPreview).not.toHaveBeenCalled(); expect(harness.openExternal).not.toHaveBeenCalled(); }); it.each([ - ["open-in-browser" as const, "openInBrowser" as const], + ["open-in-preview" as const, "openInPreview" as const], ["open-external" as const, "openExternal" as const], ])("preserves the %s action", async (selection, expectedCallback) => { const harness = createHarness(selection); @@ -91,13 +91,13 @@ describe("external chat link context menu", () => { }); expect(harness.reportFailure).toHaveBeenCalledWith("show-link-context-menu", cause); - expect(harness.openInBrowser).not.toHaveBeenCalled(); + expect(harness.openInPreview).not.toHaveBeenCalled(); expect(harness.openExternal).not.toHaveBeenCalled(); expect(harness.copyLink).not.toHaveBeenCalled(); }); it.each([ - ["open-in-browser" as const, "openInBrowser" as const, "open-link-in-preview"], + ["open-in-preview" as const, "openInPreview" as const, "open-link-in-preview"], ["open-external" as const, "openExternal" as const, "open-link-external"], ])("reports a failed %s action", async (selection, callback, operation) => { const harness = createHarness(selection); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index 408945cec9e..020c6169ff4 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -1,6 +1,6 @@ import type { ContextMenuItem } from "@t3tools/contracts"; -export type ExternalLinkContextMenuAction = "open-in-browser" | "open-external" | "copy-link"; +export type ExternalLinkContextMenuAction = "open-in-preview" | "open-external" | "copy-link"; export type ExternalLinkContextMenuFailureOperation = | "show-link-context-menu" @@ -9,13 +9,13 @@ export type ExternalLinkContextMenuFailureOperation = | "copy-link"; const FAILURE_OPERATION_BY_ACTION = { - "open-in-browser": "open-link-in-preview", + "open-in-preview": "open-link-in-preview", "open-external": "open-link-external", "copy-link": "copy-link", } as const satisfies Record; const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ - { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "open-in-preview", label: "Open in integrated browser" }, { id: "open-external", label: "Open in system browser" }, { id: "copy-link", label: "Copy Link" }, ] as const satisfies readonly ContextMenuItem[]; @@ -27,7 +27,7 @@ interface ShowExternalLinkContextMenuOptions { items: readonly ContextMenuItem[], position: { readonly x: number; readonly y: number }, ) => Promise; - readonly openInBrowser: (href: string) => Promise; + readonly openInPreview: (href: string) => Promise; readonly openExternal: (href: string) => Promise; readonly copyLink: (href: string) => Promise; readonly reportFailure: ( @@ -49,7 +49,7 @@ export async function showExternalLinkContextMenu({ href, position, showContextMenu, - openInBrowser, + openInPreview, openExternal, copyLink, reportFailure, @@ -63,8 +63,8 @@ export async function showExternalLinkContextMenu({ } try { - if (action === "open-in-browser") { - await openInBrowser(href); + if (action === "open-in-preview") { + await openInPreview(href); } else if (action === "open-external") { await openExternal(href); } else if (action === "copy-link") { From d7370cbf3b45ecf1934d41387f82a7b6daa116ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 17:14:41 +0200 Subject: [PATCH 3/3] Consolidate external link parsing --- apps/web/src/components/ChatMarkdown.tsx | 20 ++++++------------ .../chat/externalLinkContextMenu.test.ts | 21 ++++++++++--------- .../chat/externalLinkContextMenu.ts | 8 ++++--- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 36fab2d5da7..fac3bf7d245 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,7 +42,10 @@ import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { isExternalWebLink, showExternalLinkContextMenu } from "./chat/externalLinkContextMenu"; +import { + resolveExternalWebLinkHost, + showExternalLinkContextMenu, +} from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -833,17 +836,6 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); -function resolveExternalLinkHost(href: string | undefined): string | null { - if (!href) return null; - try { - const url = new URL(href); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.hostname || null; - } catch { - return null; - } -} - const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( @@ -1395,7 +1387,7 @@ function ChatMarkdown({ const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { - const faviconHost = resolveExternalLinkHost(href); + const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -1412,7 +1404,7 @@ function ChatMarkdown({ } }} onContextMenu={(event) => { - if (!canOpenInPreview || !href || !isExternalWebLink(href)) return; + if (!canOpenInPreview || !href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts index 1f2b6b5ae0f..64935d53e46 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { isExternalWebLink, showExternalLinkContextMenu } from "./externalLinkContextMenu"; +import { resolveExternalWebLinkHost, showExternalLinkContextMenu } from "./externalLinkContextMenu"; function createHarness(selection: "open-in-preview" | "open-external" | "copy-link" | null) { const showContextMenu = vi.fn().mockResolvedValue(selection); @@ -114,14 +114,15 @@ describe("external chat link context menu", () => { }); it.each([ - ["https://example.com", true], - ["http://localhost:3000/path", true], - ["#details", false], - ["mailto:hello@example.com", false], - ["file:///tmp/example.txt", false], - ["javascript:void(0)", false], - ["not a URL", false], - ])("classifies %s as an external web link: %s", (href, expected) => { - expect(isExternalWebLink(href)).toBe(expected); + ["https://example.com", "example.com"], + ["http://localhost:3000/path", "localhost"], + ["#details", null], + ["mailto:hello@example.com", null], + ["file:///tmp/example.txt", null], + ["javascript:void(0)", null], + ["not a URL", null], + [undefined, null], + ])("resolves the external web-link host for %s as %s", (href, expected) => { + expect(resolveExternalWebLinkHost(href)).toBe(expected); }); }); diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index 020c6169ff4..398ca40da51 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -36,12 +36,14 @@ interface ShowExternalLinkContextMenuOptions { ) => void; } -export function isExternalWebLink(href: string): boolean { +export function resolveExternalWebLinkHost(href: string | undefined): string | null { + if (!href) return null; try { const url = new URL(href); - return (url.protocol === "http:" || url.protocol === "https:") && url.hostname.length > 0; + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.hostname || null; } catch { - return false; + return null; } }