mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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)
|
||||
Reference in New Issue
Block a user