feat(chat): онлайн-доставка как в Telegram — watchdog SSE, тихий скролл, персистентный кэш сообщений, невидимое обновление SW

- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с
- Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата)
- Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity
- SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
This commit is contained in:
2026-09-21 20:29:13 +03:00
parent f8d78afd2e
commit 34838a302d
9 changed files with 336 additions and 26 deletions

View File

@@ -20,9 +20,10 @@ import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Send, User, AtSign, Reply, X, Check, MoreVertical, Bot, Link as LinkIcon, BarChart2, Paperclip, Loader2 } from 'lucide-react';
import { Send, User, AtSign, Reply, X, Check, MoreVertical, Bot, Link as LinkIcon, BarChart2, Paperclip, Loader2, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isPWAInstalled, reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
import { saveCachedMessages, getCachedMessages } from '@/lib/messageCache';
import { PendingAttachmentList } from '@/components/chat/ChatAttachments';
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
import ReactMarkdown from 'react-markdown';
@@ -264,6 +265,15 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
queryKey: ['/api/tasks', taskId, 'messages'],
enabled: taskId > 0,
staleTime: 10000, // Данные считаются свежими 10 секунд - предотвращает лишние запросы
gcTime: Infinity, // Кэш не удалять при размонтировании — возврат в чат без «Загрузка...»
// Персистентный кэш: мгновенный рендер последних сообщений после перезагрузки
// страницы, свежие данные догружаются в фоне (initialDataUpdatedAt старше
// staleTime → фоновый refetch сразу после монтирования)
initialData: () => {
const cached = getCachedMessages(`task:${taskId}`);
return cached ? { success: true, messages: cached.messages } : undefined;
},
initialDataUpdatedAt: () => getCachedMessages(`task:${taskId}`)?.savedAt,
// ВАЖНО: Отключаем automatic refetch чтобы не удалять optimistic messages
// SSE обеспечивает real-time обновления, polling fallback — ручной интервал выше
refetchIntervalInBackground: false, // Не делать polling когда вкладка неактивна
@@ -312,6 +322,15 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
const messages: TaskMessage[] = useMemo(() => (messagesData as any)?.messages || [], [messagesData]);
// Сохраняем последние сообщения в localStorage — для мгновенного рендера
// после перезагрузки страницы (см. initialData в useQuery выше)
useEffect(() => {
const msgs = (messagesData as any)?.messages;
if (taskId > 0 && Array.isArray(msgs) && msgs.length > 0) {
saveCachedMessages(`task:${taskId}`, msgs);
}
}, [messagesData, taskId]);
// Сообщения, дополненные полем replyTo для общего MessageBubble (из replyToMessage)
const displayMessages: TaskChatMessage[] = useMemo(
() => messages.map(m => ({
@@ -451,13 +470,19 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда
const atBottomRef = useRef(true);
// Счётчик «новых снизу» (плавающий индикатор как в Telegram): показывается,
// когда пользователь листает историю и вниз приходят новые сообщения
const [newBelowCount, setNewBelowCount] = useState(0);
// Отслеживаем, находится ли пользователь у низа списка
useEffect(() => {
const viewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (!viewport) return;
const onScroll = () => {
atBottomRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
atBottomRef.current = atBottom;
// Доскроллил до низа — индикатор «новых» больше не нужен
if (atBottom) setNewBelowCount(0);
};
viewport.addEventListener('scroll', onScroll, { passive: true });
return () => viewport.removeEventListener('scroll', onScroll);
@@ -467,22 +492,56 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
useEffect(() => {
if (!taskId) return;
atBottomRef.current = true;
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи
setNewBelowCount(0);
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 150);
setTimeout(() => scrollToBottomRef.current(), 150);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
// Автоскролл при новых сообщениях: только если пользователь у низа списка
// или последнее сообщение — его собственное (как в мессенджере)
// или последнее сообщение — его собственное (как в мессенджере).
// ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены,
// atBottomRef держит позицию на момент ухода, и любой скролл в фоне «выбрасывает»
// пользователя вниз при возврате. Вместо этого копим счётчик «новых снизу».
const lastSeenLengthRef = useRef(0);
const scrollToBottomRef = useRef(scrollToBottom);
scrollToBottomRef.current = scrollToBottom;
useEffect(() => {
if (isLoading || messages.length === 0) return;
const added = messages.length - lastSeenLengthRef.current;
lastSeenLengthRef.current = messages.length;
if (added <= 0) return;
const isHidden = typeof document !== 'undefined' && document.hidden;
const last = messages[messages.length - 1];
const isMine = last?.author?.id === user?.id;
if (!atBottomRef.current && !isMine) return;
if (isHidden || (!atBottomRef.current && !isMine)) {
// Пользователь читает историю (или вкладка скрыта) — не двигаем вид,
// показываем индикатор новых сообщений
if (!atBottomRef.current || isHidden) setNewBelowCount(c => c + added);
return;
}
setNewBelowCount(0);
// Небольшая задержка чтобы DOM успел обновиться
setTimeout(scrollToBottom, 100);
setTimeout(() => scrollToBottomRef.current(), 100);
}, [messages.length, isLoading, user?.id]);
// Возврат на вкладку: если пользователь был у низа — мгновенно прижимаем
// к последнему сообщению; если листал историю — остаёмся на месте
// (индикатор «новых» уже показан счётчиком выше)
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState !== 'visible') return;
if (atBottomRef.current) {
scrollToBottomRef.current();
setNewBelowCount(0);
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
// Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID)
const userMessageIds = user && messages.length > 0
? messages
@@ -1540,6 +1599,23 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
)}
</ScrollArea>
{/* Индикатор новых сообщений снизу (как в Telegram): виден, когда
пользователь листает историю и вниз приходят сообщения */}
{newBelowCount > 0 && (
<button
type="button"
onClick={() => {
setNewBelowCount(0);
scrollToBottomRef.current();
}}
className="absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
data-testid="new-messages-pill"
>
<ChevronDown className="w-3.5 h-3.5" />
{newBelowCount === 1 ? '1 новое' : `${newBelowCount} новых`}
</button>
)}
{/* Форма отправки сообщения */}
<div className="relative px-3 pb-3">
{/* Показать упоминаемых пользователей и ботов */}

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { useLocation, useParams } from 'wouter';
import { useVirtualizer } from '@tanstack/react-virtual';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { saveCachedMessages, getCachedMessages } from '@/lib/messageCache';
import {
enqueueAttachment,
getAllAttachments,
@@ -192,6 +193,20 @@ export function useChatController() {
queryKey: ['/api/messenger/conversations', activeConvId, 'messages'],
enabled: !!activeConvId,
refetchInterval: false,
staleTime: 10000, // Данные свежие 10 секунд; восстановленный из localStorage кэш старше → фоновый refetch
gcTime: Infinity, // Кэш не удалять при размонтировании — возврат в чат без «Загрузка...»
// Персистентный кэш: мгновенный рендер последних сообщений после перезагрузки
// страницы, свежие данные догружаются в фоне (initialDataUpdatedAt старше
// staleTime → фоновый refetch сразу после монтирования)
initialData: () => {
if (!activeConvId) return undefined;
const cached = getCachedMessages<ConvMessage>(`conv:${activeConvId}`);
return cached ? { success: true, messages: cached.messages } : undefined;
},
initialDataUpdatedAt: () => {
if (!activeConvId) return undefined;
return getCachedMessages<ConvMessage>(`conv:${activeConvId}`)?.savedAt;
},
// Reconciliation: merge optimistic pending messages back after server refetches
select: (data) => {
if (!data) return data;
@@ -214,6 +229,14 @@ export function useChatController() {
});
const initialMessages = msgsData?.messages ?? [];
// Сохраняем последние сообщения в localStorage — для мгновенного рендера
// после перезагрузки страницы (см. initialData в useQuery выше)
useEffect(() => {
if (activeConvId && Array.isArray(msgsData?.messages) && msgsData.messages.length > 0) {
saveCachedMessages(`conv:${activeConvId}`, msgsData.messages);
}
}, [msgsData, activeConvId]);
// Reset pagination state on conversation switch
useEffect(() => {
setOlderMessages([]);
@@ -261,6 +284,11 @@ export function useChatController() {
// Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда
const atBottomRef = useRef(true);
// Длина списка на момент последнего автоскролла — для подсчёта «новых снизу»
const lastSeenLengthRef = useRef(0);
// Счётчик «новых снизу» (плавающий индикатор как в Telegram): показывается,
// когда пользователь листает историю и вниз приходят новые сообщения
const [newBelowCount, setNewBelowCount] = useState(0);
const scrollToBottom = useCallback(() => {
if (chatListStateRef.current.shouldVirtualize) {
@@ -276,7 +304,10 @@ export function useChatController() {
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (!viewport) return;
const onScroll = () => {
atBottomRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80;
atBottomRef.current = atBottom;
// Доскроллил до низа — индикатор «новых» больше не нужен
if (atBottom) setNewBelowCount(0);
};
viewport.addEventListener('scroll', onScroll, { passive: true });
return () => viewport.removeEventListener('scroll', onScroll);
@@ -662,7 +693,7 @@ export function useChatController() {
}, []),
});
// Lightweight polling fallback: если SSE не доставил событие, раз в 10 секунд
// Lightweight polling fallback: если SSE не доставил событие, раз в 5 секунд
// запрашиваем только новые сообщения после последнего известного id.
useMessagePolling<ConvMessage>({
queryKey: ['/api/messenger/conversations', activeConvId, 'messages'],
@@ -676,26 +707,58 @@ export function useChatController() {
useEffect(() => {
if (!activeConvId) return;
atBottomRef.current = true;
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене диалога
setNewBelowCount(0);
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 120);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConvId, scrollToBottom]);
// Scroll to bottom when new messages arrive (tracked via initialMessages.length),
// NOT when old messages are prepended via load-more. Скроллим только если
// пользователь уже у низа списка или последнее сообщение — его собственное.
// ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены,
// atBottomRef держит позицию на момент ухода, и любой скролл в фоне «выбрасывает»
// пользователя вниз при возврате. Вместо этого копим счётчик «новых снизу».
useEffect(() => {
if (msgsLoading || initialMessages.length === 0) return;
const added = initialMessages.length - lastSeenLengthRef.current;
lastSeenLengthRef.current = initialMessages.length;
if (added <= 0) return;
const isHidden = typeof document !== 'undefined' && document.hidden;
const last = initialMessages[initialMessages.length - 1];
const isMine = last?.author?.id === user?.id;
if (!atBottomRef.current && !isMine) return;
if (isHidden || (!atBottomRef.current && !isMine)) {
// Пользователь читает историю (или вкладка скрыта) — не двигаем вид,
// показываем индикатор новых сообщений
if (!atBottomRef.current || isHidden) setNewBelowCount(c => c + added);
return;
}
setNewBelowCount(0);
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 80);
});
// initialMessages намеренно отслеживаем только по длине: prepend старых
// сообщений через load-more не должен вызывать автоскролл
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialMessages.length, msgsLoading, scrollToBottom, user?.id]);
// Возврат на вкладку: если пользователь был у низа — мгновенно прижимаем
// к последнему сообщению; если листал историю — остаёмся на месте
// (индикатор «новых» уже показан счётчиком выше)
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState !== 'visible') return;
if (atBottomRef.current) {
scrollToBottom();
setNewBelowCount(0);
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [scrollToBottom]);
// ── Mark as read when switching conversations ─────────────────────────────────
const markReadMutation = useMutation({
@@ -1160,6 +1223,9 @@ export function useChatController() {
scrollRef,
msgsLoading,
messages,
newBelowCount,
setNewBelowCount,
scrollToBottom,
hasMore,
loadMoreMessages,
isLoadingMore,

View File

@@ -17,7 +17,7 @@ interface UseMessagePollingOptions {
buildUrl: (afterId: number) => string;
/** Опрос активен только при true (например, пока SSE отключён или чат открыт). */
enabled: boolean;
/** Интервал опроса в мс (по умолчанию 10 секунд). */
/** Интервал опроса в мс (по умолчанию 5 секунд — только пока SSE отключён). */
intervalMs?: number;
}
@@ -25,7 +25,7 @@ export function useMessagePolling<T extends { id: number }>({
queryKey,
buildUrl,
enabled,
intervalMs = 10000,
intervalMs = 5000,
}: UseMessagePollingOptions) {
// buildUrl — обычно inline-функция; держим в ref, чтобы не пересоздавать интервал на каждый рендер
const buildUrlRef = useRef(buildUrl);

View File

@@ -0,0 +1,70 @@
// ── Персистентный кэш последних сообщений чата ────────────────────────────────
// После перезагрузки страницы (деплой, выгрузка вкладки Chrome Memory Saver,
// перезапуск PWA) кэш react-query пуст, и чат показывает «Загрузка сообщений...»
// плюс кадр «старые → новые». Чтобы этого не было, последние сообщения каждого
// чата сохраняются в localStorage и подкладываются в useQuery как initialData:
// чат рисуется мгновенно с последними известными сообщениями, а свежие данные
// догружаются в фоне (isLoading сразу false — без спиннера).
const PREFIX = 'msgcache:';
const MAX_MESSAGES = 100;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // неделя
interface CachedMessages {
savedAt: number;
messages: unknown[];
}
// Удаляем протухшие ключи — иначе localService со временем забьётся старыми чатами
function pruneExpired() {
try {
const now = Date.now();
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith(PREFIX)) continue;
try {
const raw = localStorage.getItem(key);
if (!raw) continue;
const data = JSON.parse(raw) as CachedMessages;
if (!data?.savedAt || now - data.savedAt > MAX_AGE_MS) {
keysToRemove.push(key);
}
} catch {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch {
// localStorage недоступен — пропускаем
}
}
export function saveCachedMessages<T>(key: string, messages: T[]) {
try {
const data: CachedMessages = {
savedAt: Date.now(),
messages: messages.slice(-MAX_MESSAGES),
};
localStorage.setItem(PREFIX + key, JSON.stringify(data));
pruneExpired();
} catch {
// localStorage переполнен или недоступен — не критично, кэш best-effort
}
}
export function getCachedMessages<T>(key: string): { messages: T[]; savedAt: number } | null {
try {
const raw = localStorage.getItem(PREFIX + key);
if (!raw) return null;
const data = JSON.parse(raw) as CachedMessages;
if (!data || !Array.isArray(data.messages) || !data.savedAt) return null;
if (Date.now() - data.savedAt > MAX_AGE_MS) {
localStorage.removeItem(PREFIX + key);
return null;
}
return { messages: data.messages as T[], savedAt: data.savedAt };
} catch {
return null;
}
}

View File

@@ -71,13 +71,27 @@ export async function registerServiceWorker(): Promise<ServiceWorkerRegistration
});
// When the service worker controller changes (new version activated),
// reload the page once so the user runs the latest code.
// reload the page so the user runs the latest code. НО не на глазах у
// пользователя: на видимой вкладке ждём, пока она уйдёт в фон, и перезагружаем
// там — обновление происходит незаметно. Ранее reload был мгновенным и
// выглядел как внезапный сброс чата посреди работы.
let refreshing = false;
const reloadOnce = () => {
if (refreshing) return;
refreshing = true;
console.log('[PWA] Service worker controller changed, reloading...');
window.location.reload();
};
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true;
console.log('[PWA] Service worker controller changed, reloading...');
window.location.reload();
if (document.hidden) {
reloadOnce();
} else {
document.addEventListener('visibilitychange', function onHidden() {
if (document.hidden) {
document.removeEventListener('visibilitychange', onHidden);
reloadOnce();
}
});
}
});

View File

@@ -7,13 +7,48 @@ const listeners = new Map<string, Set<Listener>>();
let eventSource: EventSource | null = null;
let isConnecting = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = 3000;
let reconnectDelay = 1000;
let connectedListeners = new Set<(connected: boolean, connId: string | null) => void>();
let lastConnState = false;
let lastConnId: string | null = null;
let activeUserId: number | null = null;
let lastEventId: string | null = null;
// ── Watchdog живости соединения ──────────────────────────────────────────────
// Сервер шлёт именованное событие `ping` каждые 25с. Если ни одного события/ping
// не было дольше WATCHDOG_TIMEOUT_MS — соединение считаем мёртвым (NAT/файрвол
// молча дропнул поток, EventSource об этом не узнает) и принудительно пересоздаём.
// Без этого клиент мог часами «полагать», что он онлайн, и получать сообщения
// только polling-фолбэком пачками раз в 10 секунд.
let lastActivityAt = Date.now();
const WATCHDOG_INTERVAL_MS = 15000;
const WATCHDOG_TIMEOUT_MS = 45000;
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
function touch() {
lastActivityAt = Date.now();
}
function forceReconnect(reason: string) {
console.log(`[SSE] ${reason} — forcing reconnect`);
if (eventSource) { eventSource.close(); eventSource = null; }
lastConnState = false;
lastConnId = null;
notifyConnState();
scheduleReconnect();
}
function ensureWatchdog() {
if (watchdogTimer) return;
watchdogTimer = setInterval(() => {
if (!eventSource || activeUserId === null) return;
if (eventSource.readyState !== EventSource.OPEN) return;
if (Date.now() - lastActivityAt > WATCHDOG_TIMEOUT_MS) {
forceReconnect('watchdog: no activity');
}
}, WATCHDOG_INTERVAL_MS);
}
const KNOWN_EVENTS = [
'message_read',
'message_created',
@@ -61,7 +96,7 @@ async function connect() {
try {
const tokenRes = await apiRequest('POST', '/api/events/token');
if (tokenRes.status === 429) {
reconnectDelay = Math.min(reconnectDelay * 2, 60000);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
isConnecting = false;
scheduleReconnect();
return;
@@ -81,13 +116,20 @@ async function connect() {
isConnecting = false;
es.onopen = () => {
reconnectDelay = 3000;
reconnectDelay = 1000;
touch();
lastConnState = true;
notifyConnState();
};
// Именованный ping от сервера (heartbeat) — подтверждение живости соединения
es.addEventListener('ping', () => {
touch();
});
es.onmessage = (event) => {
try {
touch();
if (event.lastEventId) {
lastEventId = event.lastEventId;
}
@@ -102,6 +144,7 @@ async function connect() {
for (const evName of KNOWN_EVENTS) {
es.addEventListener(evName, (e: MessageEvent) => {
try {
touch();
if (e.lastEventId) {
lastEventId = e.lastEventId;
}
@@ -138,7 +181,7 @@ async function connect() {
dispatchAuthFailure();
return;
}
reconnectDelay = Math.min(reconnectDelay * 2, 60000);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
scheduleReconnect();
}
}
@@ -158,6 +201,7 @@ export const sseManager = {
if (userId === null) {
disconnect();
} else {
ensureWatchdog();
connect();
}
},
@@ -181,8 +225,18 @@ export const sseManager = {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (!document.hidden && activeUserId !== null && !eventSource && !isConnecting) {
if (document.hidden || activeUserId === null) return;
// Возврат на вкладку: сбрасываем backoff и переподключаемся сразу, не ждём
// таймер. Если соединение формально открыто, но давно без активности
// (вкладка была в фоне, watchdog троттлится) — пересоздаём принудительно.
reconnectDelay = 1000;
if (!eventSource && !isConnecting) {
connect();
return;
}
if (eventSource && eventSource.readyState === EventSource.OPEN &&
Date.now() - lastActivityAt > WATCHDOG_TIMEOUT_MS) {
forceReconnect('visibility: stale connection');
}
});
}

View File

@@ -9,7 +9,7 @@ import { getAvatarColor, getUserInitials } from '@/lib/avatar';
import {
MessageSquare, Plus, Send, Reply, X, Check,
Users, Link2, Paperclip, Loader2,
ChevronLeft, Bell, BellOff, BarChart2,
ChevronLeft, ChevronDown, Bell, BellOff, BarChart2,
} from 'lucide-react';
import { PendingAttachmentList } from '@/components/chat/ChatAttachments';
import { cn } from '@/lib/utils';
@@ -54,6 +54,9 @@ export default function ChatPage() {
scrollRef,
msgsLoading,
messages,
newBelowCount,
setNewBelowCount,
scrollToBottom,
hasMore,
loadMoreMessages,
isLoadingMore,
@@ -522,6 +525,23 @@ export default function ChatPage() {
<TypingIndicator typingBots={typingBots} />
</ScrollArea>
{/* Индикатор новых сообщений снизу (как в Telegram): виден, когда
пользователь листает историю и вниз приходят сообщения */}
{newBelowCount > 0 && (
<button
type="button"
onClick={() => {
setNewBelowCount(0);
scrollToBottom();
}}
className="absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
data-testid="new-messages-pill"
>
<ChevronDown className="w-3.5 h-3.5" />
{newBelowCount === 1 ? '1 новое' : `${newBelowCount} новых`}
</button>
)}
{/* Input area */}
<div className="shrink-0 border-t border-border p-3 space-y-2">
{replyTo && (