diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 9630011..94b8dd5 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -488,18 +488,36 @@ const TaskChat = ({ taskId }: TaskChatProps) => { return () => viewport.removeEventListener('scroll', onScroll); }, [taskId, isLoading]); - // Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query) - useEffect(() => { + // Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query). + // useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был + // у низа списка, а не вверху старой части (иначе пользователь видит «старые + // сообщения → прыжок к последним»). Отложенная коррекция остаётся — после + // измерения высот виртуализатором позиция может сместиться. + useLayoutEffect(() => { if (!taskId) return; atBottomRef.current = true; lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи setNewBelowCount(0); + scrolledToFirstBatchRef.current = false; + scrollToBottomRef.current(); // до paint — первый кадр уже у низа requestAnimationFrame(() => { - setTimeout(() => scrollToBottomRef.current(), 150); + setTimeout(() => scrollToBottomRef.current(), 150); // коррекция после measure }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId]); + // Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования + // (нет кэша react-query и localStorage — первый визит в этот чат): + // до paint подменяем верх списка на низ. + const scrolledToFirstBatchRef = useRef(false); + useLayoutEffect(() => { + if (isLoading || messages.length === 0) return; + if (scrolledToFirstBatchRef.current) return; + scrolledToFirstBatchRef.current = true; + scrollToBottomRef.current(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, messages.length]); + // Автоскролл при новых сообщениях: только если пользователь у низа списка // или последнее сообщение — его собственное (как в мессенджере). // ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены, diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts index 0d415d1..2cc0b1b 100644 --- a/client/src/components/chat/useChatController.ts +++ b/client/src/components/chat/useChatController.ts @@ -704,17 +704,33 @@ export function useChatController() { // ── Auto-scroll ─────────────────────────────────────────────────────────────── // Scroll to bottom whenever the active conversation changes (open from cache, new chat, etc.) - useEffect(() => { + // useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был + // у низа списка, а не вверху старой части (иначе «старые сообщения → прыжок»). + // Отложенная коррекция остаётся — после измерения высот виртуализатором. + useLayoutEffect(() => { if (!activeConvId) return; atBottomRef.current = true; lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене диалога setNewBelowCount(0); + scrolledToFirstBatchRef.current = false; + scrollToBottom(); // до paint — первый кадр уже у низа requestAnimationFrame(() => { - setTimeout(scrollToBottom, 120); + setTimeout(scrollToBottom, 120); // коррекция после measure }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeConvId, scrollToBottom]); + // Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования + // (нет кэша react-query и localStorage — первый визит в этот диалог). + const scrolledToFirstBatchRef = useRef(false); + useLayoutEffect(() => { + if (msgsLoading || initialMessages.length === 0) return; + if (scrolledToFirstBatchRef.current) return; + scrolledToFirstBatchRef.current = true; + scrollToBottom(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [msgsLoading, initialMessages.length]); + // Scroll to bottom when new messages arrive (tracked via initialMessages.length), // NOT when old messages are prepended via load-more. Скроллим только если // пользователь уже у низа списка или последнее сообщение — его собственное. diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 718697a..b5a1849 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -575,14 +575,20 @@ const TaskDetail = () => { formNamesMap[f.id] = f.name; }); - // Компонент чата (без заголовка — он вынесен в полосу вкладок) - const ChatSection = ({ className = '' }: { className?: string }) => ( + // Компонент чата (без заголовка — он вынесен в полосу вкладок). + // ВАЖНО: обязательно useCallback со стабильной идентичностью — компонент, + // объявленный inline в теле рендера, получает НОВЫЙ тип на каждый рендер, + // и React полностью перемонтирует чат (сброс скролла и состояния) при ЛЮБОМ + // ре-рендере TaskDetail (SSE-событие, refetch задачи, возврат на вкладку). + // Именно этот remount и был первопричиной «дёрганья» чата. Тот же паттерн + // уже исправлен для мобильного accordion (см. комментарий ниже). + const ChatSection = useCallback(({ className = '' }: { className?: string }) => (
- ); + ), [taskIdNumber]); const taskContent = (