From d29a0c5697e8c8ea443472e11093722ea832d220 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 11 Jun 2026 00:46:34 +0300 Subject: [PATCH] fix: style agent mentions with primary color in markdown messages Agent @mentions in markdown use the primary accent instead of generic link color Mentions render without external URL favicons Markdown mode now matches plain text mention styling --- .../components/chat/MarkdownRendererImpl.tsx | 37 +++++++++++++++++++ .../chat/message/parts/UserTextPart.tsx | 31 +++++----------- packages/ui/src/index.css | 11 ++++++ .../ui/src/lib/messages/inlineMessageLinks.ts | 29 +++++++++++++++ 4 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/lib/messages/inlineMessageLinks.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index efd7d0b1..e1390129 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -19,6 +19,11 @@ import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url'; +import { + buildAgentMentionUrl, + parseAgentHref, + parseSkillHref, +} from '@/lib/messages/inlineMessageLinks'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -1000,6 +1005,38 @@ const buildMarkdownComponents = ({ }, a({ href, children, ...props }) { const targetHref = href ?? ''; + const agentName = parseAgentHref(targetHref); + if (agentName) { + return ( + event.stopPropagation()} + > + {children} + + ); + } + + const skillName = parseSkillHref(targetHref); + if (skillName) { + return ( + event.stopPropagation()} + > + {children} + + ); + } + const isExternal = isExternalHttpUrl(targetHref); const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false; return ( diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 205c2780..b7955325 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -8,6 +8,12 @@ import { useSkillsStore } from '@/stores/useSkillsStore'; import { Icon } from "@/components/icon/Icon"; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getDirectoryForFilePath } from '@/lib/path-utils'; +import { + buildAgentHref, + buildAgentMentionUrl, + buildSkillHref, + parseSkillHref, +} from '@/lib/messages/inlineMessageLinks'; type PartWithText = Part & { text?: string; content?: string; value?: string }; @@ -18,24 +24,7 @@ type UserTextPartProps = { agentMention?: AgentMentionInfo; }; -const buildMentionUrl = (name: string): string => { - const encoded = encodeURIComponent(name); - return `https://opencode.ai/docs/agents/#${encoded}`; -}; - const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g; -const SKILL_LINK_PREFIX = '#openchamber-skill:'; - -const buildSkillHref = (name: string): string => `${SKILL_LINK_PREFIX}${encodeURIComponent(name)}`; - -const parseSkillHref = (href: string | null | undefined): string | null => { - if (!href?.startsWith(SKILL_LINK_PREFIX)) return null; - try { - return decodeURIComponent(href.slice(SKILL_LINK_PREFIX.length)); - } catch { - return null; - } -}; const escapeHtml = (text: string): string => { return text @@ -150,10 +139,10 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti // Step 1: First escape HTML to protect against XSS and ensure HTML tags display as text content = escapeHtml(content); - // Step 2: Then insert agent mention links (after escaping, so tags won't be escaped) + // Step 2: Insert agent mention links with an internal href so markdown renders them as mentions, not external links. if (agentMention?.token && content.includes(agentMention.token)) { - const mentionHtml = `${agentMention.token}`; - content = content.replace(agentMention.token, mentionHtml); + const mentionMarkdown = `[${agentMention.token}](${buildAgentHref(agentMention.name)})`; + content = content.replace(agentMention.token, mentionMarkdown); } content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => { @@ -214,7 +203,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti node.slice(0, idx), { + const encoded = encodeURIComponent(name); + return `https://opencode.ai/docs/agents/#${encoded}`; +}; + +export const buildSkillHref = (name: string): string => `${SKILL_LINK_PREFIX}${encodeURIComponent(name)}`; + +export const buildAgentHref = (name: string): string => `${AGENT_LINK_PREFIX}${encodeURIComponent(name)}`; + +export const parseSkillHref = (href: string | null | undefined): string | null => { + if (!href?.startsWith(SKILL_LINK_PREFIX)) return null; + try { + return decodeURIComponent(href.slice(SKILL_LINK_PREFIX.length)); + } catch { + return null; + } +}; + +export const parseAgentHref = (href: string | null | undefined): string | null => { + if (!href?.startsWith(AGENT_LINK_PREFIX)) return null; + try { + return decodeURIComponent(href.slice(AGENT_LINK_PREFIX.length)); + } catch { + return null; + } +};