From 106041d5f410f90266fc2719b66f614eec2df887 Mon Sep 17 00:00:00 2001 From: byteoxo Date: Mon, 20 Jul 2026 21:54:16 +0800 Subject: [PATCH 1/2] feat(ai-chat): render Markdown while assistant replies stream --- CHANGELOG.md | 4 + .../Views/AIChat/AIChatCodeBlockView.swift | 29 ++- TablePro/Views/AIChat/AIChatMessageView.swift | 12 +- TablePro/Views/AIChat/MarkdownView.swift | 102 ++++++-- .../AIChat/MarkdownBlockParserTests.swift | 218 ++++++++++++++++++ 5 files changed, 335 insertions(+), 30 deletions(-) create mode 100644 TableProTests/Views/AIChat/MarkdownBlockParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfc64703..546cf2157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SSH tunnels can authenticate with no password or key, for hosts that handle SSH auth themselves like Tailscale SSH. Pick **None** as the SSH auth method. (#1907) +### Changed + +- AI Chat renders Markdown while the assistant reply is still streaming, including open fenced code blocks, instead of waiting until the reply finishes. + ## [0.58.0] - 2026-07-18 ### Added diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 1e1d059d1..8ddb2a3d4 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -11,9 +11,12 @@ import SwiftUI struct AIChatCodeBlockView: View, Equatable { let code: String let language: String? + var prefersLightweightRendering: Bool = false static func == (lhs: AIChatCodeBlockView, rhs: AIChatCodeBlockView) -> Bool { - lhs.code == rhs.code && lhs.language == rhs.language + lhs.code == rhs.code + && lhs.language == rhs.language + && lhs.prefersLightweightRendering == rhs.prefersLightweightRendering } @State private var isCopied: Bool = false @@ -26,6 +29,10 @@ struct AIChatCodeBlockView: View, Equatable { focusedActions ?? commandRegistry.current } + private var usesLightweightContent: Bool { + prefersLightweightRendering || !isEditorReady + } + var body: some View { GroupBox { codeContent @@ -33,7 +40,11 @@ struct AIChatCodeBlockView: View, Equatable { codeBlockHeader } .groupBoxStyle(CodeBlockGroupBoxStyle()) - .task { + .task(id: prefersLightweightRendering) { + guard !prefersLightweightRendering else { + isEditorReady = false + return + } isEditorReady = true } .onDisappear { @@ -92,7 +103,16 @@ struct AIChatCodeBlockView: View, Equatable { @ViewBuilder private var codeContent: some View { - if isEditorReady { + if usesLightweightContent { + Text(code.isEmpty ? " " : code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .frame(minHeight: 32, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) + } else { SourceEditor( .constant(code), language: treeSitterLanguage, @@ -100,9 +120,6 @@ struct AIChatCodeBlockView: View, Equatable { state: $editorState ) .frame(height: editorHeight) - } else { - Color(nsColor: .textBackgroundColor) - .frame(height: editorHeight) } } diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 4707cebd1..bd7a792c3 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -165,16 +165,8 @@ private struct AIChatBlockView: View { var body: some View { switch block.kind { case .text(let text): - if block.isStreaming { - Text(text) - .font(.body) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - } else { - MarkdownView(source: text) - .padding(.horizontal, 8) - } + MarkdownView(source: text, isStreaming: block.isStreaming) + .padding(.horizontal, 8) case .toolUse(let useBlock): AIChatToolUseBlockView(block: useBlock) case .toolResult(let resultBlock): diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index f702266ba..d0662fa3e 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -4,6 +4,8 @@ // // Block-level markdown renderer backed by Foundation's AttributedString(markdown:) // for inline formatting and native SwiftUI views for block layout. +// Supports progressive (streaming) sources with open fenced code blocks and +// throttled re-parses while the source is still growing. // import AppKit @@ -11,16 +13,59 @@ import SwiftUI struct MarkdownView: View { let source: String + var isStreaming: Bool = false + @State private var cache = MarkdownDocumentCache() + @State private var displaySource: String = "" + @State private var throttleTask: Task? var body: some View { - let blocks = cache.blocks(for: source) + let blocks = cache.blocks(for: displaySource.isEmpty ? source : displaySource) VStack(alignment: .leading, spacing: 6) { ForEach(blocks) { block in - MarkdownBlockView(block: block) + MarkdownBlockView(block: block, prefersLightweightCode: isStreaming) .equatable() } } + .onAppear { + displaySource = source + } + .onChange(of: source) { _, newValue in + scheduleDisplayUpdate(newValue) + } + .onChange(of: isStreaming) { _, streaming in + if !streaming { + flushDisplayUpdate(source) + } + } + .onDisappear { + throttleTask?.cancel() + throttleTask = nil + } + } + + private func scheduleDisplayUpdate(_ newValue: String) { + if !isStreaming { + flushDisplayUpdate(newValue) + return + } + if displaySource.isEmpty { + displaySource = newValue + return + } + throttleTask?.cancel() + throttleTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(48)) + guard !Task.isCancelled else { return } + displaySource = newValue + throttleTask = nil + } + } + + private func flushDisplayUpdate(_ newValue: String) { + throttleTask?.cancel() + throttleTask = nil + displaySource = newValue } } @@ -39,9 +84,10 @@ final class MarkdownDocumentCache { private struct MarkdownBlockView: View, Equatable { let block: MarkdownBlock + let prefersLightweightCode: Bool static func == (lhs: Self, rhs: Self) -> Bool { - lhs.block == rhs.block + lhs.block == rhs.block && lhs.prefersLightweightCode == rhs.prefersLightweightCode } var body: some View { @@ -58,13 +104,21 @@ private struct MarkdownBlockView: View, Equatable { .padding(.bottom, 2) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - case .codeBlock(let code, let language): - AIChatCodeBlockView(code: code, language: language) - .equatable() + case .codeBlock(let code, let language, _): + AIChatCodeBlockView( + code: code, + language: language, + prefersLightweightRendering: prefersLightweightCode + ) + .equatable() case .unorderedList(let items): - MarkdownListView(items: items, style: .unordered) + MarkdownListView(items: items, style: .unordered, prefersLightweightCode: prefersLightweightCode) case .orderedList(let start, let items): - MarkdownListView(items: items, style: .ordered(start: start)) + MarkdownListView( + items: items, + style: .ordered(start: start), + prefersLightweightCode: prefersLightweightCode + ) case .blockquote(let lines): MarkdownBlockquoteView(lines: lines) case .table(let headers, let alignments, let rows): @@ -94,6 +148,7 @@ private struct MarkdownBlockView: View, Equatable { private struct MarkdownListView: View { let items: [MarkdownListItem] let style: ListStyle + let prefersLightweightCode: Bool enum ListStyle: Equatable { case unordered @@ -113,7 +168,7 @@ private struct MarkdownListView: View { .frame(maxWidth: .infinity, alignment: .leading) if !item.children.isEmpty { ForEach(item.children) { child in - MarkdownBlockView(block: child) + MarkdownBlockView(block: child, prefersLightweightCode: prefersLightweightCode) .equatable() } .padding(.leading, 4) @@ -238,7 +293,7 @@ struct MarkdownBlock: Identifiable, Equatable { enum Kind: Equatable { case paragraph(String) case header(level: Int, text: String) - case codeBlock(code: String, language: String?) + case codeBlock(code: String, language: String?, isClosed: Bool) case unorderedList([MarkdownListItem]) case orderedList(start: Int, items: [MarkdownListItem]) case blockquote(String) @@ -359,21 +414,40 @@ enum MarkdownBlockParser { private static func parseFencedCodeBlock(_ lines: inout [String], index: Int) -> MarkdownBlock { let opener = lines.removeFirst() let trimmedOpener = opener.trimmingCharacters(in: .whitespaces) - let fence = trimmedOpener.hasPrefix("```") ? "```" : "~~~" + let fenceChar: Character = trimmedOpener.hasPrefix("`") ? "`" : "~" + let fenceLength = trimmedOpener.prefix(while: { $0 == fenceChar }).count let language: String? = { - let info = String(trimmedOpener.dropFirst(3)).trimmingCharacters(in: .whitespaces) + let info = String(trimmedOpener.dropFirst(fenceLength)).trimmingCharacters(in: .whitespaces) return info.isEmpty ? nil : info }() var bodyLines: [String] = [] + var isClosed = false while let line = lines.first { - if line.trimmingCharacters(in: .whitespaces).hasPrefix(fence) { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if isFencedCodeClose(trimmedLine, fenceChar: fenceChar, fenceLength: fenceLength) { lines.removeFirst() + isClosed = true break } bodyLines.append(line) lines.removeFirst() } - return MarkdownBlock(id: index, kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) + return MarkdownBlock( + id: index, + kind: .codeBlock( + code: bodyLines.joined(separator: "\n"), + language: language, + isClosed: isClosed + ) + ) + } + + private static func isFencedCodeClose(_ trimmed: String, fenceChar: Character, fenceLength: Int) -> Bool { + guard !trimmed.isEmpty else { return false } + let run = trimmed.prefix(while: { $0 == fenceChar }) + guard run.count >= fenceLength else { return false } + let rest = trimmed.dropFirst(run.count) + return rest.allSatisfy { $0.isWhitespace } } private static func parseBlockquote(_ lines: inout [String], index: Int) -> MarkdownBlock { diff --git a/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift new file mode 100644 index 000000000..d8c16b324 --- /dev/null +++ b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift @@ -0,0 +1,218 @@ +// +// MarkdownBlockParserTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("MarkdownBlockParser") +struct MarkdownBlockParserTests { + @Test("Closed fenced code block is marked closed") + func closedFence() { + let source = """ + ```sql + SELECT 1 + ``` + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "SELECT 1") + #expect(language == "sql") + #expect(isClosed == true) + } + + @Test("Unclosed fenced code block stays a code block while streaming") + func unclosedFence() { + let source = """ + ```sql + SELECT * FROM users + WHERE id = 1 + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code.contains("SELECT * FROM users")) + #expect(code.contains("WHERE id = 1")) + #expect(language == "sql") + #expect(isClosed == false) + } + + @Test("Unclosed fence with only opener yields empty open code block") + func unclosedFenceOpenerOnly() { + let blocks = MarkdownBlockParser.parse("```sql") + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code.isEmpty) + #expect(language == "sql") + #expect(isClosed == false) + } + + @Test("Tilde fences support unclosed streaming") + func unclosedTildeFence() { + let source = """ + ~~~javascript + db.users.find({}) + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "db.users.find({})") + #expect(language == "javascript") + #expect(isClosed == false) + } + + @Test("Paragraph before open fence remains a separate block") + func paragraphThenOpenFence() { + let source = """ + Here is the query: + + ```sql + SELECT 1 + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 2) + guard case .paragraph(let text) = blocks[0].kind else { + Issue.record("Expected paragraph") + return + } + #expect(text.contains("Here is the query")) + guard case .codeBlock(let code, let language, let isClosed) = blocks[1].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "SELECT 1") + #expect(language == "sql") + #expect(isClosed == false) + } + + @Test("Incomplete table header without separator stays a paragraph") + func incompleteTableAsParagraph() { + let source = "| name | age |" + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .paragraph(let text) = blocks[0].kind else { + Issue.record("Expected paragraph for incomplete table") + return + } + #expect(text == "| name | age |") + } + + @Test("Complete table still parses with alignments") + func completeTable() { + let source = """ + | name | age | + | :--- | --: | + | Ada | 36 | + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .table(let headers, let alignments, let rows) = blocks[0].kind else { + Issue.record("Expected table") + return + } + #expect(headers == ["name", "age"]) + #expect(alignments == [.left, .right]) + #expect(rows.count == 1) + #expect(rows[0] == ["Ada", "36"]) + } + + @Test("Headers and lists parse during partial stream") + func headersAndLists() { + let source = """ + ## Plan + + 1. Index the foreign key + 2. Rewrite the join + - also check nulls + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count >= 3) + guard case .header(let level, let text) = blocks[0].kind else { + Issue.record("Expected header") + return + } + #expect(level == 2) + #expect(text == "Plan") + guard case .orderedList(let start, let orderedItems) = blocks[1].kind else { + Issue.record("Expected ordered list") + return + } + #expect(start == 1) + #expect(orderedItems.count == 2) + guard case .unorderedList(let unorderedItems) = blocks[2].kind else { + Issue.record("Expected unordered list") + return + } + #expect(unorderedItems.count == 1) + } + + @Test("Closing a previously open fence marks the block closed") + func fenceClosesOnFinalBackticks() { + let open = MarkdownBlockParser.parse("```\nSELECT 1\n") + guard case .codeBlock(_, _, let openClosed) = open[0].kind else { + Issue.record("Expected open code block") + return + } + #expect(openClosed == false) + + let closed = MarkdownBlockParser.parse("```\nSELECT 1\n```") + guard case .codeBlock(let code, _, let isClosed) = closed[0].kind else { + Issue.record("Expected closed code block") + return + } + #expect(code == "SELECT 1") + #expect(isClosed == true) + } + + @Test("Longer closing fence matches CommonMark minimum length rule") + func longerClosingFence() { + let source = """ + ````sql + SELECT 1 + ```` + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code == "SELECT 1") + #expect(language == "sql") + #expect(isClosed == true) + } + + @Test("Inner shorter fence does not close a longer opener") + func innerFenceDoesNotClose() { + let source = """ + ```` + ``` + still code + ```` + """ + let blocks = MarkdownBlockParser.parse(source) + #expect(blocks.count == 1) + guard case .codeBlock(let code, _, let isClosed) = blocks[0].kind else { + Issue.record("Expected code block") + return + } + #expect(code.contains("```")) + #expect(code.contains("still code")) + #expect(isClosed == true) + } +} From 76a3d393c269680bf9820886f41df13c7e241f7d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 21 Jul 2026 14:10:03 +0700 Subject: [PATCH 2/2] refactor(ai-chat): drop redundant markdown throttle, highlight closed code blocks while streaming --- TablePro/Views/AIChat/MarkdownView.swift | 51 ++----------------- .../AIChat/MarkdownBlockParserTests.swift | 26 ++++++++++ 2 files changed, 31 insertions(+), 46 deletions(-) diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index d0662fa3e..15927b02c 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -4,8 +4,8 @@ // // Block-level markdown renderer backed by Foundation's AttributedString(markdown:) // for inline formatting and native SwiftUI views for block layout. -// Supports progressive (streaming) sources with open fenced code blocks and -// throttled re-parses while the source is still growing. +// Supports progressive (streaming) sources: an open fenced code block renders as +// lightweight code until its closing fence arrives, then switches to the editor. // import AppKit @@ -16,56 +16,15 @@ struct MarkdownView: View { var isStreaming: Bool = false @State private var cache = MarkdownDocumentCache() - @State private var displaySource: String = "" - @State private var throttleTask: Task? var body: some View { - let blocks = cache.blocks(for: displaySource.isEmpty ? source : displaySource) + let blocks = cache.blocks(for: source) VStack(alignment: .leading, spacing: 6) { ForEach(blocks) { block in MarkdownBlockView(block: block, prefersLightweightCode: isStreaming) .equatable() } } - .onAppear { - displaySource = source - } - .onChange(of: source) { _, newValue in - scheduleDisplayUpdate(newValue) - } - .onChange(of: isStreaming) { _, streaming in - if !streaming { - flushDisplayUpdate(source) - } - } - .onDisappear { - throttleTask?.cancel() - throttleTask = nil - } - } - - private func scheduleDisplayUpdate(_ newValue: String) { - if !isStreaming { - flushDisplayUpdate(newValue) - return - } - if displaySource.isEmpty { - displaySource = newValue - return - } - throttleTask?.cancel() - throttleTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(48)) - guard !Task.isCancelled else { return } - displaySource = newValue - throttleTask = nil - } - } - - private func flushDisplayUpdate(_ newValue: String) { - throttleTask?.cancel() - throttleTask = nil - displaySource = newValue } } @@ -104,11 +63,11 @@ private struct MarkdownBlockView: View, Equatable { .padding(.bottom, 2) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - case .codeBlock(let code, let language, _): + case .codeBlock(let code, let language, let isClosed): AIChatCodeBlockView( code: code, language: language, - prefersLightweightRendering: prefersLightweightCode + prefersLightweightRendering: prefersLightweightCode && !isClosed ) .equatable() case .unorderedList(let items): diff --git a/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift index d8c16b324..77689223b 100644 --- a/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift +++ b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift @@ -215,4 +215,30 @@ struct MarkdownBlockParserTests { #expect(code.contains("still code")) #expect(isClosed == true) } + + @Test("A closed code block stays closed while a later fence streams open") + func closedBlockThenOpenBlockDuringStream() { + let source = """ + ```sql + SELECT 1 + ``` + + Then run: + + ```sql + SELECT 2 + """ + let blocks = MarkdownBlockParser.parse(source) + guard case .codeBlock(_, _, let firstClosed) = blocks.first?.kind else { + Issue.record("Expected a leading code block") + return + } + #expect(firstClosed == true) + guard case .codeBlock(let lastCode, _, let lastClosed) = blocks.last?.kind else { + Issue.record("Expected a trailing code block") + return + } + #expect(lastCode == "SELECT 2") + #expect(lastClosed == false) + } }