diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e4cb9b..1c0aa668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,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..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 { @@ -169,84 +168,88 @@ struct AIChatPanelView: View { // MARK: - Message List private var messageList: some View { - ZStack(alignment: .bottom) { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(viewModel.messages) { message in - if message.role != .system { - // Extra spacing before user messages to separate conversation turns - if message.role == .user, - let msgIndex = viewModel.messages.firstIndex(where: { $0.id == message.id }), - msgIndex > 0, - viewModel.messages[msgIndex - 1].role == .assistant - { - Spacer() - .frame(height: 16) + let spacedMessageIDs: Set = { + var ids = Set() + let visible = viewModel.messages.filter { $0.role != .system } + 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) - } 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) - } } } @@ -327,10 +330,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) } }