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}
+
+
+
);
}