fix: new message indicator, cursor desync, button overlap, avatar fallback

- #63: 'New messages' label only shows when actual new messages arrive while scrolled up; plain arrow button shown otherwise
- #64: Fix cursor desync in HighlightedTextarea by matching backdrop border width to textarea
- #65: Move floating buttons inside scroll container with sticky positioning to prevent overlap with growing textarea
- #66: Graceful fallback to Bot icon when agent avatar image fails to load
This commit is contained in:
Nicolas Varrot
2026-02-13 20:51:09 +00:00
parent c47bca4e2e
commit 84512b1f15
5 changed files with 104 additions and 28 deletions

View File

@@ -671,3 +671,27 @@
- **Status:** done - **Status:** done
- **Completed:** 2026-02-13 — commit `2f25c45` - **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. - **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.
## Item #63
- **Date:** 2026-02-13
- **Priority:** high
- **Status:** pending
- **Description:** "New message" indicator shows when scrolling up even when there are no new messages. The indicator should only appear when an actual new message arrives while scrolled up.
## Item #64
- **Date:** 2026-02-13
- **Priority:** high
- **Status:** pending
- **Description:** Cursor desync in textarea — the cursor position gets ahead of where characters actually appear (whitespace gap between cursor and text). Likely related to HighlightedTextarea overlay sync issue.
## Item #65
- **Date:** 2026-02-13
- **Priority:** high
- **Status:** pending
- **Description:** "New message" indicator and expand/collapse toggle overlap with the textarea when it grows (multi-line input). These elements should stay outside/above the textarea area and not overlap.
## Item #66
- **Date:** 2026-02-13
- **Priority:** medium
- **Status:** pending
- **Description:** Avatar image shows as broken for some deployments. Bardak's instance (deployed by Pelouse) shows a broken image. Works on Nicolas's instance. Likely the avatar URL configured by Pelouse is invalid or blocked. PinchChat should handle broken avatar images gracefully (fallback to initials or default icon).

View File

