From 8662665b2839829ca2b9ee3bf851462e08d903b5 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 10 Jan 2025 10:48:50 +0100 Subject: [PATCH 1/2] Scroll when streaming to ChatFeed and ChatStep (#7608) --- panel/chat/feed.py | 6 ++- panel/chat/step.py | 3 ++ panel/layout/feed.py | 11 ++++- panel/models/column.ts | 10 ++++- panel/models/feed.py | 5 ++- panel/models/feed.ts | 11 ++--- panel/tests/ui/layout/test_feed.py | 66 ++++++++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 12 deletions(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index c299b948db..d5bda8cabe 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -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): @@ -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( @@ -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: @@ -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( diff --git a/panel/chat/step.py b/panel/chat/step.py index 87e1ca6e6d..bbdbd1e146 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -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, diff --git a/panel/layout/feed.py b/panel/layout/feed.py index 3976c7673c..eeec31dfc9 100644 --- a/panel/layout/feed.py +++ b/panel/layout/feed.py @@ -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) diff --git a/panel/models/column.ts b/panel/models/column.ts index f6c026875c..e5a0cf8f7f 100644 --- a/panel/models/column.ts +++ b/panel/models/column.ts @@ -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) }) diff --git a/panel/models/feed.py b/panel/models/feed.py index 15ae4b6023..eeaf7d6af1 100644 --- a/panel/models/feed.py +++ b/panel/models/feed.py @@ -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): diff --git a/panel/models/feed.ts b/panel/models/feed.ts index 51a5194b6d..d4a4a4aaf8 100644 --- a/panel/models/feed.ts +++ b/panel/models/feed.ts @@ -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) } } @@ -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 } diff --git a/panel/tests/ui/layout/test_feed.py b/panel/tests/ui/layout/test_feed.py index 736e031675..ae62b4980f 100644 --- a/panel/tests/ui/layout/test_feed.py +++ b/panel/tests/ui/layout/test_feed.py @@ -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 @@ -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) @@ -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( + '(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) From 6544d78438439d3d4adf55af4281623fb62e0692 Mon Sep 17 00:00:00 2001 From: jonatantreijs <62055117+jonatantreijs@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:02:48 +0100 Subject: [PATCH 2/2] Add callback for onChangeText to jsoneditor.ts (#7610) * Add callback for jsoneditor.onChangeText * Test jsoneditor editing while in text mode * Fix lint issue --- panel/models/jsoneditor.ts | 7 +++++++ panel/tests/ui/widgets/test_jsoneditor.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/panel/models/jsoneditor.ts b/panel/models/jsoneditor.ts index eafc454353..9cfa287b17 100644 --- a/panel/models/jsoneditor.ts +++ b/panel/models/jsoneditor.ts @@ -69,6 +69,13 @@ export class JSONEditorView extends HTMLBoxView { onChangeJSON: (json: any) => { this.model.data = json }, + onChangeText: (text: any) => { + try { + this.model.data = JSON.parse(text) + } catch (e) { + console.warn(e) + } + }, onSelectionChange: (start: any, end: any) => { this.model.selection = [start, end] }, diff --git a/panel/tests/ui/widgets/test_jsoneditor.py b/panel/tests/ui/widgets/test_jsoneditor.py index cea0fe2eb5..c96779b67b 100644 --- a/panel/tests/ui/widgets/test_jsoneditor.py +++ b/panel/tests/ui/widgets/test_jsoneditor.py @@ -35,3 +35,19 @@ def test_json_editor_edit(page): page.locator('.jsoneditor').click() wait_until(lambda: editor.value['str'] == 'new', page) + +def test_json_editor_edit_in_text_mode(page): + editor = JSONEditor(value={'str': 'string', 'int': 1}, mode='text') + + msgs, _ = serve_component(page, editor) + + expect(page.locator('.jsoneditor')).to_have_count(1) + + page.locator('.jsoneditor-text').click() + ctrl_key = 'Meta' if sys.platform == 'darwin' else 'Control' + page.keyboard.press(f'{ctrl_key}+A') + page.keyboard.press('Backspace') + page.keyboard.type('{"str": "new"}') + page.locator('.jsoneditor').click() + + wait_until(lambda: editor.value['str'] == 'new', page)