refactor(chat): update user message layout and styling

This commit is contained in:
Bohdan Triapitsyn
2025-12-20 23:44:53 +02:00
parent 64a0b80710
commit e349f885b6
3 changed files with 106 additions and 112 deletions
+27 -33
View File
@@ -706,39 +706,33 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
<div className="chat-column"> <div className="chat-column">
{isUser ? ( {isUser ? (
<FadeInOnReveal> <FadeInOnReveal>
<div <div className="flex justify-end">
className={cn( <div className="max-w-[85%] rounded-xl rounded-br-xs bg-primary/10 dark:bg-primary/8 px-3.5 pt-2.5 pb-1.5">
'rounded-xl border bg-input/10 dark:bg-input/30 pt-2 pb-2.5 relative' <MessageBody
)} messageId={message.info.id}
style={{ parts={visibleParts}
borderColor: 'color-mix(in srgb, var(--primary-muted, var(--primary)) 40%, var(--interactive-border, transparent))' isUser={isUser}
}} isMessageCompleted={isMessageCompleted}
> syntaxTheme={syntaxTheme}
<MessageBody isMobile={isMobile}
messageId={message.info.id} hasTouchInput={hasTouchInput}
parts={visibleParts} copiedCode={copiedCode}
isUser={isUser} onCopyCode={handleCopyCode}
isMessageCompleted={isMessageCompleted} expandedTools={expandedTools}
syntaxTheme={syntaxTheme} onToggleTool={handleToggleTool}
isMobile={isMobile} onShowPopup={handleShowPopup}
hasTouchInput={hasTouchInput} streamPhase={streamPhase}
copiedCode={copiedCode} allowAnimation={allowAnimation}
onCopyCode={handleCopyCode} onContentChange={onContentChange}
expandedTools={expandedTools} shouldShowHeader={false}
onToggleTool={handleToggleTool} hasTextContent={hasTextContent}
onShowPopup={handleShowPopup} onCopyMessage={handleCopyMessage}
streamPhase={streamPhase} copiedMessage={copiedMessage}
allowAnimation={allowAnimation} showReasoningTraces={showReasoningTraces}
onContentChange={onContentChange} onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
shouldShowHeader={false} agentMention={agentMention}
hasTextContent={hasTextContent} />
onCopyMessage={handleCopyMessage} </div>
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
/>
</div> </div>
</FadeInOnReveal> </FadeInOnReveal>
) : ( ) : (
@@ -208,68 +208,60 @@ const UserMessageBody: React.FC<{
style={{ contain: 'layout', transform: 'translateZ(0)' }} style={{ contain: 'layout', transform: 'translateZ(0)' }}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined} onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
> >
{canCopyMessage && ( <div className="leading-normal overflow-hidden text-foreground/85">
<Button {textParts.map((part, index) => {
type="button" let mentionForPart: AgentMentionInfo | undefined;
variant="ghost" if (agentMention && mentionToken && !mentionInjected) {
size="icon" const candidateText = extractTextContent(part);
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined} if (candidateText.includes(mentionToken)) {
className={cn( mentionForPart = agentMention;
'absolute z-10 flex h-7 w-7 items-center justify-center rounded-full border border-border/40 shadow-none bg-background/95 supports-[backdrop-filter]:bg-background/80 hover:bg-accent duration-150', mentionInjected = true;
'opacity-0 pointer-events-none disabled:opacity-30 disabled:text-muted-foreground/40',
hasCopyableText &&
'group-hover/message:opacity-60 group-hover/message:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto',
(copyHintVisible || isMessageCopied) && 'opacity-100 pointer-events-auto'
)}
style={{ insetInlineEnd: '0.32rem', insetBlockStart: '-0.28rem' }}
disabled={!hasCopyableText}
aria-label="Copy message text"
aria-hidden={!hasCopyableText}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => {
if (hasCopyableText) {
setCopyHintVisible(true);
} }
}} }
onBlur={() => { return (
if (!isMessageCopied) { <FadeInOnReveal key={`user-text-${index}`}>
setCopyHintVisible(false); <UserTextPart
} part={part}
}} messageId={messageId}
> isMobile={isMobile}
{isMessageCopied ? ( agentMention={mentionForPart}
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" /> />
) : ( </FadeInOnReveal>
<RiFileCopyLine className="h-3.5 w-3.5 text-foreground hover:text-primary focus-visible:text-primary" /> );
)} })}
</Button>
)}
<div className="px-3">
<div className="leading-normal overflow-hidden text-foreground/90">
{textParts.map((part, index) => {
let mentionForPart: AgentMentionInfo | undefined;
if (agentMention && mentionToken && !mentionInjected) {
const candidateText = extractTextContent(part);
if (candidateText.includes(mentionToken)) {
mentionForPart = agentMention;
mentionInjected = true;
}
}
return (
<FadeInOnReveal key={`user-text-${index}`}>
<UserTextPart
part={part}
messageId={messageId}
isMobile={isMobile}
agentMention={mentionForPart}
/>
</FadeInOnReveal>
);
})}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
</div> </div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{canCopyMessage && hasCopyableText && (
<div className="mt-1 flex items-center justify-end gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Copy message text"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3.5 w-3.5 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
</Tooltip>
</div>
)}
</div> </div>
); );
}; };
@@ -1075,6 +1067,21 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const footerButtons = ( const footerButtons = (
<> <>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
</Tooltip>
{onCopyMessage && ( {onCopyMessage && (
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -1085,8 +1092,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined} data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className={cn( className={cn(
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50', 'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
!hasCopyableText && 'opacity-50', !hasCopyableText && 'opacity-50'
(copyHintVisible || isMessageCopied) && 'text-primary'
)} )}
disabled={!hasCopyableText} disabled={!hasCopyableText}
aria-label="Copy message text" aria-label="Copy message text"
@@ -1114,21 +1120,6 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<TooltipContent sideOffset={6}>Copy answer</TooltipContent> <TooltipContent sideOffset={6}>Copy answer</TooltipContent>
</Tooltip> </Tooltip>
)} )}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleForkClick}
>
<RiChatNewLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Start new session from this answer</TooltipContent>
</Tooltip>
</> </>
); );
@@ -29,9 +29,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
React.useEffect(() => { React.useEffect(() => {
const el = textRef.current; const el = textRef.current;
if (el && !isExpanded) { if (!el || isExpanded) return;
const checkTruncation = () => {
setIsTruncated(el.scrollHeight > el.clientHeight); setIsTruncated(el.scrollHeight > el.clientHeight);
} };
checkTruncation();
const resizeObserver = new ResizeObserver(checkTruncation);
resizeObserver.observe(el);
return () => resizeObserver.disconnect();
}, [textContent, isExpanded]); }, [textContent, isExpanded]);
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {