diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 3c03d464d6..d5acd159bf 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -55,6 +55,7 @@ "* **`placeholder_threshold`** (float): Min duration in seconds of buffering before displaying the placeholder. If 0, the placeholder will be disabled. Defaults to 0.2.\n", "* **`auto_scroll_limit`** (int): Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 disables auto-scrolling.\n", "* **`scroll_button_threshold`** (int): Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 disables the scroll button.\n", + "* **`show_activity_dot`** (bool): Whether to show an activity dot on the ChatMessage while streaming the callback response.\n", "* **`view_latest`** (bool): Whether to scroll to the latest object on init. If not enabled the view will be on the first object. Defaults to True.\n", "\n", "#### Methods\n", diff --git a/examples/reference/chat/ChatMessage.ipynb b/examples/reference/chat/ChatMessage.ipynb index 4f5b485e97..6fb9e99e73 100644 --- a/examples/reference/chat/ChatMessage.ipynb +++ b/examples/reference/chat/ChatMessage.ipynb @@ -59,6 +59,7 @@ "* **`show_timestamp`** (bool): Whether to display the timestamp of the message.\n", "* **`show_reaction_icons`** (bool): Whether to display the reaction icons.\n", "* **`show_copy_icon`** (bool): Whether to show the copy icon.\n", + "* **`show_activity_dot`** (bool): Whether to show the activity dot.\n", "* **`name`** (str): The title or name of the chat message widget, if any.\n", "\n", "___" diff --git a/panel/chat/feed.py b/panel/chat/feed.py index ffa45b44cc..d566030f1e 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -197,6 +197,10 @@ class ChatFeed(ListPanel): display the scroll button. Setting to 0 disables the scroll button.""") + show_activity_dot = param.Boolean(default=True, doc=""" + Whether to show an activity dot on the ChatMessage while + streaming the callback response.""") + view_latest = param.Boolean(default=True, doc=""" Whether to scroll to the latest object on init. If not enabled the view will be on the first object.""") @@ -334,6 +338,7 @@ def _build_message( message_params["avatar"] = avatar if self.width: message_params["width"] = int(self.width - 80) + message = ChatMessage(**message_params) return message @@ -401,18 +406,24 @@ async def _serialize_response(self, response: Any) -> ChatMessage | None: updating the message's value. """ response_message = None - if isasyncgen(response): - self._callback_state = CallbackState.GENERATING - async for token in response: - response_message = self._upsert_message(token, response_message) - elif isgenerator(response): - self._callback_state = CallbackState.GENERATING - for token in response: - response_message = self._upsert_message(token, response_message) - elif isawaitable(response): - response_message = self._upsert_message(await response, response_message) - else: - response_message = self._upsert_message(response, response_message) + try: + if isasyncgen(response): + self._callback_state = CallbackState.GENERATING + async for token in response: + response_message = self._upsert_message(token, response_message) + response_message.show_activity_dot = self.show_activity_dot + elif isgenerator(response): + self._callback_state = CallbackState.GENERATING + for token in response: + response_message = self._upsert_message(token, response_message) + response_message.show_activity_dot = self.show_activity_dot + elif isawaitable(response): + response_message = self._upsert_message(await response, response_message) + else: + response_message = self._upsert_message(response, response_message) + finally: + if response_message: + response_message.show_activity_dot = False return response_message async def _schedule_placeholder( diff --git a/panel/chat/message.py b/panel/chat/message.py index bb93b0d8f3..2b4f7c02ce 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -197,6 +197,9 @@ class ChatMessage(PaneBase): show_copy_icon = param.Boolean(default=True, doc=""" Whether to display the copy icon.""") + show_activity_dot = param.Boolean(default=False, doc=""" + Whether to show the activity dot.""") + renderers = param.HookList(doc=""" A callable or list of callables that accept the object and return a Panel object to render the object. If a list is provided, will @@ -240,6 +243,9 @@ def __init__(self, object=None, **params): self._build_layout() def _build_layout(self): + self._activity_dot = HTML( + "●", css_classes=["activity-dot"], visible=self.param.show_activity_dot + ) self._left_col = left_col = Column( self._render_avatar(), max_width=60, @@ -275,6 +281,7 @@ def _build_layout(self): Row( self._user_html, self.chat_copy_icon, + self._activity_dot, stylesheets=self._stylesheets, sizing_mode="stretch_width", css_classes=["header"] diff --git a/panel/dist/css/chat_message.css b/panel/dist/css/chat_message.css index 66731f60bf..3aa87b0753 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -115,3 +115,23 @@ margin-block: 0px; margin-inline: 2px; } + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.activity-dot { + display: inline-block; + animation: fadeOut 2s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55); + color: #32cd32; + font-size: 1.25em; + margin-block: 0px; +} diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 2c44c315d3..754c30898e 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -501,12 +501,14 @@ async def echo(contents, user, instance): for char in contents: message += char yield message + assert instance.objects[-1].show_activity_dot chat_feed.callback = echo chat_feed.send("Message", respond=True) await asyncio.sleep(0.5) assert len(chat_feed.objects) == 2 assert chat_feed.objects[1].object == "Message" + assert not chat_feed.objects[-1].show_activity_dot @pytest.mark.asyncio async def test_async_generator(self, chat_feed): @@ -519,12 +521,14 @@ async def echo(contents, user, instance): async for char in async_gen(contents): message += char yield message + assert instance.objects[-1].show_activity_dot chat_feed.callback = echo chat_feed.send("Message", respond=True) await asyncio.sleep(0.5) assert len(chat_feed.objects) == 2 assert chat_feed.objects[1].object == "Message" + assert not chat_feed.objects[-1].show_activity_dot def test_placeholder_disabled(self, chat_feed): def echo(contents, user, instance):