From 906ea0fa354b45aa26374b86f4eb6816a08836b8 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 3 Aug 2026 14:43:21 -0700 Subject: [PATCH] [eric] privacy: domain icons render locally (simple-icons + monogram), Google favicon beacon deleted --- frontend/package-lock.json | 20 ++++++ frontend/package.json | 1 + .../tool-bubbles/CompactMcpBubble.tsx | 14 +---- .../tool-bubbles/DefaultToolBubble.tsx | 14 +---- .../AgentChat/tool-bubbles/DomainIcon.tsx | 47 ++++++++++++++ .../AgentChat/tool-bubbles/SourceFavicons.tsx | 54 ++++++---------- .../tool-bubbles/domainBrand.test.ts | 47 ++++++++++++++ .../AgentChat/tool-bubbles/domainBrand.ts | 63 +++++++++++++++++++ 8 files changed, 204 insertions(+), 56 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/tool-bubbles/DomainIcon.tsx create mode 100644 frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts create mode 100644 frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 83b1a80e..7b3a20e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "recharts": "^2.15.4", "remark-gfm": "^4.0.1", "shiki": "^3.23.0", + "simple-icons": "^16.28.0", "supercluster": "^8.0.1", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" @@ -12843,6 +12844,25 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-icons": { + "version": "16.28.0", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.28.0.tgz", + "integrity": "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4e7c16ba..b9710730 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "recharts": "^2.15.4", "remark-gfm": "^4.0.1", "shiki": "^3.23.0", + "simple-icons": "^16.28.0", "supercluster": "^8.0.1", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index 5dc598c1..3866dfe8 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -19,7 +19,8 @@ import { ParsedResult } from '../parsing/toolResultParsing'; import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta'; import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta'; import { McpResultCard } from '../mcp-cards/McpResultCard'; -import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons'; +import { domainFromUrl } from './SourceFavicons'; +import { DomainIcon } from './DomainIcon'; interface CompactMcpBubbleProps { call: AgentMessage; @@ -106,16 +107,7 @@ export const CompactMcpBubble: React.FC = ({ )} {visibleSummary && !isError && !stackBelow && ( <> - {webDomain && ( - ) => { e.currentTarget.style.display = 'none'; }} - sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }} - /> - )} + {webDomain && } = ({ )} {inputSummary && !isStreaming && ( - {webDomain && ( - ) => { e.currentTarget.style.display = 'none'; }} - sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }} - /> - )} + {webDomain && } = ({ domain, size, shape = 'rounded' }) => { + const c = useClaudeTokens(); + const icon = brandIconForDomain(domain); + if (icon) { + return ( + + + + ); + } + return ( + + {(domain.replace(/^www\./, '')[0] ?? '?').toUpperCase()} + + ); +}; diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx index a6411542..7f17fa11 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/SourceFavicons.tsx @@ -1,12 +1,7 @@ -import React, { useState } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -// Same favicon service LibreChat ships with; the browser cards already load arbitrary sites, so -// fetching site icons adds no new exposure class. -export function faviconUrlForDomain(domain: string): string { - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; -} +import { DomainIcon } from './DomainIcon'; export function domainFromUrl(url: string): string { try { @@ -20,35 +15,26 @@ const MAX_STACK = 3; const FaviconDot: React.FC<{ domain: string; size: number; overlap: boolean; z: number }> = ({ domain, size, overlap, z }) => { const c = useClaudeTokens(); - const [failed, setFailed] = useState(false); - const ring = { - width: size, - height: size, - borderRadius: '50%', - border: `1.5px solid ${c.bg.elevated}`, - bgcolor: c.bg.secondary, - ml: overlap ? '-6px' : 0, - zIndex: z, - position: 'relative' as const, - flexShrink: 0, - }; - if (failed) { - // assistant-ui's fallback: the domain's first letter beats a hole in the stack. - return ( - - {(domain[0] || '?').toUpperCase()} - - ); - } return ( setFailed(true)} - sx={ring} - /> + sx={{ + width: size, + height: size, + borderRadius: '50%', + border: `1.5px solid ${c.bg.elevated}`, + bgcolor: c.bg.secondary, + ml: overlap ? '-6px' : 0, + zIndex: z, + position: 'relative', + flexShrink: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }} + > + + ); }; diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts b/frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts new file mode 100644 index 00000000..86197eb7 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts @@ -0,0 +1,47 @@ +// Run: node --test frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { brandIconForDomain, monogramHue } from './domainBrand.ts'; + +test('a known brand domain resolves to a bundled icon', () => { + assert.ok(brandIconForDomain('github.com')); +}); + +test('a subdomain walks up to its registered brand', () => { + assert.equal(brandIconForDomain('docs.github.com'), brandIconForDomain('github.com')); + assert.equal(brandIconForDomain('en.wikipedia.org'), brandIconForDomain('wikipedia.org')); +}); + +test('www and case are ignored', () => { + assert.equal(brandIconForDomain('www.GitHub.com'), brandIconForDomain('github.com')); +}); + +test('an unknown domain has no brand icon', () => { + assert.equal(brandIconForDomain('example.org'), null); +}); + +test('lookalike hosts never match across a label boundary', () => { + assert.equal(brandIconForDomain('evilgithub.com'), null); +}); + +test('monogram hue is deterministic and a valid hue', () => { + assert.equal(monogramHue('example.org'), monogramHue('example.org')); + for (const d of ['a', 'example.org', 'sub.long-domain-name.co.uk']) { + const h = monogramHue(d); + assert.ok(h >= 0 && h < 360, `${d} hue out of range: ${h}`); + } +}); + +test('no favicon beacon: nothing in tool-bubbles builds a remote favicon URL', () => { + const dir = dirname(fileURLToPath(import.meta.url)); + const sources = readdirSync(dir).filter((n) => (n.endsWith('.ts') || n.endsWith('.tsx')) && !n.includes('.test.')); + for (const f of sources) { + const src = readFileSync(join(dir, f), 'utf8'); + assert.ok(!src.includes('s2/favicons'), `${f} references a favicon service`); + assert.ok(!src.includes('favicons?domain='), `${f} builds a favicon beacon URL`); + assert.ok(!src.includes('faviconUrlForDomain'), `${f} still references the deleted beacon helper`); + } +}); diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.ts b/frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.ts new file mode 100644 index 00000000..9d612951 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.ts @@ -0,0 +1,63 @@ +import type { SimpleIcon } from 'simple-icons'; +import { + siAnthropic, siArxiv, siBitbucket, siCloudflare, siDiscord, siDuckduckgo, siFacebook, siFigma, + siGithub, siGitlab, siGoogle, siHuggingface, siInstagram, siKaggle, siMedium, siMozilla, + siNetflix, siNotion, siNpm, siPinterest, siQuora, siReddit, siSpotify, siStackoverflow, + siSubstack, siTiktok, siTwitch, siVercel, siWikipedia, siX, siYcombinator, siYoutube, +} from 'simple-icons'; + +// Bundled CC0 marks instead of a favicon service: fetching icons remotely reports the user's reading list to a third party (ENG-130). +const BRAND_BY_DOMAIN: Record = { + 'anthropic.com': siAnthropic, + 'arxiv.org': siArxiv, + 'bitbucket.org': siBitbucket, + 'cloudflare.com': siCloudflare, + 'discord.com': siDiscord, + 'duckduckgo.com': siDuckduckgo, + 'facebook.com': siFacebook, + 'figma.com': siFigma, + 'github.com': siGithub, + 'gitlab.com': siGitlab, + 'google.com': siGoogle, + 'huggingface.co': siHuggingface, + 'instagram.com': siInstagram, + 'kaggle.com': siKaggle, + 'medium.com': siMedium, + 'mozilla.org': siMozilla, + 'netflix.com': siNetflix, + 'notion.so': siNotion, + 'npmjs.com': siNpm, + 'pinterest.com': siPinterest, + 'quora.com': siQuora, + 'reddit.com': siReddit, + 'spotify.com': siSpotify, + 'stackoverflow.com': siStackoverflow, + 'substack.com': siSubstack, + 'tiktok.com': siTiktok, + 'twitch.tv': siTwitch, + 'twitter.com': siX, + 'vercel.com': siVercel, + 'wikipedia.org': siWikipedia, + 'x.com': siX, + 'ycombinator.com': siYcombinator, + 'youtube.com': siYoutube, +}; + +export function brandIconForDomain(domain: string): SimpleIcon | null { + let host = domain.toLowerCase().replace(/^www\./, ''); + while (host.length > 0) { + const hit = BRAND_BY_DOMAIN[host]; + if (hit !== undefined) return hit; + const dot = host.indexOf('.'); + if (dot === -1) return null; + host = host.slice(dot + 1); + } + return null; +} + +// Deterministic hue, so a domain's monogram tile never changes color between renders or sessions. +export function monogramHue(domain: string): number { + let h = 0; + for (let i = 0; i < domain.length; i += 1) h = (h * 31 + domain.charCodeAt(i)) | 0; + return Math.abs(h) % 360; +}