Исправлена автопрокрутка журнала

Журнал перерисовывается целиком, и очистка документа сбрасывала прокрутку в начало:
следующая перерисовка принимала это за прокрутку пользователем и оставляла журнал наверху.

- Режим «следовать за низом» меняется только прокруткой пользователя
- Позиция восстанавливается по rangeChanged, когда раскладка документа действительно обновилась
- Новый запрос всегда прокручивает вниз
- Тесты автопрокрутки и сохранения позиции при прокрутке вверх

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 04:50:29 +03:00
co-authored by Claude Opus 5
parent 04029f3750
commit 85f4210480
2 changed files with 111 additions and 13 deletions
+39 -13
View File
@@ -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"Модель: <b>{self._assistant.provider_name}</b> · {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
+72
View File
@@ -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()