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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user