Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions examples/reference/chat/ChatMessage.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"* **`avatar`** (str | BinaryIO): The avatar to use for the user. Can be a single character text, an emoji, or anything supported by `pn.pane.Image`. If not set, uses the first character of the name.\n",
"* **`default_avatars`** (Dict[str, str | BinaryIO]): A default mapping of user names to their corresponding avatars to use when the user is set but the avatar is not. You can modify, but not replace the dictionary. Note, the keys are *only* alphanumeric sensitive, meaning spaces, special characters, and case sensitivity is disregarded, e.g. `\"Chat-GPT3.5\"`, `\"chatgpt 3.5\"` and `\"Chat GPT 3.5\"` all map to the same value.\n",
"* **`edited`** (bool): An event that is triggered when the message is edited\n",
"* **`footer_actions`** (List): A list of icon button objects to display in the action row of the message footer, alongside the copy, edit, and reaction icons.\n",
"* **`footer_objects`** (List): A list of objects to display in the column of the footer of the message.\n",
"* **`header_objects`** (List): A list of objects to display in the row of the header of the message.\n",
"* **`avatar_lookup`** (Callable): A function that can lookup an `avatar` from a user name. The function signature should be `(user: str) -> Avatar`. If this is set, `default_avatars` is disregarded.\n",
Expand Down Expand Up @@ -461,6 +462,37 @@
":::"
]
},
{
"cell_type": "markdown",
"source": [
"#### Footer Actions\n",
"\n",
"The `footer_actions` parameter lets you place custom icon buttons inline with the built-in copy, edit, and reaction icons. Unlike `footer_objects`, which renders below the action row, `footer_actions` renders inside it for a consistent look."
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from panel_material_ui import IconButton\n",
"\n",
"btn = IconButton(\n",
" icon=\"lightbulb_outlined\",\n",
" active_icon=\"check\",\n",
" margin=(0, 0),\n",
" toggle_duration=1000,\n",
" description=\"Suggest a query\",\n",
" size=\"small\",\n",
" color=\"default\",\n",
" icon_size=\"0.8em\",\n",
" styles={\"padding\": \"0 0.1em\"},\n",
")\n",
"ChatMessage(\"Results here\", footer_actions=[btn])"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
10 changes: 6 additions & 4 deletions src/panel_material_ui/chat/ChatMessage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function render({model, view}) {

const header = model.get_child("header_objects")
const footer = model.get_child("footer_objects")
const footerActions = model.get_child("footer_actions")

model.on("msg:custom", (msg) => {
navigator.clipboard.writeText(msg.text)
Expand Down Expand Up @@ -206,13 +207,13 @@ export function render({model, view}) {
<Paper ref={paperRef} elevation={elevation} sx={{bgcolor: "background.paper", width: isResponsive ? "100%" : "fit-content"}}>
{object}
</Paper>
<Stack direction="row" spacing={0}>
{show_edit_icon && <IconButton disableRipple size="small" sx={{padding: "0 0.1em"}} onClick={() => { model.send_msg("edit") }}>
<EditNoteIcon sx={{width: "0.8em"}} color="lightgray"/>
</IconButton>}
<Stack direction="row" spacing={0} sx={{position: "relative", zIndex: 1, alignItems: "center"}}>
{show_copy_icon && <IconButton disableRipple size="small" sx={{padding: "0 0.1em"}} onClick={() => { model.send_msg("copy") }}>
<ContentCopyIcon sx={{width: "0.5em"}} color="lightgray"/>
</IconButton>}
{show_edit_icon && <IconButton disableRipple size="small" sx={{padding: "0 0.1em"}} onClick={() => { model.send_msg("edit") }}>
<EditNoteIcon sx={{width: "0.8em"}} color="lightgray"/>
</IconButton>}
{show_reaction_icons && reactions.map((reaction) => (
<IconButton key={`reaction-${reaction}`} disableRipple size="small" sx={{padding: "0 0.1em"}} onClick={() => { model.send_msg(reaction) }}>
{(() => {
Expand All @@ -222,6 +223,7 @@ export function render({model, view}) {
})()}
</IconButton>
))}
{footerActions}
</Stack>
<Stack direction="row" spacing={0}>
{footer}
Expand Down
6 changes: 5 additions & 1 deletion src/panel_material_ui/chat/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from panel.pane.image import FileBase, Image, ImageBase
from panel.pane.markup import HTMLBasePane
from panel.util import isfile
from panel.viewable import Child
from panel.viewable import Child, Children
from panel.widgets import Widget

from ..base import MaterialComponent
Expand Down Expand Up @@ -77,6 +77,10 @@ class ChatMessage(MaterialComponent, ChatMessage):

elevation = param.Integer(default=2, doc="The elevation of the message.")

footer_actions = Children(default=[], doc="""
A list of icon button objects to display in the action row
of the message footer, after the copy, edit, and reaction icons.""")

Comment on lines +80 to +83

@ahuang11 ahuang11 Mar 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Torn between having instantiated icon buttons or something like https://github.com/holoviz/panel/blob/main/panel/chat/interface.py#L122-L146

Image

OR whether that's too complicated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Children approach is the right call here. The dict-based CallbackIcon pattern from Panel's ChatInterface works well for a fixed set of built-in actions, but for footer_actions the user is bringing their own widgets, so passing instantiated components gives them full control over styling, callbacks, toggle behavior, etc. without us having to design a config schema for every possible option.

It also stays consistent with how footer_objects and header_objects already work in ChatMessage.

Let me know if you'd prefer the dict approach though!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So actions is a loaded term in panel-material-ui because Menu components do use it for the dictionary-like specification. So if we are calling it footer_action then I would lean to the dict approach.

placement = param.Selector(default="left", objects=["left", "right"], doc="The placement of the message.")

_internal_state = param.ClassSelector(class_=MessageState, default=MessageState())
Expand Down
9 changes: 9 additions & 0 deletions tests/chat/test_message_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from panel_material_ui.chat import ChatMessage
from panel_material_ui.chat.input import ChatAreaInput
from panel_material_ui.widgets import IconButton

pn.extension()

Expand Down Expand Up @@ -73,3 +74,11 @@ def test_edit_area_is_chat_area_input():
"""The edit area should be a ChatAreaInput instance."""
msg = ChatMessage(object="Hello")
assert isinstance(msg._edit_area, ChatAreaInput)


def test_footer_actions_accepts_icon_buttons():
"""footer_actions should accept a list of objects and expose them."""
btn = IconButton(icon="lightbulb", size="small")
msg = ChatMessage(object="Hello", footer_actions=[btn])
assert len(msg.footer_actions) == 1
assert msg.footer_actions[0] is btn
Loading