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
This commit is contained in:
Bohdan Triapitsyn
2026-06-11 00:46:34 +03:00
parent b46153801b
commit d29a0c5697
4 changed files with 87 additions and 21 deletions
@@ -19,6 +19,11 @@ import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url'; import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
import {
buildAgentMentionUrl,
parseAgentHref,
parseSkillHref,
} from '@/lib/messages/inlineMessageLinks';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes'; import { getDefaultTheme } from '@/lib/theme/themes';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -1000,6 +1005,38 @@ const buildMarkdownComponents = ({
}, },
a({ href, children, ...props }) { a({ href, children, ...props }) {
const targetHref = href ?? ''; const targetHref = href ?? '';
const agentName = parseAgentHref(targetHref);
if (agentName) {
return (
<a
{...props}
href={buildAgentMentionUrl(agentName)}
data-openchamber-agent-mention="true"
className={cn('text-primary hover:underline', props.className)}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{children}
</a>
);
}
const skillName = parseSkillHref(targetHref);
if (skillName) {
return (
<a
{...props}
href={targetHref}
data-skill-name={skillName}
className={cn('text-primary hover:underline', props.className)}
onClick={(event) => event.stopPropagation()}
>
{children}
</a>
);
}
const isExternal = isExternalHttpUrl(targetHref); const isExternal = isExternalHttpUrl(targetHref);
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false; const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
return ( return (
@@ -8,6 +8,12 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getDirectoryForFilePath } from '@/lib/path-utils'; import { getDirectoryForFilePath } from '@/lib/path-utils';
import {
buildAgentHref,
buildAgentMentionUrl,
buildSkillHref,
parseSkillHref,
} from '@/lib/messages/inlineMessageLinks';
type PartWithText = Part & { text?: string; content?: string; value?: string }; type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -18,24 +24,7 @@ type UserTextPartProps = {
agentMention?: AgentMentionInfo; 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_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 => { const escapeHtml = (text: string): string => {
return text return text
@@ -150,10 +139,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
// Step 1: First escape HTML to protect against XSS and ensure HTML tags display as text // Step 1: First escape HTML to protect against XSS and ensure HTML tags display as text
content = escapeHtml(content); content = escapeHtml(content);
// Step 2: Then insert agent mention links (after escaping, so <a> 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)) { if (agentMention?.token && content.includes(agentMention.token)) {
const mentionHtml = `<a href="${buildMentionUrl(agentMention.name)}" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${agentMention.token}</a>`; const mentionMarkdown = `[${agentMention.token}](${buildAgentHref(agentMention.name)})`;
content = content.replace(agentMention.token, mentionHtml); content = content.replace(agentMention.token, mentionMarkdown);
} }
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => { content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => {
@@ -214,7 +203,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
node.slice(0, idx), node.slice(0, idx),
<a <a
key={`agent-${index}`} key={`agent-${index}`}
href={buildMentionUrl(agentMention.name)} href={buildAgentMentionUrl(agentMention.name)}
className="text-primary hover:underline" className="text-primary hover:underline"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
+11
View File
@@ -1069,6 +1069,17 @@ html:not(.dark) .chat-scroll {
color: var(--markdown-link-hover, var(--primary)); color: var(--markdown-link-hover, var(--primary));
} }
/* Agent/skill mentions use the primary accent, not the markdown link color */
.markdown-content a[data-openchamber-agent-mention="true"],
.markdown-content a[data-skill-name] {
color: var(--primary);
}
.markdown-content a[data-openchamber-agent-mention="true"]:hover,
.markdown-content a[data-skill-name]:hover {
color: var(--primary);
}
/* Markdown blockquote - use theme colors */ /* Markdown blockquote - use theme colors */
.markdown-content blockquote { .markdown-content blockquote {
color: var(--markdown-blockquote, var(--muted-foreground)); color: var(--markdown-blockquote, var(--muted-foreground));
@@ -0,0 +1,29 @@
export const SKILL_LINK_PREFIX = '#openchamber-skill:';
export const AGENT_LINK_PREFIX = '#openchamber-agent:';
export const buildAgentMentionUrl = (name: string): string => {
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;
}
};