fix(code): hydrate virtualized transcript on scroll offset changes (#4646)

Follow-up to #4549 (transcript virtualization).

Fixed older messages failing to load when scrolling up long
conversations in `dcode` with a trackpad, mouse wheel, or keyboard.

---

Scrolling up a long `dcode` thread with a trackpad (or wheel/keyboard)
never loaded older messages — the viewport parked in the blank top
spacer. Transcript virtualization triggered hydration only from
app-level `on_scroll_up`/`on_scroll_down` handlers bound to
`textual.scrollbar`'s `ScrollUp`/`ScrollDown` messages. Those messages
never fire for wheel/trackpad/keyboard scrolling (which scroll via
`MouseScroll*` events), and for scrollbar-track clicks they are
`bubble=False` and consumed by the `_ChatScroll` container's own handler
before they can reach the app — so hydration effectively never ran on
scroll.

Verified against Textual 8.2.7: a wheel scroll and a scrollbar-track
action both move `scroll_y` but invoke the app-level handler zero times.

This drives hydration off the actual scroll offset instead:
`_ChatScroll` posts a `Scrolled` message from `watch_scroll_y`, and the
app checks both hydration directions in `on_chat_scrolled`. That covers
every input device (wheel, trackpad, keyboard, scrollbar, programmatic)
uniformly.

Made by [Open
SWE](https://openswe.vercel.app/agents/0f94cbf2-4d61-e04a-9e7b-520902ec436d)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-13 17:18:51 -04:00
committed by GitHub
parent d3a3077e85
commit f77eeb0a03
2 changed files with 291 additions and 6 deletions
+65 -6
View File
@@ -349,7 +349,6 @@ if TYPE_CHECKING:
from textual.events import MouseUp, Paste, Resize
from textual.geometry import Size
from textual.layout import DockArrangeResult
from textual.scrollbar import ScrollDown, ScrollUp
from textual.timer import Timer
from textual.widget import Widget
from textual.worker import Worker
@@ -1863,6 +1862,20 @@ class _ChatScroll(VerticalScroll):
FOCUS_ON_CLICK = False
class Scrolled(Message, namespace="chat"):
"""Posted whenever the chat's vertical scroll offset changes.
Transcript hydration keys off the actual scroll offset instead of the
scrollbar `ScrollUp`/`ScrollDown` messages, because those never reach
the app for the common scroll paths: wheel/trackpad scrolling arrives as
`MouseScroll*` events, keyboard scrolling runs through key-binding scroll
actions, and both move `scroll_y` directly without posting a scrollbar
message. Scrollbar-track clicks do post `ScrollUp`/`ScrollDown`, but this
container's own `_on_scroll_up`/`_on_scroll_down` handlers consume them
via `event.stop()` before they can bubble to the app. Watching `scroll_y`
covers every input device uniformly. Validated against Textual 8.2.7.
"""
# The deferred-anchor logic below drives the base class through its private
# anchor state (`_anchored`, `_anchor_released`) and mirrors the compositor's
# arrange-then-check ordering. Validated against Textual 8.2.7; a base-class
@@ -1935,6 +1948,19 @@ class _ChatScroll(VerticalScroll):
self.set_reactive(VerticalScroll.scroll_target_y, 0.0)
return result
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
"""Announce vertical scroll changes so the app can hydrate history.
Args:
old_value: Previous vertical scroll offset.
new_value: New vertical scroll offset.
"""
super().watch_scroll_y(old_value, new_value)
# Guard on `is_attached` (mirrors `ThreadControlsScroll.watch_scroll_y`)
# so mount/teardown offset changes don't post to a detached widget.
if old_value != new_value and self.is_attached:
self.post_message(self.Scrolled())
def _is_scrollable(self) -> bool:
"""Return whether current chat content overflows the viewport."""
return self.max_scroll_y > 0
@@ -1979,6 +2005,12 @@ class DeepAgentsApp(App):
SCROLL_SENSITIVITY_Y = 1.0
"""Vertical scroll speed (reduced from Textual default for finer control)."""
_hydration_failure_notified: bool = False
"""Set once a hydration failure has been surfaced, to avoid toast spam.
Hydration now runs on every scroll-offset delta, so a persistent failure
would otherwise notify on every scroll tick."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding(
@@ -5714,12 +5746,13 @@ class DeepAgentsApp(App):
markup=False,
)
def on_scroll_up(self, _event: ScrollUp) -> None:
"""Handle scroll up to check if we need to hydrate older messages."""
self._check_hydration_needed()
def on_chat_scrolled(self, _event: _ChatScroll.Scrolled) -> None:
"""Hydrate history in both directions whenever the chat scrolls.
def on_scroll_down(self, _event: ScrollDown) -> None:
"""Handle scroll down to hydrate newer messages below the window."""
Driven by `_ChatScroll.Scrolled` (see that message for why hydration
keys off the scroll offset rather than the scrollbar messages).
"""
self._check_hydration_needed()
self._check_hydration_below_needed()
def on_resize(self, _event: Resize) -> None:
@@ -5859,6 +5892,23 @@ class DeepAgentsApp(App):
if self._status_bar:
self._status_bar.show_pending_tokens()
def _notify_hydration_failure(self) -> None:
"""Surface transcript hydration failures to the user, once per session.
The `logger.warning` in the hydrate loops records every failure, but the
user only sees a gap where history should be. Show a single toast so the
missing history is explainable, without spamming on repeated scrolls.
"""
if self._hydration_failure_notified:
return
self._hydration_failure_notified = True
self.notify(
"Some earlier messages couldn't be loaded. See the debug log for details.",
severity="warning",
timeout=6,
markup=False,
)
def _check_hydration_needed(self) -> None:
"""Check if we need to hydrate messages from the store.
@@ -5960,6 +6010,7 @@ class DeepAgentsApp(App):
msg_data.id,
exc_info=True,
)
self._notify_hydration_failure()
break
if hydrated_count > 0:
@@ -5976,6 +6027,10 @@ class DeepAgentsApp(App):
# Collapse any completed tool runs brought in above the window so
# hydrated history matches the live transcript.
await self._regroup_completed_tools()
if hydrated_count > 0:
# Re-check after layout because a boundary scroll cannot emit
# another `Scrolled` message while its offset remains unchanged.
self.call_after_refresh(self._check_hydration_needed)
async def _hydrate_messages_below(self) -> None:
"""Hydrate newer messages when scrolling down toward the tail."""
@@ -6019,6 +6074,7 @@ class DeepAgentsApp(App):
msg_data.id,
exc_info=True,
)
self._notify_hydration_failure()
break
if hydrated_count == 0:
@@ -6029,6 +6085,9 @@ class DeepAgentsApp(App):
self._sync_transcript_spacers(messages_container)
chat.scroll_y = old_scroll_y
await self._regroup_completed_tools()
# Re-check after layout because a boundary scroll cannot emit another
# `Scrolled` message while its offset remains unchanged.
self.call_after_refresh(self._check_hydration_below_needed)
async def _mount_before_queued(self, container: Container, widget: Widget) -> None:
"""Mount a widget in the messages container, kept above the bottom anchors.
@@ -0,0 +1,226 @@
"""Focused TUI tests for transcript virtualization scroll hydration.
These bind the behavior that history hydration is driven by real changes to the
chat's vertical scroll offset (`_ChatScroll.watch_scroll_y` -> the
`_ChatScroll.Scrolled` message -> `DeepAgentsApp.on_chat_scrolled`), rather than
the scrollbar `ScrollUp`/`ScrollDown` messages the feature originally relied on.
See `_ChatScroll.Scrolled` for why those scrollbar messages never reached the app
for wheel/trackpad/keyboard scrolling, so hydration never ran for the common case
of scrolling with a trackpad.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Static
from deepagents_code.app import DeepAgentsApp, _ChatScroll
from deepagents_code.tui.widgets.messages import UserMessage
if TYPE_CHECKING:
import pytest
class _ScrollProbeApp(App[None]):
"""Minimal app that counts `_ChatScroll.Scrolled` notifications."""
CSS = """
#chat {
height: 6;
}
"""
def __init__(self) -> None:
super().__init__()
self.scroll_notifications = 0
def compose(self) -> ComposeResult:
"""Compose an overflowing transcript inside a `_ChatScroll`."""
with _ChatScroll(id="chat"):
yield Static("\n".join(f"line {index}" for index in range(60)))
def on_chat_scrolled(self, _event: _ChatScroll.Scrolled) -> None:
self.scroll_notifications += 1
class TestChatScrollNotifies:
"""`_ChatScroll` must announce every scroll offset change to the app."""
async def test_wheel_scroll_notifies_app(self) -> None:
"""Wheel/trackpad scrolling (MouseScroll events) reaches `on_chat_scrolled`."""
app = _ScrollProbeApp()
async with app.run_test(size=(40, 6)) as pilot:
chat = app.query_one("#chat", _ChatScroll)
chat.scroll_end(animate=False)
await pilot.pause()
assert chat.max_scroll_y > 0
app.scroll_notifications = 0
# A trackpad/wheel scroll is delivered as a MouseScrollUp event,
# never as a scrollbar ScrollUp message.
chat.post_message(
events.MouseScrollUp(
widget=chat,
x=1,
y=1,
delta_x=0,
delta_y=-1,
button=0,
shift=False,
meta=False,
ctrl=False,
)
)
await pilot.pause()
await pilot.pause()
assert chat.scroll_y < chat.max_scroll_y
assert app.scroll_notifications > 0
async def test_keyboard_scroll_notifies_app(self) -> None:
"""Keyboard scrolling (key-binding scroll actions) reaches the app."""
app = _ScrollProbeApp()
async with app.run_test(size=(40, 6)) as pilot:
chat = app.query_one("#chat", _ChatScroll)
chat.focus()
await pilot.pause()
assert chat.max_scroll_y > 0
app.scroll_notifications = 0
# `pagedown` routes through a key binding -> `action_page_down` ->
# `scroll_y`, never through a scrollbar `ScrollDown` message.
await pilot.press("pagedown")
await pilot.pause()
assert chat.scroll_y > 0
assert app.scroll_notifications > 0
async def test_scrollbar_track_scroll_notifies_app(self) -> None:
"""Scrollbar-track paging also flows through the scroll-offset watcher."""
app = _ScrollProbeApp()
async with app.run_test(size=(40, 6)) as pilot:
chat = app.query_one("#chat", _ChatScroll)
await pilot.pause()
app.scroll_notifications = 0
scrollbar = chat._vertical_scrollbar
assert scrollbar is not None
scrollbar.action_scroll_down()
await pilot.pause()
await pilot.pause()
assert app.scroll_notifications > 0
async def test_unchanged_offset_does_not_notify(self) -> None:
"""Re-setting the same offset must not churn the app with notifications."""
app = _ScrollProbeApp()
async with app.run_test(size=(40, 6)) as pilot:
chat = app.query_one("#chat", _ChatScroll)
await pilot.pause()
app.scroll_notifications = 0
# Assigning the same value: Textual's reactive dedups this before the
# watcher even runs, so no notification regardless of our guard.
chat.scroll_y = chat.scroll_y
await pilot.pause()
assert app.scroll_notifications == 0
# Invoke the watcher directly with equal offsets to bind the
# `old_value != new_value` guard itself (not just Textual's dedup):
# deleting the guard would make this post a `Scrolled` message.
chat.watch_scroll_y(5.0, 5.0)
await pilot.pause()
assert app.scroll_notifications == 0
async def _mount_user_messages(app: DeepAgentsApp, count: int) -> None:
"""Mount `count` `UserMessage` rows through the real mount path."""
for index in range(count):
await app._mount_message(UserMessage(f"m{index}", id=f"m{index}"))
class TestScrollDrivenHydration:
"""Scrolling into a spacer must hydrate the adjacent archived history."""
async def test_scroll_up_hydrates_archived_history(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Scrolling up toward the top spacer remounts older messages."""
app = DeepAgentsApp()
async with app.run_test(size=(80, 10)) as pilot:
await pilot.pause()
await _mount_user_messages(app, 20)
await pilot.pause()
# Shrink the window and prune the oldest rows so history is archived
# above the mounted tail (the state after a long transcript grows).
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 3)
monkeypatch.setattr(app._message_store, "HYDRATE_BUFFER", 2)
await app._prune_old_messages()
await pilot.pause()
start_before, _end_before = app._message_store.get_visible_range()
assert app._message_store.has_messages_above
assert start_before > 0
# The oldest row (`m0`) is archived, so no widget for it is mounted.
# The DOM stays bounded at `WINDOW_SIZE`, so hydration swaps rows
# rather than growing the count — assert the boundary row itself is
# (re)mounted, which binds the store counters to real widgets.
messages = app.query_one("#messages", Container)
assert not messages.query("#m0")
chat = app.query_one("#chat", _ChatScroll)
chat.scroll_end(animate=False)
await pilot.pause()
chat.scroll_to(y=0, animate=False)
for _ in range(30):
await pilot.pause()
if not app._message_store.has_messages_above:
break
start_after, _end_after = app._message_store.get_visible_range()
assert start_after == 0
assert messages.query("#m0")
async def test_scroll_down_hydrates_tail_below(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Scrolling down toward the bottom spacer remounts newer messages."""
app = DeepAgentsApp()
async with app.run_test(size=(80, 10)) as pilot:
await pilot.pause()
await _mount_user_messages(app, 20)
await pilot.pause()
# Archive the newest rows below the window (the state after the user
# has scrolled up and older history was mounted in their place).
monkeypatch.setattr(app._message_store, "WINDOW_SIZE", 3)
monkeypatch.setattr(app._message_store, "HYDRATE_BUFFER", 2)
messages = app.query_one("#messages", Container)
await app._prune_messages_below_window(messages)
await pilot.pause()
_start_before, _end_before = app._message_store.get_visible_range()
assert app._message_store.has_messages_below
# The newest row is archived below the window, so it is not mounted.
last_id = f"#m{app._message_store.total_count - 1}"
assert not messages.query(last_id)
chat = app.query_one("#chat", _ChatScroll)
chat.scroll_to(y=0, animate=False)
await pilot.pause()
chat.scroll_end(animate=False)
for _ in range(30):
await pilot.pause()
if not app._message_store.has_messages_below:
break
_start_after, end_after = app._message_store.get_visible_range()
assert end_after == app._message_store.total_count
# Bind the store counter to a real widget: the tail row is mounted.
assert messages.query(last_id)