Skip to content

Commit a319380

Browse files
committed
feat(workflow): handle log trimming pause on user scroll away
- Added `onPauseTrimmingChange` prop to notify parent components when users scroll away. - Updated log viewer and shells to pause log trimming when users are not at the bottom. - Enhanced `updateWindowedLines` to skip trimming when pause is active.
1 parent 968280d commit a319380

6 files changed

Lines changed: 79 additions & 50 deletions

File tree

src/cli/tui/routes/workflow/components/modals/log-viewer/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function LogViewer(props: LogViewerProps) {
6565
isLoadingEarlier={logStream.isLoadingEarlier}
6666
loadEarlierError={logStream.loadEarlierError}
6767
onLoadMore={() => logStream.loadEarlierLines()}
68+
onPauseTrimmingChange={(paused) => logStream.setPauseTrimming(paused)}
6869
/>
6970
<LogFooter
7071
total={logStream.totalLineCount}

src/cli/tui/routes/workflow/components/modals/log-viewer/log-content.tsx

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface LogContentProps {
2525
isLoadingEarlier?: boolean
2626
loadEarlierError?: string | null
2727
onLoadMore?: () => number
28+
onPauseTrimmingChange?: (paused: boolean) => void
2829
}
2930

3031
export function LogContent(props: LogContentProps) {
@@ -34,63 +35,74 @@ export function LogContent(props: LogContentProps) {
3435
const themeCtx = useTheme()
3536
const [scrollRef, setScrollRef] = createSignal<ScrollBoxRenderable | undefined>()
3637
const [userScrolledAway, setUserScrolledAway] = createSignal(false)
37-
const [prevLineCount, setPrevLineCount] = createSignal(0)
3838

3939
// Reset userScrolledAway when lines reset (indicates agent/log change)
40-
createEffect(() => {
41-
const currentCount = props.lines.length
42-
const prev = prevLineCount()
43-
// If lines dropped significantly (more than 50% or to near zero), it's a new log
44-
if (prev > 10 && currentCount < prev * 0.5) {
45-
debug('[LogContent] Lines reset detected (prev=%d, current=%d), resetting scroll state', prev, currentCount)
46-
setUserScrolledAway(false)
47-
}
48-
setPrevLineCount(currentCount)
49-
})
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+
)
5053

5154
// Handle scroll events: load earlier lines + track if user scrolled away from bottom
52-
createEffect(() => {
53-
const ref = scrollRef()
54-
debug('[LogContent] Effect running, ref exists: %s', !!ref)
55-
if (!ref) return
56-
57-
const handleScrollChange = () => {
58-
const scrollTop = ref.scrollTop
59-
const scrollHeight = ref.scrollHeight
60-
const viewportHeight = ref.height
61-
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
62-
const isAtBottom = scrollTop >= maxScroll - 3
63-
64-
debug('[LogContent] scroll: top=%d, max=%d, atBottom=%s, hasMore=%s', scrollTop, maxScroll, isAtBottom, props.hasMoreAbove)
65-
66-
// Track if user scrolled away from bottom (to disable stickyScroll)
67-
if (!isAtBottom && !userScrolledAway()) {
68-
debug('[LogContent] User scrolled away from bottom')
69-
setUserScrolledAway(true)
70-
} else if (isAtBottom && userScrolledAway()) {
71-
debug('[LogContent] User returned to bottom')
72-
setUserScrolledAway(false)
73-
}
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+
}
7478

75-
// Trigger load when near the top (within 3 lines) - skip if already loading
76-
if (scrollTop <= 3 && props.hasMoreAbove && props.onLoadMore && !props.isLoadingEarlier) {
77-
debug('[LogContent] Loading earlier lines...')
78-
const linesLoaded = props.onLoadMore()
79-
debug('[LogContent] Lines loaded: %d', linesLoaded)
80-
if (linesLoaded > 0) {
81-
ref.scrollTop = linesLoaded // Maintain view position
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+
}
8287
}
8388
}
84-
}
8589

86-
debug('[LogContent] Setting up scroll listener, verticalScrollBar exists: %s', !!ref.verticalScrollBar)
87-
ref.verticalScrollBar?.on("change", handleScrollChange)
88-
onCleanup(() => ref.verticalScrollBar?.off("change", handleScrollChange))
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+
)
9095

9196
// Compute whether stickyScroll should be active (only when running AND user hasn't scrolled away)
9297
const shouldStickyScroll = () => (props.isRunning ?? true) && !userScrolledAway()
9398

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+
)
105+
94106
return (
95107
<box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1}>
96108
<Show when={props.isLoading}>

