feat: show favicons for external chat links

Adds DuckDuckGo favicon lookup for external URLs
Shows favicons in markdown and fetch tool link rows
Hides missing icons automatically
This commit is contained in:
Bohdan Triapitsyn
2026-05-18 19:25:10 +03:00
parent 81c834d194
commit eb3c78e224
3 changed files with 60 additions and 5 deletions
@@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon";
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -92,6 +92,27 @@ const useExternalLinkInteractions = ({
}, [containerRef, enabled]);
};
const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => {
const [failed, setFailed] = React.useState(false);
const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]);
if (!faviconUrl || failed) {
return null;
}
return (
<img
src={faviconUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="mr-1 inline-block size-4 rounded-sm align-[-0.1875em]"
onError={() => setFailed(true)}
/>
);
};
// Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = [];
@@ -935,15 +956,17 @@ const buildMarkdownComponents = ({
},
a({ href, children, ...props }) {
const targetHref = href ?? '';
const isExternal = isExternalHttpUrl(targetHref);
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
return (
<>
<a
{...props}
href={href}
target={isExternalHttpUrl(targetHref) ? '_blank' : undefined}
rel={isExternalHttpUrl(targetHref) ? 'noopener noreferrer' : undefined}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
>
{isExternal ? <ExternalLinkFavicon href={targetHref} /> : null}
{children}
</a>
{isLoopback && onPreviewLoopback ? (
@@ -22,6 +22,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { getExternalFaviconUrl } from '@/lib/url';
interface ProgressiveGroupProps {
parts: TurnActivityPart[];
@@ -41,6 +42,27 @@ interface ProgressiveGroupProps {
renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode;
}
const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => {
const [failed, setFailed] = React.useState(false);
const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]);
if (!faviconUrl || failed) {
return null;
}
return (
<img
src={faviconUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="size-4 flex-shrink-0 rounded-sm"
onError={() => setFailed(true)}
/>
);
};
const isActivityRunning = (activity: TurnActivityPart): boolean => {
if (activity.kind !== 'tool') return false;
const part = activity.part as ToolPartType;
@@ -728,13 +750,14 @@ const StaticToolRowInner: React.FC<{
target="_blank"
rel="noopener noreferrer"
className={cn(
'min-w-0 flex-1 underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90',
'min-w-0 flex-1 inline-flex items-center gap-1.5 underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90',
'truncate whitespace-nowrap typography-meta'
)}
style={{ color: 'var(--status-info)' }}
title={url}
>
{url}
<ExternalLinkFavicon href={url} />
<span className="min-w-0 truncate">{url}</span>
</a>
))
: null}
+9
View File
@@ -28,6 +28,15 @@ export const isExternalHttpUrl = (url: string): boolean => {
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
};
export const getExternalFaviconUrl = (url: string): string | null => {
const parsed = parseUrlSafely(url.trim());
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
return null;
}
return `https://icons.duckduckgo.com/ip3/${parsed.hostname.toLowerCase()}.ico`;
};
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']);
/**