From 85f4210480542c65b9e98264fb9d18829b0ae206 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 17 Sep 2026 04:50:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B0=20=D0=B6=D1=83=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Журнал перерисовывается целиком, и очистка документа сбрасывала прокрутку в начало: следующая перерисовка принимала это за прокрутку пользователем и оставляла журнал наверху. - Режим «следовать за низом» меняется только прокруткой пользователя - Позиция восстанавливается по rangeChanged, когда раскладка документа действительно обновилась - Новый запрос всегда прокручивает вниз - Тесты автопрокрутки и сохранения позиции при прокрутке вверх Co-Authored-By: Claude Opus 5 --- src/agr_assistent/ui/chat_window.py | 52 +++++++++++++++------ tests/test_chat_window.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 tests/test_chat_window.py diff --git a/src/agr_assistent/ui/chat_window.py b/src/agr_assistent/ui/chat_window.py index d4de6f9..e5b606c 100644 --- a/src/agr_assistent/ui/chat_window.py +++ b/src/agr_assistent/ui/chat_window.py @@ -46,6 +46,8 @@ _VOICE_BUTTON_TEXTS = { _RENDER_INTERVAL_MS = 50 # Журнал перерисовывается целиком, поэтому храним только последние записи _MAX_ENTRIES = 100 +# Насколько можно не дотянуть до низа, чтобы журнал всё равно следовал за новыми записями +_BOTTOM_TOLERANCE_PX = 4 _META_COLOR = "#9e9e9e" @@ -89,6 +91,14 @@ class ChatWindow(QWidget): self._transcript = QTextBrowser() self._transcript.setOpenExternalLinks(True) + # Прилипать к низу, пока пользователь сам не прокрутил журнал вверх + self._follow_bottom = True + self._scroll_position = 0 + self._rendering = False + self._adjusting_scroll = False + transcript_scrollbar = self._transcript.verticalScrollBar() + transcript_scrollbar.valueChanged.connect(self._on_user_scrolled) + transcript_scrollbar.rangeChanged.connect(self._restore_scroll) self._input = _MessageInput() self._input.setPlaceholderText( @@ -135,9 +145,7 @@ class ChatWindow(QWidget): assistant.state_changed.connect(self._on_state_changed) assistant.provider_changed.connect(self._update_provider_label) - assistant.request_added.connect( - lambda text, follow_up: self._append(_Entry("user", text, follow_up)) - ) + assistant.request_added.connect(self._on_request_added) assistant.reply_started.connect(lambda: self._append(_Entry("assistant", ""))) assistant.reply_chunk.connect(self._on_reply_chunk) assistant.reply_finished.connect(self._on_reply_finished) @@ -190,6 +198,11 @@ class ChatWindow(QWidget): f"Модель: {self._assistant.provider_name} · {self._assistant.model_name}" ) + def _on_request_added(self, text: str, follow_up: bool) -> None: + # Новый запрос всегда показываем, даже если журнал был прокручен вверх + self._follow_bottom = True + self._append(_Entry("user", text, follow_up)) + def _on_reply_chunk(self, piece: str) -> None: # После действия текст ответа продолжается новой записью под ним if not self._entries or self._entries[-1].role != "assistant": @@ -226,9 +239,8 @@ class ChatWindow(QWidget): def _render(self) -> None: scrollbar = self._transcript.verticalScrollBar() - previous_position = scrollbar.value() - stick_to_bottom = previous_position >= scrollbar.maximum() - 4 - + # Журнал перестраивается целиком, и clear() сбрасывает прокрутку в начало + self._rendering = True document = self._transcript.document() document.clear() cursor = QTextCursor(document) @@ -260,10 +272,24 @@ class ChatWindow(QWidget): else: cursor.insertText(entry.text) - # Диапазон прокрутки обновляется после раскладки документа - QTimer.singleShot( - 0, - lambda: scrollbar.setValue( - scrollbar.maximum() if stick_to_bottom else previous_position - ), - ) + self._rendering = False + self._restore_scroll(scrollbar.minimum(), scrollbar.maximum()) + + def _on_user_scrolled(self, value: int) -> None: + # Перерисовка и наша же подстройка тоже двигают полосу — это не действие пользователя + if self._rendering or self._adjusting_scroll: + return + scrollbar = self._transcript.verticalScrollBar() + self._follow_bottom = value >= scrollbar.maximum() - _BOTTOM_TOLERANCE_PX + self._scroll_position = value + + def _restore_scroll(self, _minimum: int, maximum: int) -> None: + """Диапазон прокрутки меняется после раскладки документа, иногда позже перерисовки.""" + if self._rendering: + return + self._adjusting_scroll = True + try: + scrollbar = self._transcript.verticalScrollBar() + scrollbar.setValue(maximum if self._follow_bottom else self._scroll_position) + finally: + self._adjusting_scroll = False diff --git a/tests/test_chat_window.py b/tests/test_chat_window.py new file mode 100644 index 0000000..318380e --- /dev/null +++ b/tests/test_chat_window.py @@ -0,0 +1,72 @@ +import time + +import pytest +from PySide6.QtCore import QCoreApplication +from PySide6.QtWidgets import QApplication + +from agr_assistent.core.memory import MemoryStore +from agr_assistent.ui.chat_window import ChatWindow +from tests.fake_llm import FakeLLMServer +from tests.test_assistant_flow import _assistant + + +def _pump(seconds: float = 0.1) -> None: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + QCoreApplication.processEvents() + time.sleep(0.005) + + +@pytest.fixture +def window(qapp: QApplication, fake_llm: FakeLLMServer, tmp_path) -> ChatWindow: # type: ignore[no-untyped-def] + memory = MemoryStore(tmp_path / "memory.sqlite3") + chat = ChatWindow(_assistant(fake_llm, memory)) + chat.resize(500, 300) + chat.show() + yield chat + chat.hide() + memory.close() + + +def _scrollbar(window: ChatWindow): # type: ignore[no-untyped-def] + return window._transcript.verticalScrollBar() + + +def _stream_reply(window: ChatWindow, request: str, chunks: int) -> None: + assistant = window._assistant + assistant.request_added.emit(request, False) + assistant.reply_started.emit() + for index in range(chunks): + assistant.reply_chunk.emit(f"Строка ответа номер {index}.\n\n") + _pump(0.01) + assistant.reply_finished.emit("") + _pump(0.2) + + +def test_journal_follows_new_content(window: ChatWindow) -> None: + for request in range(3): + _stream_reply(window, f"Вопрос {request}", chunks=15) + + scrollbar = _scrollbar(window) + assert scrollbar.maximum() > 0 + assert scrollbar.value() == scrollbar.maximum() + + +def test_journal_keeps_position_when_user_scrolled_up(window: ChatWindow) -> None: + _stream_reply(window, "Длинный вопрос", chunks=30) + scrollbar = _scrollbar(window) + scrollbar.setValue(10) + _pump() + + assistant = window._assistant + assistant.reply_started.emit() + for index in range(10): + assistant.reply_chunk.emit(f"Ещё строка {index}.\n\n") + _pump(0.01) + _pump(0.2) + assert scrollbar.value() == 10 + + # Новый запрос пользователя снова прокручивает вниз + assistant.request_added.emit("Новый вопрос", False) + _pump(0.2) + assert scrollbar.value() == scrollbar.maximum()