@@ -4,6 +4,7 @@ import tippy, {
44} from 'tippy.js' ;
55import { format , formatRelative } from 'date-fns' ;
66import { enUS } from 'date-fns/locale' ;
7+ import { marked } from 'marked' ;
78import type { PhoenixHook } from './PhoenixHook' ;
89
910import LogLineHighlight from './LogLineHighlight' ;
@@ -684,9 +685,39 @@ export const BlurDataclipEditor = {
684685
685686export const ScrollToMessage = {
686687 mounted ( ) {
688+ this . shouldAutoScroll = true ;
689+
690+ // Throttle scroll tracking to reduce CPU usage
691+ this . handleScrollThrottled = this . throttle ( ( ) => {
692+ const isAtBottom = this . isAtBottom ( ) ;
693+ this . shouldAutoScroll = isAtBottom ;
694+ } , 100 ) ; // Only check every 100ms
695+
696+ this . el . addEventListener ( 'scroll' , this . handleScrollThrottled ) ;
687697 this . handleScroll ( ) ;
688698 } ,
689699
700+ destroyed ( ) {
701+ if ( this . handleScrollThrottled ) {
702+ this . el . removeEventListener ( 'scroll' , this . handleScrollThrottled ) ;
703+ }
704+ if ( this . throttleTimeout !== undefined ) {
705+ clearTimeout ( this . throttleTimeout ) ;
706+ }
707+ } ,
708+
709+ throttle ( func : ( ) => void , wait : number ) : ( ) => void {
710+ return ( ) => {
711+ if ( this . throttleTimeout !== undefined ) {
712+ clearTimeout ( this . throttleTimeout ) ;
713+ }
714+ this . throttleTimeout = setTimeout ( ( ) => {
715+ func ( ) ;
716+ this . throttleTimeout = undefined ;
717+ } , wait ) as unknown as number ;
718+ } ;
719+ } ,
720+
690721 updated ( ) {
691722 this . handleScroll ( ) ;
692723 } ,
@@ -696,7 +727,8 @@ export const ScrollToMessage = {
696727
697728 if ( targetMessageId ) {
698729 this . scrollToSpecificMessage ( targetMessageId ) ;
699- } else {
730+ } else if ( this . shouldAutoScroll ) {
731+ // Only auto-scroll if user hasn't manually scrolled up
700732 this . scrollToBottom ( ) ;
701733 }
702734 } ,
@@ -717,18 +749,26 @@ export const ScrollToMessage = {
717749 }
718750 } ,
719751
752+ isAtBottom ( ) {
753+ const threshold = 50 ; // pixels from bottom
754+ const position = this . el . scrollTop + this . el . clientHeight ;
755+ const height = this . el . scrollHeight ;
756+ return height - position <= threshold ;
757+ } ,
758+
720759 scrollToBottom ( ) {
721- setTimeout ( ( ) => {
722- this . el . scrollTo ( {
723- top : this . el . scrollHeight ,
724- behavior : 'smooth' ,
725- } ) ;
726- } , 600 ) ;
760+ // Use instant scroll during updates to prevent jank
761+ this . el . scrollTop = this . el . scrollHeight ;
727762 } ,
728763} as PhoenixHook < {
764+ shouldAutoScroll : boolean ;
765+ handleScrollThrottled ?: ( ) => void ;
766+ throttleTimeout ?: number ;
767+ throttle : ( func : ( ) => void , wait : number ) => ( ) => void ;
729768 handleScroll : ( ) => void ;
730769 scrollToSpecificMessage : ( messageId : string ) => void ;
731770 scrollToBottom : ( ) => void ;
771+ isAtBottom : ( ) => boolean ;
732772} > ;
733773
734774export const Copy = {
@@ -1020,3 +1060,98 @@ export const LocalTimeConverter = {
10201060 convertDateTime : ( ) => void ;
10211061 convertToDisplayTime : ( isoTimestamp : string , display : string ) => void ;
10221062} > ;
1063+
1064+ export const StreamingText = {
1065+ mounted ( ) {
1066+ this . lastContent = '' ;
1067+ this . renderer = this . createCustomRenderer ( ) ;
1068+ this . parseCount = 0 ;
1069+ this . pendingUpdate = undefined ;
1070+ this . updateContent ( ) ;
1071+ } ,
1072+
1073+ updated ( ) {
1074+ // Debounce updates by 50ms to batch rapid chunk arrivals
1075+ if ( this . pendingUpdate !== undefined ) {
1076+ clearTimeout ( this . pendingUpdate ) ;
1077+ }
1078+
1079+ this . pendingUpdate = setTimeout ( ( ) => {
1080+ this . updateContent ( ) ;
1081+ this . pendingUpdate = undefined ;
1082+ } , 50 ) as unknown as number ;
1083+ } ,
1084+
1085+ destroyed ( ) {
1086+ if ( this . pendingUpdate !== undefined ) {
1087+ clearTimeout ( this . pendingUpdate ) ;
1088+ }
1089+ } ,
1090+
1091+ createCustomRenderer ( ) {
1092+ const renderer = new marked . Renderer ( ) ;
1093+
1094+ // Apply custom CSS classes to match backend Earmark styles
1095+ renderer . code = ( code , language ) => {
1096+ const lang = language ? ` class="${ language } "` : '' ;
1097+ return `<pre class="rounded-md font-mono bg-slate-100 border-2 border-slate-200 text-slate-800 my-4 p-2 overflow-auto"><code${ lang } >${ code } </code></pre>` ;
1098+ } ;
1099+
1100+ renderer . link = ( href , title , text ) => {
1101+ return `<a href="${ href } " class="text-primary-400 hover:text-primary-600" target="_blank">${ text } </a>` ;
1102+ } ;
1103+
1104+ renderer . heading = ( text , level ) => {
1105+ const classes = level === 1 ? 'text-2xl font-bold mb-6' : 'text-xl font-semibold mb-4 mt-8' ;
1106+ return `<h${ level } class="${ classes } ">${ text } </h${ level } >` ;
1107+ } ;
1108+
1109+ renderer . list = ( body , ordered ) => {
1110+ const tag = ordered ? 'ol' : 'ul' ;
1111+ const classes = ordered ? 'list-decimal pl-8 space-y-1' : 'list-disc pl-8 space-y-1' ;
1112+ return `<${ tag } class="${ classes } ">${ body } </${ tag } >` ;
1113+ } ;
1114+
1115+ renderer . listitem = ( text ) => {
1116+ return `<li class="text-gray-800">${ text } </li>` ;
1117+ } ;
1118+
1119+ renderer . paragraph = ( text ) => {
1120+ return `<p class="mt-1 mb-2 text-gray-800">${ text } </p>` ;
1121+ } ;
1122+
1123+ return renderer ;
1124+ } ,
1125+
1126+ updateContent ( ) {
1127+ const start = performance . now ( ) ;
1128+ const newContent = this . el . dataset . streamingContent || '' ;
1129+
1130+ if ( newContent !== this . lastContent ) {
1131+ this . parseCount ++ ;
1132+
1133+ // Re-parse entire content as markdown
1134+ // This handles split ticks because we always parse the full accumulated string
1135+ const htmlContent = marked . parse ( newContent , {
1136+ renderer : this . renderer ,
1137+ breaks : true ,
1138+ gfm : true ,
1139+ } ) ;
1140+
1141+ this . el . innerHTML = htmlContent ;
1142+ this . lastContent = newContent ;
1143+
1144+ const duration = performance . now ( ) - start ;
1145+ console . debug (
1146+ `[StreamingText] Parse #${ this . parseCount } : ${ duration . toFixed ( 2 ) } ms for ${ newContent . length } chars`
1147+ ) ;
1148+ }
1149+ } ,
1150+ } as PhoenixHook < {
1151+ lastContent : string ;
1152+ renderer : marked . Renderer ;
1153+ parseCount : number ;
1154+ pendingUpdate ?: number ;
1155+ createCustomRenderer : ( ) => marked . Renderer ;
1156+ updateContent : ( ) => void ;
1157+ } > ;
0 commit comments