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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user