Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions js/chat/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ shiny-chat-container {
shiny-chat-input {
margin-top: auto;
position: sticky;
background-color: var(--bs-body-bg, white);
Copy link
Collaborator Author

@cpsievert cpsievert Jul 8, 2024

Choose a reason for hiding this comment

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

This part is a bit tangental to the rest of this PR -- the intention here is to prevent message content from being visible below the input

Before

Screenshot 2024-07-08 at 10 47 31 AM

After

Screenshot 2024-07-08 at 10 49 43 AM

bottom: 0;
padding: 0.25rem;
textarea {
Expand Down
70 changes: 51 additions & 19 deletions js/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ type ShinyChatMessage = {
obj: Message;
};

type requestScrollEvent = {
cancelIfScrolledUp: boolean;
};

// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734
declare global {
interface GlobalEventHandlersEventMap {
Expand All @@ -32,6 +36,7 @@ declare global {
"shiny-chat-clear-messages": CustomEvent;
"shiny-chat-set-user-input": CustomEvent<string>;
"shiny-chat-remove-loading-message": CustomEvent;
"shiny-chat-request-scroll": CustomEvent<requestScrollEvent>;
}
}

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

const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
el.dispatchEvent(
new CustomEvent("shiny-chat-request-scroll", {
detail: { cancelIfScrolledUp },
bubbles: true,
composed: true,
})
);
};

// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
class LightElement extends LitElement {
createRenderRoot() {
Expand All @@ -51,6 +66,7 @@ class LightElement extends LitElement {
class ChatMessage extends LightElement {
@property() content = "...";
@property() content_type: ContentType = "markdown";
@property({ type: Boolean, reflect: true }) is_streaming = false;

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

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

private resizeObserver!: ResizeObserver;

render(): ReturnType<LitElement["render"]> {
const input_id = this.id + "_user_input";
return html`
Expand Down Expand Up @@ -252,6 +273,10 @@ class ChatContainer extends LightElement {
"shiny-chat-remove-loading-message",
this.#onRemoveLoadingMessage
);
this.addEventListener("shiny-chat-request-scroll", this.#onRequestScroll);

this.resizeObserver = new ResizeObserver(() => requestScroll(this, true));
this.resizeObserver.observe(this);
}

disconnectedCallback(): void {
Expand All @@ -269,6 +294,12 @@ class ChatContainer extends LightElement {
"shiny-chat-remove-loading-message",
this.#onRemoveLoadingMessage
);
this.removeEventListener(
"shiny-chat-request-scroll",
this.#onRequestScroll
);

this.resizeObserver.disconnect();
}

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

// Scroll to the bottom to show the new message
this.#scrollToBottom();

if (finalize) {
this.#finalizeMessage();
}
Expand Down Expand Up @@ -325,26 +353,18 @@ class ChatContainer extends LightElement {
this.#appendMessage(message, false);
return;
}
if (message.chunk_type === "message_end") {
this.#finalizeMessage();
return;
}

const messages = this.messages;
const lastMessage = messages.lastElementChild as HTMLElement;
const lastMessage = this.messages.lastElementChild as HTMLElement;
if (!lastMessage) throw new Error("No messages found in the chat output");
const content = lastMessage.getAttribute("content");
lastMessage.setAttribute("content", message.content);

// Don't scroll to bottom if the user has scrolled up a bit
if (
messages.scrollTop + messages.clientHeight <
messages.scrollHeight - 50
) {
if (message.chunk_type === "message_end") {
lastMessage.removeAttribute("is_streaming");
this.#finalizeMessage();
return;
}

this.#scrollToBottom();
lastMessage.setAttribute("is_streaming", "");
lastMessage.setAttribute("content", message.content);
}

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

#scrollToBottom(): void {
this.messages.scrollTop = this.messages.scrollHeight;
#onRequestScroll(event: CustomEvent<requestScrollEvent>): void {
// When streaming or resizing, only scroll if the user near the bottom
const { cancelIfScrolledUp } = event.detail;
if (cancelIfScrolledUp) {
if (this.scrollTop + this.clientHeight < this.scrollHeight - 50) {
return;
}
}

// Smooth scroll to the bottom if we're not streaming or resizing
this.scroll({
top: this.scrollHeight,
behavior: cancelIfScrolledUp ? "auto" : "smooth",
});
}
}

Expand Down
2 changes: 1 addition & 1 deletion shiny/www/py-shiny/chat/chat.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading