diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a2ffa40..e70d3c251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- AI Chat: Copilot Agent mode now persists across follow-up turns. The chat-mode flag was only set on `conversationCreate`, so the first message in a conversation ran with tools but the second and later messages reverted to plain chat (the model would say "I don't have access to query execution tools"). `CopilotConversationTurnParams` now carries `chatMode`, `customChatModeId`, and `needToolCallConfirmation`, and `streamChat` sends them on every turn +- iOS: data browser no longer flashes the "No Data" empty state mid-reload when the search field is cleared, filters change, sort changes, or page size changes. `DataBrowserViewModel.load()` now sets `isPageLoading` when rows already exist (so the in-list overlay shows during the reload), and the view renders a progress spinner instead of the empty state while a reload is in flight (reported via TestFlight, iPhone 17 Pro / iOS 26.4.2) +- AI Chat: streaming responses no longer hang the process at 100% CPU, and Agent-mode assistant turns that emit text before a tool call now show that text above the tool card (it previously appeared below). The previous design rebuilt the assistant turn's text block on every 150 ms flush, recomputed `plainText` from blocks on every observer tick, and re-fed the result to `Markdown(text)` keyed by `\.offset`, making MarkdownUI reparse the full message every flush. The fix is structural: an assistant turn is now a single ordered list of `ChatContentBlock` reference instances (`@Observable` classes with stable UUIDs), tokens flow directly into the trailing streaming block, and tool blocks are appended after a `finishStreamingTextBlock()` call so text emitted before any tool call lands first. Per-property Observation scopes re-renders to the one streaming text view; siblings are not invalidated. Markdown rendering moved off the third-party `swift-markdown-ui` dependency to a native renderer built on Foundation's `AttributedString(markdown:)` with SwiftUI block layout (paragraphs, headers, lists, blockquotes, fenced code, GFM tables). Schema prefetch and turn resolution run on `Task.detached` so the main thread stays free between flushes. Providers and persistence now operate on a separate `ChatTurnWire` value type, so live message instances never cross actor boundaries (#1205) - MySQL/MariaDB: `BINARY(N)` and `VARBINARY(N)` columns are no longer classified as text. The plugin's type switch returned `"CHAR"` for type code 254 and `"VARCHAR"` for type code 253 without consulting the binary flag, so binary columns came through as `.text` instead of `.blob`. The data grid then allowed inline edit on those cells, the editor opened with an empty field (because `PluginCellValue.bytes(_).asText` returns `nil`), and focus-out marked the cell as modified-to-empty. A single accidental click was enough to schedule destruction of the original bytes on Save. Case 253 now returns `"VARBINARY"` and case 254 returns `"BINARY"` when the binary flag is set, so these cells route through the blob editor (chevron-triggered) instead of inline text edit - Data grid: dropdown / boolean / date / JSON / blob cells now show or hide their chevron accessory when the cell's editability changes (refresh, safe-mode toggle, view-to-table switch). The performance pass in #1212 made `configure` skip `needsDisplay = true` when nothing visible changed, but the editability flag was updated unconditionally without flagging a redraw, so chevrons could stick in their stale state (visible in `audit_log.payload` JSON cells after save / refresh / reopen) - MySQL/MariaDB: JSON column detection no longer flickers across refreshes. The plugin reads `mariadb_field_attr(MARIADB_FIELD_ATTR_FORMAT_NAME)` to recognize JSON-stored-as-LONGTEXT. The returned `MARIADB_CONST_STRING` is a length-prefixed buffer (not null-terminated), but we were reading it with `String(cString:)`, which scans bytes until the next `\0` and so reads past the buffer into adjacent memory. When that memory happened to contain a null byte the comparison passed and we tagged the column `JSON`; when it contained garbage the comparison failed and we fell through to `LONGTEXT`. Read exactly `attr.length` bytes via `String(data: Data(bytes:count:), encoding: .utf8)` diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index a0a905023..3459dfb51 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -54,7 +54,6 @@ 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; - 5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; }; 5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; }; 5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; }; 5ADDB00100000000000000A3 /* DynamoDBPartiQLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A3 /* DynamoDBPartiQLParser.swift */; }; @@ -674,7 +673,6 @@ 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */, 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */, 5ACE00012F4F00000000000A /* Sparkle in Frameworks */, - 5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1049,7 +1047,6 @@ 5ACE00012F4F000000000003 /* CodeEditLanguages */, 5ACE00012F4F000000000007 /* CodeEditTextView */, 5ACE00012F4F000000000009 /* Sparkle */, - 5ACE00012F4F00000000000C /* MarkdownUI */, 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */, 5A3A69B92F976F38000AC5B2 /* GhosttyTheme */, 5ACE00012F4F000000000010 /* TableProAnalytics */, @@ -1640,7 +1637,6 @@ packageReferences = ( 5A0000012F4F000000000101 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditSourceEditor" */, 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */, - 5ACE00012F4F00000000000B /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, 5A3A69B62F976F38000AC5B2 /* XCRemoteSwiftPackageReference "libghostty-spm" */, @@ -4054,14 +4050,6 @@ minimumVersion = 2.0.0; }; }; - 5ACE00012F4F00000000000B /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TableProApp/oracle-nio"; @@ -4105,11 +4093,6 @@ package = 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - 5ACE00012F4F00000000000C /* MarkdownUI */ = { - isa = XCSwiftPackageProductDependency; - package = 5ACE00012F4F00000000000B /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; - productName = MarkdownUI; - }; 5ACE00012F4F00000000000F /* OracleNIO */ = { isa = XCSwiftPackageProductDependency; package = 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */; diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d001c8ce9..fad9d505d 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ccbbba919cff7f0502bbda9aa6e6649b4d27cfcc4abba225f2b659610d6c0108", + "originHash" : "e49cd26950b2b9a687427c0c4359980403a2242976e9d9b5daed883f22e23a11", "pins" : [ { "identity" : "codeeditsymbols", @@ -28,15 +28,6 @@ "version" : "2.1.0" } }, - { - "identity" : "networkimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", - "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, { "identity" : "oracle-nio", "kind" : "remoteSourceControl", @@ -99,15 +90,6 @@ "version" : "1.19.0" } }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", - "state" : { - "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", - "version" : "0.7.1" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -144,15 +126,6 @@ "version" : "1.12.0" } }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swift-markdown-ui", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } - }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index 6d87297a9..6b9274e35 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -24,7 +24,7 @@ final class AnthropicProvider: ChatTransport { } func streamChat( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -111,7 +111,7 @@ final class AnthropicProvider: ChatTransport { func testConnection() async throws -> Bool { let testModel = model.isEmpty ? (Self.knownModels.first ?? "") : model - let testTurn = ChatTurn(role: .user, blocks: [.text("Hi")]) + let testTurn = ChatTurnWire(role: .user, blocks: [.text("Hi")]) let testOptions = ChatTransportOptions(model: testModel, maxOutputTokens: 1) let request = try buildMessagesRequest(turns: [testTurn], options: testOptions, stream: false) @@ -136,7 +136,7 @@ final class AnthropicProvider: ChatTransport { } private func buildMessagesRequest( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions, stream: Bool = true ) throws -> URLRequest { @@ -256,10 +256,10 @@ final class AnthropicProvider: ChatTransport { ] } - static func encodeTurn(_ turn: ChatTurn) throws -> [String: Any]? { + static func encodeTurn(_ turn: ChatTurnWire) throws -> [String: Any]? { let blocks = turn.blocks let needsTypedBlocks = blocks.contains { block in - switch block { + switch block.kind { case .toolUse, .toolResult: return true case .text, .attachment: @@ -278,8 +278,8 @@ final class AnthropicProvider: ChatTransport { return ["role": turn.role.rawValue, "content": text] } - static func encodeBlock(_ block: ChatContentBlock) throws -> [String: Any]? { - switch block { + static func encodeBlock(_ block: ChatContentBlockWire) throws -> [String: Any]? { + switch block.kind { case .text(let text): guard !text.isEmpty else { return nil } return ["type": "text", "text": text] diff --git a/TablePro/Core/AI/Chat/ChatTransport.swift b/TablePro/Core/AI/Chat/ChatTransport.swift index e7e6f8322..df4a944ba 100644 --- a/TablePro/Core/AI/Chat/ChatTransport.swift +++ b/TablePro/Core/AI/Chat/ChatTransport.swift @@ -7,7 +7,7 @@ import Foundation protocol ChatTransport: AnyObject, Sendable { func streamChat( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) -> AsyncThrowingStream diff --git a/TablePro/Core/AI/Chat/ChatTurn.swift b/TablePro/Core/AI/Chat/ChatTurn.swift index 00ca24bce..2b8150291 100644 --- a/TablePro/Core/AI/Chat/ChatTurn.swift +++ b/TablePro/Core/AI/Chat/ChatTurn.swift @@ -4,6 +4,7 @@ // import Foundation +import Observation enum ChatRole: String, Codable, Sendable { case user @@ -11,9 +12,65 @@ enum ChatRole: String, Codable, Sendable { case system } -struct ChatTurn: Codable, Equatable, Identifiable, Sendable { +enum ChatContentBlockKind: Sendable, Equatable { + case text(String) + case toolUse(ToolUseBlock) + case toolResult(ToolResultBlock) + case attachment(ContextItem) +} + +@MainActor @Observable +final class ChatContentBlock: Identifiable { + let id: UUID + var kind: ChatContentBlockKind + var isStreaming: Bool + + init(id: UUID = UUID(), kind: ChatContentBlockKind, isStreaming: Bool = false) { + self.id = id + self.kind = kind + self.isStreaming = isStreaming + } + + func appendText(_ chunk: String) { + guard !chunk.isEmpty, case .text(let existing) = kind else { return } + kind = .text(existing + chunk) + } + + func setKind(_ newKind: ChatContentBlockKind) { + kind = newKind + } + + func finishStreaming() { + isStreaming = false + } + + var wireSnapshot: ChatContentBlockWire { + ChatContentBlockWire(id: id, kind: kind) + } +} + +extension ChatContentBlock { + static func text(_ text: String, isStreaming: Bool = false) -> ChatContentBlock { + ChatContentBlock(kind: .text(text), isStreaming: isStreaming) + } + + static func toolUse(_ block: ToolUseBlock) -> ChatContentBlock { + ChatContentBlock(kind: .toolUse(block)) + } + + static func toolResult(_ block: ToolResultBlock) -> ChatContentBlock { + ChatContentBlock(kind: .toolResult(block)) + } + + static func attachment(_ item: ContextItem) -> ChatContentBlock { + ChatContentBlock(kind: .attachment(item)) + } +} + +@MainActor +struct ChatTurn: Identifiable { let id: UUID - var role: ChatRole + let role: ChatRole var blocks: [ChatContentBlock] let timestamp: Date var usage: AITokenUsage? @@ -31,107 +88,239 @@ struct ChatTurn: Codable, Equatable, Identifiable, Sendable { ) { self.id = id self.role = role - self.blocks = blocks + self.blocks = Self.coalesceAdjacentText(blocks) self.timestamp = timestamp self.usage = usage self.modelId = modelId self.providerId = providerId } - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - role = try container.decode(ChatRole.self, forKey: .role) - timestamp = try container.decode(Date.self, forKey: .timestamp) - usage = try container.decodeIfPresent(AITokenUsage.self, forKey: .usage) - modelId = try container.decodeIfPresent(String.self, forKey: .modelId) - providerId = try container.decodeIfPresent(String.self, forKey: .providerId) + init(wire: ChatTurnWire) { + self.id = wire.id + self.role = wire.role + self.blocks = wire.blocks.map { ChatContentBlock(id: $0.id, kind: $0.kind) } + self.timestamp = wire.timestamp + self.usage = wire.usage + self.modelId = wire.modelId + self.providerId = wire.providerId + } - if let decodedBlocks = try container.decodeIfPresent([ChatContentBlock].self, forKey: .blocks) { - blocks = decodedBlocks - } else { - let legacyContainer = try decoder.container(keyedBy: LegacyKeys.self) - if let legacyText = try legacyContainer.decodeIfPresent(String.self, forKey: .content) { - blocks = [.text(legacyText)] - } else { - blocks = [] + var plainText: String { + var result = "" + for block in blocks { + if case .text(let text) = block.kind { + result.append(text) } } + return result } - private enum CodingKeys: String, CodingKey { - case id, role, blocks, timestamp, usage, modelId, providerId + var wireSnapshot: ChatTurnWire { + ChatTurnWire( + id: id, + role: role, + blocks: blocks.map { $0.wireSnapshot }, + timestamp: timestamp, + usage: usage, + modelId: modelId, + providerId: providerId + ) } - private enum LegacyKeys: String, CodingKey { - case content + mutating func appendStreamingToken(_ chunk: String) { + guard !chunk.isEmpty else { return } + if let last = blocks.last, case .text = last.kind, last.isStreaming { + last.appendText(chunk) + } else { + blocks.append(ChatContentBlock(kind: .text(chunk), isStreaming: true)) + } } - var plainText: String { - blocks.compactMap { block in - if case .text(let text) = block { return text } - return nil - }.joined() + mutating func finishStreamingTextBlock() { + if let last = blocks.last, case .text = last.kind, last.isStreaming { + last.finishStreaming() + } } - mutating func appendText(_ text: String) { - guard !text.isEmpty else { return } - if case .text(let existing) = blocks.last { - blocks[blocks.count - 1] = .text(existing + text) - } else { - blocks.append(.text(text)) + mutating func appendBlock(_ block: ChatContentBlock) { + finishStreamingTextBlock() + blocks.append(block) + } + + private static func coalesceAdjacentText(_ blocks: [ChatContentBlock]) -> [ChatContentBlock] { + var result: [ChatContentBlock] = [] + result.reserveCapacity(blocks.count) + for block in blocks { + if case .text(let text) = block.kind, + let last = result.last, + case .text(let existing) = last.kind, + !last.isStreaming, !block.isStreaming { + last.kind = .text(existing + text) + } else { + result.append(block) + } } + return result } } -enum ChatContentBlock: Codable, Equatable, Sendable { - case text(String) - case toolUse(ToolUseBlock) - case toolResult(ToolResultBlock) - case attachment(ContextItem) +extension ChatTurn: Equatable { + nonisolated static func == (lhs: ChatTurn, rhs: ChatTurn) -> Bool { + MainActor.assumeIsolated { + lhs.wireSnapshot == rhs.wireSnapshot + } + } +} + +struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { + let id: UUID + let kind: ChatContentBlockKind + + init(id: UUID = UUID(), kind: ChatContentBlockKind) { + self.id = id + self.kind = kind + } + + static func text(_ text: String) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .text(text)) + } + + static func toolUse(_ block: ToolUseBlock) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .toolUse(block)) + } + + static func toolResult(_ block: ToolResultBlock) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .toolResult(block)) + } + + static func attachment(_ item: ContextItem) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .attachment(item)) + } private enum CodingKeys: String, CodingKey { - case kind, text, toolUse, toolResult, attachment + case blockId, kind, text, toolUse, toolResult, attachment } - private enum Kind: String, Codable { + private enum KindMarker: String, Codable { case text, toolUse, toolResult, attachment } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let kind = try container.decode(Kind.self, forKey: .kind) - switch kind { + let resolvedID = (try container.decodeIfPresent(UUID.self, forKey: .blockId)) ?? UUID() + let marker = try container.decode(KindMarker.self, forKey: .kind) + let resolvedKind: ChatContentBlockKind + switch marker { case .text: - self = .text(try container.decode(String.self, forKey: .text)) + resolvedKind = .text(try container.decode(String.self, forKey: .text)) case .toolUse: - self = .toolUse(try container.decode(ToolUseBlock.self, forKey: .toolUse)) + resolvedKind = .toolUse(try container.decode(ToolUseBlock.self, forKey: .toolUse)) case .toolResult: - self = .toolResult(try container.decode(ToolResultBlock.self, forKey: .toolResult)) + resolvedKind = .toolResult(try container.decode(ToolResultBlock.self, forKey: .toolResult)) case .attachment: - self = .attachment(try container.decode(ContextItem.self, forKey: .attachment)) + resolvedKind = .attachment(try container.decode(ContextItem.self, forKey: .attachment)) } + self.init(id: resolvedID, kind: resolvedKind) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - switch self { + try container.encode(id, forKey: .blockId) + switch kind { case .text(let text): - try container.encode(Kind.text, forKey: .kind) + try container.encode(KindMarker.text, forKey: .kind) try container.encode(text, forKey: .text) case .toolUse(let block): - try container.encode(Kind.toolUse, forKey: .kind) + try container.encode(KindMarker.toolUse, forKey: .kind) try container.encode(block, forKey: .toolUse) case .toolResult(let block): - try container.encode(Kind.toolResult, forKey: .kind) + try container.encode(KindMarker.toolResult, forKey: .kind) try container.encode(block, forKey: .toolResult) case .attachment(let item): - try container.encode(Kind.attachment, forKey: .kind) + try container.encode(KindMarker.attachment, forKey: .kind) try container.encode(item, forKey: .attachment) } } } +struct ChatTurnWire: Codable, Equatable, Sendable, Identifiable { + let id: UUID + let role: ChatRole + var blocks: [ChatContentBlockWire] + let timestamp: Date + var usage: AITokenUsage? + var modelId: String? + var providerId: String? + + init( + id: UUID = UUID(), + role: ChatRole, + blocks: [ChatContentBlockWire], + timestamp: Date = Date(), + usage: AITokenUsage? = nil, + modelId: String? = nil, + providerId: String? = nil + ) { + self.id = id + self.role = role + self.blocks = blocks + self.timestamp = timestamp + self.usage = usage + self.modelId = modelId + self.providerId = providerId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + role = try container.decode(ChatRole.self, forKey: .role) + timestamp = try container.decode(Date.self, forKey: .timestamp) + usage = try container.decodeIfPresent(AITokenUsage.self, forKey: .usage) + modelId = try container.decodeIfPresent(String.self, forKey: .modelId) + providerId = try container.decodeIfPresent(String.self, forKey: .providerId) + + if let decodedBlocks = try container.decodeIfPresent([ChatContentBlockWire].self, forKey: .blocks) { + blocks = decodedBlocks + } else { + let legacyContainer = try decoder.container(keyedBy: LegacyKeys.self) + if let legacyText = try legacyContainer.decodeIfPresent(String.self, forKey: .content) { + blocks = [ChatContentBlockWire(kind: .text(legacyText))] + } else { + blocks = [] + } + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(role, forKey: .role) + try container.encode(blocks, forKey: .blocks) + try container.encode(timestamp, forKey: .timestamp) + try container.encodeIfPresent(usage, forKey: .usage) + try container.encodeIfPresent(modelId, forKey: .modelId) + try container.encodeIfPresent(providerId, forKey: .providerId) + } + + var plainText: String { + var result = "" + for block in blocks { + if case .text(let text) = block.kind { + result.append(text) + } + } + return result + } + + private enum CodingKeys: String, CodingKey { + case id, role, blocks, timestamp, usage, modelId, providerId + } + + private enum LegacyKeys: String, CodingKey { + case content + } +} + struct ToolUseBlock: Codable, Equatable, Sendable { let id: String let name: String diff --git a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift index 9458ad8eb..08a5f170b 100644 --- a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift +++ b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift @@ -23,7 +23,7 @@ final class CopilotChatProvider: ChatTransport { ) func streamChat( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -69,6 +69,7 @@ final class CopilotChatProvider: ChatTransport { let userMessage = turns.last(where: { $0.role == .user })?.plainText ?? "" let effectiveModel: String? = options.model.isEmpty ? nil : options.model + let toolsAvailable = !options.tools.isEmpty && !self.registeredToolNames.isEmpty if self.conversationId == nil { let systemPrefix = options.systemPrompt.map { $0 + "\n\n" } ?? "" @@ -77,7 +78,6 @@ final class CopilotChatProvider: ChatTransport { response: "", turnId: "" )] - let toolsAvailable = !options.tools.isEmpty && !self.registeredToolNames.isEmpty let params = CopilotConversationCreateParams( workDoneToken: token, turns: conversationTurns, @@ -103,7 +103,10 @@ final class CopilotChatProvider: ChatTransport { message: userMessage, source: "panel", model: effectiveModel, - workspaceFolders: nil + workspaceFolders: nil, + chatMode: toolsAvailable ? "Agent" : nil, + customChatModeId: toolsAvailable ? "Agent" : nil, + needToolCallConfirmation: toolsAvailable ? false : nil ) let result = try await client.conversationTurn(params: params) self.turnIds.append(result.turnId) diff --git a/TablePro/Core/AI/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index dac07ce49..c58d6ab6e 100644 --- a/TablePro/Core/AI/GeminiProvider.swift +++ b/TablePro/Core/AI/GeminiProvider.swift @@ -22,7 +22,7 @@ final class GeminiProvider: ChatTransport { } func streamChat( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -155,7 +155,7 @@ final class GeminiProvider: ChatTransport { } private func buildStreamRequest( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) throws -> URLRequest { guard let encodedModel = options.model.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), @@ -196,7 +196,7 @@ final class GeminiProvider: ChatTransport { return request } - func encodeContents(turns: [ChatTurn]) -> [[String: Any]] { + func encodeContents(turns: [ChatTurnWire]) -> [[String: Any]] { var encoded: [[String: Any]] = [] for (index, turn) in turns.enumerated() where turn.role != .system { let priorTurns = Array(turns.prefix(index)) @@ -206,12 +206,12 @@ final class GeminiProvider: ChatTransport { return encoded } - func encodeTurn(_ turn: ChatTurn, priorTurns: [ChatTurn]) -> [String: Any]? { + func encodeTurn(_ turn: ChatTurnWire, priorTurns: [ChatTurnWire]) -> [String: Any]? { let role = turn.role == .assistant ? "model" : "user" var parts: [[String: Any]] = [] for block in turn.blocks { - switch block { + switch block.kind { case .text(let text): guard !text.isEmpty else { continue } parts.append(["text": text]) @@ -248,10 +248,10 @@ final class GeminiProvider: ChatTransport { return ["role": role, "parts": parts] } - func resolveToolName(forToolUseId id: String, in priorTurns: [ChatTurn]) -> String? { + func resolveToolName(forToolUseId id: String, in priorTurns: [ChatTurnWire]) -> String? { for turn in priorTurns.reversed() { for block in turn.blocks { - if case .toolUse(let useBlock) = block, useBlock.id == id { + if case .toolUse(let useBlock) = block.kind, useBlock.id == id { return useBlock.name } } diff --git a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift index a3315322a..0f3a23e2a 100644 --- a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift +++ b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -34,7 +34,7 @@ final class AIChatInlineSource: InlineSuggestionSource { let userMessage = AIPromptTemplates.inlineSuggest(textBefore: context.textBefore, fullQuery: context.fullText) let turns = [ - ChatTurn(role: .user, blocks: [.text(userMessage)]) + ChatTurnWire(role: .user, blocks: [.text(userMessage)]) ] let systemPrompt = await buildSystemPrompt() diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index 3ee7babdb..304a7f354 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -39,7 +39,7 @@ final class OpenAICompatibleProvider: ChatTransport { } func streamChat( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -299,7 +299,7 @@ final class OpenAICompatibleProvider: ChatTransport { } private func buildChatCompletionRequest( - turns: [ChatTurn], + turns: [ChatTurnWire], options: ChatTransportOptions ) throws -> URLRequest { let chatPath = providerType == .ollama @@ -351,13 +351,13 @@ final class OpenAICompatibleProvider: ChatTransport { return request } - func encodeTurn(_ turn: ChatTurn) -> [[String: Any]] { + func encodeTurn(_ turn: ChatTurnWire) -> [[String: Any]] { let toolUseBlocks = turn.blocks.compactMap { block -> ToolUseBlock? in - if case .toolUse(let useBlock) = block { return useBlock } + if case .toolUse(let useBlock) = block.kind { return useBlock } return nil } let toolResultBlocks = turn.blocks.compactMap { block -> ToolResultBlock? in - if case .toolResult(let resultBlock) = block { return resultBlock } + if case .toolResult(let resultBlock) = block.kind { return resultBlock } return nil } let textContent = turn.plainText diff --git a/TablePro/Core/LSP/LSPTypes.swift b/TablePro/Core/LSP/LSPTypes.swift index 3d5c7c7fe..080aff3b4 100644 --- a/TablePro/Core/LSP/LSPTypes.swift +++ b/TablePro/Core/LSP/LSPTypes.swift @@ -219,6 +219,31 @@ struct CopilotConversationTurnParams: Codable, Sendable { let source: String let model: String? let workspaceFolders: [LSPWorkspaceFolder]? + let chatMode: String? + let customChatModeId: String? + let needToolCallConfirmation: Bool? + + init( + workDoneToken: String, + conversationId: String, + message: String, + source: String, + model: String?, + workspaceFolders: [LSPWorkspaceFolder]?, + chatMode: String? = nil, + customChatModeId: String? = nil, + needToolCallConfirmation: Bool? = nil + ) { + self.workDoneToken = workDoneToken + self.conversationId = conversationId + self.message = message + self.source = source + self.model = model + self.workspaceFolders = workspaceFolders + self.chatMode = chatMode + self.customChatModeId = customChatModeId + self.needToolCallConfirmation = needToolCallConfirmation + } } struct CopilotConversationTurnResult: Codable, Sendable { diff --git a/TablePro/Models/AI/AIConversation.swift b/TablePro/Models/AI/AIConversation.swift index 2323e481c..1c2f1ebb2 100644 --- a/TablePro/Models/AI/AIConversation.swift +++ b/TablePro/Models/AI/AIConversation.swift @@ -5,12 +5,12 @@ import Foundation -struct AIConversation: Codable, Equatable, Identifiable { +struct AIConversation: Codable, Equatable, Identifiable, Sendable { static let currentSchemaVersion = 1 let id: UUID var title: String - var messages: [ChatTurn] + var messages: [ChatTurnWire] let createdAt: Date var updatedAt: Date var connectionName: String? @@ -19,7 +19,7 @@ struct AIConversation: Codable, Equatable, Identifiable { init( id: UUID = UUID(), title: String = "", - messages: [ChatTurn] = [], + messages: [ChatTurnWire] = [], createdAt: Date = Date(), updatedAt: Date = Date(), connectionName: String? = nil, @@ -38,7 +38,7 @@ struct AIConversation: Codable, Equatable, Identifiable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" - messages = try container.decodeIfPresent([ChatTurn].self, forKey: .messages) ?? [] + messages = try container.decodeIfPresent([ChatTurnWire].self, forKey: .messages) ?? [] createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) connectionName = try container.decodeIfPresent(String.self, forKey: .connectionName) diff --git a/TablePro/ViewModels/AIChatViewModel+MessageEditing.swift b/TablePro/ViewModels/AIChatViewModel+MessageEditing.swift index 88b635d65..758d1d9c4 100644 --- a/TablePro/ViewModels/AIChatViewModel+MessageEditing.swift +++ b/TablePro/ViewModels/AIChatViewModel+MessageEditing.swift @@ -12,43 +12,44 @@ extension AIChatViewModel { inputText = message.plainText attachedContext = message.blocks.compactMap { block in - if case .attachment(let item) = block { return item } + if case .attachment(let item) = block.kind { return item } return nil } messages.removeSubrange(idx...) persistCurrentConversation() } - func resolveTurnForWire(_ turn: ChatTurn) async -> ChatTurn { - let attachments = turn.blocks.compactMap { block -> ContextItem? in - if case .attachment(let item) = block { return item } + func resolveTurnForWire(_ turn: ChatTurn) async -> ChatTurnWire { + let snapshot = turn.wireSnapshot + let attachments = snapshot.blocks.compactMap { block -> ContextItem? in + if case .attachment(let item) = block.kind { return item } return nil } - guard !attachments.isEmpty else { return turn } + guard !attachments.isEmpty else { return snapshot } for item in attachments { await primeAttachmentData(for: item) } - let typed = turn.blocks.compactMap { block -> String? in - if case .text(let value) = block { return value } + let typed = snapshot.blocks.compactMap { block -> String? in + if case .text(let value) = block.kind { return value } return nil }.joined() let resolved = attachments .compactMap { resolveAttachment($0) } .joined(separator: "\n\n") - if resolved.isEmpty { return turn } + if resolved.isEmpty { return snapshot } let combined = typed.isEmpty ? resolved : typed + "\n\n---\n\n" + resolved - return ChatTurn( - id: turn.id, - role: turn.role, + return ChatTurnWire( + id: snapshot.id, + role: snapshot.role, blocks: [.text(combined)], - timestamp: turn.timestamp, - usage: turn.usage, - modelId: turn.modelId, - providerId: turn.providerId + timestamp: snapshot.timestamp, + usage: snapshot.usage, + modelId: snapshot.modelId, + providerId: snapshot.providerId ) } diff --git a/TablePro/ViewModels/AIChatViewModel+Persistence.swift b/TablePro/ViewModels/AIChatViewModel+Persistence.swift index 01eeb7b82..72b34596e 100644 --- a/TablePro/ViewModels/AIChatViewModel+Persistence.swift +++ b/TablePro/ViewModels/AIChatViewModel+Persistence.swift @@ -15,7 +15,7 @@ extension AIChatViewModel { self.conversations = loaded if let mostRecent = loaded.first { self.activeConversationID = mostRecent.id - self.messages = mostRecent.messages + self.messages = mostRecent.messages.map { ChatTurn(wire: $0) } } } } @@ -45,10 +45,11 @@ extension AIChatViewModel { func persistCurrentConversation() { guard !messages.isEmpty else { return } + let wireMessages = messages.map { $0.wireSnapshot } if let existingID = activeConversationID, var conversation = conversations.first(where: { $0.id == existingID }) { - conversation.messages = messages + conversation.messages = wireMessages conversation.updatedAt = Date() conversation.updateTitle() conversation.connectionName = connection?.name @@ -59,7 +60,7 @@ extension AIChatViewModel { } } else { var conversation = AIConversation( - messages: messages, + messages: wireMessages, connectionName: connection?.name ) conversation.updateTitle() diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 5420409eb..53e5db4c7 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -12,8 +12,8 @@ extension AIChatViewModel { struct ToolRoundtripContinuation { let nextAssistantID: UUID - let assistantTurn: ChatTurn - let userTurn: ChatTurn + let assistantTurn: ChatTurnWire + let userTurn: ChatTurnWire } private struct StreamRoundResult { @@ -70,31 +70,39 @@ extension AIChatViewModel { let assistantID = assistantMessage.id streamingState = .streaming(assistantID: assistantID) - prepTask = Task { [weak self] in + prepTask = Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } if settings.includeSchema { await self.ensureSchemaLoaded() } - guard !Task.isCancelled else { return } - let promptContext = self.capturePromptContext(settings: settings) - var chatMessages: [ChatTurn] = [] - for turn in self.messages.dropLast() { + if Task.isCancelled { return } + let priorTurns: [ChatTurn] = await MainActor.run { + Array(self.messages.dropLast()) + } + var chatMessages: [ChatTurnWire] = [] + for turn in priorTurns { + if Task.isCancelled { return } chatMessages.append(await self.resolveTurnForWire(turn)) } - guard !Task.isCancelled else { return } - self.runStream( - chatMessages: chatMessages, - promptContext: promptContext, - resolved: resolved, - assistantID: assistantID, - settings: settings - ) - self.prepTask = nil + if Task.isCancelled { return } + let promptContext: PromptContext? = await MainActor.run { + self.capturePromptContext(settings: settings) + } + await MainActor.run { + self.runStream( + chatMessages: chatMessages, + promptContext: promptContext, + resolved: resolved, + assistantID: assistantID, + settings: settings + ) + self.prepTask = nil + } } } func runStream( - chatMessages: [ChatTurn], + chatMessages: [ChatTurnWire], promptContext: PromptContext?, resolved: AIProviderFactory.ResolvedProvider, assistantID: UUID, @@ -102,6 +110,7 @@ extension AIChatViewModel { ) { let chatMode = settings.chatMode streamingTask = Task.detached(priority: .userInitiated) { [weak self] in + var currentAssistantID = assistantID do { let systemPrompt = Self.buildSystemPrompt(promptContext, mode: chatMode) guard let self else { return } @@ -114,7 +123,6 @@ extension AIChatViewModel { let toolSpecs = await MainActor.run { ChatToolRegistry.shared.allSpecs(for: chatMode) } var workingTurns = chatMessages - var currentAssistantID = assistantID for roundtrip in 0.. StreamRoundResult { @@ -236,6 +254,15 @@ extension AIChatViewModel { case .usage(let usage): pendingUsage = usage case .toolUseStart(let id, let name): + if !pendingContent.isEmpty { + await self.flushPending(content: pendingContent, usage: pendingUsage, into: assistantID) + pendingContent = "" + pendingUsage = nil + lastFlushTime = .now + } + await MainActor.run { [weak self] in + self?.finalizeStreamingMessage(id: assistantID) + } if toolUseInputs[id] == nil { toolUseOrder.append(id) toolUseInputs[id] = "" @@ -300,8 +327,9 @@ extension AIChatViewModel { self.errorMessage = String( localized: "AI made too many tool calls in one response. Try simplifying the request." ) + self.finalizeStreamingMessage(id: assistantID) if let idx = self.messages.firstIndex(where: { $0.id == assistantID }), - self.messages[idx].plainText.isEmpty { + self.messages[idx].blocks.isEmpty { self.messages.remove(at: idx) } self.streamingState = .failed(nil) @@ -315,23 +343,22 @@ extension AIChatViewModel { resolved: AIProviderFactory.ResolvedProvider ) async -> ToolRoundtripContinuation { await MainActor.run { [weak self] () -> ToolRoundtripContinuation in - let assistantText: String = { + self?.finalizeStreamingMessage(id: assistantIDForRound) + let assistantWire: ChatTurnWire = { guard let self, let idx = self.messages.firstIndex(where: { $0.id == assistantIDForRound }) - else { return "" } - return self.messages[idx].plainText + else { + return ChatTurnWire( + id: assistantIDForRound, + role: .assistant, + blocks: [], + modelId: resolved.model, + providerId: resolved.config.id.uuidString + ) + } + return self.messages[idx].wireSnapshot }() - var assistantBlocks: [ChatContentBlock] = [] - if !assistantText.isEmpty { assistantBlocks.append(.text(assistantText)) } - assistantBlocks.append(contentsOf: toolUseBlocks.map { .toolUse($0) }) - let assistantTurn = ChatTurn( - id: assistantIDForRound, - role: .assistant, - blocks: assistantBlocks, - modelId: resolved.model, - providerId: resolved.config.id.uuidString - ) - let userTurn = ChatTurn( + let userTurn = ChatTurnWire( role: .user, blocks: toolResultBlocks.map { .toolResult($0) } ) @@ -341,12 +368,13 @@ extension AIChatViewModel { modelId: resolved.model, providerId: resolved.config.id.uuidString ) - self?.messages.append(userTurn) + let nextAssistantID = nextAssistant.id + self?.messages.append(ChatTurn(wire: userTurn)) self?.messages.append(nextAssistant) - self?.streamingState = .streaming(assistantID: nextAssistant.id) + self?.streamingState = .streaming(assistantID: nextAssistantID) return ToolRoundtripContinuation( - nextAssistantID: nextAssistant.id, - assistantTurn: assistantTurn, + nextAssistantID: nextAssistantID, + assistantTurn: assistantWire, userTurn: userTurn ) } @@ -359,7 +387,7 @@ extension AIChatViewModel { let idx = self.messages.firstIndex(where: { $0.id == assistantID }) else { return } if !content.isEmpty { - self.messages[idx].appendText(content) + self.messages[idx].appendStreamingToken(content) } if let usage { self.messages[idx].usage = usage @@ -367,7 +395,7 @@ extension AIChatViewModel { } } - func preflightCheck(systemPrompt: String?, turns: [ChatTurn], assistantID: UUID) async -> Bool { + func preflightCheck(systemPrompt: String?, turns: [ChatTurnWire], assistantID: UUID) async -> Bool { let totalSize = ((systemPrompt ?? "") as NSString).length + turns.reduce(0) { $0 + ($1.plainText as NSString).length } guard totalSize > 100_000 else { return true } diff --git a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift index d824cefc7..07f48bfe0 100644 --- a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift +++ b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift @@ -91,17 +91,17 @@ extension AIChatViewModel { func appendPendingToolUseBlocks(_ blocks: [ToolUseBlock], assistantID: UUID) { guard let idx = messages.firstIndex(where: { $0.id == assistantID }) else { return } for block in blocks { - messages[idx].blocks.append(.toolUse(block)) + messages[idx].appendBlock(.toolUse(block)) } } @MainActor func updateApprovalState(blockID: String, newState: ToolApprovalState, assistantID: UUID) { guard let idx = messages.firstIndex(where: { $0.id == assistantID }) else { return } - for blockIdx in messages[idx].blocks.indices { - if case .toolUse(var block) = messages[idx].blocks[blockIdx], block.id == blockID { + for chatBlock in messages[idx].blocks { + if case .toolUse(var block) = chatBlock.kind, block.id == blockID { block.approvalState = newState - messages[idx].blocks[blockIdx] = .toolUse(block) + chatBlock.setKind(.toolUse(block)) return } } diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index d3797a954..fa03a1ddd 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -137,9 +137,11 @@ final class AIChatViewModel { ToolApprovalCenter.shared.cancelAll() if case .streaming(let assistantID) = streamingState, - let idx = messages.firstIndex(where: { $0.id == assistantID }), - messages[idx].plainText.isEmpty { - messages.remove(at: idx) + let idx = messages.firstIndex(where: { $0.id == assistantID }) { + messages[idx].finishStreamingTextBlock() + if messages[idx].blocks.isEmpty { + messages.remove(at: idx) + } } streamingState = .idle persistCurrentConversation() @@ -190,7 +192,7 @@ final class AIChatViewModel { AIProviderFactory.resetCopilotConversation() cancelStream() persistCurrentConversation() - messages = conversation.messages + messages = conversation.messages.map { ChatTurn(wire: $0) } activeConversationID = conversation.id clearError() } diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index b29d0b127..c57b626ff 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -6,10 +6,8 @@ // import AppKit -import MarkdownUI import SwiftUI -/// Displays a single AI chat message with appropriate styling struct AIChatMessageView: View { private static let userBubbleTintOpacity: Double = 0.08 @@ -20,7 +18,7 @@ struct AIChatMessageView: View { private var attachedContextItems: [ContextItem] { message.blocks.compactMap { block in - if case .attachment(let item) = block { return item } + if case .attachment(let item) = block.kind { return item } return nil } } @@ -28,7 +26,6 @@ struct AIChatMessageView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { if message.role == .user { - // User: timestamp header, then message text in tinted bubble VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Spacer() @@ -45,9 +42,7 @@ struct AIChatMessageView: View { .padding(.bottom, 2) } - Markdown(message.plainText) - .markdownTheme(.tableProChat) - .textSelection(.enabled) + MarkdownView(source: message.plainText) .frame(maxWidth: .infinity, alignment: .leading) if let onEdit { @@ -68,12 +63,10 @@ struct AIChatMessageView: View { .background(Color.accentColor.opacity(Self.userBubbleTintOpacity)) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { - // Assistant: role header above content roleHeader messageContent } - // Footer (assistant only) if message.role == .assistant { HStack(spacing: 8) { if let onRegenerate { @@ -102,7 +95,6 @@ struct AIChatMessageView: View { .padding(.horizontal, 8) } - // Retry button (error case) if let onRetry { Button { onRetry() @@ -138,117 +130,58 @@ struct AIChatMessageView: View { @ViewBuilder private var messageContent: some View { - let renderable = renderableBlocks - if renderable.isEmpty { - TypingIndicatorView() + let visibleBlocks = message.blocks.filter { block in + switch block.kind { + case .text(let text): + return !text.isEmpty || block.isStreaming + case .toolUse, .toolResult: + return true + case .attachment: + return false + } + } + if visibleBlocks.isEmpty { + ChatTypingIndicatorView() .padding(.horizontal, 8) .padding(.vertical, 6) } else { VStack(alignment: .leading, spacing: 6) { - ForEach(Array(renderable.enumerated()), id: \.offset) { _, block in - switch block { - case .text(let text): - Markdown(text) - .markdownTheme(.tableProChat) - .textSelection(.enabled) - .padding(.horizontal, 8) - case .toolUse(let useBlock): - AIChatToolUseBlockView(block: useBlock) - case .toolResult(let resultBlock): - AIChatToolResultBlockView(block: resultBlock) - case .attachment: - EmptyView() - } + ForEach(visibleBlocks) { block in + AIChatBlockView(block: block) } } .padding(.vertical, 6) } } - - private var renderableBlocks: [ChatContentBlock] { - var result: [ChatContentBlock] = [] - for block in message.blocks { - switch block { - case .text(let text): - if text.isEmpty { continue } - if case .text(let existing) = result.last { - result[result.count - 1] = .text(existing + text) - } else { - result.append(.text(text)) - } - case .toolUse, .toolResult: - result.append(block) - case .attachment: - continue - } - } - return result - } } -// MARK: - TablePro Chat Theme +private struct AIChatBlockView: View { + @Bindable var block: ChatContentBlock -extension MarkdownUI.Theme { - static let tableProChat = MarkdownUI.Theme() - .text { - FontSize(.em(1.0)) - } - .code { - FontFamilyVariant(.monospaced) - FontSize(.em(0.85)) - ForegroundColor(Color(nsColor: .controlTextColor)) - BackgroundColor(Color(nsColor: .quaternarySystemFill)) - } - .heading1 { configuration in - configuration.label - .markdownMargin(top: 12, bottom: 4) - .markdownTextStyle { - FontWeight(.bold) - FontSize(.em(1.5)) - } - } - .heading2 { configuration in - configuration.label - .markdownMargin(top: 10, bottom: 4) - .markdownTextStyle { - FontWeight(.semibold) - FontSize(.em(1.3)) - } - } - .heading3 { configuration in - configuration.label - .markdownMargin(top: 8, bottom: 4) - .markdownTextStyle { - FontWeight(.bold) - FontSize(.em(1.15)) - } - } - .blockquote { configuration in - HStack(spacing: 0) { - RoundedRectangle(cornerRadius: 4) - .fill(Color(nsColor: .tertiaryLabelColor)) - .frame(width: 3) - configuration.label - .markdownTextStyle { - ForegroundColor(.secondary) - FontSize(.em(1.0)) - } - .padding(Edge.Set.leading, 8) + 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) } - .markdownMargin(top: 4, bottom: 4) - } - .codeBlock { configuration in - AIChatCodeBlockView( - code: configuration.content, - language: configuration.language - ) + case .toolUse(let useBlock): + AIChatToolUseBlockView(block: useBlock) + case .toolResult(let resultBlock): + AIChatToolResultBlockView(block: resultBlock) + case .attachment: + EmptyView() } + } } -// MARK: - Typing Indicator - -/// Animated three-dot typing indicator -private struct TypingIndicatorView: View { +struct ChatTypingIndicatorView: View { @State private var animating = false var body: some View { @@ -267,8 +200,6 @@ private struct TypingIndicatorView: View { } } .frame(height: 16) - .padding(.horizontal, 8) - .padding(.vertical, 6) .onAppear { animating = true } } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 3d7d88fac..f01b0c0c3 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -18,7 +18,6 @@ struct AIChatPanelView: View { @Bindable var viewModel: AIChatViewModel private let settingsManager = AppSettingsManager.shared @State private var isUserScrolledUp = false - @State private var lastAutoScrollTime: Date = .distantPast @State private var mentionState = MentionPopoverState() private var hasConfiguredProvider: Bool { @@ -149,13 +148,6 @@ struct AIChatPanelView: View { isUserScrolledUp = false scrollToBottom(proxy: proxy, animated: true) } - .onChange(of: viewModel.messages.last?.plainText) { - 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) @@ -517,7 +509,7 @@ struct AIChatPanelView: View { guard message.role != .system else { return false } if message.role == .user { let hasUserContent = message.blocks.contains { block in - switch block { + switch block.kind { case .text(let value): return !value.isEmpty case .attachment: return true case .toolUse, .toolResult: return false diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift new file mode 100644 index 000000000..416be61a5 --- /dev/null +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -0,0 +1,493 @@ +// +// MarkdownView.swift +// TablePro +// +// Block-level markdown renderer backed by Foundation's AttributedString(markdown:) +// for inline formatting and native SwiftUI views for block layout. +// + +import AppKit +import SwiftUI + +struct MarkdownView: View { + let source: String + + var body: some View { + let blocks = MarkdownBlockParser.parse(source) + VStack(alignment: .leading, spacing: 6) { + ForEach(blocks) { block in + MarkdownBlockView(block: block) + } + } + } +} + +private struct MarkdownBlockView: View { + let block: MarkdownBlock + + var body: some View { + switch block.kind { + case .paragraph(let text): + Text(attributed(text)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + case .header(let level, let text): + Text(attributed(text)) + .font(headerFont(for: level)) + .fontWeight(headerWeight(for: level)) + .padding(.top, level == 1 ? 6 : 4) + .padding(.bottom, 2) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + case .codeBlock(let code, let language): + AIChatCodeBlockView(code: code, language: language) + case .unorderedList(let items): + MarkdownListView(items: items, style: .unordered) + case .orderedList(let start, let items): + MarkdownListView(items: items, style: .ordered(start: start)) + case .blockquote(let lines): + MarkdownBlockquoteView(lines: lines) + case .table(let headers, let alignments, let rows): + MarkdownTableView(headers: headers, alignments: alignments, rows: rows) + case .thematicBreak: + Divider() + .padding(.vertical, 4) + } + } + + private func headerFont(for level: Int) -> Font { + switch level { + case 1: return .title2 + case 2: return .title3 + case 3: return .headline + default: return .subheadline + } + } + + private func headerWeight(for level: Int) -> Font.Weight { + level <= 2 ? .bold : .semibold + } + + private func attributed(_ markdown: String) -> AttributedString { + MarkdownInline.parse(markdown) + } +} + +// MARK: - List + +private struct MarkdownListView: View { + let items: [MarkdownListItem] + let style: ListStyle + + enum ListStyle { + case unordered + case ordered(start: Int) + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(marker(for: index)) + .foregroundStyle(.secondary) + .frame(minWidth: 16, alignment: .trailing) + VStack(alignment: .leading, spacing: 3) { + Text(MarkdownInline.parse(item.text)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if !item.children.isEmpty { + ForEach(item.children) { child in + MarkdownBlockView(block: child) + } + .padding(.leading, 4) + } + } + } + } + } + } + + private func marker(for index: Int) -> String { + switch style { + case .unordered: + return "•" + case .ordered(let start): + return "\(start + index)." + } + } +} + +// MARK: - Blockquote + +private struct MarkdownBlockquoteView: View { + let lines: String + + var body: some View { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(nsColor: .tertiaryLabelColor)) + .frame(width: 3) + Text(MarkdownInline.parse(lines)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } +} + +// MARK: - Table + +private struct MarkdownTableView: View { + let headers: [String] + let alignments: [MarkdownTableAlignment] + let rows: [[String]] + + var body: some View { + let columnCount = headers.count + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + ForEach(0.. some View { + let attributed = MarkdownInline.parse(text) + switch alignment { + case .left: + Text(attributed).frame(maxWidth: .infinity, alignment: .leading) + case .center: + Text(attributed).frame(maxWidth: .infinity, alignment: .center) + case .right: + Text(attributed).frame(maxWidth: .infinity, alignment: .trailing) + } + } +} + +// MARK: - Inline parsing + +enum MarkdownInline { + static func parse(_ source: String) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + if let attributed = try? AttributedString(markdown: source, options: options) { + return attributed + } + return AttributedString(source) + } +} + +// MARK: - Block model + +struct MarkdownBlock: Identifiable { + let id = UUID() + let kind: Kind + + enum Kind { + case paragraph(String) + case header(level: Int, text: String) + case codeBlock(code: String, language: String?) + case unorderedList([MarkdownListItem]) + case orderedList(start: Int, items: [MarkdownListItem]) + case blockquote(String) + case table(headers: [String], alignments: [MarkdownTableAlignment], rows: [[String]]) + case thematicBreak + } +} + +struct MarkdownListItem: Identifiable { + let id = UUID() + let text: String + let children: [MarkdownBlock] +} + +enum MarkdownTableAlignment { + case left + case center + case right +} + +// MARK: - Parser + +enum MarkdownBlockParser { + static func parse(_ source: String) -> [MarkdownBlock] { + var lines = source.components(separatedBy: "\n") + var blocks: [MarkdownBlock] = [] + while !lines.isEmpty { + if let parsed = parseNextBlock(&lines) { + blocks.append(parsed) + } + } + return blocks + } + + private static func parseNextBlock(_ lines: inout [String]) -> MarkdownBlock? { + guard let first = lines.first else { return nil } + let trimmed = first.trimmingCharacters(in: .whitespaces) + + if trimmed.isEmpty { + lines.removeFirst() + return nil + } + + if isFencedCodeStart(trimmed) { + return parseFencedCodeBlock(&lines) + } + + 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)) + } + + if isThematicBreak(trimmed) { + lines.removeFirst() + return MarkdownBlock(kind: .thematicBreak) + } + + if trimmed.hasPrefix(">") { + return parseBlockquote(&lines) + } + + if isUnorderedListMarker(trimmed) { + return parseUnorderedList(&lines) + } + + if let start = orderedListStart(trimmed) { + return parseOrderedList(&lines, startingAt: start) + } + + if let table = parseTable(&lines) { + return table + } + + return parseParagraph(&lines) + } + + private static func headerLevel(_ trimmed: String) -> Int? { + guard trimmed.hasPrefix("#") else { return nil } + let hashes = trimmed.prefix { $0 == "#" }.count + guard hashes <= 6, + trimmed.count > hashes, + trimmed[trimmed.index(trimmed.startIndex, offsetBy: hashes)] == " " + else { return nil } + return hashes + } + + private static func isThematicBreak(_ trimmed: String) -> Bool { + let collapsed = trimmed.replacingOccurrences(of: " ", with: "") + guard collapsed.count >= 3 else { return false } + return collapsed.allSatisfy { $0 == "-" } + || collapsed.allSatisfy { $0 == "*" } + || collapsed.allSatisfy { $0 == "_" } + } + + private static func isFencedCodeStart(_ trimmed: String) -> Bool { + trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") + } + + private static func isUnorderedListMarker(_ trimmed: String) -> Bool { + guard trimmed.count >= 2 else { return false } + let first = trimmed.first + let second = trimmed[trimmed.index(after: trimmed.startIndex)] + return (first == "-" || first == "*" || first == "+") && second == " " + } + + private static func orderedListStart(_ trimmed: String) -> Int? { + let scanner = Scanner(string: trimmed) + scanner.charactersToBeSkipped = nil + var value: Int = 0 + guard scanner.scanInt(&value) else { return nil } + guard scanner.scanString(".") != nil else { return nil } + guard scanner.scanString(" ") != nil else { return nil } + return value + } + + private static func parseFencedCodeBlock(_ lines: inout [String]) -> MarkdownBlock { + let opener = lines.removeFirst() + let trimmedOpener = opener.trimmingCharacters(in: .whitespaces) + let fence = trimmedOpener.hasPrefix("```") ? "```" : "~~~" + let language: String? = { + let info = String(trimmedOpener.dropFirst(3)).trimmingCharacters(in: .whitespaces) + return info.isEmpty ? nil : info + }() + var bodyLines: [String] = [] + while let line = lines.first { + if line.trimmingCharacters(in: .whitespaces).hasPrefix(fence) { + lines.removeFirst() + break + } + bodyLines.append(line) + lines.removeFirst() + } + return MarkdownBlock(kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) + } + + private static func parseBlockquote(_ lines: inout [String]) -> MarkdownBlock { + var collected: [String] = [] + while let line = lines.first { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix(">") else { break } + let content = trimmed.dropFirst() + .trimmingCharacters(in: .whitespaces) + collected.append(String(content)) + lines.removeFirst() + } + return MarkdownBlock(kind: .blockquote(collected.joined(separator: "\n"))) + } + + private static func parseUnorderedList(_ lines: inout [String]) -> MarkdownBlock { + var items: [MarkdownListItem] = [] + while let item = parseListItem(&lines, ordered: false) { + items.append(item) + } + return MarkdownBlock(kind: .unorderedList(items)) + } + + private static func parseOrderedList(_ lines: inout [String], startingAt start: Int) -> MarkdownBlock { + var items: [MarkdownListItem] = [] + while let item = parseListItem(&lines, ordered: true) { + items.append(item) + } + return MarkdownBlock(kind: .orderedList(start: start, items: items)) + } + + private static func parseListItem(_ lines: inout [String], ordered: Bool) -> MarkdownListItem? { + guard let first = lines.first else { return nil } + let trimmed = first.trimmingCharacters(in: .whitespaces) + let markerLength: Int + if ordered { + guard orderedListStart(trimmed) != nil, + let dotIndex = trimmed.firstIndex(of: ".") else { return nil } + markerLength = trimmed.distance(from: trimmed.startIndex, to: dotIndex) + 2 + } else { + guard isUnorderedListMarker(trimmed) else { return nil } + markerLength = 2 + } + let bodyStart = trimmed.index(trimmed.startIndex, offsetBy: markerLength) + var textLines: [String] = [String(trimmed[bodyStart...])] + lines.removeFirst() + + while let next = lines.first { + let nextTrimmed = next.trimmingCharacters(in: .whitespaces) + if nextTrimmed.isEmpty { break } + if isUnorderedListMarker(nextTrimmed) || orderedListStart(nextTrimmed) != nil { break } + if next.hasPrefix(" ") || next.hasPrefix("\t") { + textLines.append(next.trimmingCharacters(in: .whitespaces)) + lines.removeFirst() + continue + } + break + } + return MarkdownListItem(text: textLines.joined(separator: " "), children: []) + } + + private static func parseTable(_ lines: inout [String]) -> MarkdownBlock? { + guard lines.count >= 2 else { return nil } + let headerLine = lines[0] + let separatorLine = lines[1] + guard headerLine.contains("|"), isTableSeparator(separatorLine) else { return nil } + + let headers = splitTableRow(headerLine) + let alignments = parseTableAlignments(separatorLine) + guard !headers.isEmpty, headers.count == alignments.count else { return nil } + + lines.removeFirst(2) + var rows: [[String]] = [] + while let line = lines.first, looksLikeTableRow(line, columnCount: headers.count) { + let row = splitTableRow(line) + var padded = row + while padded.count < headers.count { padded.append("") } + rows.append(Array(padded.prefix(headers.count))) + lines.removeFirst() + } + return MarkdownBlock(kind: .table(headers: headers, alignments: alignments, rows: rows)) + } + + private static func looksLikeTableRow(_ line: String, columnCount: Int) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.contains("|") else { return false } + guard trimmed.hasPrefix("|") || trimmed.hasSuffix("|") else { return false } + let segments = splitTableRow(line) + return segments.count >= max(2, columnCount - 1) + } + + private static func isTableSeparator(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.contains("|") else { return false } + let segments = splitTableRow(line) + guard !segments.isEmpty else { return false } + return segments.allSatisfy { segment in + let inner = segment.trimmingCharacters(in: .whitespaces) + guard !inner.isEmpty else { return false } + return inner.allSatisfy { $0 == "-" || $0 == ":" } + } + } + + private static func parseTableAlignments(_ line: String) -> [MarkdownTableAlignment] { + splitTableRow(line).map { segment in + let inner = segment.trimmingCharacters(in: .whitespaces) + let leadingColon = inner.hasPrefix(":") + let trailingColon = inner.hasSuffix(":") + switch (leadingColon, trailingColon) { + case (true, true): return .center + case (false, true): return .right + default: return .left + } + } + } + + private static func splitTableRow(_ line: String) -> [String] { + var trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("|") { trimmed.removeFirst() } + if trimmed.hasSuffix("|") { trimmed.removeLast() } + return trimmed + .split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + } + + private static func parseParagraph(_ lines: inout [String]) -> MarkdownBlock { + var collected: [String] = [] + while let line = lines.first { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { break } + if isFencedCodeStart(trimmed) { break } + if headerLevel(trimmed) != nil { break } + if isThematicBreak(trimmed) { break } + if trimmed.hasPrefix(">") { break } + if isUnorderedListMarker(trimmed) { break } + if orderedListStart(trimmed) != nil { break } + collected.append(line) + lines.removeFirst() + } + return MarkdownBlock(kind: .paragraph(collected.joined(separator: "\n"))) + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift b/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift index 0064ba271..8ed7c8308 100644 --- a/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift +++ b/TableProTests/Core/AI/AnthropicProviderEncodingTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("AnthropicProvider wire encoding") @@ -25,7 +25,7 @@ struct AnthropicProviderEncodingTests { @Test("Plain text turn renders content as a string") func plainTextTurn() throws { - let turn = ChatTurn(role: .user, blocks: [.text("hello")]) + let turn = ChatTurnWire(role: .user, blocks: [.text("hello")]) let encoded = try AnthropicProvider.encodeTurn(turn) #expect(encoded?["role"] as? String == "user") #expect(encoded?["content"] as? String == "hello") @@ -34,7 +34,7 @@ struct AnthropicProviderEncodingTests { @Test("Turn with toolUse becomes a typed-block array, not a flat string") func turnWithToolUseIsBlockArray() throws { let toolUse = ToolUseBlock(id: "abc", name: "list_tables", input: .object([:])) - let turn = ChatTurn(role: .assistant, blocks: [.text("checking"), .toolUse(toolUse)]) + let turn = ChatTurnWire(role: .assistant, blocks: [.text("checking"), .toolUse(toolUse)]) let encoded = try AnthropicProvider.encodeTurn(turn) #expect(encoded?["role"] as? String == "assistant") let blocks = encoded?["content"] as? [[String: Any]] @@ -47,7 +47,7 @@ struct AnthropicProviderEncodingTests { @Test("Turn with toolResult uses tool_use_id and omits is_error when false") func turnWithSuccessfulToolResult() throws { let result = ToolResultBlock(toolUseId: "abc", content: "ok", isError: false) - let turn = ChatTurn(role: .user, blocks: [.toolResult(result)]) + let turn = ChatTurnWire(role: .user, blocks: [.toolResult(result)]) let encoded = try AnthropicProvider.encodeTurn(turn) let blocks = encoded?["content"] as? [[String: Any]] #expect(blocks?.count == 1) @@ -60,7 +60,7 @@ struct AnthropicProviderEncodingTests { @Test("toolResult with isError emits is_error: true") func turnWithErrorToolResult() throws { let result = ToolResultBlock(toolUseId: "abc", content: "boom", isError: true) - let turn = ChatTurn(role: .user, blocks: [.toolResult(result)]) + let turn = ChatTurnWire(role: .user, blocks: [.toolResult(result)]) let encoded = try AnthropicProvider.encodeTurn(turn) let blocks = encoded?["content"] as? [[String: Any]] #expect((blocks?[0])?["is_error"] as? Bool == true) @@ -68,7 +68,7 @@ struct AnthropicProviderEncodingTests { @Test("Empty text turn is dropped") func emptyTurnReturnsNil() throws { - let turn = ChatTurn(role: .user, blocks: [.text("")]) + let turn = ChatTurnWire(role: .user, blocks: [.text("")]) let encoded = try AnthropicProvider.encodeTurn(turn) #expect(encoded == nil) } diff --git a/TableProTests/Core/AI/ChatTurnInterleavingTests.swift b/TableProTests/Core/AI/ChatTurnInterleavingTests.swift new file mode 100644 index 000000000..7d688a3c9 --- /dev/null +++ b/TableProTests/Core/AI/ChatTurnInterleavingTests.swift @@ -0,0 +1,151 @@ +// +// ChatTurnInterleavingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ChatTurn streaming + block interleaving") +@MainActor +struct ChatTurnInterleavingTests { + @Test("appendStreamingToken creates a streaming text block on first token") + func firstTokenCreatesStreamingBlock() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("Hello") + + #expect(turn.blocks.count == 1) + if case .text(let text) = turn.blocks[0].kind { + #expect(text == "Hello") + } else { + Issue.record("expected text block") + } + #expect(turn.blocks[0].isStreaming == true) + } + + @Test("Successive tokens extend the same streaming text block") + func tokensCoalesceIntoOneBlock() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("Hello ") + turn.appendStreamingToken("world") + + #expect(turn.blocks.count == 1) + if case .text(let text) = turn.blocks[0].kind { + #expect(text == "Hello world") + } else { + Issue.record("expected single text block") + } + } + + @Test("Appending a non-text block finalises the streaming text block first") + func toolBlockAppendedAfterStreamingTextPreservesOrder() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("I'll check the schema") + turn.appendStreamingToken(" first.") + let toolBlock = ChatContentBlock.toolUse( + ToolUseBlock(id: "1", name: "list_tables", input: .object([:])) + ) + turn.appendBlock(toolBlock) + + #expect(turn.blocks.count == 2) + if case .text(let text) = turn.blocks[0].kind { + #expect(text == "I'll check the schema first.") + } else { + Issue.record("expected text as first block") + } + #expect(turn.blocks[0].isStreaming == false) + if case .toolUse = turn.blocks[1].kind { + // ok + } else { + Issue.record("expected toolUse as second block") + } + } + + @Test("Multiple appended tool blocks land in order after the finalised text") + func multipleToolBlocksKeepOrder() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("Doing work") + turn.appendBlock(.toolUse(ToolUseBlock(id: "a", name: "t1", input: .object([:])))) + turn.appendBlock(.toolUse(ToolUseBlock(id: "b", name: "t2", input: .object([:])))) + + #expect(turn.blocks.count == 3) + if case .toolUse(let one) = turn.blocks[1].kind { #expect(one.id == "a") } + if case .toolUse(let two) = turn.blocks[2].kind { #expect(two.id == "b") } + } + + @Test("Streaming token after a tool block starts a fresh streaming text block") + func textAfterToolBlockOpensNewTextBlock() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("First text") + turn.appendBlock(.toolUse(ToolUseBlock(id: "a", name: "t", input: .object([:])))) + turn.appendStreamingToken("Second text") + + #expect(turn.blocks.count == 3) + if case .text(let last) = turn.blocks[2].kind { + #expect(last == "Second text") + } else { + Issue.record("expected third block to be a fresh text block") + } + #expect(turn.blocks[2].isStreaming == true) + } + + @Test("finishStreamingTextBlock marks the trailing streaming text as committed") + func finishStreamingTextBlockMarksCommitted() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("done") + turn.finishStreamingTextBlock() + + #expect(turn.blocks.count == 1) + #expect(turn.blocks[0].isStreaming == false) + } + + @Test("wireSnapshot reflects current block order and content") + func wireSnapshotPreservesOrder() { + var turn = ChatTurn(role: .assistant, blocks: []) + turn.appendStreamingToken("text") + turn.appendBlock(.toolUse(ToolUseBlock(id: "1", name: "t", input: .object([:])))) + + let wire = turn.wireSnapshot + #expect(wire.blocks.count == 2) + if case .text(let value) = wire.blocks[0].kind { #expect(value == "text") } + if case .toolUse(let value) = wire.blocks[1].kind { #expect(value.id == "1") } + } + + @Test("ChatTurnWire round-trips through JSON Codable") + func wireCodableRoundTrip() throws { + let originalWire = ChatTurnWire( + role: .assistant, + blocks: [ + .text("hello"), + .toolUse(ToolUseBlock(id: "1", name: "fn", input: .object([:]))) + ] + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(originalWire) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let restored = try decoder.decode(ChatTurnWire.self, from: data) + + #expect(restored.id == originalWire.id) + #expect(restored.blocks.count == 2) + if case .text(let value) = restored.blocks[0].kind { #expect(value == "hello") } + if case .toolUse(let value) = restored.blocks[1].kind { #expect(value.id == "1") } + } + + @Test("ChatTurn(wire:) reconstructs an observable turn with the same block ids") + func roundTripPreservesBlockIDs() { + let originalTurn = ChatTurn(role: .assistant, blocks: [ + .text("hi"), + .toolUse(ToolUseBlock(id: "x", name: "fn", input: .object([:]))) + ]) + let restored = ChatTurn(wire: originalTurn.wireSnapshot) + + #expect(restored.blocks.count == originalTurn.blocks.count) + for index in restored.blocks.indices { + #expect(restored.blocks[index].id == originalTurn.blocks[index].id) + } + } +} diff --git a/TableProTests/Core/AI/GeminiProviderEncodingTests.swift b/TableProTests/Core/AI/GeminiProviderEncodingTests.swift index b80d939d4..d1a4ee648 100644 --- a/TableProTests/Core/AI/GeminiProviderEncodingTests.swift +++ b/TableProTests/Core/AI/GeminiProviderEncodingTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("GeminiProvider wire encoding") @@ -19,7 +19,7 @@ struct GeminiProviderEncodingTests { @Test("Plain text user turn becomes parts:[{text:...}] with role user") func plainTextTurn() throws { - let turn = ChatTurn(role: .user, blocks: [.text("hello")]) + let turn = ChatTurnWire(role: .user, blocks: [.text("hello")]) let encoded = try #require(makeProvider().encodeTurn(turn, priorTurns: [])) #expect(encoded["role"] as? String == "user") let parts = encoded["parts"] as? [[String: Any]] @@ -29,7 +29,7 @@ struct GeminiProviderEncodingTests { @Test("Assistant role maps to model") func assistantRoleMapsToModel() throws { - let turn = ChatTurn(role: .assistant, blocks: [.text("hi")]) + let turn = ChatTurnWire(role: .assistant, blocks: [.text("hi")]) let encoded = try #require(makeProvider().encodeTurn(turn, priorTurns: [])) #expect(encoded["role"] as? String == "model") } @@ -41,7 +41,7 @@ struct GeminiProviderEncodingTests { name: "list_tables", input: .object(["connection_id": .string("abc")]) ) - let turn = ChatTurn(role: .assistant, blocks: [.toolUse(toolUse)]) + let turn = ChatTurnWire(role: .assistant, blocks: [.toolUse(toolUse)]) let encoded = try #require(makeProvider().encodeTurn(turn, priorTurns: [])) let parts = encoded["parts"] as? [[String: Any]] let functionCall = (parts?[0])?["functionCall"] as? [String: Any] @@ -54,9 +54,9 @@ struct GeminiProviderEncodingTests { @Test("toolResult resolves the originating tool name from prior turns") func toolResultLookupAcrossTurns() throws { let toolUse = ToolUseBlock(id: "call_1", name: "list_tables", input: .object([:])) - let assistantTurn = ChatTurn(role: .assistant, blocks: [.toolUse(toolUse)]) - let interveningTurn = ChatTurn(role: .user, blocks: [.text("ok")]) - let resultTurn = ChatTurn( + let assistantTurn = ChatTurnWire(role: .assistant, blocks: [.toolUse(toolUse)]) + let interveningTurn = ChatTurnWire(role: .user, blocks: [.text("ok")]) + let resultTurn = ChatTurnWire( role: .user, blocks: [.toolResult(ToolResultBlock(toolUseId: "call_1", content: "rows", isError: false))] ) @@ -74,7 +74,7 @@ struct GeminiProviderEncodingTests { @Test("toolResult with no matching toolUse falls back to toolUseId as name") func toolResultFallback() throws { - let resultTurn = ChatTurn( + let resultTurn = ChatTurnWire( role: .user, blocks: [.toolResult(ToolResultBlock(toolUseId: "unknown", content: "x", isError: false))] ) @@ -86,8 +86,8 @@ struct GeminiProviderEncodingTests { @Test("System turns are skipped from encoded contents") func systemTurnsSkipped() { - let system = ChatTurn(role: .system, blocks: [.text("ignored")]) - let user = ChatTurn(role: .user, blocks: [.text("hello")]) + let system = ChatTurnWire(role: .system, blocks: [.text("ignored")]) + let user = ChatTurnWire(role: .user, blocks: [.text("hello")]) let contents = makeProvider().encodeContents(turns: [system, user]) #expect(contents.count == 1) #expect(contents[0]["role"] as? String == "user") diff --git a/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift b/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift index d644a0ef2..e0c44d0d4 100644 --- a/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift +++ b/TableProTests/Core/AI/OpenAICompatibleProviderEncodingTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("OpenAICompatibleProvider wire encoding") @@ -36,7 +36,7 @@ struct OpenAICompatibleProviderEncodingTests { @Test("Plain text turn renders flat content string") func plainTextTurn() { - let turn = ChatTurn(role: .user, blocks: [.text("hello")]) + let turn = ChatTurnWire(role: .user, blocks: [.text("hello")]) let encoded = makeProvider().encodeTurn(turn) #expect(encoded.count == 1) #expect(encoded[0]["role"] as? String == "user") @@ -46,7 +46,7 @@ struct OpenAICompatibleProviderEncodingTests { @Test("Assistant turn with toolUse emits tool_calls with arguments-as-string") func assistantWithToolUse() { let toolUse = ToolUseBlock(id: "call_1", name: "list_tables", input: .object([:])) - let turn = ChatTurn(role: .assistant, blocks: [.text("checking"), .toolUse(toolUse)]) + let turn = ChatTurnWire(role: .assistant, blocks: [.text("checking"), .toolUse(toolUse)]) let messages = makeProvider().encodeTurn(turn) #expect(messages.count == 1) let message = messages[0] @@ -65,7 +65,7 @@ struct OpenAICompatibleProviderEncodingTests { @Test("Assistant turn with toolUse but no text emits content: NSNull") func assistantWithoutText() { let toolUse = ToolUseBlock(id: "call_1", name: "list_tables", input: .object([:])) - let turn = ChatTurn(role: .assistant, blocks: [.toolUse(toolUse)]) + let turn = ChatTurnWire(role: .assistant, blocks: [.toolUse(toolUse)]) let messages = makeProvider().encodeTurn(turn) #expect(messages[0]["content"] is NSNull) } @@ -73,7 +73,7 @@ struct OpenAICompatibleProviderEncodingTests { @Test("User turn with toolResult emits role:tool with tool_call_id") func toolResultBecomesToolMessage() { let result = ToolResultBlock(toolUseId: "call_1", content: "rows", isError: false) - let turn = ChatTurn(role: .user, blocks: [.toolResult(result)]) + let turn = ChatTurnWire(role: .user, blocks: [.toolResult(result)]) let messages = makeProvider().encodeTurn(turn) #expect(messages.count == 1) #expect(messages[0]["role"] as? String == "tool") @@ -85,7 +85,7 @@ struct OpenAICompatibleProviderEncodingTests { func multipleToolResults() { let r1 = ToolResultBlock(toolUseId: "call_1", content: "a", isError: false) let r2 = ToolResultBlock(toolUseId: "call_2", content: "b", isError: false) - let turn = ChatTurn(role: .user, blocks: [.toolResult(r1), .toolResult(r2)]) + let turn = ChatTurnWire(role: .user, blocks: [.toolResult(r1), .toolResult(r2)]) let messages = makeProvider().encodeTurn(turn) #expect(messages.count == 2) #expect(messages[0]["tool_call_id"] as? String == "call_1") @@ -94,7 +94,7 @@ struct OpenAICompatibleProviderEncodingTests { @Test("Empty text turn returns no messages") func emptyTurnYieldsNothing() { - let turn = ChatTurn(role: .user, blocks: [.text("")]) + let turn = ChatTurnWire(role: .user, blocks: [.text("")]) let messages = makeProvider().encodeTurn(turn) #expect(messages.isEmpty) } diff --git a/TableProTests/Models/AIConversationTests.swift b/TableProTests/Models/AIConversationTests.swift index 5527b78ab..08aaeade0 100644 --- a/TableProTests/Models/AIConversationTests.swift +++ b/TableProTests/Models/AIConversationTests.swift @@ -4,14 +4,14 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("AIConversation") struct AIConversationTests { - private func makeUserTurn(_ text: String) -> ChatTurn { - ChatTurn(role: .user, blocks: [.text(text)]) + private func makeUserTurn(_ text: String) -> ChatTurnWire { + ChatTurnWire(role: .user, blocks: [.text(text)]) } @Test("updateTitle truncates long content") diff --git a/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift b/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift index 938e193d8..ff8ee2a17 100644 --- a/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift +++ b/TableProTests/ViewModels/AIChatViewModelMentionsTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("AIChatViewModel @-mentions") @@ -52,7 +52,7 @@ struct AIChatViewModelMentionsTests { let userTurn = vm.messages.first(where: { $0.role == .user }) #expect(userTurn != nil) let attachmentBlocks = userTurn?.blocks.compactMap { block -> ContextItem? in - if case .attachment(let item) = block { return item } + if case .attachment(let item) = block.kind { return item } return nil } #expect(attachmentBlocks?.count == 1) @@ -104,7 +104,7 @@ struct AIChatViewModelMentionsTests { let userTurn = vm.messages.first(where: { $0.role == .user }) #expect(userTurn?.plainText == "Explain") #expect(userTurn?.blocks.contains(where: { - if case .attachment = $0 { return true } else { return false } + if case .attachment = $0.kind { return true } else { return false } }) == true) } @@ -112,7 +112,7 @@ struct AIChatViewModelMentionsTests { func resolveTurnForWireExpands() async { let vm = AIChatViewModel() vm.connection = TestFixtures.makeConnection(type: .mysql) - let raw = ChatTurn(role: .user, blocks: [ + let raw = ChatTurnWire(role: .user, blocks: [ .text("Explain"), .attachment(.currentQuery(text: "SELECT * FROM Customer")) ])