fix: links in chat could not be opened (#8544)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-04-21 18:05:36 +12:00
committed by GitHub
parent 436e126628
commit 70e12d9430
14 changed files with 267 additions and 63 deletions
@@ -45,6 +45,13 @@
"title": "Environment Variables",
"toggleVisibility": "Toggle value visibility"
},
"linkSafety": {
"copied": "Copied!",
"copyLink": "Copy link",
"description": "You're about to visit an external website.",
"openLink": "Open link",
"title": "Open external link?"
},
"messageBranch": {
"next": "Next branch",
"page": "{{current}} of {{total}}",
@@ -40,6 +40,13 @@
"codeBlock": {
"copyLabel": "Copiar código"
},
"linkSafety": {
"copied": "¡Copiado!",
"copyLink": "Copiar enlace",
"description": "Estás a punto de visitar un sitio web externo.",
"openLink": "Abrir enlace",
"title": "¿Abrir enlace externo?"
},
"environmentVariables": {
"copyLabel": "Copiar variable de entorno",
"title": "Variables de entorno",
@@ -0,0 +1,10 @@
export function isExternalHref(href?: string): boolean {
if (!href) return false;
const lower = href.trim().toLowerCase();
return (
lower.startsWith("http://") ||
lower.startsWith("https://") ||
lower.startsWith("mailto:") ||
lower.startsWith("tel:")
);
}
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
interface LinkSafetyModalProps {
isOpen: boolean;
onClose: () => void;
url: string;
}
export function LinkSafetyModal({
isOpen,
onClose,
url,
}: LinkSafetyModalProps) {
const { t } = useTranslation("common");
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
useEffect(() => {
if (isOpen) setIsCopied(false);
}, [isOpen]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
const handleOpen = useCallback(async () => {
try {
await openUrl(url);
} catch (e: unknown) {
console.error("[linkSafety] openUrl failed:", e);
}
onClose();
}, [url, onClose]);
const handleCopy = useCallback(() => {
if (isCopied) return;
navigator.clipboard
.writeText(url)
.then(() => {
setIsCopied(true);
timeoutRef.current = window.setTimeout(() => setIsCopied(false), 2000);
})
.catch((e: unknown) =>
console.error("[linkSafety] clipboard write failed:", e),
);
}, [url, isCopied]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) onClose();
},
[onClose],
);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("components.linkSafety.title")}</DialogTitle>
<DialogDescription>
{t("components.linkSafety.description")}
</DialogDescription>
</DialogHeader>
<div className="break-all rounded-md bg-muted p-3 font-mono text-sm">
{url}
</div>
<DialogFooter className="flex-row">
<Button
className="flex-1"
onClick={handleCopy}
type="button"
variant="outline"
>
{isCopied
? t("components.linkSafety.copied")
: t("components.linkSafety.copyLink")}
</Button>
<Button className="flex-1" onClick={handleOpen} type="button">
{t("components.linkSafety.openLink")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+99 -10
View File
@@ -6,6 +6,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
import { isExternalHref } from "@/shared/lib/isExternalHref";
import { LinkSafetyModal } from "@/shared/ui/ai-elements/link-safety-modal";
import { cn } from "@/shared/lib/cn";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
@@ -325,17 +327,104 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
type OpenLinkSafetyModal = (url: string) => void;
const LinkSafetyContext = createContext<OpenLinkSafetyModal | null>(null);
/**
* Custom link component that splits behavior by link type:
* - External links → <a> with preventDefault that opens a LinkSafetyModal via context
* - Internal links → plain <a> so useArtifactLinkHandler can intercept via closest("a")
*
* Both render as <a> elements. useArtifactLinkHandler has an early return for external
* hrefs, so there is no conflict with its delegated click handler.
*
* This replaces Streamdown's built-in linkSafety which renders <button> for ALL
* links, breaking artifact navigation since useArtifactLinkHandler matches on <a>.
*/
const MarkdownLink = memo(
({
children,
href,
node: _node,
...rest
}: ComponentProps<"a"> & { node?: unknown }) => {
const openModal = useContext(LinkSafetyContext);
if (isExternalHref(href)) {
return (
<a
className="wrap-anywhere font-medium text-primary underline"
data-streamdown="link"
href={href}
rel="noreferrer"
onClick={(e) => {
e.preventDefault();
openModal?.(href ?? "");
}}
{...rest}
>
{children}
</a>
);
}
return (
<a
className="wrap-anywhere font-medium text-primary underline"
data-streamdown="link"
href={href}
rel="noreferrer"
{...rest}
>
{children}
</a>
);
},
);
MarkdownLink.displayName = "MarkdownLink";
const streamdownComponents = { a: MarkdownLink };
const linkSafetyConfig: ComponentProps<typeof Streamdown>["linkSafety"] = {
enabled: false,
};
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
plugins={streamdownPlugins}
{...props}
/>
),
({ className, ...props }: MessageResponseProps) => {
const [modalUrl, setModalUrl] = useState<string | null>(null);
const openModal = useCallback((url: string) => {
setModalUrl(url);
}, []);
const closeModal = useCallback(() => {
setModalUrl(null);
}, []);
return (
<LinkSafetyContext.Provider value={openModal}>
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
components={streamdownComponents}
linkSafety={linkSafetyConfig}
plugins={streamdownPlugins}
{...props}
/>
<LinkSafetyModal
isOpen={modalUrl !== null}
onClose={closeModal}
url={modalUrl ?? ""}
/>
</LinkSafetyContext.Provider>
);
},
// Internal state (modalUrl) is intentionally outside this comparator —
// React always re-renders when local state changes regardless of memo.
// If modalUrl is ever lifted to a prop, this comparator must be updated.
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating,