Skip to content

Commit

Permalink
Implement new and improved auto-scroll logic
Browse files Browse the repository at this point in the history
  • Loading branch information
cpsievert committed Jan 28, 2025
1 parent 93cce86 commit 4c7cbbc
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 100 deletions.
42 changes: 1 addition & 41 deletions js/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ type ShinyChatMessage = {
obj: Message;
};

type requestScrollEvent = {
cancelIfScrolledUp: boolean;
};

type UpdateUserInput = {
value?: string;
placeholder?: string;
Expand All @@ -39,7 +35,6 @@ declare global {
"shiny-chat-clear-messages": CustomEvent;
"shiny-chat-update-user-input": CustomEvent<UpdateUserInput>;
"shiny-chat-remove-loading-message": CustomEvent;
"shiny-chat-request-scroll": CustomEvent<requestScrollEvent>;
}
}

Expand All @@ -57,16 +52,6 @@ const ICONS = {
'<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>',
};

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

class ChatMessage extends LightElement {
@property() content = "...";
@property() content_type: ContentType = "markdown";
Expand Down Expand Up @@ -236,10 +221,6 @@ 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 @@ -260,12 +241,6 @@ 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 Down Expand Up @@ -359,22 +334,6 @@ class ChatContainer extends LightElement {
#finalizeMessage(): void {
this.input.disabled = false;
}

#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 - 100) {
return;
}
}

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

// ------- Register custom elements and shiny bindings ---------
Expand All @@ -393,6 +352,7 @@ $(function () {
detail: message.obj,
});
const el = document.getElementById(message.id);
// TODO: throw an error if the element is not found?
el?.dispatchEvent(evt);
}
);
Expand Down
106 changes: 90 additions & 16 deletions js/markdown-stream/markdown-stream.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LitElement, html } from "lit";
import { PropertyValues, html } from "lit";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { property } from "lit/decorators.js";

Expand Down Expand Up @@ -35,7 +35,7 @@ function isStreamingMessage(
}

// SVG dot to indicate content is currently streaming
const SVG_DOT_CLASS = "chat-streaming-dot";
const SVG_DOT_CLASS = "markdown-stream-dot";
const SVG_DOT = createSVGIcon(
`<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" class="${SVG_DOT_CLASS}" style="margin-left:.25em;margin-top:-.25em"><circle cx="6" cy="6" r="6"/></svg>`
);
Expand Down Expand Up @@ -76,18 +76,35 @@ class MarkdownElement extends LightElement {
@property() content_type: ContentType = "markdown";
@property({ type: Boolean, reflect: true }) streaming = false;

render(): ReturnType<LitElement["render"]> {
const content = contentToHTML(this.content, this.content_type);
return html`${content}`;
render() {
return html`${contentToHTML(this.content, this.content_type)}`;
}

updated(changedProperties: Map<string, unknown>): void {
disconnectedCallback(): void {
super.disconnectedCallback();
this.#cleanup();
}

protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("content")) {
this.#updateScrollableElement();
this.#isContentBeingAdded = true;
}
}

protected updated(changedProperties: Map<string, unknown>): void {
if (changedProperties.has("content")) {
this.#highlightAndCodeCopy();
try {
this.#highlightAndCodeCopy();
} catch (error) {
console.warn("Failed to highlight code:", error);
}

if (this.streaming) this.#appendStreamingDot();
// TODO: throw an event here that content has rendered and catch it in SHINY_CHAT_MESSAGE
// requestScroll(this, this.streaming);
this.#isContentBeingAdded = false;
this.#maybeScrollToBottom();
}

if (changedProperties.has("streaming")) {
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
}
Expand All @@ -101,23 +118,25 @@ class MarkdownElement extends LightElement {
this.querySelector(`svg.${SVG_DOT_CLASS}`)?.remove();
}

// Highlight code blocks after the element is rendered
#highlightAndCodeCopy(): void {
const el = this.querySelector("pre code");
if (!el) return;
this.querySelectorAll<HTMLElement>("pre code").forEach((el) => {
// Highlight the code
if (el.dataset.highlighted === "yes") return;

hljs.highlightElement(el);
// Add a button to the code block to copy to clipboard

// Add copy button
const btn = createElement("button", {
class: "code-copy-button",
title: "Copy to clipboard",
});
btn.innerHTML = '<i class="bi"></i>';
el.prepend(btn);
// Add the clipboard functionality

// Setup clipboard
const clipboard = new ClipboardJS(btn, { target: () => el });
clipboard.on("success", function (e: ClipboardJS.Event) {
clipboard.on("success", (e) => {
btn.classList.add("code-copy-button-checked");
setTimeout(
() => btn.classList.remove("code-copy-button-checked"),
Expand All @@ -127,13 +146,68 @@ class MarkdownElement extends LightElement {
});
});
}

// ------- Scrolling logic -------

// Nearest scrollable parent element (if any)
#scrollableElement: HTMLElement | null = null;
// Whether content is currently being added to the element
#isContentBeingAdded = false;
// Whether the user has scrolled away from the bottom
#isUserScrolled = false;

#onScroll = (): void => {
if (!this.#isContentBeingAdded) {
this.#isUserScrolled = !this.#isNearBottom();
}
};

#isNearBottom(): boolean {
const el = this.#scrollableElement;
if (!el) return false;

return el.scrollHeight - (el.scrollTop + el.clientHeight) < 50;
}

#updateScrollableElement(): void {
const el = this.#findScrollableParent();

if (el !== this.#scrollableElement) {
this.#scrollableElement?.removeEventListener("scroll", this.#onScroll);
this.#scrollableElement = el;
this.#scrollableElement?.addEventListener("scroll", this.#onScroll);
}
}

#findScrollableParent(): HTMLElement | null {
// eslint-disable-next-line
let el: HTMLElement | null = this;
while (el) {
if (el.scrollHeight > el.clientHeight) return el;
el = el.parentElement;
}
return null;
}

#maybeScrollToBottom(): void {
const el = this.#scrollableElement;
if (!el || this.#isUserScrolled) return;

el.scroll({
top: el.scrollHeight - el.clientHeight,
behavior: this.streaming ? "instant" : "smooth",
});
}

#cleanup(): void {
this.#scrollableElement?.removeEventListener("scroll", this.#onScroll);
}
}

// ------- Register custom elements and shiny bindings ---------

if (!customElements.get("shiny-markdown-stream")) {
customElements.get("shiny-markdown-stream") ||
customElements.define("shiny-markdown-stream", MarkdownElement);
}

function handleMessage(message: ContentMessage | IsStreamingMessage): void {
const el = document.getElementById(message.id) as MarkdownElement;
Expand Down
2 changes: 1 addition & 1 deletion js/utils/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ function showShinyClientMessage({
);
}

export { LightElement, createElement, createSVGIcon, showShinyClientMessage };
export { LightElement, createElement, createSVGIcon, showShinyClientMessage, };
20 changes: 10 additions & 10 deletions shiny/www/py-shiny/chat/chat.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions shiny/www/py-shiny/chat/chat.js.map

Large diffs are not rendered by default.

52 changes: 26 additions & 26 deletions shiny/www/py-shiny/markdown-stream/markdown-stream.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions shiny/www/py-shiny/markdown-stream/markdown-stream.js.map

Large diffs are not rendered by default.

0 comments on commit 4c7cbbc

Please sign in to comment.