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:
24
FEEDBACK.md
24
FEEDBACK.md
@@ -671,3 +671,27 @@
|
||||
- **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.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -75,6 +75,8 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
const isNearBottomRef = useRef(true);
|
||||
const userSentRef = useRef(false);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const [hasNewMessages, setHasNewMessages] = useState(false);
|
||||
const prevMessageCountRef = useRef(messages.length);
|
||||
|
||||
const checkIfNearBottom = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
@@ -82,6 +84,9 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD;
|
||||
setShowScrollBtn(distanceFromBottom > SCROLL_THRESHOLD * 2);
|
||||
if (distanceFromBottom <= SCROLL_THRESHOLD) {
|
||||
setHasNewMessages(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
@@ -97,19 +102,53 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
return () => el.removeEventListener('scroll', handler);
|
||||
}, [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(() => {
|
||||
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) {
|
||||
// User just sent a message — always scroll to bottom
|
||||
userSentRef.current = false;
|
||||
scrollToBottom('smooth');
|
||||
isNearBottomRef.current = true;
|
||||
setHasNewMessages(false);
|
||||
return;
|
||||
}
|
||||
if (isNearBottomRef.current) {
|
||||
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
|
||||
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 />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Floating action buttons */}
|
||||
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
|
||||
{hasToolCalls && (
|
||||
<button
|
||||
onClick={globalState === 'expand-all' ? collapseAll : expandAll}
|
||||
aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
|
||||
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>
|
||||
)}
|
||||
{showScrollBtn && (
|
||||
<button
|
||||
onClick={() => scrollToBottom('smooth')}
|
||||
aria-label={t('chat.scrollToBottom')}
|
||||
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>
|
||||
</button>
|
||||
{/* Floating action buttons — sticky to bottom of scroll area */}
|
||||
{(hasToolCalls || showScrollBtn || hasNewMessages) && (
|
||||
<div className="sticky bottom-3 z-10 flex justify-center pointer-events-none pb-1">
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{hasToolCalls && (
|
||||
<button
|
||||
onClick={globalState === 'expand-all' ? collapseAll : expandAll}
|
||||
aria-label={globalState === 'expand-all' ? t('chat.collapseTools') : t('chat.expandTools')}
|
||||
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>
|
||||
)}
|
||||
{(showScrollBtn || hasNewMessages) && (
|
||||
<button
|
||||
onClick={() => { scrollToBottom('smooth'); setHasNewMessages(false); }}
|
||||
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={hasNewMessages ? 'text-pc-accent-light animate-bounce' : 'text-pc-accent-light'} />
|
||||
{hasNewMessages && <span className="hidden sm:inline">{t('chat.scrollToBottom')}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} />
|
||||
|
||||
@@ -16,6 +16,13 @@ import { useLocale } from '../hooks/useLocale';
|
||||
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
|
||||
// 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 {
|
||||
return getLocale() === 'fr' ? 'fr-FR' : 'en-US';
|
||||
}
|
||||
@@ -465,7 +472,7 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
||||
isUser
|
||||
? <User className="h-4 w-4 text-pc-accent-light" />
|
||||
: 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" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,7 @@ html, body {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: var(--pc-text-primary);
|
||||
border: none;
|
||||
border: 1px solid transparent; /* match textarea border width to keep text aligned */
|
||||
border-radius: 1rem; /* rounded-2xl */
|
||||
max-height: 200px;
|
||||
}
|
||||
@@ -257,12 +257,12 @@ html, body {
|
||||
color: #67e8f9;
|
||||
background: var(--pc-accent-glow);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
/* No padding — must match textarea text layout exactly to avoid cursor desync */
|
||||
}
|
||||
|
||||
.ht-bold {
|
||||
color: var(--pc-text-primary);
|
||||
font-weight: 700;
|
||||
/* No font-weight change — bold text is wider and causes cursor desync */
|
||||
}
|
||||
|
||||
.ht-italic {
|
||||
|
||||
@@ -43,6 +43,7 @@ const en = {
|
||||
'chat.showPreview': 'Preview markdown',
|
||||
'chat.hidePreview': 'Hide preview',
|
||||
'chat.scrollToBottom': 'New messages',
|
||||
'chat.scrollDown': 'Scroll to bottom',
|
||||
'chat.collapseTools': 'Collapse all tools',
|
||||
'chat.expandTools': 'Expand all tools',
|
||||
'chat.messages': 'Chat messages',
|
||||
@@ -164,6 +165,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'chat.showPreview': 'Aperçu markdown',
|
||||
'chat.hidePreview': 'Masquer l\'aperçu',
|
||||
'chat.scrollToBottom': 'Nouveaux messages',
|
||||
'chat.scrollDown': 'Défiler en bas',
|
||||
'chat.collapseTools': 'Replier tous les outils',
|
||||
'chat.expandTools': 'Déplier tous les outils',
|
||||
'chat.messages': 'Messages du chat',
|
||||
|
||||
Reference in New Issue
Block a user