diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 621b9fbc5..82f348817 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -9,6 +9,7 @@ public struct ClawdisChatView: View { @State private var viewModel: ClawdisChatViewModel @State private var scrollerBottomID = UUID() + @State private var scrollPosition: UUID? @State private var showSessions = false @State private var hasPerformedInitialScroll = false private let showsSessionSwitcher: Bool @@ -59,6 +60,7 @@ public struct ClawdisChatView: View { .padding(.horizontal, Layout.outerPaddingHorizontal) .padding(.vertical, Layout.outerPaddingVertical) .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity, alignment: .top) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { self.viewModel.load() } @@ -72,68 +74,78 @@ public struct ClawdisChatView: View { } private var messageList: some View { - ScrollViewReader { proxy in - ZStack { - ScrollView { - LazyVStack(spacing: Layout.messageSpacing) { - ForEach(self.visibleMessages) { msg in - ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent) - .frame( - maxWidth: .infinity, - alignment: msg.role.lowercased() == "user" ? .trailing : .leading) - } - - if self.viewModel.pendingRunCount > 0 { - HStack { - ChatTypingIndicatorBubble(style: self.style) - .equatable() - Spacer(minLength: 0) - } - } - - if !self.viewModel.pendingToolCalls.isEmpty { - ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) - .equatable() - .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: Layout.messageListPaddingBottom + 1) - .id(self.scrollerBottomID) - } - .padding(.top, Layout.messageListPaddingTop) - .padding(.horizontal, Layout.messageListPaddingHorizontal) - } - - if self.viewModel.isLoading { - ProgressView() - .controlSize(.large) - .frame(maxWidth: .infinity, maxHeight: .infinity) + ZStack { + ScrollView { + LazyVStack(spacing: Layout.messageSpacing) { + messageListRows } + // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. + .scrollTargetLayout() + .padding(.top, Layout.messageListPaddingTop) + .padding(.horizontal, Layout.messageListPaddingHorizontal) } - .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) - } + // Keep the scroll pinned to the bottom for new messages. + .scrollPosition(id: self.$scrollPosition, anchor: .bottom) + + if self.viewModel.isLoading { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } + // Ensure the message list claims vertical space on the first layout pass. + .frame(maxHeight: .infinity, alignment: .top) + .layoutPriority(1) + .onChange(of: self.viewModel.isLoading) { _, isLoading in + guard !isLoading, !self.hasPerformedInitialScroll else { return } + self.scrollPosition = self.scrollerBottomID + self.hasPerformedInitialScroll = true + } + .onChange(of: self.viewModel.messages.count) { _, _ in + guard self.hasPerformedInitialScroll else { return } + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + .onChange(of: self.viewModel.pendingRunCount) { _, _ in + guard self.hasPerformedInitialScroll else { return } + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + } + + @ViewBuilder + private var messageListRows: some View { + ForEach(self.visibleMessages) { msg in + ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent) + .frame( + maxWidth: .infinity, + alignment: msg.role.lowercased() == "user" ? .trailing : .leading) + } + + if self.viewModel.pendingRunCount > 0 { + HStack { + ChatTypingIndicatorBubble(style: self.style) + .equatable() + Spacer(minLength: 0) + } + } + + if !self.viewModel.pendingToolCalls.isEmpty { + ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) + .equatable() + .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: Layout.messageListPaddingBottom + 1) + .id(self.scrollerBottomID) } private var visibleMessages: [ClawdisChatMessage] {