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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- AI Chat: scrolling no longer pegs CPU at 100% or shows blank chat after a long response. Markdown is parsed once per message and cached, code blocks reuse their editors instead of being recreated on every re-render, and scroll position uses the native `.scrollPosition` API. (#1239)
- AI Chat: starting a new conversation now resets the Copilot server-side conversation. Previously the next message reused the prior conversation's context.
- Cassandra: connection now fails fast with a clear "Cassandra 2.x is not supported" message instead of cryptic "table not found" errors during sidebar load.
- MongoDB: dropped the `nameOnly: true` flag on `listDatabases` for servers older than 3.4, which previously rejected the flag.
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Views/AIChat/AIChatCodeBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import CodeEditLanguages
import CodeEditSourceEditor
import SwiftUI

struct AIChatCodeBlockView: View {
struct AIChatCodeBlockView: View, Equatable {
let code: String
let language: String?

static func == (lhs: AIChatCodeBlockView, rhs: AIChatCodeBlockView) -> Bool {
lhs.code == rhs.code && lhs.language == rhs.language
}

@State private var isCopied: Bool = false
@State private var isEditorReady = false
@State private var editorState = SourceEditorState()
Expand Down
126 changes: 59 additions & 67 deletions TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ struct AIChatPanelView: View {

@Bindable var viewModel: AIChatViewModel
private let settingsManager = AppSettingsManager.shared
@State private var isUserScrolledUp = false
@State private var bottomVisibleMessageID: UUID?
@State private var pinnedToBottom: Bool = true
@State private var mentionState = MentionPopoverState()

private var hasConfiguredProvider: Bool {
Expand Down Expand Up @@ -106,70 +107,71 @@ struct AIChatPanelView: View {
return ids
}()

return ScrollViewReader { proxy in
ZStack(alignment: .bottom) {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(visibleMessages) { message in
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)
let lastMessageID = visibleMessages.last?.id
let isUserScrolledUp = !pinnedToBottom && bottomVisibleMessageID != nil
&& bottomVisibleMessageID != lastMessageID

return ZStack(alignment: .bottom) {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(visibleMessages) { message in
if spacedMessageIDs.contains(message.id) {
Spacer()
.frame(height: 16)
}

Color.clear
.frame(height: 1)
.id("bottomAnchor")
.onAppear { isUserScrolledUp = false }
.onDisappear { isUserScrolledUp = true }
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)
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.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)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.scrollTargetLayout()
}
.defaultScrollAnchor(.bottom)
.scrollIndicators(.hidden)
.scrollPosition(id: $bottomVisibleMessageID, anchor: .bottom)
.onChange(of: bottomVisibleMessageID) { _, newValue in
pinnedToBottom = newValue == nil || newValue == lastMessageID
}
.onChange(of: visibleMessages.count) {
if pinnedToBottom {
bottomVisibleMessageID = lastMessageID
}
.onChange(of: viewModel.isStreaming) { _, newValue in
if !newValue, !isUserScrolledUp {
scrollToBottom(proxy: proxy, animated: true)
}
}
.onChange(of: viewModel.activeConversationID) {
pinnedToBottom = true
bottomVisibleMessageID = lastMessageID
}
.onChange(of: viewModel.isStreaming) { _, newValue in
if !newValue, pinnedToBottom {
bottomVisibleMessageID = lastMessageID
}
}

if isUserScrolledUp {
Button {
isUserScrolledUp = false
scrollToBottom(proxy: proxy, animated: true)
} label: {
Image(systemName: "arrow.down.circle.fill")
.font(.title2)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.secondary)
if isUserScrolledUp {
Button {
pinnedToBottom = true
withAnimation(.easeOut(duration: 0.2)) {
bottomVisibleMessageID = lastMessageID
}
.buttonStyle(.plain)
.padding(.bottom, 8)
.transition(.opacity)
.animation(.easeInOut(duration: 0.2), value: isUserScrolledUp)
.accessibilityLabel(String(localized: "Scroll to latest message"))
} 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)
.accessibilityLabel(String(localized: "Scroll to latest message"))
}
}
}
Expand Down Expand Up @@ -488,16 +490,6 @@ struct AIChatPanelView: View {

// MARK: - Helpers

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)
}
}

private func updateContext() {
viewModel.currentQuery = currentQuery
viewModel.queryResults = queryResults
Expand Down
Loading
Loading