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
|
- **Status:** done
|
||||||
- **Completed:** 2026-02-13 — commit `cbb4611`
|
- **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.
|
- **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 visibleMessages = useMemo(() => {
|
||||||
const filtered = messages.filter(hasVisibleContent);
|
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 dk = getDateKey(msg.timestamp);
|
||||||
const prevDk = acc.length > 0 ? getDateKey(acc[acc.length - 1].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;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
}, [messages]);
|
}, [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 className="text-sm mt-1 text-pc-text-muted">{t('chat.welcomeSub')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleMessages.map(({ msg, showSep }) => {
|
{visibleMessages.map(({ msg, showSep, isFirstInGroup }) => {
|
||||||
const isActiveMatch = searchMatches.length > 0 && searchMatches[searchActiveIndex] === msg.id;
|
const isActiveMatch = searchMatches.length > 0 && searchMatches[searchActiveIndex] === msg.id;
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} data-msg-id={msg.id}>
|
<div key={msg.id} data-msg-id={msg.id}>
|
||||||
@@ -219,7 +223,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''}>
|
<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>
|
||||||
</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
|
useLocale(); // re-render on locale change
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const isLight = resolvedTheme === 'light';
|
const isLight = resolvedTheme === 'light';
|
||||||
@@ -458,15 +458,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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' : ''}`}>
|
<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 */}
|
{/* 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 border border-pc-border bg-pc-elevated/40 overflow-hidden">
|
<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' : ''}`}>
|
||||||
{isUser
|
{isFirstInGroup ? (
|
||||||
|
isUser
|
||||||
? <User className="h-4 w-4 text-pc-accent-light" />
|
? <User className="h-4 w-4 text-pc-accent-light" />
|
||||||
: agentAvatarUrl
|
: agentAvatarUrl
|
||||||
? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" />
|
? <img src={agentAvatarUrl} alt="Agent" className="h-full w-full object-cover" />
|
||||||
: <Bot className="h-4 w-4 text-pc-accent-light" />
|
: <Bot className="h-4 w-4 text-pc-accent-light" />
|
||||||
}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bubble */}
|
{/* Bubble */}
|
||||||
|
|||||||
@@ -52,6 +52,14 @@
|
|||||||
outline-offset: 2px;
|
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 */
|
/* Remove default outline for mouse/touch users */
|
||||||
:focus:not(:focus-visible) {
|
:focus:not(:focus-visible) {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user