import React from 'react'; import { cn } from '@/lib/utils'; import type { Part } from '@opencode-ai/sdk/v2'; import type { AgentMentionInfo } from '../types'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { useUIStore } from '@/stores/useUIStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { Icon } from "@/components/icon/Icon"; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; type PartWithText = Part & { text?: string; content?: string; value?: string }; type UserTextPartProps = { part: Part; messageId: string; isMobile: boolean; 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 escapeHtml = (text: string): string => { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; const UserTextPart: React.FC = ({ part, messageId, agentMention }) => { const partWithText = part as PartWithText; const rawText = partWithText.text; const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode); const skills = useSkillsStore((state) => state.skills); const openContextFile = useUIStore((state) => state.openContextFile); const effectiveDirectory = useEffectiveDirectory(); const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode); const textRef = React.useRef(null); const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); const openSkill = React.useCallback((name: string) => { const skill = skillByName.get(name); if (!skill?.path) return; openContextFile(effectiveDirectory || skill.path.replace(/\/[^/]*$/, '') || '/', skill.path); }, [effectiveDirectory, openContextFile, skillByName]); const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => { if (typeof window === 'undefined') { return false; } const selection = window.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) { return false; } const range = selection.getRangeAt(0); return element.contains(range.startContainer) || element.contains(range.endContainer); }, []); React.useEffect(() => { const el = textRef.current; if (!el) return; const checkTruncation = () => { if (!isExpanded) { setIsTruncated(el.scrollHeight > el.clientHeight); } }; checkTruncation(); const resizeObserver = new ResizeObserver(checkTruncation); resizeObserver.observe(el); return () => resizeObserver.disconnect(); }, [textContent, isExpanded]); const handleClick = React.useCallback((event: React.MouseEvent) => { const target = event.target as HTMLElement | null; const skillLink = target?.closest('[data-skill-name]'); const skillName = skillLink?.dataset.skillName; if (skillName) { event.preventDefault(); event.stopPropagation(); openSkill(skillName); return; } const element = textRef.current; if (!element) { return; } if (hasActiveSelectionInElement(element)) { return; } if (!isExpanded && isTruncated) { setIsExpanded(true); } }, [hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]); const handleCollapse = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsExpanded(false); }, []); const processedMarkdownContent = React.useMemo(() => { let content = textContent; // 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) if (agentMention?.token && content.includes(agentMention.token)) { const mentionHtml = `${agentMention.token}`; content = content.replace(agentMention.token, mentionHtml); } content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string, offset: number) => { const slashIndex = offset + prefix.length; if (slashIndex === 0 || !skillByName.has(skillName)) return match; return `${prefix}/${skillName}`; }); return content; }, [agentMention, skillByName, textContent]); const plainTextContent = React.useMemo(() => { const nodes: React.ReactNode[] = []; let cursor = 0; let agentMentionUsed = false; let match: RegExpExecArray | null; SKILL_TOKEN_PATTERN.lastIndex = 0; while ((match = SKILL_TOKEN_PATTERN.exec(textContent)) !== null) { const prefix = match[1] || ''; const skillName = match[2]; const slashIndex = match.index + prefix.length; if (slashIndex === 0 || !skillByName.has(skillName)) continue; if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index)); if (prefix) nodes.push(prefix); nodes.push( ); cursor = slashIndex + skillName.length + 1; } if (cursor < textContent.length) nodes.push(textContent.slice(cursor)); const withSkills = nodes.length > 0 ? nodes : [textContent]; if (!agentMention?.token || !textContent.includes(agentMention.token)) { return withSkills; } return withSkills.flatMap((node, index) => { if (agentMentionUsed || typeof node !== 'string') return node; const idx = node.indexOf(agentMention.token); if (idx === -1) return node; agentMentionUsed = true; return [ node.slice(0, idx), event.stopPropagation()} > {agentMention.token} , node.slice(idx + agentMention.token.length), ]; }); }, [agentMention, openSkill, skillByName, textContent]); if (!textContent || textContent.trim().length === 0) { return null; } return (
{isExpanded && ( )}
{normalizedRenderingMode === 'markdown' ? ( ) : ( plainTextContent )}
); }; export default React.memo(UserTextPart);