From 8027047d4a1d4201eb24253184da3afd98d2e1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:10:24 +0700 Subject: [PATCH 1/2] fix: resolve AI Chat crash and hang during streaming on macOS 15.x --- CHANGELOG.md | 1 + TablePro/ViewModels/AIChatViewModel.swift | 2 +- TablePro/Views/AIChat/AIChatMessageView.swift | 1 - TablePro/Views/AIChat/AIChatPanelView.swift | 38 +++++++++++-------- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df8a35aa..41c75280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Crash when scrolling AI Chat during streaming on macOS 15.x - Connection failure on PostgreSQL-compatible databases (e.g., Aurora DSQL) that don't support `SET statement_timeout` - Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete - Alert dialogs use sheet attachment instead of bare modal diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 565db096..0d0f9ece 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -428,7 +428,7 @@ final class AIChatViewModel { // Batch tokens off the main actor, flush on interval var pendingContent = "" var pendingUsage: AITokenUsage? - let flushInterval: ContinuousClock.Duration = .milliseconds(80) + let flushInterval: ContinuousClock.Duration = .milliseconds(150) var lastFlushTime: ContinuousClock.Instant = .now for try await event in stream { diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 7adcdfeb..1519affe 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -100,7 +100,6 @@ struct AIChatMessageView: View { .padding(.horizontal, 8) } } - .fixedSize(horizontal: false, vertical: true) } private var roleHeader: some View { diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 79c7a4a2..2054f418 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -169,18 +169,22 @@ struct AIChatPanelView: View { // MARK: - Message List private var messageList: some View { - ZStack(alignment: .bottom) { + let spacedMessageIDs: Set = { + var ids = Set() + let msgs = viewModel.messages + for i in 1.. 0, - viewModel.messages[msgIndex - 1].role == .assistant - { + if spacedMessageIDs.contains(message.id) { Spacer() .frame(height: 16) } @@ -213,10 +217,11 @@ struct AIChatPanelView: View { } .onChange(of: viewModel.messages.count) { isUserScrolledUp = false - scrollToBottom(proxy: proxy) + scrollToBottom(proxy: proxy, animated: true) } .onChange(of: viewModel.activeConversationID) { - scrollToBottom(proxy: proxy) + isUserScrolledUp = false + scrollToBottom(proxy: proxy, animated: true) } .onChange(of: viewModel.messages.last?.content) { guard !isUserScrolledUp else { return } @@ -227,7 +232,7 @@ struct AIChatPanelView: View { } .onChange(of: viewModel.isStreaming) { _, newValue in if !newValue, !isUserScrolledUp { - scrollToBottom(proxy: proxy) + scrollToBottom(proxy: proxy, animated: true) } } } @@ -235,7 +240,7 @@ struct AIChatPanelView: View { if isUserScrolledUp, let proxy = scrollProxy { Button { isUserScrolledUp = false - scrollToBottom(proxy: proxy) + scrollToBottom(proxy: proxy, animated: true) } label: { Image(systemName: "arrow.down.circle.fill") .font(.title2) @@ -327,10 +332,13 @@ struct AIChatPanelView: View { // MARK: - Helpers - private func scrollToBottom(proxy: ScrollViewProxy) { - guard let lastID = viewModel.messages.last?.id else { return } - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(lastID, anchor: .bottom) + private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = false) { + if animated { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo("bottomAnchor", anchor: .bottom) + } + } else { + proxy.scrollTo("bottomAnchor", anchor: .bottom) } } From 58dcf83dbbb796760ff598b631d3e5134b89294f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 15:16:44 +0700 Subject: [PATCH 2/2] fix: move scroll button inside ScrollViewReader, skip system messages in spacing --- TablePro/Views/AIChat/AIChatPanelView.swift | 138 ++++++++++---------- 1 file changed, 68 insertions(+), 70 deletions(-) diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 2054f418..210a8743 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -17,7 +17,6 @@ struct AIChatPanelView: View { @Bindable var viewModel: AIChatViewModel private let settingsManager = AppSettingsManager.shared @State private var isUserScrolledUp = false - @State private var scrollProxy: ScrollViewProxy? @State private var lastAutoScrollTime: Date = .distantPast private var hasConfiguredProvider: Bool { @@ -171,87 +170,86 @@ struct AIChatPanelView: View { private var messageList: some View { let spacedMessageIDs: Set = { var ids = Set() - let msgs = viewModel.messages - for i in 1..= 0.1 else { return } - lastAutoScrollTime = now - scrollToBottom(proxy: proxy) - } - .onChange(of: viewModel.isStreaming) { _, newValue in - if !newValue, !isUserScrolledUp { + .defaultScrollAnchor(.bottom) + .scrollIndicators(.hidden) + .onAppear { + scrollToBottom(proxy: proxy) + } + .onChange(of: viewModel.messages.count) { + isUserScrolledUp = false scrollToBottom(proxy: proxy, animated: true) } - } - } + .onChange(of: viewModel.activeConversationID) { + isUserScrolledUp = false + scrollToBottom(proxy: proxy, animated: true) + } + .onChange(of: viewModel.messages.last?.content) { + guard !isUserScrolledUp else { return } + let now = Date() + guard now.timeIntervalSince(lastAutoScrollTime) >= 0.1 else { return } + lastAutoScrollTime = now + scrollToBottom(proxy: proxy) + } + .onChange(of: viewModel.isStreaming) { _, newValue in + if !newValue, !isUserScrolledUp { + scrollToBottom(proxy: proxy, animated: true) + } + } - if isUserScrolledUp, let proxy = scrollProxy { - Button { - isUserScrolledUp = false - scrollToBottom(proxy: proxy, animated: true) - } label: { - Image(systemName: "arrow.down.circle.fill") - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) + if isUserScrolledUp { + Button { + isUserScrolledUp = false + scrollToBottom(proxy: proxy, animated: true) + } label: { + Image(systemName: "arrow.down.circle.fill") + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.bottom, 8) + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: isUserScrolledUp) + } } - .buttonStyle(.plain) - .padding(.bottom, 8) - .transition(.opacity) - .animation(.easeInOut(duration: 0.2), value: isUserScrolledUp) - } } }