@@ -17,7 +17,6 @@ struct AIChatPanelView: View {
1717 @Bindable var viewModel : AIChatViewModel
1818 private let settingsManager = AppSettingsManager . shared
1919 @State private var isUserScrolledUp = false
20- @State private var scrollProxy : ScrollViewProxy ?
2120 @State private var lastAutoScrollTime : Date = . distantPast
2221
2322 private var hasConfiguredProvider : Bool {
@@ -169,84 +168,88 @@ struct AIChatPanelView: View {
169168 // MARK: - Message List
170169
171170 private var messageList : some View {
172- ZStack ( alignment: . bottom) {
173- ScrollViewReader { proxy in
174- ScrollView {
175- LazyVStack ( spacing: 0 ) {
176- ForEach ( viewModel. messages) { message in
177- if message. role != . system {
178- // Extra spacing before user messages to separate conversation turns
179- if message. role == . user,
180- let msgIndex = viewModel. messages. firstIndex ( where: { $0. id == message. id } ) ,
181- msgIndex > 0 ,
182- viewModel. messages [ msgIndex - 1 ] . role == . assistant
183- {
184- Spacer ( )
185- . frame ( height: 16 )
171+ let spacedMessageIDs : Set < UUID > = {
172+ var ids = Set < UUID > ( )
173+ let visible = viewModel. messages. filter { $0. role != . system }
174+ for i in 1 ..< visible. count where visible [ i] . role == . user && visible [ i - 1 ] . role == . assistant {
175+ ids. insert ( visible [ i] . id)
176+ }
177+ return ids
178+ } ( )
179+
180+ return ScrollViewReader { proxy in
181+ ZStack ( alignment: . bottom) {
182+ ScrollView {
183+ LazyVStack ( spacing: 0 ) {
184+ ForEach ( viewModel. messages) { message in
185+ if message. role != . system {
186+ if spacedMessageIDs. contains ( message. id) {
187+ Spacer ( )
188+ . frame ( height: 16 )
189+ }
190+ AIChatMessageView (
191+ message: message,
192+ onRetry: shouldShowRetry ( for: message) ? { viewModel. retry ( ) } : nil ,
193+ onRegenerate: shouldShowRegenerate ( for: message) ? { viewModel. regenerate ( ) } : nil ,
194+ onEdit: message. role == . user && !viewModel. isStreaming
195+ ? { viewModel. editMessage ( message) } : nil
196+ )
197+ . padding ( . vertical, 4 )
198+ . id ( message. id)
186199 }
187- AIChatMessageView (
188- message: message,
189- onRetry: shouldShowRetry ( for: message) ? { viewModel. retry ( ) } : nil ,
190- onRegenerate: shouldShowRegenerate ( for: message) ? { viewModel. regenerate ( ) } : nil ,
191- onEdit: message. role == . user && !viewModel. isStreaming
192- ? { viewModel. editMessage ( message) } : nil
193- )
194- . padding ( . vertical, 4 )
195- . id ( message. id)
196200 }
197- }
198201
199- Color . clear
200- . frame ( height: 1 )
201- . id ( " bottomAnchor " )
202- . onAppear { isUserScrolledUp = false }
203- . onDisappear { isUserScrolledUp = true }
202+ Color . clear
203+ . frame ( height: 1 )
204+ . id ( " bottomAnchor " )
205+ . onAppear { isUserScrolledUp = false }
206+ . onDisappear { isUserScrolledUp = true }
207+ }
208+ . padding ( . horizontal, 8 )
209+ . padding ( . vertical, 8 )
204210 }
205- . padding ( . horizontal, 8 )
206- . padding ( . vertical, 8 )
207- }
208- . defaultScrollAnchor ( . bottom)
209- . scrollIndicators ( . hidden)
210- . onAppear {
211- scrollProxy = proxy
212- scrollToBottom ( proxy: proxy)
213- }
214- . onChange ( of: viewModel. messages. count) {
215- isUserScrolledUp = false
216- scrollToBottom ( proxy: proxy)
217- }
218- . onChange ( of: viewModel. activeConversationID) {
219- scrollToBottom ( proxy: proxy)
220- }
221- . onChange ( of: viewModel. messages. last? . content) {
222- guard !isUserScrolledUp else { return }
223- let now = Date ( )
224- guard now. timeIntervalSince ( lastAutoScrollTime) >= 0.1 else { return }
225- lastAutoScrollTime = now
226- scrollToBottom ( proxy: proxy)
227- }
228- . onChange ( of: viewModel. isStreaming) { _, newValue in
229- if !newValue, !isUserScrolledUp {
211+ . defaultScrollAnchor ( . bottom)
212+ . scrollIndicators ( . hidden)
213+ . onAppear {
230214 scrollToBottom ( proxy: proxy)
231215 }
232- }
233- }
216+ . onChange ( of: viewModel. messages. count) {
217+ isUserScrolledUp = false
218+ scrollToBottom ( proxy: proxy, animated: true )
219+ }
220+ . onChange ( of: viewModel. activeConversationID) {
221+ isUserScrolledUp = false
222+ scrollToBottom ( proxy: proxy, animated: true )
223+ }
224+ . onChange ( of: viewModel. messages. last? . content) {
225+ guard !isUserScrolledUp else { return }
226+ let now = Date ( )
227+ guard now. timeIntervalSince ( lastAutoScrollTime) >= 0.1 else { return }
228+ lastAutoScrollTime = now
229+ scrollToBottom ( proxy: proxy)
230+ }
231+ . onChange ( of: viewModel. isStreaming) { _, newValue in
232+ if !newValue, !isUserScrolledUp {
233+ scrollToBottom ( proxy: proxy, animated: true )
234+ }
235+ }
234236
235- if isUserScrolledUp, let proxy = scrollProxy {
236- Button {
237- isUserScrolledUp = false
238- scrollToBottom ( proxy: proxy)
239- } label: {
240- Image ( systemName: " arrow.down.circle.fill " )
241- . font ( . title2)
242- . symbolRenderingMode ( . hierarchical)
243- . foregroundStyle ( . secondary)
237+ if isUserScrolledUp {
238+ Button {
239+ isUserScrolledUp = false
240+ scrollToBottom ( proxy: proxy, animated: true )
241+ } label: {
242+ Image ( systemName: " arrow.down.circle.fill " )
243+ . font ( . title2)
244+ . symbolRenderingMode ( . hierarchical)
245+ . foregroundStyle ( . secondary)
246+ }
247+ . buttonStyle ( . plain)
248+ . padding ( . bottom, 8 )
249+ . transition ( . opacity)
250+ . animation ( . easeInOut( duration: 0.2 ) , value: isUserScrolledUp)
251+ }
244252 }
245- . buttonStyle ( . plain)
246- . padding ( . bottom, 8 )
247- . transition ( . opacity)
248- . animation ( . easeInOut( duration: 0.2 ) , value: isUserScrolledUp)
249- }
250253 }
251254 }
252255
@@ -327,10 +330,13 @@ struct AIChatPanelView: View {
327330
328331 // MARK: - Helpers
329332
330- private func scrollToBottom( proxy: ScrollViewProxy ) {
331- guard let lastID = viewModel. messages. last? . id else { return }
332- withAnimation ( . easeOut( duration: 0.2 ) ) {
333- proxy. scrollTo ( lastID, anchor: . bottom)
333+ private func scrollToBottom( proxy: ScrollViewProxy , animated: Bool = false ) {
334+ if animated {
335+ withAnimation ( . easeOut( duration: 0.2 ) ) {
336+ proxy. scrollTo ( " bottomAnchor " , anchor: . bottom)
337+ }
338+ } else {
339+ proxy. scrollTo ( " bottomAnchor " , anchor: . bottom)
334340 }
335341 }
336342
0 commit comments