From 37715a6804239873e52c59dcebfcbf08991a5360 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:18:52 -0800 Subject: [PATCH] feat: show favicons beside agent reply links --- packages/web/src/components/markdown.test.tsx | 58 +++++++++++++++ packages/web/src/components/markdown.tsx | 73 ++++++++++++++++--- 2 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 packages/web/src/components/markdown.test.tsx diff --git a/packages/web/src/components/markdown.test.tsx b/packages/web/src/components/markdown.test.tsx new file mode 100644 index 00000000..66d7448a --- /dev/null +++ b/packages/web/src/components/markdown.test.tsx @@ -0,0 +1,58 @@ +import {cleanup, fireEvent, render} from '@testing-library/react'; +import {afterEach, describe, expect, test} from 'bun:test'; + +import {Markdown} from './markdown'; + +afterEach(cleanup); + +describe('Markdown', () => { + test('shows a decorative favicon before absolute HTTPS links', () => { + const {container} = render( + , + ); + const links = container.querySelectorAll('a'); + const favicons = container.querySelectorAll('img'); + + expect(favicons).toHaveLength(2); + expect(favicons[0]?.getAttribute('src')).toBe('/api/favicons/www.example.com'); + expect(favicons[1]?.getAttribute('src')).toBe('/api/favicons/plain.example.com'); + expect(favicons[0]?.getAttribute('alt')).toBe(''); + expect(favicons[0]?.getAttribute('loading')).toBe('lazy'); + expect(links[0]?.firstElementChild).toBe(favicons[0]); + }); + + test('leaves relative, email and insecure links without a favicon', () => { + const {container} = render( + , + ); + + expect(container.querySelectorAll('a')).toHaveLength(5); + expect(container.querySelector('img')).toBeNull(); + }); + + test('hides a favicon that cannot be loaded', () => { + const {container, rerender} = render(); + const favicon = container.querySelector('img'); + + expect(favicon).not.toBeNull(); + fireEvent.error(favicon as HTMLImageElement); + expect(favicon?.hidden).toBe(true); + + rerender(); + expect(container.querySelector('img')).toBe(favicon); + expect(favicon?.hidden).toBe(true); + }); + + test('limits one reply to eight distinct favicon hosts', () => { + const firstEight = Array.from( + {length: 8}, + (_, index) => `[Site ${index + 1}](https://site-${index + 1}.example.com)`, + ).join(' '); + const links = `${firstEight} [Site 1 again](https://site-1.example.com/again) [Site 9](https://site-9.example.com)`; + const {container} = render(); + + expect(container.querySelectorAll('a')).toHaveLength(10); + expect(container.querySelectorAll('img')).toHaveLength(9); + expect(container.querySelector('a:last-of-type img')).toBeNull(); + }); +}); diff --git a/packages/web/src/components/markdown.tsx b/packages/web/src/components/markdown.tsx index 4c5e6bd8..ec78be2e 100644 --- a/packages/web/src/components/markdown.tsx +++ b/packages/web/src/components/markdown.tsx @@ -1,23 +1,53 @@ +import {createContext, useContext, useState} from 'react'; import ReactMarkdown, {type Components} from 'react-markdown'; import remarkGfm from 'remark-gfm'; import {cn} from '@proxy/ui/lib/utils'; +const MAX_FAVICON_HOSTS = 8; +const FaviconHostsContext = createContext | null>(null); + // What a persona answers, as Markdown: paragraphs, lists, tables, code and links, styled to sit // in a chat bubble. Every link opens in a new tab, since the conversation stays here. Each // component takes the element's own props, without the syntax tree node react-markdown hands // along, which is no attribute of an element. -const components: Components = { - p: ({node: _node, className, ...props}) => ( -

- ), - a: ({node: _node, className, ...props}) => ( +const MarkdownLink: NonNullable = ({ + node: _node, + className, + href, + children, + ...props +}) => { + const hosts = useContext(FaviconHostsContext); + const host = hosts ? faviconHost(href, hosts) : null; + return ( + > + {host && ( + { + event.currentTarget.hidden = true; + }} + /> + )} + {children} + + ); +}; + +const components: Components = { + a: MarkdownLink, + p: ({node: _node, className, ...props}) => ( +

), ul: ({node: _node, className, ...props}) => (

    @@ -77,12 +107,33 @@ const components: Components = { ), }; +function faviconHost(href: string | undefined, hosts: Set): string | null { + if (!href || !/^https:\/\//i.test(href) || !URL.canParse(href)) { + return null; + } + const url = new URL(href); + if (url.protocol !== 'https:') { + return null; + } + if (hosts.has(url.hostname)) { + return url.hostname; + } + if (hosts.size >= MAX_FAVICON_HOSTS) { + return null; + } + hosts.add(url.hostname); + return url.hostname; +} + export function Markdown({text, className}: {text: string; className?: string}) { + const [faviconHosts] = useState(() => new Set()); return ( -
    - - {text} - -
    + +
    + + {text} + +
    +
    ); }