diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 5f9a34445..6ed66da74 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -10,6 +10,7 @@ public struct ClawdisChatView: View { @State private var viewModel: ClawdisChatViewModel @State private var scrollerBottomID = UUID() @State private var showSessions = false + @State private var hasPerformedInitialScroll = false private let showsSessionSwitcher: Bool private let style: Style @@ -67,44 +68,59 @@ public struct ClawdisChatView: View { private var messageList: some View { ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: Layout.messageSpacing) { - ForEach(self.visibleMessages) { msg in - ChatMessageBubble(message: msg, style: self.style) - .frame( - maxWidth: .infinity, - alignment: msg.role.lowercased() == "user" ? .trailing : .leading) - } + ZStack { + ScrollView { + LazyVStack(spacing: Layout.messageSpacing) { + ForEach(self.visibleMessages) { msg in + ChatMessageBubble(message: msg, style: self.style) + .frame( + maxWidth: .infinity, + alignment: msg.role.lowercased() == "user" ? .trailing : .leading) + } - if self.viewModel.pendingRunCount > 0 { - ChatTypingIndicatorBubble(style: self.style) - .frame(maxWidth: .infinity, alignment: .leading) - } + if self.viewModel.pendingRunCount > 0 { + ChatTypingIndicatorBubble(style: self.style) + .frame(maxWidth: .infinity, alignment: .leading) + } - if !self.viewModel.pendingToolCalls.isEmpty { - ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) - .frame(maxWidth: .infinity, alignment: .leading) - } + if !self.viewModel.pendingToolCalls.isEmpty { + ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) + .frame(maxWidth: .infinity, alignment: .leading) + } - if let text = self.viewModel.streamingAssistantText, !text.isEmpty { - ChatStreamingAssistantBubble(text: text) - .frame(maxWidth: .infinity, alignment: .leading) - } + if let text = self.viewModel.streamingAssistantText, !text.isEmpty { + ChatStreamingAssistantBubble(text: text) + .frame(maxWidth: .infinity, alignment: .leading) + } - Color.clear - .frame(height: 1) - .id(self.scrollerBottomID) + Color.clear + .frame(height: 1) + .id(self.scrollerBottomID) + } + .padding(.top, Layout.messageListPaddingTop) + .padding(.bottom, Layout.messageListPaddingBottom) + .padding(.horizontal, Layout.messageListPaddingHorizontal) } - .padding(.top, Layout.messageListPaddingTop) - .padding(.bottom, Layout.messageListPaddingBottom) - .padding(.horizontal, Layout.messageListPaddingHorizontal) + + if self.viewModel.isLoading { + 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 + guard self.hasPerformedInitialScroll else { return } withAnimation(.snappy(duration: 0.22)) { proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) } } .onChange(of: self.viewModel.pendingRunCount) { _, _ in + guard self.hasPerformedInitialScroll else { return } withAnimation(.snappy(duration: 0.22)) { proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) }