fix(chat-ui): avoid animated initial scroll

main
Peter Steinberger 2025-12-21 12:33:41 +01:00
parent 5adec0eae0
commit 4021da524c
1 changed files with 42 additions and 26 deletions

View File

@ -10,6 +10,7 @@ public struct ClawdisChatView: View {
@State private var viewModel: ClawdisChatViewModel @State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID() @State private var scrollerBottomID = UUID()
@State private var showSessions = false @State private var showSessions = false
@State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool private let showsSessionSwitcher: Bool
private let style: Style private let style: Style
@ -67,44 +68,59 @@ public struct ClawdisChatView: View {
private var messageList: some View { private var messageList: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ZStack {
LazyVStack(spacing: Layout.messageSpacing) { ScrollView {
ForEach(self.visibleMessages) { msg in LazyVStack(spacing: Layout.messageSpacing) {
ChatMessageBubble(message: msg, style: self.style) ForEach(self.visibleMessages) { msg in
.frame( ChatMessageBubble(message: msg, style: self.style)
maxWidth: .infinity, .frame(
alignment: msg.role.lowercased() == "user" ? .trailing : .leading) maxWidth: .infinity,
} alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
}
if self.viewModel.pendingRunCount > 0 { if self.viewModel.pendingRunCount > 0 {
ChatTypingIndicatorBubble(style: self.style) ChatTypingIndicatorBubble(style: self.style)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if !self.viewModel.pendingToolCalls.isEmpty { if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if let text = self.viewModel.streamingAssistantText, !text.isEmpty { if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
ChatStreamingAssistantBubble(text: text) ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
Color.clear Color.clear
.frame(height: 1) .frame(height: 1)
.id(self.scrollerBottomID) .id(self.scrollerBottomID)
}
.padding(.top, Layout.messageListPaddingTop)
.padding(.bottom, Layout.messageListPaddingBottom)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
} }
.padding(.top, Layout.messageListPaddingTop)
.padding(.bottom, Layout.messageListPaddingBottom) if self.viewModel.isLoading {
.padding(.horizontal, Layout.messageListPaddingHorizontal) ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.onChange(of: self.viewModel.isLoading) { _, isLoading in
guard !isLoading, !self.hasPerformedInitialScroll else { return }
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
self.hasPerformedInitialScroll = true
} }
.onChange(of: self.viewModel.messages.count) { _, _ in .onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) { withAnimation(.snappy(duration: 0.22)) {
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
} }
} }
.onChange(of: self.viewModel.pendingRunCount) { _, _ in .onChange(of: self.viewModel.pendingRunCount) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) { withAnimation(.snappy(duration: 0.22)) {
proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) proxy.scrollTo(self.scrollerBottomID, anchor: .bottom)
} }