@@ -75,6 +75,8 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
const userSentRef = useRef(false); const userSentRef = useRef(false);
const [showScrollBtn, setShowScrollBtn] = useState(false); const [showScrollBtn, setShowScrollBtn] = useState(false);
const [hasNewMessages, setHasNewMessages] = useState(false);
const prevMessageCountRef = useRef(messages.length);
const checkIfNearBottom = useCallback(() => { const checkIfNearBottom = useCallback(() => {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
@@ -82,6 +84,9 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD; isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD;
setShowScrollBtn(distanceFromBottom > SCROLL_THRESHOLD * 2); setShowScrollBtn(distanceFromBottom > SCROLL_THRESHOLD * 2);
if (distanceFromBottom <= SCROLL_THRESHOLD) {
setHasNewMessages(false);
}
}, []); }, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
@@ -97,19 +102,53 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
return () => el.removeEventListener('scroll', handler); return () => el.removeEventListener('scroll', handler);
}, [checkIfNearBottom]); }, [checkIfNearBottom]);
// Auto-scroll when messages change, but only if user is near bottom or just sent a message // Reset state on session switch
const prevSessionKeyRef = useRef(sessionKey);
useEffect(() => { useEffect(() => {
if (sessionKey !== prevSessionKeyRef.current) {
prevSessionKeyRef.current = sessionKey;
prevMessageCountRef.current = messages.length;
setHasNewMessages(false);
isNearBottomRef.current = true;
// Scroll to bottom on session switch
requestAnimationFrame(() => scrollToBottom('instant'));
}
}, [sessionKey, messages.length, scrollToBottom]);
// Auto-scroll when messages change, but only if user is near bottom or just sent a message
const wasLoadingHistoryRef = useRef(isLoadingHistory);
useEffect(() => {
const newCount = messages.length;
const hadNew = newCount > prevMessageCountRef.current;
// Detect history load completion (don't treat as "new messages")
const justFinishedLoading = wasLoadingHistoryRef.current && !isLoadingHistory;
wasLoadingHistoryRef.current = isLoadingHistory;
prevMessageCountRef.current = newCount;
if (justFinishedLoading) {
// History just loaded — scroll to bottom, don't show indicator
scrollToBottom('instant');
isNearBottomRef.current = true;
setHasNewMessages(false);
return;
}
if (userSentRef.current) { if (userSentRef.current) {
// User just sent a message — always scroll to bottom // User just sent a message — always scroll to bottom
userSentRef.current = false; userSentRef.current = false;
scrollToBottom('smooth'); scrollToBottom('smooth');
isNearBottomRef.current = true; isNearBottomRef.current = true;
setHasNewMessages(false);
return; return;
} }
if (isNearBottomRef.current) { if (isNearBottomRef.current) {
scrollToBottom('smooth'); scrollToBottom('smooth');
setHasNewMessages(false);
} else if (hadNew) {
// New message arrived while scrolled up
setHasNewMessages(true);
} }
}, [messages, isGenerating, scrollToBottom]); }, [messages, isGenerating, isLoadingHistory, scrollToBottom]);
// Wrap onSend to flag that user initiated a message // Wrap onSend to flag that user initiated a message
const handleSend = useCallback((text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => { const handleSend = useCallback((text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
@@ -231,28 +270,32 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
{showTyping && <TypingIndicator />} {showTyping && <TypingIndicator />}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
</div> {/* Floating action buttons — sticky to bottom of scroll area */}
{/* Floating action buttons */} {(hasToolCalls || showScrollBtn || hasNewMessages) && (
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2"> <div className="sticky bottom-3 z-10 flex justify-center pointer-events-none pb-1">
{hasToolCalls && ( <div className="flex items-center gap-2 pointer-events-auto">
<button {hasToolCalls && (
onClick={globalState === 'expand-all' ? collapseAll : expandAll} <button
aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')} onClick={globalState === 'expand-all' ? collapseAll : expandAll}
title={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')} aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-violet-500/10" title={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
> className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-violet-500/10"
{globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />} >
</button> {globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />}
)} </button>
{showScrollBtn && ( )}
<button {(showScrollBtn || hasNewMessages) && (
onClick={() => scrollToBottom('smooth')} <button
aria-label={t('chat.scrollToBottom')} onClick={() => { scrollToBottom('smooth'); setHasNewMessages(false); }}
className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3.5 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-cyan-500/10" aria-label={hasNewMessages ? t('chat.scrollToBottom') : t('chat.scrollDown')}
> className="flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3.5 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-cyan-500/10"
<ArrowDown size={14} className="text-pc-accent-light" /> >
<span className="hidden sm:inline">{t('chat.scrollToBottom')}</span> <ArrowDown size={14} className={hasNewMessages ? 'text-pc-accent-light animate-bounce' : 'text-pc-accent-light'} />
</button> {hasNewMessages && <span className="hidden sm:inline">{t('chat.scrollToBottom')}</span>}
</button>
)}
</div>
</div>
)} )}
</div> </div>
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} /> <ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} />

View File

@@ -16,6 +16,13 @@ import { useLocale } from '../hooks/useLocale';
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent'; import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage // ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
/** Avatar image with fallback to Bot icon on load error */
function AvatarImg({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
if (failed) return <Bot className="h-4 w-4 text-pc-accent-light" />;
return <img src={src} alt="Agent" className="h-full w-full object-cover" onError={() => setFailed(true)} />;
}
function getBcp47(): string { function getBcp47(): string {
return getLocale() === 'fr' ? 'fr-FR' : 'en-US'; return getLocale() === 'fr' ? 'fr-FR' : 'en-US';
} }
@@ -465,7 +472,7 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
isUser 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" /> ? <AvatarImg src={agentAvatarUrl} />
: <Bot className="h-4 w-4 text-pc-accent-light" /> : <Bot className="h-4 w-4 text-pc-accent-light" />
) : null} ) : null}
</div> </div>

View File

@@ -231,7 +231,7 @@ html, body {
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
color: var(--pc-text-primary); color: var(--pc-text-primary);
border: none; border: 1px solid transparent; /* match textarea border width to keep text aligned */
border-radius: 1rem; /* rounded-2xl */ border-radius: 1rem; /* rounded-2xl */
max-height: 200px; max-height: 200px;
} }
@@ -257,12 +257,12 @@ html, body {
color: #67e8f9; color: #67e8f9;
background: var(--pc-accent-glow); background: var(--pc-accent-glow);
border-radius: 3px; border-radius: 3px;
padding: 0 2px; /* No padding — must match textarea text layout exactly to avoid cursor desync */
} }
.ht-bold { .ht-bold {
color: var(--pc-text-primary); color: var(--pc-text-primary);
font-weight: 700; /* No font-weight change — bold text is wider and causes cursor desync */
} }
.ht-italic { .ht-italic {

View File

@@ -43,6 +43,7 @@ const en = {
'chat.showPreview': 'Preview markdown', 'chat.showPreview': 'Preview markdown',
'chat.hidePreview': 'Hide preview', 'chat.hidePreview': 'Hide preview',
'chat.scrollToBottom': 'New messages', 'chat.scrollToBottom': 'New messages',
'chat.scrollDown': 'Scroll to bottom',
'chat.collapseTools': 'Collapse all tools', 'chat.collapseTools': 'Collapse all tools',
'chat.expandTools': 'Expand all tools', 'chat.expandTools': 'Expand all tools',
'chat.messages': 'Chat messages', 'chat.messages': 'Chat messages',
@@ -164,6 +165,7 @@ const fr: Record<keyof typeof en, string> = {
'chat.showPreview': 'Aperçu markdown', 'chat.showPreview': 'Aperçu markdown',
'chat.hidePreview': 'Masquer l\'aperçu', 'chat.hidePreview': 'Masquer l\'aperçu',
'chat.scrollToBottom': 'Nouveaux messages', 'chat.scrollToBottom': 'Nouveaux messages',
'chat.scrollDown': 'Défiler en bas',
'chat.collapseTools': 'Replier tous les outils', 'chat.collapseTools': 'Replier tous les outils',
'chat.expandTools': 'Déplier tous les outils', 'chat.expandTools': 'Déplier tous les outils',
'chat.messages': 'Messages du chat', 'chat.messages': 'Messages du chat',