Skip to content

Commit 479bab8

Browse files
author
hclsys
committed
feat(webchat): add streaming autoscroll toggle
1 parent 4e8d9d2 commit 479bab8

2 files changed

Lines changed: 108 additions & 31 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
495548
private struct ChatNoticeCard: View {
496549
let systemImage: String
497550
let title: String
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Testing
2+
@testable import OpenClawChatUI
3+
4+
@Suite struct ChatAutoScrollPolicyTests {
5+
@Test func streamingAutoScrollRequiresInitialBottomAndEnabledToggle() {
6+
#expect(ChatAutoScrollPolicy.shouldScrollForStreaming(
7+
hasPerformedInitialScroll: true,
8+
isPinnedToBottom: true,
9+
autoScrollDuringStreaming: true))
10+
11+
#expect(!ChatAutoScrollPolicy.shouldScrollForStreaming(
12+
hasPerformedInitialScroll: false,
13+
isPinnedToBottom: true,
14+
autoScrollDuringStreaming: true))
15+
#expect(!ChatAutoScrollPolicy.shouldScrollForStreaming(
16+
hasPerformedInitialScroll: true,
17+
isPinnedToBottom: false,
18+
autoScrollDuringStreaming: true))
19+
#expect(!ChatAutoScrollPolicy.shouldScrollForStreaming(
20+
hasPerformedInitialScroll: true,
21+
isPinnedToBottom: true,
22+
autoScrollDuringStreaming: false))
23+
}
24+
}

0 commit comments

Comments
 (0)