feat: message grouping — hide avatar and reduce spacing for consecutive same-role messages

Consecutive messages from the same role within 2 minutes are visually
grouped: subsequent messages hide the avatar (keeping alignment) and
use tighter vertical spacing. Date separators and role changes break
groups. This matches modern chat UX patterns (Discord, Telegram).
This commit is contained in:
Nicolas Varrot
2026-02-13 18:12:44 +00:00
parent 2f25c45942
commit 51de6984b6
4 changed files with 34 additions and 14 deletions

View File

@@ -119,10 +119,14 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
const visibleMessages = useMemo(() => {
const filtered = messages.filter(hasVisibleContent);
return filtered.reduce<Array<{ msg: ChatMessage; showSep: boolean }>>((acc, msg) => {
const GROUP_GAP_MS = 2 * 60 * 1000; // 2 minutes
return filtered.reduce<Array<{ msg: ChatMessage; showSep: boolean; isFirstInGroup: boolean }>>((acc, msg) => {
const dk = getDateKey(msg.timestamp);
const prevDk = acc.length > 0 ? getDateKey(acc[acc.length - 1].msg.timestamp) : '';
acc.push({ msg, showSep: dk !== prevDk });
const showSep = dk !== prevDk;
const prev = acc.length > 0 ? acc[acc.length - 1] : null;
const isFirstInGroup = showSep || !prev || prev.msg.role !== msg.role || prev.msg.isSystemEvent !== msg.isSystemEvent || (msg.timestamp - prev.msg.timestamp > GROUP_GAP_MS);
acc.push({ msg, showSep, isFirstInGroup });
return acc;
}, []);
}, [messages]);
@@ -207,7 +211,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
<div className="text-sm mt-1 text-pc-text-muted">{t('chat.welcomeSub')}</div>
</div>
)}
{visibleMessages.map(({ msg, showSep }) => {
{visibleMessages.map(({ msg, showSep, isFirstInGroup }) => {
const isActiveMatch = searchMatches.length > 0 && searchMatches[searchActiveIndex] === msg.id;
return (
<div key={msg.id} data-msg-id={msg.id}>
@@ -219,7 +223,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
</div>
)}
<div className={isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''}>
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} />
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} isFirstInGroup={isFirstInGroup} />
</div>
</div>
);

View File

@@ -405,7 +405,7 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
);
}
export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatarUrl }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string }) {
export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatarUrl, isFirstInGroup = true }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean }) {
useLocale(); // re-render on locale change
const { resolvedTheme } = useTheme();
const isLight = resolvedTheme === 'light';
@@ -458,15 +458,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
}
return (
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''} ${message.sendStatus === 'sending' ? 'opacity-70' : ''} ${message.sendStatus === 'error' ? 'opacity-60' : ''}`}>
{/* Avatar */}
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-pc-border bg-pc-elevated/40 overflow-hidden">
{isUser
? <User className="h-4 w-4 text-pc-accent-light" />
: agentAvatarUrl
? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" />
: <Bot className="h-4 w-4 text-pc-accent-light" />
}
<div className={`animate-fade-in flex gap-3 px-4 ${isFirstInGroup ? 'py-2' : 'py-0.5'} ${isUser ? 'flex-row-reverse' : ''} ${message.sendStatus === 'sending' ? 'opacity-70' : ''} ${message.sendStatus === 'error' ? 'opacity-60' : ''}`}>
{/* Avatar — hidden for grouped messages, but keep width for alignment */}
<div className={`shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl overflow-hidden ${isFirstInGroup ? 'border border-pc-border bg-pc-elevated/40' : ''}`}>
{isFirstInGroup ? (
isUser
? <User className="h-4 w-4 text-pc-accent-light" />
: agentAvatarUrl
? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" />
: <Bot className="h-4 w-4 text-pc-accent-light" />
) : null}
</div>
{/* Bubble */}