From 078a9f4ea6ee2556a655dfd8f8ac03c86426bfcc Mon Sep 17 00:00:00 2001 From: Michaelgathara Date: Sat, 21 Jun 2025 19:03:45 -0500 Subject: [PATCH] feat: enhance web search source links with inline favicon bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace plain blue text links with small circular favicon bubbles that appear inline within text. The bubbles show the website's favicon and expand on hover to display the domain name, providing a cleaner and more intuitive way to display source links in web search results. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../web/src/components/chat-body/ChatBody.tsx | 600 +++++++++--------- .../source-bubble/SourceBubble.module.css | 79 +++ .../components/source-bubble/SourceBubble.tsx | 65 ++ 3 files changed, 441 insertions(+), 303 deletions(-) create mode 100644 frontend/web/src/components/source-bubble/SourceBubble.module.css create mode 100644 frontend/web/src/components/source-bubble/SourceBubble.tsx diff --git a/frontend/web/src/components/chat-body/ChatBody.tsx b/frontend/web/src/components/chat-body/ChatBody.tsx index 41dc652..fa2bf23 100644 --- a/frontend/web/src/components/chat-body/ChatBody.tsx +++ b/frontend/web/src/components/chat-body/ChatBody.tsx @@ -1,304 +1,298 @@ -import ReactMarkdown from 'react-markdown'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { IoCopyOutline, IoCheckmark, IoDocumentText, IoImage, IoDownload, IoSearch } from 'react-icons/io5'; -import { FaFileCsv } from "react-icons/fa6"; -import { useState } from 'react'; -import styles from './ChatBody.module.css'; -import Image from 'next/image'; - -interface Message { - id: string; - text: string; - isUser: boolean; - timestamp: Date; - isStreaming?: boolean; - webSearchUsed?: boolean; - file?: { - type: string; - url: string; - name: string; - }; -} - -interface CodeProps { - node?: any; - inline?: boolean; - className?: string; - children?: React.ReactNode; -} - -const CodeBlock = ({ className, children, ...props }: CodeProps) => { - const [copied, setCopied] = useState(false); - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : 'text'; - const codeString = String(children).replace(/\n$/, ''); - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(codeString); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }; - - if (!codeString.includes("\n")) { - return ( - - {codeString} - - ); - } - - return ( -
- - {codeString} - - -
- {language !== 'text' && ( -
- {language} -
- )} - -
-
- ); -}; - -const getFileType = (mimeType: string): 'image' | 'pdf' | 'csv' | 'unknown' => { - if (mimeType.startsWith('image/')) return 'image'; - if (mimeType === 'application/pdf') return 'pdf'; - if (mimeType === 'text/csv' || mimeType === 'application/csv') return 'csv'; - return 'unknown'; -}; - -const getFileIcon = (fileType: 'image' | 'pdf' | 'csv' | 'unknown') => { - switch (fileType) { - case 'image': - return ; - case 'pdf': - return ; - case 'csv': - return ; - default: - return ; - } -}; - -const formatFileSize = (fileName: string): string => { - const extension = fileName.split('.').pop()?.toLowerCase() || ''; - const typeMap: Record = { - 'pdf': 'PDF', - 'csv': 'CSV', - 'jpg': 'JPG', - 'jpeg': 'JPEG', - 'png': 'PNG', - 'gif': 'GIF', - 'webp': 'WebP' - }; - return typeMap[extension] || 'File'; -}; - -const FileDisplay = ({ file }: { file: { type: string; url: string; name: string } }) => { - const fileType = getFileType(file.type); - - if (fileType === 'image') { - return ( -
-
- {file.name} -
- {file.name} - - - -
-
-
- ); - } else { - return ( -
-
-
- {getFileIcon(fileType)} -
- {file.name} - - {formatFileSize(file.name)} - -
-
- - - -
-
- ); - } -}; - -export const ChatBody = ({ messages }: { messages: Message[] }) => { - return ( -
-
- {messages.length === 0 ? ( -

Hi There!

- ) : ( - messages.map((msg) => ( -
- {msg.file && ( - - )} - {msg.isUser ? ( -
{msg.text}
- ) : ( - <> - {msg.webSearchUsed && ( -
- - Web search enabled -
- )} -
- <>{children}, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - p: ({ children, node }) => { - const firstChild = node?.children?.[0]; - const hasOnlyCodeBlock = - node?.children?.length === 1 && - firstChild?.type === 'element' && - firstChild?.tagName === 'code'; - - const isBlockLevelCode = - hasOnlyCodeBlock && - firstChild?.type === 'element' && - Array.isArray(firstChild.properties?.className) && - firstChild.properties?.className?.some((cls) => - typeof cls === 'string' && cls.startsWith('language-') - ); - const hasBlockElements = node?.children?.some((child: any) => - child?.type === 'element' && - ['div', 'pre', 'blockquote', 'table', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName) - ); - - if (isBlockLevelCode || hasBlockElements) { - return <>{children}; - } - - return

{children}

; - }, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - a: ({ children, href }) => ( - - {children} - - ), - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - hr: () =>
    , - }} - > - {msg.text} -
    -
    - - )} - {msg.isStreaming && ( - - )} -
    - )) - )} -
    -
    - ); +import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { IoCopyOutline, IoCheckmark, IoDocumentText, IoImage, IoDownload, IoSearch } from 'react-icons/io5'; +import { FaFileCsv } from "react-icons/fa6"; +import { useState } from 'react'; +import styles from './ChatBody.module.css'; +import Image from 'next/image'; +import SourceBubble from '../source-bubble/SourceBubble'; + +interface Message { + id: string; + text: string; + isUser: boolean; + timestamp: Date; + isStreaming?: boolean; + webSearchUsed?: boolean; + file?: { + type: string; + url: string; + name: string; + }; +} + +interface CodeProps { + node?: any; + inline?: boolean; + className?: string; + children?: React.ReactNode; +} + +const CodeBlock = ({ className, children, ...props }: CodeProps) => { + const [copied, setCopied] = useState(false); + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : 'text'; + const codeString = String(children).replace(/\n$/, ''); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(codeString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + if (!codeString.includes("\n")) { + return ( + + {codeString} + + ); + } + + return ( +
    + + {codeString} + + +
    + {language !== 'text' && ( +
    + {language} +
    + )} + +
    +
    + ); +}; + +const getFileType = (mimeType: string): 'image' | 'pdf' | 'csv' | 'unknown' => { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType === 'application/pdf') return 'pdf'; + if (mimeType === 'text/csv' || mimeType === 'application/csv') return 'csv'; + return 'unknown'; +}; + +const getFileIcon = (fileType: 'image' | 'pdf' | 'csv' | 'unknown') => { + switch (fileType) { + case 'image': + return ; + case 'pdf': + return ; + case 'csv': + return ; + default: + return ; + } +}; + +const formatFileSize = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + const typeMap: Record = { + 'pdf': 'PDF', + 'csv': 'CSV', + 'jpg': 'JPG', + 'jpeg': 'JPEG', + 'png': 'PNG', + 'gif': 'GIF', + 'webp': 'WebP' + }; + return typeMap[extension] || 'File'; +}; + +const FileDisplay = ({ file }: { file: { type: string; url: string; name: string } }) => { + const fileType = getFileType(file.type); + + if (fileType === 'image') { + return ( +
    +
    + {file.name} +
    + {file.name} + + + +
    +
    +
    + ); + } else { + return ( +
    +
    +
    + {getFileIcon(fileType)} +
    + {file.name} + + {formatFileSize(file.name)} + +
    +
    + + + +
    +
    + ); + } +}; + +export const ChatBody = ({ messages }: { messages: Message[] }) => { + return ( +
    +
    + {messages.length === 0 ? ( +

    Hi There!

    + ) : ( + messages.map((msg) => ( +
    + {msg.file && ( + + )} + {msg.isUser ? ( +
    {msg.text}
    + ) : ( + <> + {msg.webSearchUsed && ( +
    + + Web search enabled +
    + )} +
    + <>{children}, + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + p: ({ children, node }) => { + const firstChild = node?.children?.[0]; + const hasOnlyCodeBlock = + node?.children?.length === 1 && + firstChild?.type === 'element' && + firstChild?.tagName === 'code'; + + const isBlockLevelCode = + hasOnlyCodeBlock && + firstChild?.type === 'element' && + Array.isArray(firstChild.properties?.className) && + firstChild.properties?.className?.some((cls) => + typeof cls === 'string' && cls.startsWith('language-') + ); + const hasBlockElements = node?.children?.some((child: any) => + child?.type === 'element' && + ['div', 'pre', 'blockquote', 'table', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(child.tagName) + ); + + if (isBlockLevelCode || hasBlockElements) { + return <>{children}; + } + + return

    {children}

    ; + }, + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + a: ({ children, href }) => ( + + ), + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () =>
    , + }} + > + {msg.text} +
    +
    + + )} + {msg.isStreaming && ( + + )} +
    + )) + )} +
    +
    + ); } \ No newline at end of file diff --git a/frontend/web/src/components/source-bubble/SourceBubble.module.css b/frontend/web/src/components/source-bubble/SourceBubble.module.css new file mode 100644 index 0000000..bc1f8e8 --- /dev/null +++ b/frontend/web/src/components/source-bubble/SourceBubble.module.css @@ -0,0 +1,79 @@ +.container { + position: relative; + display: inline-block; + margin: 0 2px; +} + +.bubble { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 50%; + text-decoration: none; + transition: all 0.2s ease; + position: relative; + vertical-align: middle; +} + +.bubble:hover { + background: rgba(59, 130, 246, 0.25); + border-color: rgba(59, 130, 246, 0.5); + transform: scale(1.1); +} + +.iconContainer { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; +} + +.favicon { + width: 12px; + height: 12px; + border-radius: 2px; +} + +.defaultIcon { + width: 10px; + height: 10px; + color: rgba(147, 197, 253, 0.8); +} + +.tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-4px); + background: rgba(17, 24, 39, 0.95); + color: white; + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + backdrop-filter: blur(8px); + border: 1px solid rgba(75, 85, 99, 0.3); + z-index: 1000; +} + +.tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: rgba(17, 24, 39, 0.95); +} + +.tooltip.visible { + opacity: 1; +} \ No newline at end of file diff --git a/frontend/web/src/components/source-bubble/SourceBubble.tsx b/frontend/web/src/components/source-bubble/SourceBubble.tsx new file mode 100644 index 0000000..bc8b10b --- /dev/null +++ b/frontend/web/src/components/source-bubble/SourceBubble.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import styles from './SourceBubble.module.css'; + +interface SourceBubbleProps { + href: string; + children: React.ReactNode; +} + +const SourceBubble: React.FC = ({ href }) => { + const [isHovered, setIsHovered] = useState(false); + const [imageError, setImageError] = useState(false); + + const getDomain = (url: string) => { + try { + return new URL(url).hostname.replace('www.', ''); + } catch { + return 'link'; + } + }; + + const getFaviconUrl = (url: string) => { + try { + const domain = new URL(url).hostname; + return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`; + } catch { + return null; + } + }; + + const domain = getDomain(href); + const faviconUrl = getFaviconUrl(href); + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {faviconUrl && !imageError ? ( + setImageError(true)} + /> + ) : ( + + + + )} + + + {domain} + + + + ); +}; + +export default SourceBubble; \ No newline at end of file