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

@@ -664,3 +664,10 @@
- **Status:** done
- **Completed:** 2026-02-13 — commit `cbb4611`
- **Description:** Markdown unordered lists (- item, * item) are not rendered properly in chat messages. They appear as raw text instead of formatted bullet points. Need to verify remarkGfm/ReactMarkdown config handles list rendering correctly, and ensure CSS styles are applied for ul/ol elements in the markdown-body class.
## Item #62
- **Date:** 2026-02-13
- **Priority:** high
- **Status:** done
- **Completed:** 2026-02-13 — commit `2f25c45`
- **Description:** Textarea has an ugly/thick accent-colored border (cyan) visible in the screenshot. The border around the chat input textarea looks bad — it should be more subtle (thin border, muted color, or no visible border at all). The input area should blend cleanly with its container, not have a glowing cyan outline.

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
<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 */}

View File

@@ -52,6 +52,14 @@
outline-offset: 2px;
}
/* Chat textarea: no focus ring (container handles visual feedback) */
.ht-textarea:focus-visible,
.ht-textarea:focus,
#chat-input:focus-visible,
#chat-input:focus {
outline: none;
}
/* Remove default outline for mouse/touch users */
:focus:not(:focus-visible) {
outline: none;