Skip to content

Commit eedc914

Browse files
authored
fix: resolve AI Chat crash and hang during streaming on macOS 15.x (#860)
* fix: resolve AI Chat crash and hang during streaming on macOS 15.x * fix: move scroll button inside ScrollViewReader, skip system messages in spacing
1 parent 4fc34fd commit eedc914

4 files changed

Lines changed: 83 additions & 77 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525

2626
### Fixed
2727

28+
- Crash when scrolling AI Chat during streaming on macOS 15.x
2829
- Connection failure on PostgreSQL-compatible databases (e.g., Aurora DSQL) that don't support `SET statement_timeout`
2930
- Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete
3031
- Alert dialogs use sheet attachment instead of bare modal

TablePro/ViewModels/AIChatViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ final class AIChatViewModel {
428428
// Batch tokens off the main actor, flush on interval
429429
var pendingContent = ""
430430
var pendingUsage: AITokenUsage?
431-
let flushInterval: ContinuousClock.Duration = .milliseconds(80)
431+
let flushInterval: ContinuousClock.Duration = .milliseconds(150)
432432
var lastFlushTime: ContinuousClock.Instant = .now
433433

434434
for try await event in stream {

TablePro/Views/AIChat/AIChatMessageView.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ struct AIChatMessageView: View {
100100
.padding(.horizontal, 8)
101101
}
102102
}
103-
.fixedSize(horizontal: false, vertical: true)
104103
}
105104

106105
private var roleHeader: some View {

TablePro/Views/AIChat/AIChatPanelView.swift

Lines changed: 81 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)