2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
import type { AgentMentionInfo } from '../types';
|
2026-02-28 02:22:59 +08:00
|
|
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
2026-03-02 02:11:33 +02:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
|
|
|
|
|
|
|
|
|
type UserTextPartProps = {
|
|
|
|
|
part: Part;
|
|
|
|
|
messageId: string;
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
agentMention?: AgentMentionInfo;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 17:38:08 +02:00
|
|
|
const buildMentionUrl = (name: string): string => {
|
2025-12-07 19:32:53 +02:00
|
|
|
const encoded = encodeURIComponent(name);
|
2025-12-13 17:38:08 +02:00
|
|
|
return `https://opencode.ai/docs/agents/#${encoded}`;
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
|
|
|
|
return mode === 'markdown' ? 'markdown' : 'plain';
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 17:39:36 +02:00
|
|
|
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
2026-02-11 19:28:22 +02:00
|
|
|
const CLAMP_LINES = 2;
|
2025-12-07 19:32:53 +02:00
|
|
|
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);
|
2026-02-11 19:28:22 +02:00
|
|
|
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
|
2026-03-02 02:11:33 +02:00
|
|
|
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
|
|
|
|
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
2025-12-07 19:32:53 +02:00
|
|
|
const textRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
const el = textRef.current;
|
2026-02-11 19:28:22 +02:00
|
|
|
if (!el) return;
|
2025-12-20 23:44:53 +02:00
|
|
|
|
|
|
|
|
const checkTruncation = () => {
|
2026-02-11 19:28:22 +02:00
|
|
|
if (!isExpanded) {
|
|
|
|
|
setIsTruncated(el.scrollHeight > el.clientHeight);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const styles = window.getComputedStyle(el);
|
2026-02-28 02:22:59 +08:00
|
|
|
const lineHeight = parseFloat(styles.lineHeight);
|
|
|
|
|
const fontSize = parseFloat(styles.fontSize);
|
|
|
|
|
const fallbackLineHeight = isFinite(fontSize) ? fontSize * 1.4 : 20;
|
|
|
|
|
const resolvedLineHeight = isFinite(lineHeight) ? lineHeight : fallbackLineHeight;
|
2026-02-11 19:28:22 +02:00
|
|
|
setCollapseZoneHeight(Math.max(1, Math.round(resolvedLineHeight * CLAMP_LINES)));
|
2025-12-20 23:44:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
checkTruncation();
|
|
|
|
|
|
|
|
|
|
const resizeObserver = new ResizeObserver(checkTruncation);
|
|
|
|
|
resizeObserver.observe(el);
|
|
|
|
|
|
|
|
|
|
return () => resizeObserver.disconnect();
|
2025-12-13 17:38:08 +02:00
|
|
|
}, [textContent, isExpanded]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
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);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-02-11 19:28:22 +02:00
|
|
|
}, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const processedMarkdownContent = React.useMemo(() => {
|
2025-12-13 17:38:08 +02:00
|
|
|
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
|
|
|
|
return textContent;
|
|
|
|
|
}
|
2026-02-28 02:22:59 +08:00
|
|
|
|
|
|
|
|
const mentionHtml = `<a href="${buildMentionUrl(agentMention.name)}" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${agentMention.token}</a>`;
|
|
|
|
|
return textContent.replace(agentMention.token, mentionHtml);
|
|
|
|
|
}, [agentMention, textContent]);
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
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}
|
|
|
|
|
<a
|
|
|
|
|
href={buildMentionUrl(agentMention.name)}
|
|
|
|
|
className="text-primary hover:underline"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{agentMention.token}
|
|
|
|
|
</a>
|
|
|
|
|
{after}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}, [agentMention, textContent]);
|
|
|
|
|
|
2026-02-28 02:22:59 +08:00
|
|
|
if (!textContent || textContent.trim().length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-12-13 17:38:08 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return (
|
2026-02-11 19:28:22 +02:00
|
|
|
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-02-28 02:22:59 +08:00
|
|
|
"break-words font-sans typography-markdown",
|
2026-03-02 02:11:33 +02:00
|
|
|
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
2026-02-11 19:28:22 +02:00
|
|
|
!isExpanded && "line-clamp-2",
|
|
|
|
|
isTruncated && !isExpanded && "cursor-pointer"
|
|
|
|
|
)}
|
|
|
|
|
ref={textRef}
|
|
|
|
|
onClick={handleClick}
|
|
|
|
|
>
|
2026-03-02 02:11:33 +02:00
|
|
|
{normalizedRenderingMode === 'markdown' ? (
|
|
|
|
|
<SimpleMarkdownRenderer
|
|
|
|
|
content={processedMarkdownContent}
|
|
|
|
|
disableLinkSafety
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
plainTextContent
|
|
|
|
|
)}
|
2026-02-11 19:28:22 +02:00
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default React.memo(UserTextPart);
|