Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion TablePro/Views/AIChat/AIChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ struct AIChatMessageView: View {
.padding(.horizontal, 8)
}
}
.fixedSize(horizontal: false, vertical: true)
}

private var roleHeader: some View {
Expand Down
156 changes: 81 additions & 75 deletions TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<UUID> = {
var ids = Set<UUID>()
let visible = viewModel.messages.filter { $0.role != .system }
for i in 1..<visible.count where visible[i].role == .user && visible[i - 1].role == .assistant {
ids.insert(visible[i].id)
}
return ids
}()

return ScrollViewReader { proxy in
ZStack(alignment: .bottom) {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(viewModel.messages) { message in
if message.role != .system {
if spacedMessageIDs.contains(message.id) {
Spacer()
.frame(height: 16)
}
AIChatMessageView(
message: message,
onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil,
onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil,
onEdit: message.role == .user && !viewModel.isStreaming
? { viewModel.editMessage(message) } : nil
)
.padding(.vertical, 4)
.id(message.id)
}
AIChatMessageView(
message: message,
onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil,
onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil,
onEdit: message.role == .user && !viewModel.isStreaming
? { viewModel.editMessage(message) } : nil
)
.padding(.vertical, 4)
.id(message.id)
}
}

Color.clear
.frame(height: 1)
.id("bottomAnchor")
.onAppear { isUserScrolledUp = false }
.onDisappear { isUserScrolledUp = true }
Color.clear
.frame(height: 1)
.id("bottomAnchor")
.onAppear { isUserScrolledUp = false }
.onDisappear { isUserScrolledUp = true }
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.defaultScrollAnchor(.bottom)
.scrollIndicators(.hidden)
.onAppear {
scrollProxy = proxy
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.messages.count) {
isUserScrolledUp = false
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.activeConversationID) {
scrollToBottom(proxy: proxy)
}
.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 {
.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)
}
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading