Skip to content

Commit 6d95314

Browse files
authored
Improve Chat()'s auto-scroll behavior (#1500)
1 parent c1423f8 commit 6d95314

File tree

6 files changed

+76
-44
lines changed

6 files changed

+76
-44
lines changed

js/chat/chat.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ shiny-chat-container {
5555
shiny-chat-input {
5656
margin-top: auto;
5757
position: sticky;
58+
background-color: var(--bs-body-bg, white);
5859
bottom: 0;
5960
padding: 0.25rem;
6061
textarea {
@@ -110,7 +111,6 @@ pre:has(.code-copy-button) {
110111
border: 0;
111112
margin-top: 5px;
112113
margin-right: 5px;
113-
z-index: 3;
114114
background-color: transparent;
115115

116116
> .bi {

js/chat/chat.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ type ShinyChatMessage = {
2323
obj: Message;
2424
};
2525

26+
type requestScrollEvent = {
27+
cancelIfScrolledUp: boolean;
28+
};
29+
2630
// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734
2731
declare global {
2832
interface GlobalEventHandlersEventMap {
@@ -32,6 +36,7 @@ declare global {
3236
"shiny-chat-clear-messages": CustomEvent;
3337
"shiny-chat-set-user-input": CustomEvent<string>;
3438
"shiny-chat-remove-loading-message": CustomEvent;
39+
"shiny-chat-request-scroll": CustomEvent<requestScrollEvent>;
3540
}
3641
}
3742

@@ -41,6 +46,16 @@ const CHAT_MESSAGES_TAG = "shiny-chat-messages";
4146
const CHAT_INPUT_TAG = "shiny-chat-input";
4247
const CHAT_CONTAINER_TAG = "shiny-chat-container";
4348

49+
const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
50+
el.dispatchEvent(
51+
new CustomEvent("shiny-chat-request-scroll", {
52+
detail: { cancelIfScrolledUp },
53+
bubbles: true,
54+
composed: true,
55+
})
56+
);
57+
};
58+
4459
// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
4560
class LightElement extends LitElement {
4661
createRenderRoot() {
@@ -51,6 +66,7 @@ class LightElement extends LitElement {
5166
class ChatMessage extends LightElement {
5267
@property() content = "...";
5368
@property() content_type: ContentType = "markdown";
69+
@property({ type: Boolean, reflect: true }) is_streaming = false;
5470

5571
render(): ReturnType<LitElement["render"]> {
5672
let content;
@@ -77,6 +93,9 @@ class ChatMessage extends LightElement {
7793
updated(changedProperties: Map<string, unknown>): void {
7894
if (changedProperties.has("content")) {
7995
this.#highlightAndCodeCopy();
96+
// It's important that the scroll request happens at this point in time, since
97+
// otherwise, the content may not be fully rendered yet
98+
requestScroll(this, this.is_streaming);
8099
}
81100
}
82101

@@ -225,6 +244,8 @@ class ChatContainer extends LightElement {
225244
return this.querySelector(CHAT_MESSAGES_TAG) as ChatMessages;
226245
}
227246

247+
private resizeObserver!: ResizeObserver;
248+
228249
render(): ReturnType<LitElement["render"]> {
229250
const input_id = this.id + "_user_input";
230251
return html`
@@ -252,6 +273,10 @@ class ChatContainer extends LightElement {
252273
"shiny-chat-remove-loading-message",
253274
this.#onRemoveLoadingMessage
254275
);
276+
this.addEventListener("shiny-chat-request-scroll", this.#onRequestScroll);
277+
278+
this.resizeObserver = new ResizeObserver(() => requestScroll(this, true));
279+
this.resizeObserver.observe(this);
255280
}
256281

257282
disconnectedCallback(): void {
@@ -269,6 +294,12 @@ class ChatContainer extends LightElement {
269294
"shiny-chat-remove-loading-message",
270295
this.#onRemoveLoadingMessage
271296
);
297+
this.removeEventListener(
298+
"shiny-chat-request-scroll",
299+
this.#onRequestScroll
300+
);
301+
302+
this.resizeObserver.disconnect();
272303
}
273304

274305
// When user submits input, append it to the chat, and add a loading message
@@ -290,9 +321,6 @@ class ChatContainer extends LightElement {
290321
const msg = createElement(TAG_NAME, message);
291322
this.messages.appendChild(msg);
292323

293-
// Scroll to the bottom to show the new message
294-
this.#scrollToBottom();
295-
296324
if (finalize) {
297325
this.#finalizeMessage();
298326
}
@@ -325,26 +353,18 @@ class ChatContainer extends LightElement {
325353
this.#appendMessage(message, false);
326354
return;
327355
}
328-
if (message.chunk_type === "message_end") {
329-
this.#finalizeMessage();
330-
return;
331-
}
332356

333-
const messages = this.messages;
334-
const lastMessage = messages.lastElementChild as HTMLElement;
357+
const lastMessage = this.messages.lastElementChild as HTMLElement;
335358
if (!lastMessage) throw new Error("No messages found in the chat output");
336-
const content = lastMessage.getAttribute("content");
337-
lastMessage.setAttribute("content", message.content);
338359

339-
// Don't scroll to bottom if the user has scrolled up a bit
340-
if (
341-
messages.scrollTop + messages.clientHeight <
342-
messages.scrollHeight - 50
343-
) {
360+
if (message.chunk_type === "message_end") {
361+
lastMessage.removeAttribute("is_streaming");
362+
this.#finalizeMessage();
344363
return;
345364
}
346365

347-
this.#scrollToBottom();
366+
lastMessage.setAttribute("is_streaming", "");
367+
lastMessage.setAttribute("content", message.content);
348368
}
349369

350370
#onClear(): void {
@@ -364,8 +384,20 @@ class ChatContainer extends LightElement {
364384
this.input.disabled = false;
365385
}
366386

367-
#scrollToBottom(): void {
368-
this.messages.scrollTop = this.messages.scrollHeight;
387+
#onRequestScroll(event: CustomEvent<requestScrollEvent>): void {
388+
// When streaming or resizing, only scroll if the user near the bottom
389+
const { cancelIfScrolledUp } = event.detail;
390+
if (cancelIfScrolledUp) {
391+
if (this.scrollTop + this.clientHeight < this.scrollHeight - 50) {
392+
return;
393+
}
394+
}
395+
396+
// Smooth scroll to the bottom if we're not streaming or resizing
397+
this.scroll({
398+
top: this.scrollHeight,
399+
behavior: cancelIfScrolledUp ? "auto" : "smooth",
400+
});
369401
}
370402
}
371403

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)