From eb3c78e224612bdcc7dfeed6878896b661a4c94b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 18 May 2026 19:25:10 +0300 Subject: [PATCH] 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 --- .../components/chat/MarkdownRendererImpl.tsx | 29 +++++++++++++++++-- .../chat/message/parts/ProgressiveGroup.tsx | 27 +++++++++++++++-- packages/ui/src/lib/url.ts | 9 ++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 4cd70dbb..e19bf95b 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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 ( + 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 ( <> + {isExternal ? : null} {children} {isLoopback && onPreviewLoopback ? ( diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 0f17bc99..78e0e1b6 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -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 ( + 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} + + {url} )) : null} diff --git a/packages/ui/src/lib/url.ts b/packages/ui/src/lib/url.ts index 7b623f8e..e052968f 100644 --- a/packages/ui/src/lib/url.ts +++ b/packages/ui/src/lib/url.ts @@ -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']); /**