@@ -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
2731declare 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";
4146const CHAT_INPUT_TAG = "shiny-chat-input" ;
4247const 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
4560class LightElement extends LitElement {
4661 createRenderRoot ( ) {
@@ -51,6 +66,7 @@ class LightElement extends LitElement {
5166class 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
0 commit comments