@@ -85,6 +85,7 @@ interface Props {
8585 className ?: string ;
8686 consoleOutputs : WithResponse < OutputMessage > [ ] ;
8787 stale : boolean ;
88+ running ?: boolean ;
8889 debuggerActive : boolean ;
8990 onRefactorWithAI ?: OnRefactorWithAI ;
9091 onClear ?: ( ) => void ;
@@ -101,6 +102,8 @@ export const ConsoleOutput = (props: Props) => {
101102
102103const ConsoleOutputInternal = ( props : Props ) : React . ReactNode => {
103104 const ref = React . useRef < HTMLDivElement > ( null ) ;
105+ const shouldFollowOutputRef = useRef ( true ) ;
106+ const prevRenderedOutputCountRef = useRef ( 0 ) ;
104107 const { wrapText, setWrapText } = useWrapText ( ) ;
105108 const [ isExpanded , setIsExpanded ] = useExpandedConsoleOutput ( props . cellId ) ;
106109 const [ stdinValue , setStdinValue ] = React . useState ( "" ) ;
@@ -111,6 +114,7 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
111114 const {
112115 consoleOutputs : rawConsoleOutputs ,
113116 stale,
117+ running = false ,
114118 cellName,
115119 cellId,
116120 onSubmitDebugger,
@@ -141,27 +145,37 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
141145 // Detect overflow on resize
142146 const isOverflowing = useOverflowDetection ( ref , hasOutputs ) ;
143147
144- // Keep scroll at the bottom if it is within 120px of the bottom,
145- // so when we add new content, it will lock to the bottom
146- //
147- // We use flex flex-col-reverse to handle this, but it doesn't
148- // always work perfectly when moved form the bottom and back.
148+ const isNearBottom = ( el : HTMLDivElement ) => {
149+ const threshold = 24 ;
150+ const scrollOffset = el . scrollHeight - el . clientHeight ;
151+ const distanceFromBottom = scrollOffset - el . scrollTop ;
152+ return distanceFromBottom <= threshold ;
153+ } ;
154+
155+ // Follow newly appended console output while the cell is actively running,
156+ // but stop following when the user scrolls away from the bottom.
149157 useLayoutEffect ( ( ) => {
150158 const el = ref . current ;
151159 if ( ! el ) {
152160 return ;
153161 }
154- // N.B. This won't handle large jumps in the scroll position
155- // if there is a lot of content added at once.
156- // This is 'good enough' for now.
157- const threshold = 120 ;
158162
159- const scrollOffset = el . scrollHeight - el . clientHeight ;
160- const distanceFromBottom = scrollOffset - el . scrollTop ;
161- if ( distanceFromBottom < threshold ) {
162- el . scrollTop = scrollOffset ;
163+ if ( ! hasOutputs ) {
164+ shouldFollowOutputRef . current = true ;
165+ prevRenderedOutputCountRef . current = 0 ;
166+ return ;
163167 }
164- } ) ;
168+
169+ const appendedOutput =
170+ consoleOutputs . length > prevRenderedOutputCountRef . current ;
171+ prevRenderedOutputCountRef . current = consoleOutputs . length ;
172+
173+ if ( ! running || ! appendedOutput || ! shouldFollowOutputRef . current ) {
174+ return ;
175+ }
176+
177+ el . scrollTop = el . scrollHeight - el . clientHeight ;
178+ } , [ consoleOutputs , hasOutputs , running ] ) ;
165179
166180 if ( ! hasOutputs && isInternalCellName ( cellName ) ) {
167181 return null ;
@@ -236,6 +250,9 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
236250 { ...selectAllProps }
237251 // biome-ignore lint/a11y/noNoninteractiveTabindex: Needed to capture keypress events
238252 tabIndex = { 0 }
253+ onScroll = { ( e ) => {
254+ shouldFollowOutputRef . current = isNearBottom ( e . currentTarget ) ;
255+ } }
239256 className = { cn (
240257 "console-output-area overflow-hidden rounded-b-lg flex flex-col-reverse w-full gap-1 focus:outline-hidden" ,
241258 stale && "marimo-output-stale" ,
0 commit comments