Skip to content

Commit 947dde4

Browse files
authored
fix(ai-chat): cache markdown parse, equatable code blocks, native scrollPosition (#1239) (#1244)
* fix(plugin-postgresql): rename versioned capabilities to avoid PluginCapabilities collision * fix(ai-chat): cache markdown parse, equatable code blocks, native scrollPosition (#1239) --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent f315fdd commit 947dde4

4 files changed

Lines changed: 140 additions & 112 deletions

File tree

CHANGELOG.md

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

1010
### Fixed
1111

12+
- 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)
1213
- AI Chat: starting a new conversation now resets the Copilot server-side conversation. Previously the next message reused the prior conversation's context.
1314
- 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.
1415
- MongoDB: dropped the `nameOnly: true` flag on `listDatabases` for servers older than 3.4, which previously rejected the flag.

TablePro/Views/AIChat/AIChatCodeBlockView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import CodeEditLanguages
88
import CodeEditSourceEditor
99
import SwiftUI
1010

11-
struct AIChatCodeBlockView: View {
11+
struct AIChatCodeBlockView: View, Equatable {
1212
let code: String
1313
let language: String?
1414

15+
static func == (lhs: AIChatCodeBlockView, rhs: AIChatCodeBlockView) -> Bool {
16+
lhs.code == rhs.code && lhs.language == rhs.language
17+
}
18+
1519
@State private var isCopied: Bool = false
1620
@State private var isEditorReady = false
1721
@State private var editorState = SourceEditorState()

TablePro/Views/AIChat/AIChatPanelView.swift

Lines changed: 59 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ struct AIChatPanelView: View {
1717

1818
@Bindable var viewModel: AIChatViewModel
1919
private let settingsManager = AppSettingsManager.shared
20-
@State private var isUserScrolledUp = false
20+
@State private var bottomVisibleMessageID: UUID?
21+
@State private var pinnedToBottom: Bool = true
2122
@State private var mentionState = MentionPopoverState()
2223

2324
private var hasConfiguredProvider: Bool {
@@ -106,70 +107,71 @@ struct AIChatPanelView: View {
106107
return ids
107108
}()
108109

109-
return ScrollViewReader { proxy in
110-
ZStack(alignment: .bottom) {
111-
ScrollView {
112-
LazyVStack(spacing: 0) {
113-
ForEach(visibleMessages) { message in
114-
if spacedMessageIDs.contains(message.id) {
115-
Spacer()
116-
.frame(height: 16)
117-
}
118-
AIChatMessageView(
119-
message: message,
120-
onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil,
121-
onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil,
122-
onEdit: message.role == .user && !viewModel.isStreaming
123-
? { viewModel.editMessage(message) } : nil
124-
)
125-
.padding(.vertical, 4)
126-
.id(message.id)
110+
let lastMessageID = visibleMessages.last?.id
111+
let isUserScrolledUp = !pinnedToBottom && bottomVisibleMessageID != nil
112+
&& bottomVisibleMessageID != lastMessageID
113+
114+
return ZStack(alignment: .bottom) {
115+
ScrollView {
116+
LazyVStack(spacing: 0) {
117+
ForEach(visibleMessages) { message in
118+
if spacedMessageIDs.contains(message.id) {
119+
Spacer()
120+
.frame(height: 16)
127121
}
128-
129-
Color.clear
130-
.frame(height: 1)
131-
.id("bottomAnchor")
132-
.onAppear { isUserScrolledUp = false }
133-
.onDisappear { isUserScrolledUp = true }
122+
AIChatMessageView(
123+
message: message,
124+
onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil,
125+
onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil,
126+
onEdit: message.role == .user && !viewModel.isStreaming
127+
? { viewModel.editMessage(message) } : nil
128+
)
129+
.padding(.vertical, 4)
130+
.id(message.id)
134131
}
135-
.padding(.horizontal, 8)
136-
.padding(.vertical, 8)
137-
}
138-
.defaultScrollAnchor(.bottom)
139-
.scrollIndicators(.hidden)
140-
.onAppear {
141-
scrollToBottom(proxy: proxy)
142132
}
143-
.onChange(of: viewModel.messages.count) {
144-
isUserScrolledUp = false
145-
scrollToBottom(proxy: proxy, animated: true)
146-
}
147-
.onChange(of: viewModel.activeConversationID) {
148-
isUserScrolledUp = false
149-
scrollToBottom(proxy: proxy, animated: true)
133+
.padding(.horizontal, 8)
134+
.padding(.vertical, 8)
135+
.scrollTargetLayout()
136+
}
137+
.defaultScrollAnchor(.bottom)
138+
.scrollIndicators(.hidden)
139+
.scrollPosition(id: $bottomVisibleMessageID, anchor: .bottom)
140+
.onChange(of: bottomVisibleMessageID) { _, newValue in
141+
pinnedToBottom = newValue == nil || newValue == lastMessageID
142+
}
143+
.onChange(of: visibleMessages.count) {
144+
if pinnedToBottom {
145+
bottomVisibleMessageID = lastMessageID
150146
}
151-
.onChange(of: viewModel.isStreaming) { _, newValue in
152-
if !newValue, !isUserScrolledUp {
153-
scrollToBottom(proxy: proxy, animated: true)
154-
}
147+
}
148+
.onChange(of: viewModel.activeConversationID) {
149+
pinnedToBottom = true
150+
bottomVisibleMessageID = lastMessageID
151+
}
152+
.onChange(of: viewModel.isStreaming) { _, newValue in
153+
if !newValue, pinnedToBottom {
154+
bottomVisibleMessageID = lastMessageID
155155
}
156+
}
156157

157-
if isUserScrolledUp {
158-
Button {
159-
isUserScrolledUp = false
160-
scrollToBottom(proxy: proxy, animated: true)
161-
} label: {
162-
Image(systemName: "arrow.down.circle.fill")
163-
.font(.title2)
164-
.symbolRenderingMode(.hierarchical)
165-
.foregroundStyle(.secondary)
158+
if isUserScrolledUp {
159+
Button {
160+
pinnedToBottom = true
161+
withAnimation(.easeOut(duration: 0.2)) {
162+
bottomVisibleMessageID = lastMessageID
166163
}
167-
.buttonStyle(.plain)
168-
.padding(.bottom, 8)
169-
.transition(.opacity)
170-
.animation(.easeInOut(duration: 0.2), value: isUserScrolledUp)
171-
.accessibilityLabel(String(localized: "Scroll to latest message"))
164+
} label: {
165+
Image(systemName: "arrow.down.circle.fill")
166+
.font(.title2)
167+
.symbolRenderingMode(.hierarchical)
168+
.foregroundStyle(.secondary)
172169
}
170+
.buttonStyle(.plain)
171+
.padding(.bottom, 8)
172+
.transition(.opacity)
173+
.animation(.easeInOut(duration: 0.2), value: isUserScrolledUp)
174+
.accessibilityLabel(String(localized: "Scroll to latest message"))
173175
}
174176
}
175177
}
@@ -488,16 +490,6 @@ struct AIChatPanelView: View {
488490

489491
// MARK: - Helpers
490492

491-
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = false) {
492-
if animated {
493-
withAnimation(.easeOut(duration: 0.2)) {
494-
proxy.scrollTo("bottomAnchor", anchor: .bottom)
495-
}
496-
} else {
497-
proxy.scrollTo("bottomAnchor", anchor: .bottom)
498-
}
499-
}
500-
501493
private func updateContext() {
502494
viewModel.currentQuery = currentQuery
503495
viewModel.queryResults = queryResults

0 commit comments

Comments
 (0)