Skip to content

Commit c02f12a

Browse files
matt2eclaude
andcommitted
fix: open external links in chat via Tauri opener plugin
External links in chat messages were silently ignored in Tauri's WKWebView. Add a custom MarkdownLink component that splits link behavior by type: external links show a LinkSafetyModal confirmation dialog before opening via @tauri-apps/plugin-opener, while internal links render as plain <a> elements so useArtifactLinkHandler can intercept them for artifact navigation. This replaces Streamdown's built-in linkSafety which converts all links to <button> elements, breaking the artifact policy layer that relies on closest("a") to resolve internal paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bae26d6 commit c02f12a

File tree

4 files changed

+158
-4
lines changed

4 files changed

+158
-4
lines changed

ui/goose2/src/features/chat/hooks/__tests__/useArtifactLinkHandler.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,12 @@ describe("useArtifactLinkHandler", () => {
110110
);
111111
});
112112

113-
it("ignores external URLs (does not call resolveMarkdownHref)", async () => {
113+
it("does not intercept external URLs (defers to Streamdown linkSafety modal)", async () => {
114114
const user = userEvent.setup();
115-
mockResolveMarkdownHref.mockReturnValue(null);
116115

117116
render(<Harness href="https://example.com" label="External" />);
118117
await user.click(screen.getByText("External"));
119118

120-
// isExternalHref returns true, so resolveMarkdownHref is never called
121119
expect(mockResolveMarkdownHref).not.toHaveBeenCalled();
122120
expect(mockOpenResolvedPath).not.toHaveBeenCalled();
123121
});

ui/goose2/src/features/chat/hooks/useArtifactLinkHandler.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { useArtifactPolicyContext } from "@/features/chat/hooks/ArtifactPolicyCo
55
/**
66
* Delegated click handler that intercepts local link clicks within a
77
* container and routes them through the artifact policy layer.
8+
*
9+
* External links are intentionally not handled here — Streamdown's
10+
* linkSafety modal shows a confirmation dialog before opening them.
811
*/
912
export function useArtifactLinkHandler() {
1013
const { resolveMarkdownHref, openResolvedPath } = useArtifactPolicyContext();
@@ -15,7 +18,9 @@ export function useArtifactLinkHandler() {
1518
const anchor = (event.target as HTMLElement).closest("a");
1619
if (!anchor) return;
1720
const href = anchor.getAttribute("href");
18-
if (!href || isExternalHref(href)) return;
21+
if (!href) return;
22+
23+
if (isExternalHref(href)) return;
1924

2025
event.preventDefault();
2126
const resolved = resolveMarkdownHref(href);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { openUrl } from "@tauri-apps/plugin-opener";
3+
import { Button } from "@/shared/ui/button";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/shared/ui/dialog";
12+
13+
interface LinkSafetyModalProps {
14+
isOpen: boolean;
15+
onClose: () => void;
16+
url: string;
17+
}
18+
19+
export function LinkSafetyModal({
20+
isOpen,
21+
onClose,
22+
url,
23+
}: LinkSafetyModalProps) {
24+
const handleOpen = useCallback(() => {
25+
openUrl(url).catch((e: unknown) =>
26+
console.error("[linkSafety] openUrl failed:", e),
27+
);
28+
onClose();
29+
}, [url, onClose]);
30+
31+
const [isCopied, setIsCopied] = useState(false);
32+
const timeoutRef = useRef<number>(0);
33+
34+
useEffect(
35+
() => () => {
36+
window.clearTimeout(timeoutRef.current);
37+
},
38+
[],
39+
);
40+
41+
const handleCopy = useCallback(() => {
42+
if (isCopied) return;
43+
navigator.clipboard
44+
.writeText(url)
45+
.then(() => {
46+
setIsCopied(true);
47+
timeoutRef.current = window.setTimeout(() => setIsCopied(false), 2000);
48+
})
49+
.catch((e: unknown) =>
50+
console.error("[linkSafety] clipboard write failed:", e),
51+
);
52+
}, [url, isCopied]);
53+
54+
const handleOpenChange = useCallback(
55+
(open: boolean) => {
56+
if (!open) onClose();
57+
},
58+
[onClose],
59+
);
60+
61+
return (
62+
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
63+
<DialogContent>
64+
<DialogHeader>
65+
<DialogTitle>Open external link?</DialogTitle>
66+
<DialogDescription>
67+
You&apos;re about to visit an external website.
68+
</DialogDescription>
69+
</DialogHeader>
70+
<div className="break-all rounded-md bg-muted p-3 font-mono text-sm">
71+
{url}
72+
</div>
73+
<DialogFooter className="flex-row">
74+
<Button
75+
className="flex-1"
76+
onClick={handleCopy}
77+
type="button"
78+
variant="outline"
79+
>
80+
{isCopied ? "Copied!" : "Copy link"}
81+
</Button>
82+
<Button className="flex-1" onClick={handleOpen} type="button">
83+
Open link
84+
</Button>
85+
</DialogFooter>
86+
</DialogContent>
87+
</Dialog>
88+
);
89+
}

ui/goose2/src/shared/ui/ai-elements/message.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
TooltipProvider,
77
TooltipTrigger,
88
} from "@/shared/ui/tooltip";
9+
import { isExternalHref } from "@/features/chat/lib/artifactPathPolicy";
10+
import { LinkSafetyModal } from "@/shared/ui/ai-elements/link-safety-modal";
911
import { cn } from "@/shared/lib/cn";
1012
import { cjk } from "@streamdown/cjk";
1113
import { code } from "@streamdown/code";
@@ -325,13 +327,73 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
325327

326328
const streamdownPlugins = { cjk, code, math, mermaid };
327329

330+
/**
331+
* Custom link component that splits behavior by link type:
332+
* - External links → button + LinkSafetyModal (confirmation before opening)
333+
* - Internal links → plain <a> so useArtifactLinkHandler can intercept via closest("a")
334+
*
335+
* This replaces Streamdown's built-in linkSafety which renders <button> for ALL
336+
* links, breaking artifact navigation since useArtifactLinkHandler matches on <a>.
337+
*/
338+
const MarkdownLink = memo(
339+
({
340+
children,
341+
href,
342+
node: _node,
343+
...rest
344+
}: ComponentProps<"a"> & { node?: unknown }) => {
345+
const [isOpen, setIsOpen] = useState(false);
346+
347+
if (isExternalHref(href)) {
348+
return (
349+
<>
350+
<button
351+
className="wrap-anywhere appearance-none text-left font-medium text-primary underline"
352+
data-streamdown="link"
353+
onClick={() => setIsOpen(true)}
354+
type="button"
355+
>
356+
{children}
357+
</button>
358+
<LinkSafetyModal
359+
isOpen={isOpen}
360+
onClose={() => setIsOpen(false)}
361+
url={href ?? ""}
362+
/>
363+
</>
364+
);
365+
}
366+
367+
return (
368+
<a
369+
className="wrap-anywhere font-medium text-primary underline"
370+
data-streamdown="link"
371+
href={href}
372+
rel="noreferrer"
373+
{...rest}
374+
>
375+
{children}
376+
</a>
377+
);
378+
},
379+
);
380+
MarkdownLink.displayName = "MarkdownLink";
381+
382+
const streamdownComponents = { a: MarkdownLink };
383+
384+
const linkSafetyConfig: ComponentProps<typeof Streamdown>["linkSafety"] = {
385+
enabled: false,
386+
};
387+
328388
export const MessageResponse = memo(
329389
({ className, ...props }: MessageResponseProps) => (
330390
<Streamdown
331391
className={cn(
332392
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
333393
className,
334394
)}
395+
components={streamdownComponents}
396+
linkSafety={linkSafetyConfig}
335397
plugins={streamdownPlugins}
336398
{...props}
337399
/>

0 commit comments

Comments
 (0)