@@ -20,6 +20,7 @@ import * as Buttons from '../../../ui/components/buttons/buttons.js';
2020import type * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js' ;
2121import type { MarkdownLitRenderer } from '../../../ui/components/markdown_view/MarkdownView.js' ;
2222import * as UI from '../../../ui/legacy/legacy.js' ;
23+ import { ScrollPinHelper } from './ScrollPinHelper.js' ;
2324import * as Lit from '../../../ui/lit/lit.js' ;
2425import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js' ;
2526import { PatchWidget } from '../PatchWidget.js' ;
@@ -308,28 +309,14 @@ export interface Props {
308309
309310export class ChatView extends HTMLElement {
310311 readonly #shadow = this . attachShadow ( { mode : 'open' } ) ;
311- #scrollTop?: number ;
312+ #markdownRenderer = new MarkdownRendererWithCodeBlock ( ) ;
313+ // Scroll management helper replaces ad-hoc state/logic
314+ #scrollHelper = new ScrollPinHelper ( ) ;
312315 #props: Props ;
313316 #messagesContainerElement?: Element ;
314317 #mainElementRef?: Lit . Directives . Ref < Element > = Lit . Directives . createRef ( ) ;
315318 #messagesContainerResizeObserver = new ResizeObserver ( ( ) => this . #handleMessagesContainerResize( ) ) ;
316- /**
317- * Indicates whether the chat scroll position should be pinned to the bottom.
318- *
319- * This is true when:
320- * - The scroll is at the very bottom, allowing new messages to push the scroll down automatically.
321- * - The panel is initially rendered and the user hasn't scrolled yet.
322- *
323- * It is set to false when the user scrolls up to view previous messages.
324- */
325- #pinScrollToBottom = true ;
326- /**
327- * Indicates whether the scroll event originated from code
328- * or a user action. When set to `true`, `handleScroll` will ignore the event,
329- * allowing it to only handle user-driven scrolls and correctly decide
330- * whether to pin the content to the bottom.
331- */
332- #isProgrammaticScroll = false ;
319+ #popoverHelper: UI . PopoverHelper . PopoverHelper | null = null ;
333320
334321 constructor ( props : Props ) {
335322 super ( ) ;
@@ -353,16 +340,21 @@ export class ChatView extends HTMLElement {
353340 this . #messagesContainerResizeObserver. disconnect ( ) ;
354341 }
355342
343+ // Centralize access to the textarea to avoid repeated querySelector casts
344+ #getTextArea( ) : HTMLTextAreaElement | null {
345+ return this . #shadow. querySelector ( '.chat-input' ) as HTMLTextAreaElement | null ;
346+ }
347+
356348 clearTextInput ( ) : void {
357- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
349+ const textArea = this . #getTextArea ( ) ;
358350 if ( ! textArea ) {
359351 return ;
360352 }
361353 textArea . value = '' ;
362354 }
363355
364356 focusTextInput ( ) : void {
365- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
357+ const textArea = this . #getTextArea ( ) ;
366358 if ( ! textArea ) {
367359 return ;
368360 }
@@ -371,51 +363,88 @@ export class ChatView extends HTMLElement {
371363 }
372364
373365 restoreScrollPosition ( ) : void {
374- if ( this . #scrollTop === undefined ) {
375- return ;
376- }
377-
378- if ( ! this . #mainElementRef?. value ) {
379- return ;
366+ // Ensure helper has latest element
367+ if ( this . #mainElementRef?. value ) {
368+ this . #scrollHelper. setElement ( this . #mainElementRef. value as HTMLElement ) ;
380369 }
381-
382- this . #setMainElementScrollTop( this . #scrollTop) ;
370+ this . #scrollHelper. restoreLastPosition ( ) ;
383371 }
384372
385373 scrollToBottom ( ) : void {
386- if ( ! this . #mainElementRef?. value ) {
387- return ;
374+ if ( this . #mainElementRef?. value ) {
375+ this . #scrollHelper . setElement ( this . #mainElementRef . value as HTMLElement ) ;
388376 }
389-
390- this . #setMainElementScrollTop( this . #mainElementRef. value . scrollHeight ) ;
377+ this . #scrollHelper. scrollToBottom ( ) ;
391378 }
392379
393- #handleMessagesContainerResize ( ) : void {
394- if ( ! this . #pinScrollToBottom ) {
380+ #handleChatUiRef ( el : Element | undefined ) : void {
381+ if ( ! el || this . #popoverHelper ) {
395382 return ;
396383 }
397384
398- if ( ! this . #mainElementRef?. value ) {
399- return ;
400- }
385+ // TODO: Update here when b/409965560 is fixed.
386+ this . #popoverHelper = new UI . PopoverHelper . PopoverHelper ( ( el as HTMLElement ) , event => {
387+ const popoverShownNode =
388+ event . target instanceof HTMLElement && event . target . id === RELEVANT_DATA_LINK_ID ? event . target : null ;
389+ if ( ! popoverShownNode ) {
390+ return null ;
391+ }
401392
402- if ( this . #pinScrollToBottom) {
403- this . #setMainElementScrollTop( this . #mainElementRef. value . scrollHeight ) ;
404- }
393+ // We move the glass pane to be a bit lower so
394+ // that it does not disappear when moving the cursor
395+ // over to link.
396+ const nodeBox = popoverShownNode . boxInWindow ( ) ;
397+ nodeBox . y = nodeBox . y + TOOLTIP_POPOVER_OFFSET ;
398+ return {
399+ box : nodeBox ,
400+ show : async ( popover : UI . GlassPane . GlassPane ) => {
401+ // clang-format off
402+ Lit . render ( html `
403+ < style >
404+ .info-tooltip-container {
405+ max-width : var (--sys-size-28 );
406+ padding : var (--sys-size-4 ) var (--sys-size-5 );
407+
408+ .tooltip-link {
409+ display : block;
410+ margin-top : var (--sys-size-4 );
411+ color : var (--sys-color-primary );
412+ padding-left : 0 ;
413+ }
414+ }
415+ </ style >
416+ < div class ="info-tooltip-container ">
417+ ${ this . #props. disclaimerText }
418+ < button
419+ class ="link tooltip-link "
420+ role ="link "
421+ jslog =${ VisualLogging . link ( 'open-ai-settings' ) . track ( {
422+ click : true ,
423+ } ) }
424+ @click =${ ( ) => {
425+ void UI . ViewManager . ViewManager . instance ( ) . showView ( 'chrome-ai' ) ;
426+ } }
427+ > ${ i18nString ( UIStrings . learnAbout ) } </ button >
428+ </ div > ` , popover . contentElement , { host : this } ) ;
429+ // clang-format on
430+ return true ;
431+ } ,
432+ } ;
433+ } ) ;
434+ this . #popoverHelper. setTimeout ( 0 ) ;
405435 }
406436
407- #setMainElementScrollTop ( scrollTop : number ) : void {
408- if ( ! this . #mainElementRef?. value ) {
409- return ;
437+ #handleMessagesContainerResize ( ) : void {
438+ if ( this . #mainElementRef?. value ) {
439+ this . #scrollHelper . setElement ( this . #mainElementRef . value as HTMLElement ) ;
410440 }
411-
412- this . #scrollTop = scrollTop ;
413- this . #isProgrammaticScroll = true ;
414- this . #mainElementRef. value . scrollTop = scrollTop ;
441+ this . #scrollHelper. handleResize ( ) ;
415442 }
416443
444+ // Removed ad-hoc scroll setter in favor of ScrollPinHelper
445+
417446 #setInputText( text : string ) : void {
418- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
447+ const textArea = this . #getTextArea ( ) ;
419448 if ( ! textArea ) {
420449 return ;
421450 }
@@ -430,7 +459,6 @@ export class ChatView extends HTMLElement {
430459 if ( el ) {
431460 this . #messagesContainerResizeObserver. observe ( el ) ;
432461 } else {
433- this . #pinScrollToBottom = true ;
434462 this . #messagesContainerResizeObserver. disconnect ( ) ;
435463 }
436464 }
@@ -439,18 +467,10 @@ export class ChatView extends HTMLElement {
439467 if ( ! ev . target || ! ( ev . target instanceof HTMLElement ) ) {
440468 return ;
441469 }
442-
443- // Do not handle scroll events caused by programmatically
444- // updating the scroll position. We want to know whether user
445- // did scroll the container from the user interface.
446- if ( this . #isProgrammaticScroll) {
447- this . #isProgrammaticScroll = false ;
448- return ;
470+ if ( this . #mainElementRef?. value ) {
471+ this . #scrollHelper. setElement ( this . #mainElementRef. value as HTMLElement ) ;
449472 }
450-
451- this . #scrollTop = ev . target . scrollTop ;
452- this . #pinScrollToBottom =
453- ev . target . scrollTop + ev . target . clientHeight + SCROLL_ROUNDING_OFFSET > ev . target . scrollHeight ;
473+ this . #scrollHelper. handleScroll ( ev . target ) ;
454474 } ;
455475
456476 #handleSubmit = ( ev : SubmitEvent ) : void => {
@@ -459,7 +479,7 @@ export class ChatView extends HTMLElement {
459479 return ;
460480 }
461481
462- const textArea = this . #shadow . querySelector ( '.chat-input' ) as HTMLTextAreaElement ;
482+ const textArea = this . #getTextArea ( ) ;
463483 if ( ! textArea ?. value ) {
464484 return ;
465485 }
@@ -514,44 +534,44 @@ export class ChatView extends HTMLElement {
514534 Host . userMetrics . actionTaken ( Host . UserMetrics . Action . AiAssistanceDynamicSuggestionClicked ) ;
515535 } ;
516536
517- #render( ) : void {
518- const renderFooter = ( ) : Lit . LitTemplate => {
519- const classes = Lit . Directives . classMap ( {
520- 'chat-view-footer' : true ,
521- 'has-conversation' : ! ! this . #props. conversationType ,
522- 'is-read-only' : this . #props. isReadOnly ,
523- } ) ;
524-
525- // clang-format off
526- const footerContents = this . #props. conversationType
527- ? renderRelevantDataDisclaimer ( {
537+ #renderFooter( ) : Lit . LitTemplate {
538+ const classes = Lit . Directives . classMap ( {
539+ 'chat-view-footer' : true ,
540+ 'has-conversation' : ! ! this . #props. conversationType ,
541+ 'is-read-only' : this . #props. isReadOnly ,
542+ } ) ;
543+
544+ // clang-format off
545+ const footerContents = this . #props. conversationType
546+ ? renderRelevantDataDisclaimer ( {
528547 isLoading : this . #props. isLoading ,
529548 blockedByCrossOrigin : this . #props. blockedByCrossOrigin ,
530- tooltipId : RELEVANT_DATA_LINK_FOOTER_ID ,
531- disclaimerText : this . #props. disclaimerText ,
532549 } )
533- : html `< p >
534- ${ lockedString ( UIStringsNotTranslate . inputDisclaimerForEmptyState ) }
535- < button
536- class ="link "
537- role ="link "
538- jslog =${ VisualLogging . link ( 'open-ai-settings' ) . track ( {
539- click : true ,
540- } ) }
541- @click =${ ( ) => {
542- void UI . ViewManager . ViewManager . instance ( ) . showView (
543- 'chrome-ai' ,
544- ) ;
545- } }
546- > ${ i18nString ( UIStrings . learnAbout ) } </ button >
547- </ p > ` ;
548-
549- return html `
550- < footer class =${ classes } jslog =${ VisualLogging . section ( 'footer' ) } >
551- ${ footerContents }
552- </ footer >
553- ` ;
554- } ;
550+ : html `< p >
551+ ${ lockedString ( UIStringsNotTranslate . inputDisclaimerForEmptyState ) }
552+ < button
553+ class ="link "
554+ role ="link "
555+ jslog =${ VisualLogging . link ( 'open-ai-settings' ) . track ( {
556+ click : true ,
557+ } ) }
558+ @click =${ ( ) => {
559+ void UI . ViewManager . ViewManager . instance ( ) . showView (
560+ 'chrome-ai' ,
561+ ) ;
562+ } }
563+ > ${ i18nString ( UIStrings . learnAbout ) } </ button >
564+ </ p > ` ;
565+
566+ return html `
567+ < footer class =${ classes } jslog =${ VisualLogging . section ( 'footer' ) } >
568+ ${ footerContents }
569+ </ footer >
570+ ` ;
571+ // clang-format on
572+ }
573+
574+ #render( ) : void {
555575 // clang-format off
556576 Lit . render ( html `
557577 < style > ${ chatViewStyles } </ style >
@@ -609,7 +629,7 @@ export class ChatView extends HTMLElement {
609629 } )
610630 }
611631 </ main >
612- ${ renderFooter ( ) }
632+ ${ this . # renderFooter( ) }
613633 </ div >
614634 ` , this . #shadow, { host : this } ) ;
615635 // clang-format on
0 commit comments