Skip to content

Commit

Permalink
Scroll when streaming to ChatFeed and ChatStep (#7608)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Jan 10, 2025
1 parent 55b0634 commit 8662665
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 12 deletions.
6 changes: 5 additions & 1 deletion panel/chat/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ def stream(

if message_params:
message.param.update(**message_params)
self._chat_log.scroll_to_latest(scroll_limit=self.auto_scroll_limit)
return message

if isinstance(value, ChatMessage):
Expand All @@ -715,6 +716,7 @@ def stream(
self._replace_placeholder(message)

self.param.trigger("_post_hook_trigger")
self._chat_log.scroll_to_latest(scroll_limit=self.auto_scroll_limit)
return message

def add_step(
Expand Down Expand Up @@ -778,6 +780,8 @@ def add_step(
step_params["context_exception"] = self.callback_exception
step = ChatStep(**step_params)

step._instance = self

if append:
for i in range(1, last_messages + 1):
if not self._chat_log:
Expand Down Expand Up @@ -822,7 +826,7 @@ def add_step(
self.stream(steps_layout, user=user or self.callback_user, avatar=avatar)
else:
steps_layout.append(step)
self._chat_log.scroll_to_latest()
self._chat_log.scroll_to_latest(scroll_limit=self.auto_scroll_limit)
return step

def prompt_user(
Expand Down
3 changes: 3 additions & 0 deletions panel/chat/step.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ def stream(self, token: str | None, replace: bool = False):
else:
stream_to(self.objects[-1], token, replace=replace)

if self._instance is not None:
self._instance._chat_log.scroll_to_latest(self._instance.auto_scroll_limit)

def serialize(
self,
prefix_with_viewable_label: bool = True,
Expand Down
11 changes: 9 additions & 2 deletions panel/layout/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,18 @@ def _process_event(self, event: ScrollButtonClick | None = None) -> None:
# reset the buffers and loaded objects
self.load_buffer = load_buffer

def scroll_to_latest(self):
def scroll_to_latest(self, scroll_limit: float | None = None) -> None:
"""
Scrolls the Feed to the latest entry.
Parameters
----------
scroll_limit : float, optional
Maximum pixel distance from the latest object in the Feed to
trigger scrolling. If the distance exceeds this limit, scrolling will not occur.
If this is not set, it will always scroll to the latest while setting this to 0 disables scrolling.
"""
rerender = self._last_synced and self._last_synced[-1] < len(self.objects)
if rerender:
self._process_event()
self._send_event(ScrollLatestEvent, rerender=rerender)
self._send_event(ScrollLatestEvent, rerender=rerender, scroll_limit=scroll_limit)
10 changes: 8 additions & 2 deletions panel/models/column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,14 @@ export class ColumnView extends BkColumnView {
})
}

scroll_to_latest(): void {
// Waits for the child to be rendered before scrolling
scroll_to_latest(scroll_limit: number | null = null): void {
if (scroll_limit !== null) {
const within_limit = this.distance_from_latest <= scroll_limit
if (!within_limit) {
return
}
}

requestAnimationFrame(() => {
this.model.scroll_position = Math.round(this.el.scrollHeight)
})
Expand Down
5 changes: 3 additions & 2 deletions panel/models/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ class ScrollLatestEvent(ModelEvent):

event_name = 'scroll_latest_event'

def __init__(self, model, rerender=False):
def __init__(self, model, rerender=False, scroll_limit=None):
super().__init__(model=model)
self.rerender = rerender
self.scroll_limit = scroll_limit

def event_values(self) -> dict[str, Any]:
return dict(super().event_values(), rerender=self.rerender)
return dict(super().event_values(), rerender=self.rerender, scroll_limit=self.scroll_limit)


class ScrollButtonClick(ModelEvent):
Expand Down
11 changes: 6 additions & 5 deletions panel/models/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@ import {ColumnView as BkColumnView} from "@bokehjs/models/layouts/column"

@server_event("scroll_latest_event")
export class ScrollLatestEvent extends ModelEvent {
constructor(readonly model: Feed, readonly rerender: boolean) {
constructor(readonly model: Feed, readonly rerender: boolean, readonly scroll_limit?: number | null) {
super()
this.origin = model
this.rerender = rerender
this.scroll_limit = scroll_limit
}

protected override get event_values(): Attrs {
return {model: this.origin, rerender: this.rerender}
return {model: this.origin, rerender: this.rerender, scroll_limit: this.scroll_limit}
}

static override from_values(values: object) {
const {model, rerender} = values as {model: Feed, rerender: boolean}
return new ScrollLatestEvent(model, rerender)
const {model, rerender, scroll_limit} = values as {model: Feed, rerender: boolean, scroll_limit?: number}
return new ScrollLatestEvent(model, rerender, scroll_limit)
}
}

Expand Down Expand Up @@ -71,7 +72,7 @@ export class FeedView extends ColumnView {
override connect_signals(): void {
super.connect_signals()
this.model.on_event(ScrollLatestEvent, (event: ScrollLatestEvent) => {
this.scroll_to_latest()
this.scroll_to_latest(event.scroll_limit)
if (event.rerender) {
this._rendered = false
}
Expand Down
66 changes: 66 additions & 0 deletions panel/tests/ui/layout/test_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from playwright.sync_api import expect

from panel import Feed
from panel.layout.spacer import Spacer
from panel.tests.util import serve_component, wait_until

pytestmark = pytest.mark.ui
Expand Down Expand Up @@ -50,6 +51,7 @@ def test_feed_view_latest(page):

wait_until(lambda: int(page.locator('pre').last.inner_text()) > 0.9 * ITEMS, page)


def test_feed_view_scroll_to_latest(page):
feed = Feed(*list(range(ITEMS)), height=250)
serve_component(page, feed)
Expand All @@ -68,6 +70,70 @@ def test_feed_view_scroll_to_latest(page):

wait_until(lambda: int(page.locator('pre').last.inner_text() or 0) > 0.9 * ITEMS, page)


def test_feed_scroll_to_latest_disabled_when_limit_zero(page):
"""Test that scroll_to_latest is disabled when scroll_limit = 0"""
feed = Feed(*list(range(ITEMS)), height=250)
serve_component(page, feed)

feed_el = page.locator(".bk-panel-models-feed-Feed")
initial_scroll = feed_el.evaluate('(el) => el.scrollTop')

# Try to scroll to latest
feed.scroll_to_latest(scroll_limit=0)

# Verify scroll position hasn't changed
final_scroll = feed_el.evaluate('(el) => el.scrollTop')
assert initial_scroll == final_scroll, "Scroll position should not change when limit is 0"


def test_feed_scroll_to_latest_always_when_limit_null(page):
"""Test that scroll_to_latest always triggers when scroll_limit is null"""
feed = Feed(*list(range(ITEMS)), height=250)
serve_component(page, feed)

wait_until(lambda: int(page.locator('pre').last.inner_text() or 0) < 0.9 * ITEMS, page)
feed.scroll_to_latest(scroll_limit=None)
wait_until(lambda: int(page.locator('pre').last.inner_text() or 0) > 0.9 * ITEMS, page)


def test_feed_scroll_to_latest_within_limit(page):
"""Test that scroll_to_latest only triggers within the specified limit"""
feed = Feed(
Spacer(styles=dict(background='red'), width=200, height=200),
Spacer(styles=dict(background='green'), width=200, height=200),
Spacer(styles=dict(background='blue'), width=200, height=200),
auto_scroll_limit=0, height=420
)
serve_component(page, feed)

feed_el = page.locator(".bk-panel-models-feed-Feed")

expect(feed_el).to_have_js_property('scrollTop', 0)

feed.scroll_to_latest(scroll_limit=100)

# assert scroll location is still at top
feed.append(Spacer(styles=dict(background='yellow'), width=200, height=200))

page.wait_for_timeout(500)

expect(feed_el.locator('div')).to_have_count(5)
expect(feed_el).to_have_js_property('scrollTop', 0)

# scroll to close to bottom
feed_el.evaluate('(el) => el.scrollTo({top: el.scrollHeight})')

# assert auto scroll works; i.e. distance from bottom is 0
feed.append(Spacer(styles=dict(background='yellow'), width=200, height=200))

feed.scroll_to_latest(scroll_limit=100)

wait_until(lambda: feed_el.evaluate(

Check failure on line 132 in panel/tests/ui/layout/test_feed.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_feed_scroll_to_latest_within_limit TimeoutError: wait_until timed out in 5000 milliseconds

Check failure on line 132 in panel/tests/ui/layout/test_feed.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_feed_scroll_to_latest_within_limit TimeoutError: wait_until timed out in 5000 milliseconds

Check failure on line 132 in panel/tests/ui/layout/test_feed.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_feed_scroll_to_latest_within_limit TimeoutError: wait_until timed out in 5000 milliseconds

Check failure on line 132 in panel/tests/ui/layout/test_feed.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:ubuntu-latest

test_feed_scroll_to_latest_within_limit TimeoutError: wait_until timed out in 5000 milliseconds
'(el) => el.scrollHeight - el.scrollTop - el.clientHeight'
) == 0, page)


def test_feed_view_scroll_button(page):
feed = Feed(*list(range(ITEMS)), height=250, scroll_button_threshold=50)
serve_component(page, feed)
Expand Down

0 comments on commit 8662665

Please sign in to comment.