55 * Displays scrollable log lines with OpenTUI scrollbox.
66 */
77
8- import { Show , For } from "solid-js"
8+ import type { ScrollBoxRenderable } from "@opentui/core"
9+ import { Show , For , createSignal , createEffect , onCleanup , on } from "solid-js"
910import { useTheme } from "@tui/shared/context/theme"
1011import { LogLine } from "../../shared/log-line"
1112import { LogTable } from "../../shared/log-table"
1213import { groupLinesWithTables } from "../../shared/markdown-table"
14+ import { debug } from "../../../../../../../shared/logging/logger.js"
1315
1416export interface LogContentProps {
1517 lines : string [ ]
@@ -18,10 +20,88 @@ export interface LogContentProps {
1820 error : string | null
1921 visibleHeight : number
2022 isRunning ?: boolean
23+ totalLineCount ?: number
24+ hasMoreAbove ?: boolean
25+ isLoadingEarlier ?: boolean
26+ loadEarlierError ?: string | null
27+ onLoadMore ?: ( ) => number
28+ onPauseTrimmingChange ?: ( paused : boolean ) => void
2129}
2230
2331export function LogContent ( props : LogContentProps ) {
32+ debug ( '[LogContent] Component rendering, lines=%d, isLoading=%s, hasMoreAbove=%s' ,
33+ props . lines . length , props . isLoading , props . hasMoreAbove )
34+
2435 const themeCtx = useTheme ( )
36+ const [ scrollRef , setScrollRef ] = createSignal < ScrollBoxRenderable | undefined > ( )
37+ const [ userScrolledAway , setUserScrolledAway ] = createSignal ( false )
38+
39+ // Reset userScrolledAway when lines reset (indicates agent/log change)
40+ // Use on() to explicitly track only lines.length, preventing unintended effect re-runs
41+ createEffect (
42+ on (
43+ ( ) => props . lines . length ,
44+ ( currentCount , prev ) => {
45+ // If lines dropped significantly (more than 50% or to near zero), it's a new log
46+ if ( prev !== undefined && prev > 10 && currentCount < prev * 0.5 ) {
47+ debug ( '[LogContent] Lines reset detected (prev=%d, current=%d), resetting scroll state' , prev , currentCount )
48+ setUserScrolledAway ( false )
49+ }
50+ }
51+ )
52+ )
53+
54+ // Handle scroll events: load earlier lines + track if user scrolled away from bottom
55+ // Use on() to only track scrollRef changes, not other props
56+ createEffect (
57+ on ( scrollRef , ( ref ) => {
58+ debug ( '[LogContent] Effect running, ref exists: %s' , ! ! ref )
59+ if ( ! ref ) return
60+
61+ const handleScrollChange = ( ) => {
62+ const scrollTop = ref . scrollTop
63+ const scrollHeight = ref . scrollHeight
64+ const viewportHeight = ref . height
65+ const maxScroll = Math . max ( 0 , scrollHeight - viewportHeight )
66+ const isAtBottom = scrollTop >= maxScroll - 3
67+
68+ debug ( '[LogContent] scroll: top=%d, max=%d, atBottom=%s, hasMore=%s' , scrollTop , maxScroll , isAtBottom , props . hasMoreAbove )
69+
70+ // Track if user scrolled away from bottom (to disable stickyScroll)
71+ if ( ! isAtBottom && ! userScrolledAway ( ) ) {
72+ debug ( '[LogContent] User scrolled away from bottom' )
73+ setUserScrolledAway ( true )
74+ } else if ( isAtBottom && userScrolledAway ( ) ) {
75+ debug ( '[LogContent] User returned to bottom' )
76+ setUserScrolledAway ( false )
77+ }
78+
79+ // Trigger load when near the top (within 3 lines) - skip if already loading
80+ if ( scrollTop <= 3 && props . hasMoreAbove && props . onLoadMore && ! props . isLoadingEarlier ) {
81+ debug ( '[LogContent] Loading earlier lines...' )
82+ const linesLoaded = props . onLoadMore ( )
83+ debug ( '[LogContent] Lines loaded: %d' , linesLoaded )
84+ if ( linesLoaded > 0 ) {
85+ ref . scrollTop = linesLoaded // Maintain view position
86+ }
87+ }
88+ }
89+
90+ debug ( '[LogContent] Setting up scroll listener, verticalScrollBar exists: %s' , ! ! ref . verticalScrollBar )
91+ ref . verticalScrollBar ?. on ( "change" , handleScrollChange )
92+ onCleanup ( ( ) => ref . verticalScrollBar ?. off ( "change" , handleScrollChange ) )
93+ } )
94+ )
95+
96+ // Compute whether stickyScroll should be active (only when running AND user hasn't scrolled away)
97+ const shouldStickyScroll = ( ) => ( props . isRunning ?? true ) && ! userScrolledAway ( )
98+
99+ // Notify parent when user scrolls away (to pause trimming in log stream)
100+ createEffect (
101+ on ( userScrolledAway , ( scrolledAway ) => {
102+ props . onPauseTrimmingChange ?.( scrolledAway )
103+ } )
104+ )
25105
26106 return (
27107 < box flexGrow = { 1 } flexDirection = "column" paddingLeft = { 1 } paddingRight = { 1 } paddingTop = { 1 } >
@@ -52,10 +132,19 @@ export function LogContent(props: LogContentProps) {
52132 </ box >
53133 }
54134 >
135+ { /* Loading earlier lines indicator */ }
136+ < Show when = { props . isLoadingEarlier } >
137+ < text fg = { themeCtx . theme . info } > ↑ Loading earlier lines...</ text >
138+ </ Show >
139+ { /* Error loading earlier lines */ }
140+ < Show when = { props . loadEarlierError } >
141+ < text fg = { themeCtx . theme . error } > ↑ Error: { props . loadEarlierError } </ text >
142+ </ Show >
55143 < scrollbox
144+ ref = { ( r : ScrollBoxRenderable ) => setScrollRef ( r ) }
56145 height = { props . visibleHeight }
57146 width = "100%"
58- stickyScroll = { props . isRunning ?? true }
147+ stickyScroll = { shouldStickyScroll ( ) }
59148 stickyStart = "bottom"
60149 scrollbarOptions = { {
61150 showArrows : true ,
0 commit comments