diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1edade7..887a8ee54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 539486f41..1b812a951 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -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() diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index f01b0c0c3..df6bf638d 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -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 { @@ -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")) } } } @@ -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 diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index 416be61a5..f702266ba 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -11,28 +11,47 @@ import SwiftUI struct MarkdownView: View { let source: String + @State private var cache = MarkdownDocumentCache() var body: some View { - let blocks = MarkdownBlockParser.parse(source) + let blocks = cache.blocks(for: source) VStack(alignment: .leading, spacing: 6) { ForEach(blocks) { block in MarkdownBlockView(block: block) + .equatable() } } } } -private struct MarkdownBlockView: View { +@MainActor +final class MarkdownDocumentCache { + private var lastSource: String = "\u{FFFF}" + private var lastBlocks: [MarkdownBlock] = [] + + func blocks(for source: String) -> [MarkdownBlock] { + if source == lastSource { return lastBlocks } + lastSource = source + lastBlocks = MarkdownBlockParser.parse(source) + return lastBlocks + } +} + +private struct MarkdownBlockView: View, Equatable { let block: MarkdownBlock + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.block == rhs.block + } + var body: some View { switch block.kind { case .paragraph(let text): - Text(attributed(text)) + Text(MarkdownInline.parse(text)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) case .header(let level, let text): - Text(attributed(text)) + Text(MarkdownInline.parse(text)) .font(headerFont(for: level)) .fontWeight(headerWeight(for: level)) .padding(.top, level == 1 ? 6 : 4) @@ -41,6 +60,7 @@ private struct MarkdownBlockView: View { .frame(maxWidth: .infinity, alignment: .leading) case .codeBlock(let code, let language): AIChatCodeBlockView(code: code, language: language) + .equatable() case .unorderedList(let items): MarkdownListView(items: items, style: .unordered) case .orderedList(let start, let items): @@ -67,10 +87,6 @@ private struct MarkdownBlockView: View { private func headerWeight(for level: Int) -> Font.Weight { level <= 2 ? .bold : .semibold } - - private func attributed(_ markdown: String) -> AttributedString { - MarkdownInline.parse(markdown) - } } // MARK: - List @@ -79,7 +95,7 @@ private struct MarkdownListView: View { let items: [MarkdownListItem] let style: ListStyle - enum ListStyle { + enum ListStyle: Equatable { case unordered case ordered(start: Int) } @@ -98,6 +114,7 @@ private struct MarkdownListView: View { if !item.children.isEmpty { ForEach(item.children) { child in MarkdownBlockView(block: child) + .equatable() } .padding(.leading, 4) } @@ -187,24 +204,38 @@ private struct MarkdownTableView: View { // MARK: - Inline parsing enum MarkdownInline { + private static let cache: NSCache = { + let c = NSCache() + c.countLimit = 4_000 + return c + }() + static func parse(_ source: String) -> AttributedString { + let key = source as NSString + if let cached = cache.object(forKey: key) { + return AttributedString(cached) + } let options = AttributedString.MarkdownParsingOptions( interpretedSyntax: .inlineOnlyPreservingWhitespace ) - if let attributed = try? AttributedString(markdown: source, options: options) { - return attributed + let attributed: AttributedString + if let parsed = try? AttributedString(markdown: source, options: options) { + attributed = parsed + } else { + attributed = AttributedString(source) } - return AttributedString(source) + cache.setObject(NSAttributedString(attributed), forKey: key) + return attributed } } // MARK: - Block model -struct MarkdownBlock: Identifiable { - let id = UUID() +struct MarkdownBlock: Identifiable, Equatable { + let id: Int let kind: Kind - enum Kind { + enum Kind: Equatable { case paragraph(String) case header(level: Int, text: String) case codeBlock(code: String, language: String?) @@ -216,13 +247,13 @@ struct MarkdownBlock: Identifiable { } } -struct MarkdownListItem: Identifiable { - let id = UUID() +struct MarkdownListItem: Identifiable, Equatable { + let id: Int let text: String let children: [MarkdownBlock] } -enum MarkdownTableAlignment { +enum MarkdownTableAlignment: Equatable { case left case center case right @@ -235,14 +266,14 @@ enum MarkdownBlockParser { var lines = source.components(separatedBy: "\n") var blocks: [MarkdownBlock] = [] while !lines.isEmpty { - if let parsed = parseNextBlock(&lines) { + if let parsed = parseNextBlock(&lines, index: blocks.count) { blocks.append(parsed) } } return blocks } - private static func parseNextBlock(_ lines: inout [String]) -> MarkdownBlock? { + private static func parseNextBlock(_ lines: inout [String], index: Int) -> MarkdownBlock? { guard let first = lines.first else { return nil } let trimmed = first.trimmingCharacters(in: .whitespaces) @@ -252,38 +283,38 @@ enum MarkdownBlockParser { } if isFencedCodeStart(trimmed) { - return parseFencedCodeBlock(&lines) + return parseFencedCodeBlock(&lines, index: index) } if let level = headerLevel(trimmed) { lines.removeFirst() let stripped = String(trimmed.dropFirst(level + 1)) .trimmingCharacters(in: .whitespaces) - return MarkdownBlock(kind: .header(level: level, text: stripped)) + return MarkdownBlock(id: index, kind: .header(level: level, text: stripped)) } if isThematicBreak(trimmed) { lines.removeFirst() - return MarkdownBlock(kind: .thematicBreak) + return MarkdownBlock(id: index, kind: .thematicBreak) } if trimmed.hasPrefix(">") { - return parseBlockquote(&lines) + return parseBlockquote(&lines, index: index) } if isUnorderedListMarker(trimmed) { - return parseUnorderedList(&lines) + return parseUnorderedList(&lines, index: index) } if let start = orderedListStart(trimmed) { - return parseOrderedList(&lines, startingAt: start) + return parseOrderedList(&lines, startingAt: start, index: index) } - if let table = parseTable(&lines) { + if let table = parseTable(&lines, index: index) { return table } - return parseParagraph(&lines) + return parseParagraph(&lines, index: index) } private static func headerLevel(_ trimmed: String) -> Int? { @@ -325,7 +356,7 @@ enum MarkdownBlockParser { return value } - private static func parseFencedCodeBlock(_ lines: inout [String]) -> MarkdownBlock { + private static func parseFencedCodeBlock(_ lines: inout [String], index: Int) -> MarkdownBlock { let opener = lines.removeFirst() let trimmedOpener = opener.trimmingCharacters(in: .whitespaces) let fence = trimmedOpener.hasPrefix("```") ? "```" : "~~~" @@ -342,10 +373,10 @@ enum MarkdownBlockParser { bodyLines.append(line) lines.removeFirst() } - return MarkdownBlock(kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) + return MarkdownBlock(id: index, kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) } - private static func parseBlockquote(_ lines: inout [String]) -> MarkdownBlock { + private static func parseBlockquote(_ lines: inout [String], index: Int) -> MarkdownBlock { var collected: [String] = [] while let line = lines.first { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -355,26 +386,26 @@ enum MarkdownBlockParser { collected.append(String(content)) lines.removeFirst() } - return MarkdownBlock(kind: .blockquote(collected.joined(separator: "\n"))) + return MarkdownBlock(id: index, kind: .blockquote(collected.joined(separator: "\n"))) } - private static func parseUnorderedList(_ lines: inout [String]) -> MarkdownBlock { + private static func parseUnorderedList(_ lines: inout [String], index: Int) -> MarkdownBlock { var items: [MarkdownListItem] = [] - while let item = parseListItem(&lines, ordered: false) { + while let item = parseListItem(&lines, ordered: false, itemIndex: items.count) { items.append(item) } - return MarkdownBlock(kind: .unorderedList(items)) + return MarkdownBlock(id: index, kind: .unorderedList(items)) } - private static func parseOrderedList(_ lines: inout [String], startingAt start: Int) -> MarkdownBlock { + private static func parseOrderedList(_ lines: inout [String], startingAt start: Int, index: Int) -> MarkdownBlock { var items: [MarkdownListItem] = [] - while let item = parseListItem(&lines, ordered: true) { + while let item = parseListItem(&lines, ordered: true, itemIndex: items.count) { items.append(item) } - return MarkdownBlock(kind: .orderedList(start: start, items: items)) + return MarkdownBlock(id: index, kind: .orderedList(start: start, items: items)) } - private static func parseListItem(_ lines: inout [String], ordered: Bool) -> MarkdownListItem? { + private static func parseListItem(_ lines: inout [String], ordered: Bool, itemIndex: Int) -> MarkdownListItem? { guard let first = lines.first else { return nil } let trimmed = first.trimmingCharacters(in: .whitespaces) let markerLength: Int @@ -401,10 +432,10 @@ enum MarkdownBlockParser { } break } - return MarkdownListItem(text: textLines.joined(separator: " "), children: []) + return MarkdownListItem(id: itemIndex, text: textLines.joined(separator: " "), children: []) } - private static func parseTable(_ lines: inout [String]) -> MarkdownBlock? { + private static func parseTable(_ lines: inout [String], index: Int) -> MarkdownBlock? { guard lines.count >= 2 else { return nil } let headerLine = lines[0] let separatorLine = lines[1] @@ -423,7 +454,7 @@ enum MarkdownBlockParser { rows.append(Array(padded.prefix(headers.count))) lines.removeFirst() } - return MarkdownBlock(kind: .table(headers: headers, alignments: alignments, rows: rows)) + return MarkdownBlock(id: index, kind: .table(headers: headers, alignments: alignments, rows: rows)) } private static func looksLikeTableRow(_ line: String, columnCount: Int) -> Bool { @@ -468,7 +499,7 @@ enum MarkdownBlockParser { .map { $0.trimmingCharacters(in: .whitespaces) } } - private static func parseParagraph(_ lines: inout [String]) -> MarkdownBlock { + private static func parseParagraph(_ lines: inout [String], index: Int) -> MarkdownBlock { var collected: [String] = [] while let line = lines.first { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -482,7 +513,7 @@ enum MarkdownBlockParser { collected.append(line) lines.removeFirst() } - return MarkdownBlock(kind: .paragraph(collected.joined(separator: "\n"))) + return MarkdownBlock(id: index, kind: .paragraph(collected.joined(separator: "\n"))) } }