diff --git a/client/public/sw.js b/client/public/sw.js
index 83c9e44..fcc454e 100644
--- a/client/public/sw.js
+++ b/client/public/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'iistwin-pwa-v14';
+const CACHE_NAME = 'iistwin-pwa-v15';
const STATIC_ASSETS = [
'/',
'/index.html',
@@ -328,9 +328,16 @@ self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys().then((cacheNames) => {
+ // Старая страница может ещё работать под новым SW (reload отложен до ухода
+ // вкладки в фон). Если удалить её кэш сразу, ленивые чанки старой сборки
+ // дадут 404 — поэтому предыдущую версию кэша сохраняем.
+ const versionMatch = CACHE_NAME.match(/^(.*-v)(\d+)$/);
+ const prevCacheName = versionMatch
+ ? `${versionMatch[1]}${Number(versionMatch[2]) - 1}`
+ : null;
return Promise.all(
cacheNames
- .filter((name) => name !== CACHE_NAME)
+ .filter((name) => name !== CACHE_NAME && name !== prevCacheName)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx
index 25c8cae..9630011 100644
--- a/client/src/components/TaskChat.tsx
+++ b/client/src/components/TaskChat.tsx
@@ -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) => {
)}
+ {/* Индикатор новых сообщений снизу (как в Telegram): виден, когда
+ пользователь листает историю и вниз приходят сообщения */}
+ {newBelowCount > 0 && (
+
+ )}
+
{/* Форма отправки сообщения */}
{/* Показать упоминаемых пользователей и ботов */}
diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts
index bf1b511..0d415d1 100644
--- a/client/src/components/chat/useChatController.ts
+++ b/client/src/components/chat/useChatController.ts
@@ -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(`conv:${activeConvId}`);
+ return cached ? { success: true, messages: cached.messages } : undefined;
+ },
+ initialDataUpdatedAt: () => {
+ if (!activeConvId) return undefined;
+ return getCachedMessages(`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({
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,
diff --git a/client/src/components/chat/useMessagePolling.ts b/client/src/components/chat/useMessagePolling.ts
index eade71b..4163b27 100644
--- a/client/src/components/chat/useMessagePolling.ts
+++ b/client/src/components/chat/useMessagePolling.ts
@@ -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({
queryKey,
buildUrl,
enabled,
- intervalMs = 10000,
+ intervalMs = 5000,
}: UseMessagePollingOptions) {
// buildUrl — обычно inline-функция; держим в ref, чтобы не пересоздавать интервал на каждый рендер
const buildUrlRef = useRef(buildUrl);
diff --git a/client/src/lib/messageCache.ts b/client/src/lib/messageCache.ts
new file mode 100644
index 0000000..76ff95a
--- /dev/null
+++ b/client/src/lib/messageCache.ts
@@ -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(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(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;
+ }
+}
diff --git a/client/src/lib/pwa.ts b/client/src/lib/pwa.ts
index f4bbfdb..fa87717 100644
--- a/client/src/lib/pwa.ts
+++ b/client/src/lib/pwa.ts
@@ -71,13 +71,27 @@ export async function registerServiceWorker(): Promise {
+ 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();
+ }
+ });
}
});
diff --git a/client/src/lib/sseManager.ts b/client/src/lib/sseManager.ts
index ff6b952..de72c86 100644
--- a/client/src/lib/sseManager.ts
+++ b/client/src/lib/sseManager.ts
@@ -7,13 +7,48 @@ const listeners = new Map>();
let eventSource: EventSource | null = null;
let isConnecting = false;
let reconnectTimer: ReturnType | 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 | 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');
}
});
}
diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx
index 4376449..1fd12ce 100644
--- a/client/src/pages/Chat.tsx
+++ b/client/src/pages/Chat.tsx
@@ -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() {
+ {/* Индикатор новых сообщений снизу (как в Telegram): виден, когда
+ пользователь листает историю и вниз приходят сообщения */}
+ {newBelowCount > 0 && (
+
+ )}
+
{/* Input area */}
{replyTo && (
diff --git a/server/routes/shared.ts b/server/routes/shared.ts
index 4cf65f3..6112408 100644
--- a/server/routes/shared.ts
+++ b/server/routes/shared.ts
@@ -84,7 +84,7 @@ export interface BufferedEvent extends Event {
export class EventBus {
// Индекс соединений по организации/пользователю — publishEvent не перебирает все соединения
private index = new SseConnectionIndex();
- private heartbeatInterval = 30000; // 30 seconds
+ private heartbeatInterval = 25000; // 25 seconds — должен быть заметно меньше client watchdog (45s)
private heartbeatTimer: NodeJS.Timeout | null = null;
private eventIdCounter = 0;
// Буферы последних событий для восстановления после reconnect
@@ -112,7 +112,10 @@ export class EventBus {
this.heartbeatTimer = setInterval(() => {
this.index.forEach((connection) => {
try {
- connection.res.write(`: heartbeat\n\n`);
+ // Именованное событие ping — в отличие от SSE-комментария (": heartbeat"),
+ // оно доставляется клиенту как обычное событие и позволяет ему отличить
+ // живое соединение от мёртвого (watchdog в sseManager).
+ connection.res.write(`event: ping\ndata: {}\n\n`);
const resAny = connection.res as any;
if (typeof resAny.flush === 'function') {
resAny.flush();