@@ -15,6 +15,7 @@ import * as Marked from '../../../third_party/marked/marked.js';
1515import * as Buttons from '../../../ui/components/buttons/buttons.js' ;
1616import type * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js' ;
1717import * as UI from '../../../ui/legacy/legacy.js' ;
18+ import { ScrollPinHelper } from './ScrollPinHelper.js' ;
1819import * as Lit from '../../../ui/lit/lit.js' ;
1920import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js' ;
2021import { PatchWidget } from '../PatchWidget.js' ;
@@ -304,29 +305,13 @@ export interface Props {
304305export class ChatView extends HTMLElement {
305306 readonly #shadow = this . attachShadow ( { mode : 'open' } ) ;
306307 #markdownRenderer = new MarkdownRendererWithCodeBlock ( ) ;
307- #scrollTop?: number ;
308+ // Scroll management helper replaces ad-hoc state/logic
309+ #scrollHelper = new ScrollPinHelper ( ) ;
308310 #props: Props ;
309311 #messagesContainerElement?: Element ;
310312 #mainElementRef?: Lit . Directives . Ref < Element > = Lit . Directives . createRef ( ) ;
311313 #messagesContainerResizeObserver = new ResizeObserver ( ( ) => this . #handleMessagesContainerResize( ) ) ;
312314 #popoverHelper: UI . PopoverHelper . PopoverHelper | null = null ;
313- /**
314- * Indicates whether the chat scroll position should be pinned to the bottom.
315- *
316- * This is true when:
317- * - The scroll is at the very bottom, allowing new messages to push the scroll down automatically.
318- * - The panel is initially rendered and the user hasn't scrolled yet.
319- *
320- * It is set to false when the user scrolls up to view previous messages.
321- */
322- #pinScrollToBottom = true ;
323- /**
324- * Indicates whether the scroll event originated from code
325- * or a user action. When set to `true`, `handleScroll` will ignore the event,
326- * allowing it to only handle user-driven scrolls and correctly decide
327- * whether to pin the content to the bottom.
328- */
329- #isProgrammaticScroll = false ;
330315
331316 constructor ( props : Props ) {
332317 super ( ) ;
@@ -351,16 +336,21 @@ export class ChatView extends HTMLElement {
351336 this . #messagesContainerResizeObserver. disconnect ( ) ;
352337 }
353338
339+ // Centralize access to the textarea to avoid repeated querySelector casts
340+ #getTextArea( ) : HTMLTextAreaElement | null {
341+ return this . #shadow. querySelector ( '.chat-input' ) as HTMLTextAreaElement | null ;
342+ }
343+
354344 clearTextInput ( ) : void {
355- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
345+ const textArea = this . #getTextArea ( ) ;
356346 if ( ! textArea ) {
357347 return ;
358348 }
359349 textArea . value = '' ;
360350 }
361351
362352 focusTextInput ( ) : void {
363- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
353+ const textArea = this . #getTextArea ( ) ;
364354 if ( ! textArea ) {
365355 return ;
366356 }
@@ -369,23 +359,18 @@ export class ChatView extends HTMLElement {
369359 }
370360
371361 restoreScrollPosition ( ) : void {
372- if ( this . #scrollTop === undefined ) {
373- return ;
374- }
375-
376- if ( ! this . #mainElementRef?. value ) {
377- return ;
362+ // Ensure helper has latest element
363+ if ( this . #mainElementRef?. value ) {
364+ this . #scrollHelper. setElement ( this . #mainElementRef. value as HTMLElement ) ;
378365 }
379-
380- this . #setMainElementScrollTop( this . #scrollTop) ;
366+ this . #scrollHelper. restoreLastPosition ( ) ;
381367 }
382368
383369 scrollToBottom ( ) : void {
384- if ( ! this . #mainElementRef?. value ) {
385- return ;
370+ if ( this . #mainElementRef?. value ) {
371+ this . #scrollHelper . setElement ( this . #mainElementRef . value as HTMLElement ) ;
386372 }
387-
388- this . #setMainElementScrollTop( this . #mainElementRef. value . scrollHeight ) ;
373+ this . #scrollHelper. scrollToBottom ( ) ;
389374 }
390375
391376 #handleChatUiRef( el : Element | undefined ) : void {
@@ -446,31 +431,16 @@ export class ChatView extends HTMLElement {
446431 }
447432
448433 #handleMessagesContainerResize( ) : void {
449- if ( ! this . #pinScrollToBottom) {
450- return ;
451- }
452-
453- if ( ! this . #mainElementRef?. value ) {
454- return ;
455- }
456-
457- if ( this . #pinScrollToBottom) {
458- this . #setMainElementScrollTop( this . #mainElementRef. value . scrollHeight ) ;
434+ if ( this . #mainElementRef?. value ) {
435+ this . #scrollHelper. setElement ( this . #mainElementRef. value as HTMLElement ) ;
459436 }
437+ this . #scrollHelper. handleResize ( ) ;
460438 }
461439
462- #setMainElementScrollTop( scrollTop : number ) : void {
463- if ( ! this . #mainElementRef?. value ) {
464- return ;
465- }
466-
467- this . #scrollTop = scrollTop ;
468- this . #isProgrammaticScroll = true ;
469- this . #mainElementRef. value . scrollTop = scrollTop ;
470- }
440+ // Removed ad-hoc scroll setter in favor of ScrollPinHelper
471441
472442 #setInputText( text : string ) : void {
473- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
443+ const textArea = this . #getTextArea ( ) ;
474444 if ( ! textArea ) {
475445 return ;
476446 }
@@ -485,7 +455,6 @@ export class ChatView extends HTMLElement {
485455 if ( el ) {
486456 this . #messagesContainerResizeObserver. observe ( el ) ;
487457 } else {
488- this . #pinScrollToBottom = true ;
489458 this . #messagesContainerResizeObserver. disconnect ( ) ;
490459 }
491460 }
@@ -494,18 +463,10 @@ export class ChatView extends HTMLElement {
494463 if ( ! ev . target || ! ( ev . target instanceof HTMLElement ) ) {
495464 return ;
496465 }
497-
498- // Do not handle scroll events caused by programmatically
499- // updating the scroll position. We want to know whether user
500- // did scroll the container from the user interface.
501- if ( this . #isProgrammaticScroll) {
502- this . #isProgrammaticScroll = false ;
503- return ;
466+ if ( this . #mainElementRef?. value ) {
467+ this . #scrollHelper. setElement ( this . #mainElementRef. value as HTMLElement ) ;
504468 }
505-
506- this . #scrollTop = ev . target . scrollTop ;
507- this . #pinScrollToBottom =
508- ev . target . scrollTop + ev . target . clientHeight + SCROLL_ROUNDING_OFFSET > ev . target . scrollHeight ;
469+ this . #scrollHelper. handleScroll ( ev . target ) ;
509470 } ;
510471
511472 #handleSubmit = ( ev : SubmitEvent ) : void => {
@@ -514,7 +475,7 @@ export class ChatView extends HTMLElement {
514475 return ;
515476 }
516477
517- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
478+ const textArea = this . #getTextArea ( ) ;
518479 if ( ! textArea ?. value ) {
519480 return ;
520481 }
@@ -569,42 +530,44 @@ export class ChatView extends HTMLElement {
569530 Host . userMetrics . actionTaken ( Host . UserMetrics . Action . AiAssistanceDynamicSuggestionClicked ) ;
570531 } ;
571532
533+ #renderFooter( ) : Lit . LitTemplate {
534+ const classes = Lit . Directives . classMap ( {
535+ 'chat-view-footer' : true ,
536+ 'has-conversation' : ! ! this . #props. conversationType ,
537+ 'is-read-only' : this . #props. isReadOnly ,
538+ } ) ;
539+
540+ // clang-format off
541+ const footerContents = this . #props. conversationType
542+ ? renderRelevantDataDisclaimer ( {
543+ isLoading : this . #props. isLoading ,
544+ blockedByCrossOrigin : this . #props. blockedByCrossOrigin ,
545+ } )
546+ : html `< p >
547+ ${ lockedString ( UIStringsNotTranslate . inputDisclaimerForEmptyState ) }
548+ < button
549+ class ="link "
550+ role ="link "
551+ jslog =${ VisualLogging . link ( 'open-ai-settings' ) . track ( {
552+ click : true ,
553+ } ) }
554+ @click =${ ( ) => {
555+ void UI . ViewManager . ViewManager . instance ( ) . showView (
556+ 'chrome-ai' ,
557+ ) ;
558+ } }
559+ > ${ i18nString ( UIStrings . learnAbout ) } </ button >
560+ </ p > ` ;
561+
562+ return html `
563+ < footer class =${ classes } jslog =${ VisualLogging . section ( 'footer' ) } >
564+ ${ footerContents }
565+ </ footer >
566+ ` ;
567+ // clang-format on
568+ }
569+
572570 #render( ) : void {
573- const renderFooter = ( ) : Lit . LitTemplate => {
574- const classes = Lit . Directives . classMap ( {
575- 'chat-view-footer' : true ,
576- 'has-conversation' : ! ! this . #props. conversationType ,
577- 'is-read-only' : this . #props. isReadOnly ,
578- } ) ;
579-
580- // clang-format off
581- const footerContents = this . #props. conversationType
582- ? renderRelevantDataDisclaimer ( {
583- isLoading : this . #props. isLoading ,
584- blockedByCrossOrigin : this . #props. blockedByCrossOrigin ,
585- } )
586- : html `< p >
587- ${ lockedString ( UIStringsNotTranslate . inputDisclaimerForEmptyState ) }
588- < button
589- class ="link "
590- role ="link "
591- jslog =${ VisualLogging . link ( 'open-ai-settings' ) . track ( {
592- click : true ,
593- } ) }
594- @click =${ ( ) => {
595- void UI . ViewManager . ViewManager . instance ( ) . showView (
596- 'chrome-ai' ,
597- ) ;
598- } }
599- > ${ i18nString ( UIStrings . learnAbout ) } </ button >
600- </ p > ` ;
601-
602- return html `
603- < footer class =${ classes } jslog =${ VisualLogging . section ( 'footer' ) } >
604- ${ footerContents }
605- </ footer >
606- ` ;
607- } ;
608571 // clang-format off
609572 Lit . render ( html `
610573 < style > ${ chatViewStyles } </ style >
@@ -661,7 +624,7 @@ export class ChatView extends HTMLElement {
661624 } )
662625 }
663626 </ main >
664- ${ renderFooter ( ) }
627+ ${ this . # renderFooter( ) }
665628 </ div >
666629 ` , this . #shadow, { host : this } ) ;
667630 // clang-format on
0 commit comments