Skip to content

Commit 048a36b

Browse files
committed
refactor: Use shadow root and slots in ChatContainer and ChatMessage
Currently used to manage the assistant icon, but this pattern can be used for other icons as well.
1 parent 2f14281 commit 048a36b

File tree

8 files changed

+177
-57
lines changed

8 files changed

+177
-57
lines changed

js/chat/chat.scss

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,32 +57,16 @@ shiny-chat-messages {
5757
}
5858

5959
shiny-chat-message {
60-
display: grid;
61-
grid-template-columns: auto minmax(0, 1fr);
62-
gap: 1rem;
6360
> * {
6461
height: fit-content;
6562
}
66-
.message-icon {
67-
border-radius: 50%;
68-
border: var(--shiny-chat-border);
69-
height: 2rem;
70-
width: 2rem;
71-
display: grid;
72-
place-items: center;
73-
74-
> * {
75-
height: 20px;
76-
width: 20px;
77-
margin: 0 !important;
78-
max-height: 85%;
79-
max-width: 85%;
80-
object-fit: contain;
81-
}
82-
}
83-
/* Vertically center the 2nd column (message content) */
84-
shiny-markdown-stream {
85-
align-self: center;
63+
[slot="icon"] {
64+
height: 20px;
65+
width: 20px;
66+
margin: 0 !important;
67+
max-height: 85%;
68+
max-width: 85%;
69+
object-fit: contain;
8670
}
8771
}
8872

js/chat/chat.ts

Lines changed: 102 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { LitElement, html } from "lit";
1+
import { LitElement, css, html } from "lit";
22
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
33
import { property } from "lit/decorators.js";
44

@@ -57,23 +57,63 @@ const ICONS = {
5757
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>',
5858
};
5959

60-
class ChatMessage extends LightElement {
60+
class ChatMessage extends LitElement {
6161
@property() content = "...";
6262
@property() content_type: ContentType = "markdown";
6363
@property({ type: Boolean, reflect: true }) streaming = false;
64-
@property() icon = "";
6564

66-
render() {
67-
let msg_icon = ICONS.dots_fade;
65+
static styles = css`
66+
:host {
67+
display: grid;
68+
grid-template-columns: auto minmax(0, 1fr);
69+
gap: 1rem;
70+
align-items: start;
71+
}
6872
69-
// Show dots until we have content
70-
const hasContent = this.content.trim().length !== 0;
71-
if (hasContent) {
72-
msg_icon = this.icon || ICONS.robot;
73+
:host > * {
74+
height: fit-content;
75+
}
76+
77+
.message-icon {
78+
border-radius: 50%;
79+
border: var(--shiny-chat-border);
80+
height: 2rem;
81+
width: 2rem;
82+
display: grid;
83+
place-items: center;
7384
}
7485
86+
slot[name="icon"] > * {
87+
height: 20px;
88+
width: 20px;
89+
margin: 0 !important;
90+
max-height: 85%;
91+
max-width: 85%;
92+
object-fit: contain;
93+
}
94+
95+
/* Vertically center the 2nd column (message content) */
96+
shiny-markdown-stream {
97+
align-self: center;
98+
99+
p:first-child {
100+
margin-block-start: 0;
101+
}
102+
p:last-child {
103+
margin-block-end: 0;
104+
}
105+
}
106+
`;
107+
108+
render() {
109+
// Show dots until we have content
110+
const hasContent = this.content.trim().length > 0;
111+
const defaultIcon = hasContent ? ICONS.robot : ICONS.dots_fade;
112+
75113
return html`
76-
<div class="message-icon">${unsafeHTML(msg_icon)}</div>
114+
<div class="message-icon">
115+
<slot name="icon">${unsafeHTML(defaultIcon)}</slot>
116+
</div>
77117
<shiny-markdown-stream
78118
content=${this.content}
79119
content-type=${this.content_type}
@@ -268,9 +308,7 @@ class ChatInput extends LightElement {
268308
}
269309
}
270310

271-
class ChatContainer extends LightElement {
272-
@property() icon = "";
273-
311+
class ChatContainer extends LitElement {
274312
private get input(): ChatInput {
275313
return this.querySelector(CHAT_INPUT_TAG) as ChatInput;
276314
}
@@ -284,8 +322,30 @@ class ChatContainer extends LightElement {
284322
return last ? (last as ChatMessage) : null;
285323
}
286324

325+
private get iconAssistant(): Element | void {
326+
const slot = this.shadowRoot?.querySelector(
327+
'slot[name="icon-assistant"]'
328+
) as HTMLSlotElement;
329+
330+
if (!slot) return;
331+
332+
let icon: Element | undefined | null = slot.assignedElements()[0];
333+
if (!icon) return;
334+
335+
if (icon?.matches(".icon-container")) {
336+
// From Python/R we use a wrapper element because users may give raw HTML
337+
icon = icon.firstElementChild;
338+
}
339+
340+
return icon ? icon : undefined;
341+
}
342+
287343
render() {
288-
return html``;
344+
return html`
345+
<slot name="icon-assistant" style="display: none"></slot>
346+
<slot name="messages"></slot>
347+
<slot name="input"></slot>
348+
`;
289349
}
290350

291351
firstUpdated(): void {
@@ -351,17 +411,42 @@ class ChatContainer extends LightElement {
351411
}
352412
}
353413

414+
#messageIcon(message: Message): HTMLElement | undefined {
415+
if (message.role === "user") return;
416+
417+
let icon: HTMLElement | undefined;
418+
if (message.icon) {
419+
icon = document.createElement("div");
420+
icon.innerHTML = message.icon;
421+
if (icon.firstChild) {
422+
icon = icon.firstChild.cloneNode() as HTMLElement;
423+
} else {
424+
icon = undefined;
425+
}
426+
}
427+
428+
if (!icon && this.iconAssistant) {
429+
icon = this.iconAssistant.cloneNode(true) as HTMLElement;
430+
}
431+
432+
if (!icon) return;
433+
icon.setAttribute("slot", "icon");
434+
return icon;
435+
}
436+
354437
#appendMessage(message: Message, finalize = true): void {
355438
this.#initMessage();
356439

357440
const TAG_NAME =
358441
message.role === "user" ? CHAT_USER_MESSAGE_TAG : CHAT_MESSAGE_TAG;
359442

360-
if (this.icon) {
361-
message.icon = message.icon || this.icon;
443+
const msg = createElement(TAG_NAME, message);
444+
445+
const icon = this.#messageIcon(message);
446+
if (icon) {
447+
msg.appendChild(icon);
362448
}
363449

364-
const msg = createElement(TAG_NAME, message);
365450
this.messages.appendChild(msg);
366451

367452
if (finalize) {

shiny/ui/_chat.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,11 +1233,13 @@ def chat_ui(
12331233

12341234
res = Tag(
12351235
"shiny-chat-container",
1236-
Tag("shiny-chat-messages", *message_tags),
1236+
Tag("div", icon_assistant, class_="icon-container", slot="icon-assistant"),
1237+
Tag("shiny-chat-messages", *message_tags, slot="messages"),
12371238
Tag(
12381239
"shiny-chat-input",
12391240
id=f"{id}_user_input",
12401241
placeholder=placeholder,
1242+
slot="input",
12411243
),
12421244
chat_deps(),
12431245
html_deps,
@@ -1250,7 +1252,6 @@ def chat_ui(
12501252
id=id,
12511253
placeholder=placeholder,
12521254
fill=fill,
1253-
icon=str(icon_assistant) if icon_assistant else None,
12541255
**kwargs,
12551256
)
12561257

shiny/www/py-shiny/chat/chat.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)