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 { 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 (
<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 isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
return (
@@ -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<UserTextPartProps> = ({ 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 <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)) {
const mentionHtml = `<a href="${buildMentionUrl(agentMention.name)}" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${agentMention.token}</a>`;
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<UserTextPartProps> = ({ part, messageId, agentMenti
node.slice(0, idx),
<a
key={`agent-${index}`}
href={buildMentionUrl(agentMention.name)}
href={buildAgentMentionUrl(agentMention.name)}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
+11
View File
@@ -1069,6 +1069,17 @@ html:not(.dark) .chat-scroll {
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-content blockquote {
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;
}
};