@@ -17,6 +17,8 @@ public struct OpenClawChatView: View {
1717 @State private var hasPerformedInitialScroll = false
1818 @State private var isPinnedToBottom = true
1919 @State private var lastUserMessageID : UUID ?
20+ @AppStorage ( " openclaw.chat.autoScrollDuringStreaming " )
21+ private var autoScrollDuringStreaming = true
2022 private let showsSessionSwitcher : Bool
2123 private let style : Style
2224 private let markdownVariant : ChatMarkdownVariant
@@ -178,7 +180,11 @@ public struct OpenClawChatView: View {
178180 }
179181 }
180182 . onChange ( of: self . viewModel. streamingAssistantText) { _, _ in
181- guard self . hasPerformedInitialScroll, self . isPinnedToBottom else { return }
183+ guard ChatAutoScrollPolicy . shouldScrollForStreaming (
184+ hasPerformedInitialScroll: self . hasPerformedInitialScroll,
185+ isPinnedToBottom: self . isPinnedToBottom,
186+ autoScrollDuringStreaming: self . autoScrollDuringStreaming)
187+ else { return }
182188 withAnimation ( . snappy( duration: 0.22 ) ) {
183189 self . scrollPosition = self . scrollerBottomID
184190 }
@@ -238,45 +244,82 @@ public struct OpenClawChatView: View {
238244
239245 @ViewBuilder
240246 private var messageListOverlay : some View {
241- if self . viewModel. isLoading {
242- EmptyView ( )
243- } else if let error = self . activeErrorText {
244- let presentation = self . errorPresentation ( for: error)
245- if self . hasVisibleMessageListContent {
246- VStack ( spacing: 0 ) {
247- ChatNoticeBanner (
247+ ZStack {
248+ if self . viewModel. isLoading {
249+ EmptyView ( )
250+ } else if let error = self . activeErrorText {
251+ let presentation = self . errorPresentation ( for: error)
252+ if self . hasVisibleMessageListContent {
253+ VStack ( spacing: 0 ) {
254+ ChatNoticeBanner (
255+ systemImage: presentation. systemImage,
256+ title: presentation. title,
257+ message: error,
258+ tint: presentation. tint,
259+ dismiss: { self . viewModel. errorText = nil } ,
260+ refresh: { self . viewModel. refresh ( ) } )
261+ Spacer ( minLength: 0 )
262+ }
263+ . padding ( . horizontal, 10 )
264+ . padding ( . top, 8 )
265+ . frame ( maxWidth: . infinity, maxHeight: . infinity, alignment: . top)
266+ } else {
267+ ChatNoticeCard (
248268 systemImage: presentation. systemImage,
249269 title: presentation. title,
250270 message: error,
251271 tint: presentation. tint,
252- dismiss: { self . viewModel. errorText = nil } ,
253- refresh: { self . viewModel. refresh ( ) } )
254- Spacer ( minLength: 0 )
272+ actionTitle: " Refresh " ,
273+ action: { self . viewModel. refresh ( ) } )
274+ . padding ( . horizontal, 24 )
275+ . frame ( maxWidth: . infinity, maxHeight: . infinity)
255276 }
256- . padding ( . horizontal, 10 )
257- . padding ( . top, 8 )
258- . frame ( maxWidth: . infinity, maxHeight: . infinity, alignment: . top)
259- } else {
277+ } else if self . showsEmptyState {
260278 ChatNoticeCard (
261- systemImage: presentation . systemImage ,
262- title: presentation . title ,
263- message: error ,
264- tint: presentation . tint ,
265- actionTitle: " Refresh " ,
266- action: { self . viewModel . refresh ( ) } )
279+ systemImage: " bubble.left.and.bubble.right.fill " ,
280+ title: self . emptyStateTitle ,
281+ message: self . emptyStateMessage ,
282+ tint: . accentColor ,
283+ actionTitle: nil ,
284+ action: nil )
267285 . padding ( . horizontal, 24 )
268286 . frame ( maxWidth: . infinity, maxHeight: . infinity)
269287 }
270- } else if self . showsEmptyState {
271- ChatNoticeCard (
272- systemImage: " bubble.left.and.bubble.right.fill " ,
273- title: self . emptyStateTitle,
274- message: self . emptyStateMessage,
275- tint: . accentColor,
276- actionTitle: nil ,
277- action: nil )
278- . padding ( . horizontal, 24 )
279- . frame ( maxWidth: . infinity, maxHeight: . infinity)
288+
289+ self . streamingAutoScrollToggle
290+ }
291+ }
292+
293+ @ViewBuilder
294+ private var streamingAutoScrollToggle : some View {
295+ if self . hasVisibleMessageListContent && !self . viewModel. isLoading {
296+ VStack {
297+ Spacer ( minLength: 0 )
298+ HStack {
299+ Spacer ( minLength: 0 )
300+ Button {
301+ self . autoScrollDuringStreaming. toggle ( )
302+ } label: {
303+ Image (
304+ systemName: self . autoScrollDuringStreaming
305+ ? " arrow.down.to.line.compact "
306+ : " pause.fill " )
307+ }
308+ . buttonStyle ( . bordered)
309+ . controlSize ( . small)
310+ . accessibilityLabel (
311+ self . autoScrollDuringStreaming
312+ ? " Disable streaming autoscroll "
313+ : " Enable streaming autoscroll " )
314+ . help (
315+ self . autoScrollDuringStreaming
316+ ? " Disable streaming autoscroll "
317+ : " Enable streaming autoscroll " )
318+ }
319+ }
320+ . padding ( . trailing, 12 )
321+ . padding ( . bottom, 12 )
322+ . frame ( maxWidth: . infinity, maxHeight: . infinity, alignment: . bottomTrailing)
280323 }
281324 }
282325
@@ -492,6 +535,16 @@ public struct OpenClawChatView: View {
492535 }
493536}
494537
538+ enum ChatAutoScrollPolicy {
539+ static func shouldScrollForStreaming(
540+ hasPerformedInitialScroll: Bool ,
541+ isPinnedToBottom: Bool ,
542+ autoScrollDuringStreaming: Bool ) -> Bool
543+ {
544+ hasPerformedInitialScroll && isPinnedToBottom && autoScrollDuringStreaming
545+ }
546+ }
547+
495548private struct ChatNoticeCard : View {
496549 let systemImage : String
497550 let title : String
0 commit comments