fix: external links in desktop app - context menu and open behavior (#716)

* fix: allow native context menu on links in chat messages

The desktop app was blocking the context menu on all elements except
specific allowlisted ones (terminal, input, textarea, etc.). This
prevented users from right-clicking on HTTP links in chat messages
to access the 'Open Link' option.

Added 'a' (anchor) tag to the allowlist to restore native context
menu functionality for links.

Fixes #708

* fix: use tauri.shell.open for external links in desktop app

- Add global window.open override to init_script that routes HTTP/HTTPS
  URLs through tauri.shell.open() instead of window.open()
- Add openExternalUrl utility that prefers tauri.shell.open with window.open fallback
- Add openExternalUrl to MarkdownRenderer for link safety.onLinkCheck
- Replace window.open with openExternalUrl in ProvidersPage for OAuth URLs

Fixes #708

* fix: unify external link opening across desktop and UI

Added a shared URL opener that only allows http/https links.
Replaced duplicated Tauri/window link-open logic in key UI sections.
Removed fragile desktop window.open override and markdown external-link modal behavior.

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-03-20 13:11:45 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent a346d3f3da
commit bf61ccedc7
11 changed files with 158 additions and 151 deletions
@@ -11,6 +11,7 @@ import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
@@ -152,6 +153,59 @@ const useCurrentMermaidTheme = () => {
: fallbackLight);
};
const useExternalLinkInteractions = ({
containerRef,
enabled,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
enabled?: boolean;
}) => {
React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
}, [containerRef, enabled]);
};
// Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = [];
@@ -1356,6 +1410,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode,
deferValidationUntilIdle: isStreaming,
});
useExternalLinkInteractions({ containerRef: streamdownContainerRef });
const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
@@ -1377,12 +1432,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
mode={isStreaming && !disableStreamAnimation ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={streamdownControls}
plugins={streamdownPlugins}
components={streamdownComponents}
animated={disableStreamAnimation ? undefined : streamdownAnimated}
isAnimating={disableStreamAnimation ? false : isStreaming}
>
controls={streamdownControls}
plugins={streamdownPlugins}
components={streamdownComponents}
animated={disableStreamAnimation ? undefined : streamdownAnimated}
isAnimating={disableStreamAnimation ? false : isStreaming}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={{ enabled: false }}
>
{content}
</Streamdown>
</div>
@@ -1438,6 +1495,7 @@ export const SimpleMarkdownRenderer: React.FC<{
editor,
preferRuntimeEditor: runtime.isVSCode,
});
useExternalLinkInteractions({ containerRef: streamdownContainerRef, enabled: !disableLinkSafety });
const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
@@ -1460,7 +1518,7 @@ export const SimpleMarkdownRenderer: React.FC<{
plugins={streamdownPlugins}
components={streamdownComponents}
// @ts-expect-error Streamdown type missing linkSafety in older minor
linkSafety={disableLinkSafety ? { enabled: false } : undefined}
linkSafety={{ enabled: false }}
>
{renderedContent}
</Streamdown>