src/cli/tui/routes/workflow/components/output/output-window.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface OutputWindowProps {
5252
isLoadingEarlier?: boolean
5353
loadEarlierError?: string | null
5454
onLoadMore?: () => number
55+
onPauseTrimmingChange?: (paused: boolean) => void
5556
}
5657

5758
/**
@@ -124,6 +125,13 @@ export function OutputWindow(props: OutputWindowProps) {
124125
// Compute whether stickyScroll should be active
125126
const shouldStickyScroll = () => !userScrolledAway()
126127

128+
// Notify parent when user scrolls away (to pause trimming in log stream)
129+
createEffect(
130+
on(userScrolledAway, (scrolledAway) => {
131+
props.onPauseTrimmingChange?.(scrolledAway)
132+
})
133+
)
134+
127135
// Check if we have enough width to show status inline (based on output section width)
128136
const isWideLayout = createMemo(() => (props.availableWidth ?? 80) >= MIN_WIDTH_FOR_INLINE_STATUS)
129137

src/cli/tui/routes/workflow/components/shells/controller-shell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function ControllerShell(props: ControllerShellProps) {
3232
isLoadingEarlier={shell.logStream.isLoadingEarlier}
3333
loadEarlierError={shell.logStream.loadEarlierError}
3434
onLoadMore={() => shell.logStream.loadEarlierLines()}
35+
onPauseTrimmingChange={(paused) => shell.logStream.setPauseTrimming(paused)}
3536
inputState={shell.state().inputState}
3637
workflowStatus={shell.state().workflowStatus}
3738
isPromptBoxFocused={shell.isPromptBoxFocused()}

src/cli/tui/routes/workflow/components/shells/executing-shell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function ExecutingShell(props: ExecutingShellProps) {
6565
isLoadingEarlier={shell.logStream.isLoadingEarlier}
6666
loadEarlierError={shell.logStream.loadEarlierError}
6767
onLoadMore={() => shell.logStream.loadEarlierLines()}
68+
onPauseTrimmingChange={(paused) => shell.logStream.setPauseTrimming(paused)}
6869
inputState={inputStateForOutput()}
6970
workflowStatus={shell.state().workflowStatus}
7071
isPromptBoxFocused={shell.isPromptBoxFocused()}

src/cli/tui/routes/workflow/hooks/useLogStream.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface LogStreamResult {
5555
isLoadingEarlier: boolean
5656
loadEarlierError: string | null
5757
loadEarlierLines: () => number
58+
setPauseTrimming: (pause: boolean) => void
5859
}
5960

6061
/**
@@ -222,15 +223,17 @@ interface WindowedLinesState {
222223

223224
/**
224225
* Update windowed lines with new data, trimming from front if needed
226+
* @param skipTrim - If true, don't trim lines (used when user has scrolled away)
225227
*/
226228
function updateWindowedLines(
227229
current: WindowedLinesState,
228230
newLines: string[],
229231
totalLines: number,
230232
maxWindow: number,
231-
wasReset: boolean
233+
wasReset: boolean,
234+
skipTrim = false
232235
): WindowedLinesState {
233-
// If file was reset, start fresh
236+
// If file was reset, start fresh (always trim on reset)
234237
if (wasReset) {
235238
const trimCount = Math.max(0, newLines.length - maxWindow)
236239
return {
@@ -243,8 +246,8 @@ function updateWindowedLines(
243246
// Append new lines
244247
const allLines = [...current.lines, ...newLines]
245248

246-
// Trim from the front if exceeding window
247-
if (allLines.length > maxWindow) {
249+
// Trim from the front if exceeding window (unless skipTrim is true)
250+
if (!skipTrim && allLines.length > maxWindow) {
248251
const trimCount = allLines.length - maxWindow
249252
return {
250253
lines: allLines.slice(trimCount),
@@ -327,6 +330,7 @@ export function useLogStream(
327330
const [currentLogPath, setCurrentLogPath] = createSignal<string>("")
328331
const [isLoadingEarlier, setIsLoadingEarlier] = createSignal(false)
329332
const [loadEarlierError, setLoadEarlierError] = createSignal<string | null>(null)
333+
const [pauseTrimming, setPauseTrimming] = createSignal(false)
330334

331335
/**
332336
* Load earlier lines when user scrolls to top of windowed view
@@ -522,7 +526,8 @@ export function useLogStream(
522526
filteredNewLines,
523527
current.totalLineCount + filteredNewLines.length,
524528
maxWindow,
525-
false
529+
false,
530+
pauseTrimming() // Skip trimming when user has scrolled away
526531
)
527532
setWindowedState(updated)
528533

@@ -762,5 +767,6 @@ export function useLogStream(
762767
return loadEarlierError()
763768
},
764769
loadEarlierLines,
770+
setPauseTrimming,
765771
}
766772
}

0 commit comments

Comments
 (0)