diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 70a6a0338ca3..bdcf5d103e2c 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -19,7 +19,15 @@ import { RowButton } from '@/components/ui/row-button' import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listAllProfileSessions } from '@/hermes' import { type Translations, useI18n } from '@/i18n' -import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' +import { resolveBrandIcon } from '@/lib/brand-icon' +import { + ExternalLink, + ExternalLinkIcon, + hostPathLabel, + shortHostLabel, + urlSlugTitleLabel, + useLinkTitle +} from '@/lib/external-link' import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons' import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media' import { normalize } from '@/lib/text' @@ -543,7 +551,8 @@ function ArtifactCellAction({ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { const isLink = artifact.kind === 'link' - const Icon = isLink ? Link2 : FileText + const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null + const Icon = brand ?? (isLink ? Link2 : FileText) const fetchedTitle = useLinkTitle(isLink ? artifact.href : null) const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 988562c04941..b8e8ce66eca6 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -521,7 +521,7 @@ export const SessionRefLink: FC<{ return ( { event.preventDefault() diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index a54b1d1efe91..15842f15695e 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -233,7 +233,7 @@ function MediaAttachment({ path }: { path: string }) { return ( { event.preventDefault() @@ -283,7 +283,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a if (!target || !/^https?:\/\//i.test(target)) { return ( (Tag: T) { function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) { if (!href || !/^https?:\/\//i.test(href)) { return ( - + {children} ) diff --git a/apps/desktop/src/components/chat/generated-image-result.tsx b/apps/desktop/src/components/chat/generated-image-result.tsx index 32cb453c69c6..9ad4922644e3 100644 --- a/apps/desktop/src/components/chat/generated-image-result.tsx +++ b/apps/desktop/src/components/chat/generated-image-result.tsx @@ -96,7 +96,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({ if (failed && image) { return ( { event.preventDefault() diff --git a/apps/desktop/src/lib/brand-icon.test.ts b/apps/desktop/src/lib/brand-icon.test.ts new file mode 100644 index 000000000000..1b23ec458587 --- /dev/null +++ b/apps/desktop/src/lib/brand-icon.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { resolveBrandIcon } from './brand-icon' + +describe('resolveBrandIcon', () => { + it('resolves a registrable domain to a brand glyph', () => { + expect(resolveBrandIcon('github.com')).toBeTruthy() + expect(resolveBrandIcon('gitlab.com')).toBeTruthy() + }) + + it('ignores case and a leading www.', () => { + const github = resolveBrandIcon('github.com') + + expect(resolveBrandIcon('WWW.GitHub.com')).toBe(github) + }) + + it('inherits the parent brand on subdomains', () => { + const github = resolveBrandIcon('github.com') + + expect(resolveBrandIcon('gist.github.com')).toBe(github) + expect(resolveBrandIcon('api.github.com')).toBe(github) + }) + + it('prefers the longest matching suffix over its parent', () => { + const google = resolveBrandIcon('google.com') + const docs = resolveBrandIcon('docs.google.com') + + expect(docs).toBeTruthy() + expect(docs).not.toBe(google) + }) + + it('returns null for unknown hosts and bare labels', () => { + expect(resolveBrandIcon('example.com')).toBeNull() + expect(resolveBrandIcon('localhost')).toBeNull() + expect(resolveBrandIcon('')).toBeNull() + }) + + it('never matches on a bare public suffix', () => { + // `github.io` is a real entry; a lone `io` must not inherit it. + expect(resolveBrandIcon('io')).toBeNull() + expect(resolveBrandIcon('com')).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/brand-icon.ts b/apps/desktop/src/lib/brand-icon.ts new file mode 100644 index 000000000000..000a066d858f --- /dev/null +++ b/apps/desktop/src/lib/brand-icon.ts @@ -0,0 +1,426 @@ +import { + SiAnthropic, + SiArchlinux, + SiArxiv, + SiAsana, + SiAtlassian, + SiBehance, + SiBitbucket, + SiBlender, + SiBluesky, + SiBun, + SiBuymeacoffee, + SiCircleci, + SiClaude, + SiCloudflare, + SiCodeberg, + SiCodecov, + SiCodesandbox, + SiConfluence, + SiCoursera, + SiCrunchbase, + SiCursor, + SiDatadog, + SiDebian, + SiDeno, + SiDevdotto, + SiDigitalocean, + SiDiscord, + SiDjango, + SiDocker, + SiDropbox, + SiElectron, + SiEslint, + SiExcalidraw, + SiFacebook, + SiFastapi, + SiFigma, + SiFirebase, + SiFlask, + SiForgejo, + SiGhost, + SiGitea, + SiGithub, + SiGitlab, + SiGmail, + SiGo, + SiGodotengine, + SiGoodreads, + SiGoogle, + SiGoogledocs, + SiGoogledrive, + SiGooglegemini, + SiGooglemaps, + SiGooglescholar, + SiGrafana, + SiGraphql, + SiHashnode, + SiHomebrew, + SiHuggingface, + SiImdb, + SiInstagram, + SiInternetarchive, + SiItchdotio, + SiJenkins, + SiJira, + SiJupyter, + SiKaggle, + SiKofi, + SiKotlin, + SiKubernetes, + SiLaravel, + SiLeetcode, + SiLinear, + SiMastodon, + SiMdnwebdocs, + SiMedium, + SiMiro, + SiMistralai, + SiMongodb, + SiNetflix, + SiNetlify, + SiNodedotjs, + SiNotion, + SiNpm, + SiNvidia, + SiObsidian, + SiOllama, + SiOpenrouter, + SiOpenstreetmap, + SiOverleaf, + SiPatreon, + SiPaypal, + SiPerplexity, + SiPhp, + SiPinterest, + SiPnpm, + SiPostgresql, + SiPostman, + SiPrisma, + SiProducthunt, + SiPypi, + SiPython, + SiPytorch, + SiQuora, + SiRailway, + SiRaspberrypi, + SiRaycast, + SiReact, + SiReadthedocs, + SiReddit, + SiRedis, + SiRender, + SiReplicate, + SiReplit, + SiResearchgate, + SiRuby, + SiRubyonrails, + SiRust, + SiScikitlearn, + SiSemanticscholar, + SiSentry, + SiServerfault, + SiShopify, + SiSnyk, + SiSoundcloud, + SiSourcehut, + SiSpotify, + SiStackblitz, + SiStackexchange, + SiStackoverflow, + SiSteam, + SiStorybook, + SiStripe, + SiSubstack, + SiSupabase, + SiSuperuser, + SiSwift, + SiTailwindcss, + SiTauri, + SiTelegram, + SiTensorflow, + SiThreedotjs, + SiTiktok, + SiTldraw, + SiTrello, + SiTurborepo, + SiTwitch, + SiTypescript, + SiUbuntu, + SiUdemy, + SiUnity, + SiUnsplash, + SiVercel, + SiVimeo, + SiVite, + SiWarp, + SiWebflow, + SiWeightsandbiases, + SiWikipedia, + SiWindsurf, + SiWordpress, + SiX, + SiYcombinator, + SiYelp, + SiYoutube, + SiZedindustries, + SiZoom, + SiZotero +} from '@icons-pack/react-simple-icons' +import type { ComponentType, SVGProps } from 'react' + +// Simple Icons components accept a `title` prop on top of the usual SVG props. +// It matters: they always render a element, defaulting to the brand +// name, so callers need to be able to blank it out (see `LinkBrandIcon`). +export type BrandIcon = ComponentType<SVGProps<SVGSVGElement> & { title?: string }> + +// simpleicons.org brand marks keyed by registrable domain. Lookup walks the +// host's suffixes (see `resolveBrandIcon`), so one `github.com` entry also +// covers `gist.github.com` and `api.github.com` — only list a subdomain when it +// belongs to a *different* brand than its parent. +// +// Some brands are absent on purpose: Simple Icons has removed Slack, LinkedIn, +// OpenAI, Amazon, and CodePen at their owners' request, so those hosts fall +// through to no glyph rather than a lookalike. +const BRAND_ICONS: Record<string, BrandIcon> = { + // Code hosting & package registries + 'github.com': SiGithub, + 'github.io': SiGithub, + 'githubusercontent.com': SiGithub, + 'gitlab.com': SiGitlab, + 'gitlab.io': SiGitlab, + 'bitbucket.org': SiBitbucket, + 'codeberg.org': SiCodeberg, + 'gitea.com': SiGitea, + 'gitea.io': SiGitea, + 'forgejo.org': SiForgejo, + 'sr.ht': SiSourcehut, + 'npmjs.com': SiNpm, + 'pypi.org': SiPypi, + 'crates.io': SiRust, + 'huggingface.co': SiHuggingface, + 'hf.co': SiHuggingface, + + // Q&A, docs & reference + 'stackoverflow.com': SiStackoverflow, + 'stackexchange.com': SiStackexchange, + 'serverfault.com': SiServerfault, + 'superuser.com': SiSuperuser, + 'developer.mozilla.org': SiMdnwebdocs, + 'readthedocs.io': SiReadthedocs, + 'readthedocs.org': SiReadthedocs, + 'wikipedia.org': SiWikipedia, + 'archive.org': SiInternetarchive, + + // Research + 'arxiv.org': SiArxiv, + 'semanticscholar.org': SiSemanticscholar, + 'scholar.google.com': SiGooglescholar, + 'researchgate.net': SiResearchgate, + 'zotero.org': SiZotero, + 'overleaf.com': SiOverleaf, + + // AI + 'anthropic.com': SiAnthropic, + 'claude.ai': SiClaude, + 'gemini.google.com': SiGooglegemini, + 'perplexity.ai': SiPerplexity, + 'openrouter.ai': SiOpenrouter, + 'mistral.ai': SiMistralai, + 'ollama.com': SiOllama, + 'replicate.com': SiReplicate, + 'wandb.ai': SiWeightsandbiases, + 'kaggle.com': SiKaggle, + + // Social + 'x.com': SiX, + 'twitter.com': SiX, + 't.co': SiX, + 'reddit.com': SiReddit, + 'redd.it': SiReddit, + 'news.ycombinator.com': SiYcombinator, + 'bsky.app': SiBluesky, + 'mastodon.social': SiMastodon, + 'joinmastodon.org': SiMastodon, + 'facebook.com': SiFacebook, + 'instagram.com': SiInstagram, + 'tiktok.com': SiTiktok, + 'pinterest.com': SiPinterest, + 'discord.com': SiDiscord, + 'discord.gg': SiDiscord, + 't.me': SiTelegram, + 'telegram.org': SiTelegram, + 'producthunt.com': SiProducthunt, + 'crunchbase.com': SiCrunchbase, + 'quora.com': SiQuora, + + // Writing & blogs + 'medium.com': SiMedium, + 'substack.com': SiSubstack, + 'dev.to': SiDevdotto, + 'hashnode.dev': SiHashnode, + 'hashnode.com': SiHashnode, + 'wordpress.com': SiWordpress, + 'wordpress.org': SiWordpress, + 'ghost.org': SiGhost, + + // Media + 'youtube.com': SiYoutube, + 'youtu.be': SiYoutube, + 'vimeo.com': SiVimeo, + 'twitch.tv': SiTwitch, + 'soundcloud.com': SiSoundcloud, + 'spotify.com': SiSpotify, + 'netflix.com': SiNetflix, + 'imdb.com': SiImdb, + 'goodreads.com': SiGoodreads, + 'unsplash.com': SiUnsplash, + 'behance.net': SiBehance, + 'store.steampowered.com': SiSteam, + 'itch.io': SiItchdotio, + + // Product & project tools + 'linear.app': SiLinear, + 'notion.so': SiNotion, + 'notion.site': SiNotion, + 'figma.com': SiFigma, + 'atlassian.net': SiAtlassian, + 'atlassian.com': SiJira, + 'confluence.com': SiConfluence, + 'asana.com': SiAsana, + 'trello.com': SiTrello, + 'obsidian.md': SiObsidian, + 'miro.com': SiMiro, + 'excalidraw.com': SiExcalidraw, + 'tldraw.com': SiTldraw, + 'zoom.us': SiZoom, + + // Google + 'google.com': SiGoogle, + 'goo.gl': SiGoogle, + 'docs.google.com': SiGoogledocs, + 'drive.google.com': SiGoogledrive, + 'mail.google.com': SiGmail, + 'maps.google.com': SiGooglemaps, + 'openstreetmap.org': SiOpenstreetmap, + + // Hosting & infra + 'vercel.com': SiVercel, + 'vercel.app': SiVercel, + 'netlify.com': SiNetlify, + 'netlify.app': SiNetlify, + 'cloudflare.com': SiCloudflare, + 'pages.dev': SiCloudflare, + 'workers.dev': SiCloudflare, + 'railway.app': SiRailway, + 'render.com': SiRender, + 'digitalocean.com': SiDigitalocean, + 'firebase.google.com': SiFirebase, + 'supabase.com': SiSupabase, + 'docker.com': SiDocker, + 'kubernetes.io': SiKubernetes, + 'grafana.com': SiGrafana, + 'datadoghq.com': SiDatadog, + 'sentry.io': SiSentry, + 'snyk.io': SiSnyk, + 'codecov.io': SiCodecov, + 'circleci.com': SiCircleci, + 'jenkins.io': SiJenkins, + + // Languages, frameworks & tooling + 'python.org': SiPython, + 'nodejs.org': SiNodedotjs, + 'react.dev': SiReact, + 'typescriptlang.org': SiTypescript, + 'tailwindcss.com': SiTailwindcss, + 'vite.dev': SiVite, + 'vitejs.dev': SiVite, + 'rust-lang.org': SiRust, + 'go.dev': SiGo, + 'golang.org': SiGo, + 'ruby-lang.org': SiRuby, + 'rubyonrails.org': SiRubyonrails, + 'php.net': SiPhp, + 'laravel.com': SiLaravel, + 'djangoproject.com': SiDjango, + 'flask.palletsprojects.com': SiFlask, + 'fastapi.tiangolo.com': SiFastapi, + 'swift.org': SiSwift, + 'kotlinlang.org': SiKotlin, + 'deno.com': SiDeno, + 'bun.sh': SiBun, + 'pnpm.io': SiPnpm, + 'turborepo.com': SiTurborepo, + 'eslint.org': SiEslint, + 'storybook.js.org': SiStorybook, + 'graphql.org': SiGraphql, + 'prisma.io': SiPrisma, + 'postgresql.org': SiPostgresql, + 'redis.io': SiRedis, + 'mongodb.com': SiMongodb, + 'pytorch.org': SiPytorch, + 'tensorflow.org': SiTensorflow, + 'scikit-learn.org': SiScikitlearn, + 'jupyter.org': SiJupyter, + 'threejs.org': SiThreedotjs, + 'blender.org': SiBlender, + 'godotengine.org': SiGodotengine, + 'unity.com': SiUnity, + 'electronjs.org': SiElectron, + 'tauri.app': SiTauri, + 'brew.sh': SiHomebrew, + 'archlinux.org': SiArchlinux, + 'debian.org': SiDebian, + 'ubuntu.com': SiUbuntu, + 'raspberrypi.com': SiRaspberrypi, + 'nvidia.com': SiNvidia, + + // Dev environments & editors + 'codesandbox.io': SiCodesandbox, + 'stackblitz.com': SiStackblitz, + 'replit.com': SiReplit, + 'cursor.com': SiCursor, + 'windsurf.com': SiWindsurf, + 'zed.dev': SiZedindustries, + 'warp.dev': SiWarp, + 'raycast.com': SiRaycast, + 'postman.com': SiPostman, + + // Commerce, learning & the rest + 'stripe.com': SiStripe, + 'paypal.com': SiPaypal, + 'patreon.com': SiPatreon, + 'ko-fi.com': SiKofi, + 'buymeacoffee.com': SiBuymeacoffee, + 'shopify.com': SiShopify, + 'webflow.com': SiWebflow, + 'dropbox.com': SiDropbox, + 'leetcode.com': SiLeetcode, + 'coursera.org': SiCoursera, + 'udemy.com': SiUdemy, + 'yelp.com': SiYelp +} + +// Resolve a hostname to its brand glyph, walking suffixes so subdomains inherit +// their parent's mark (`api.github.com` → `github.com`). Longest match wins, so +// a subdomain entry like `docs.google.com` beats the `google.com` fallback. The +// bare TLD is never tested. +export function resolveBrandIcon(hostname: string): BrandIcon | null { + const host = hostname.trim().toLowerCase().replace(/^www\./, '') + + if (!host.includes('.')) { + return null + } + + const parts = host.split('.') + + for (let i = 0; i < parts.length - 1; i += 1) { + const icon = BRAND_ICONS[parts.slice(i).join('.')] + + if (icon) { + return icon + } + } + + return null +} diff --git a/apps/desktop/src/lib/external-link.test.tsx b/apps/desktop/src/lib/external-link.test.tsx index 57c0176e37c8..5407639199c4 100644 --- a/apps/desktop/src/lib/external-link.test.tsx +++ b/apps/desktop/src/lib/external-link.test.tsx @@ -248,4 +248,28 @@ describe('external link helpers', () => { const link = screen.getByRole('link', { name: 'agent.log' }) expect(link.getAttribute('href')).toBe('https://agent.log') }) + + it('prefixes a pretty link to a known host with its brand glyph', () => { + installDesktopBridge() + + const url = 'https://github.com/NousResearch/hermes-agent/pull/123' + + render(<PrettyLink fallbackLabel="#123" href={url} />) + + const link = screen.getByTitle(url) + + expect(link.querySelector('svg')).toBeTruthy() + // The glyph is decorative — it must not pollute the link's accessible name. + expect(link.textContent).toBe('#123') + }) + + it('renders no brand glyph for an unknown host', () => { + installDesktopBridge() + + const url = 'https://example.com/some/page' + + render(<PrettyLink fallbackLabel="Some Page" href={url} />) + + expect(screen.getByTitle(url).querySelector('svg')).toBeNull() + }) }) diff --git a/apps/desktop/src/lib/external-link.tsx b/apps/desktop/src/lib/external-link.tsx index 17f82c6a2cb9..df853911247a 100644 --- a/apps/desktop/src/lib/external-link.tsx +++ b/apps/desktop/src/lib/external-link.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { ArrowUpRight } from '@/lib/icons' +import { resolveBrandIcon } from './brand-icon' import { cn } from './utils' const titleCache = new Map<string, string>() @@ -210,6 +211,22 @@ export function ExternalLinkIcon({ className }: { className?: string }) { return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} /> } +// Brand mark for a known host, sized in `em` so it tracks the surrounding text +// at any font size. It paints in `currentColor` rather than the brand hex — +// several brand colors (GitHub's near-black, Unity's white) vanish against one +// theme or the other. +// +// `title=""` is load-bearing: Simple Icons always renders a <title> defaulting +// to the brand name, which lands in the anchor's textContent and accessible +// name — a PR link would read "GitHub#123". +export function LinkBrandIcon({ className, href }: { className?: string; href: string }) { + const Icon = resolveBrandIcon(shortHostLabel(href)) + + return Icon ? ( + <Icon aria-hidden className={cn('mr-1 inline size-[0.85em] align-[-0.12em] opacity-80', className)} title="" /> + ) : null +} + export function ExternalLink({ children, className, @@ -222,7 +239,7 @@ export function ExternalLink({ return ( <a - className={cn('link-chip font-semibold', className)} + className={cn('link-chip', className)} href={target} onClick={event => { event.stopPropagation() @@ -261,7 +278,8 @@ export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: P return ( <ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}> - <span className="font-medium">{display}</span> + <LinkBrandIcon href={target} /> + {display} </ExternalLink> ) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 76579e42fd31..980501ded4e4 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -556,11 +556,17 @@ background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem; } -/* Inline content links: a small tinted chip instead of an underline. Tint is - currentColor-relative (the old `decoration-current/20` idiom), so text and - fill share a hue on every theme from one `color` declaration. */ +/* Inline content links: color at rest, a tinted chip on hover, never an + underline. Tint is currentColor-relative (the old `decoration-current/20` + idiom), so text and fill share a hue on every theme from one `color`. */ .link-chip { - --link-chip-tint: 8%; + /* The one knob: resting fill. 0% = color-only until hovered. */ + --link-chip-tint: 0%; + + /* Prose links inherit the surrounding text's weight. `@tailwindcss/typography` + sets `prose a { font-weight: 500 }`, which outranks a utility class on the + anchor — so the override belongs here, on the shared chip. */ + font-weight: inherit; /* `ch`/`em` so the chip tracks the text at any size. Block padding is asymmetric because an inline box's content area is ascent+descent — equal