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'; 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 normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; const UserTextPart: React.FC = ({ part, messageId, agentMention }) => { const CLAMP_LINES = 2; 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 [collapseZoneHeight, setCollapseZoneHeight] = React.useState(0); const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode); const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode); const textRef = React.useRef(null); 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); } const styles = window.getComputedStyle(el); const lineHeight = parseFloat(styles.lineHeight); const fontSize = parseFloat(styles.fontSize); const fallbackLineHeight = isFinite(fontSize) ? fontSize * 1.4 : 20; const resolvedLineHeight = isFinite(lineHeight) ? lineHeight : fallbackLineHeight; setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES))); }; checkTruncation(); const resizeObserver = new ResizeObserver(checkTruncation); resizeObserver.observe(el); return () => resizeObserver.disconnect(); }, [textContent, isExpanded]); const handleClick = React.useCallback((event: React.MouseEvent) => { const element = textRef.current; if (!element) { return; } if (hasActiveSelectionInElement(element)) { return; } if (!isExpanded) { if (isTruncated) { setIsExpanded(true); } return; } const clickY = event.clientY - element.getBoundingClientRect().top; if (clickY <= collapseZoneHeight) { setIsExpanded(false); } }, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]); const processedMarkdownContent = React.useMemo(() => { if (!agentMention?.token || !textContent.includes(agentMention.token)) { return textContent; } const mentionHtml = `${agentMention.token}`; return textContent.replace(agentMention.token, mentionHtml); }, [agentMention, textContent]); const plainTextContent = React.useMemo(() => { if (!agentMention?.token || !textContent.includes(agentMention.token)) { return textContent; } const idx = textContent.indexOf(agentMention.token); const before = textContent.slice(0, idx); const after = textContent.slice(idx + agentMention.token.length); return ( <> {before} event.stopPropagation()} > {agentMention.token} {after} ); }, [agentMention, textContent]); if (!textContent || textContent.trim().length === 0) { return null; } return (
{normalizedRenderingMode === 'markdown' ? ( ) : ( plainTextContent )}
); }; export default React.memo(UserTextPart);