Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
41f63ca
refactor(ai-chat): replace animated focus border with native macOS fo…
datlechin May 8, 2026
6730b6c
refactor(ai-chat): decompose AIChatViewModel into responsibility exte…
datlechin May 8, 2026
012ee0b
refactor(ai-providers): extract endpoint normalization and JSON encoding
datlechin May 8, 2026
13a7ae3
refactor(mcp): split HTTP transport, migrate pairing store to actor, …
datlechin May 8, 2026
d0c38d8
docs(changelog): log AI provider extraction
datlechin May 8, 2026
e0a6c13
build(ai-chat): fix imports and visibility for AIChatViewModel split
datlechin May 8, 2026
456be04
refactor(ai-chat): unify JSONValue across chat layer and MCP wire
datlechin May 8, 2026
06a0ef5
build(ai-chat): import os in SlashCommands extension
datlechin May 8, 2026
7bbface
refactor(ai-chat): add schema versioning to conversations and consist…
datlechin May 8, 2026
acbab6e
docs(changelog): log JSON value type unification
datlechin May 8, 2026
0f6b30c
refactor(inline-suggest): replace Timer debounce with Task.sleep and …
datlechin May 8, 2026
a8da24d
refactor(copilot): harden lifecycle and binary trust
datlechin May 8, 2026
6e8ea2d
test(ai-chat): update tests for new ChatTurn block-based API and View…
datlechin May 8, 2026
9d6c657
docs(changelog): adopt Keep a Changelog 1.1.0 format link
datlechin May 8, 2026
59e7dad
docs(claude-md): document Conventional Commits scopes and atomic API …
datlechin May 8, 2026
0d78c12
revert(ai-chat): restore IntelligenceFocusBorder for Apple Intelligen…
datlechin May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@

All notable changes to TablePro will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- AI inline suggestions: debounce now uses structured Swift concurrency, and the delay is configurable via the `inlineSuggestionDebounceMs` setting (default 500ms)
- Copilot LSP shutdown caps at 10 seconds, closes pipes explicitly, and strips the quarantine attribute from the downloaded binary
- AI Chat: streaming view model split into focused extensions backed by a single `streamingState` enum
- MCP HTTP server: split transport into connection, router, and SSE writer files; pairing exchange store moved to a Swift actor; SSE streams send a 30-second keep-alive
- AI providers: shared endpoint normalization and JSON encoding helpers; consistent 5s timeout and known-model fallback when listing models
- AI settings: include schema and current query default to on for new installs, matching the previous decoded fallback
- AI Chat: persisted conversations now carry a schema version so future migrations can read older files cleanly
- AI Chat: custom slash commands reject duplicate names, including case-insensitive collisions on rename
- Internal: unify JSON value type used by AI tools and MCP wire

## [0.39.1] - 2026-05-08

### Added
Expand Down
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ When approaching limits: extract into `TypeName+Category.swift` extension files

These are **non-negotiable** — never skip them:

1. **CHANGELOG.md**: Update under `[Unreleased]` section (Added/Fixed/Changed) for new features and notable changes. Do **not** add a "Fixed" entry for fixing something that is itself still unreleased. "Fixed" entries are only for bugs in already-released features. Documentation-only changes (`docs/`) do **not** need a CHANGELOG entry. Each entry is one line, user-facing, with no file paths, class names, or method signatures; reference IDs go in parens at the end: `(#1234)`.
1. **CHANGELOG.md**: Follow [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Update under `[Unreleased]` using the canonical sections: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. Do **not** add a "Fixed" entry for fixing something that is itself still unreleased; fold the fix into the Added or Changed entry instead. Documentation-only changes (`docs/`, `CLAUDE.md`, `CHANGELOG.md` formatting) do **not** need a CHANGELOG entry. Each entry is one line, user-facing, with no file paths, class names, or method signatures; reference IDs go in parens at the end: `(#1234)`.

2. **Localization**: Use `String(localized:)` for new user-facing strings in computed properties, AppKit code, alerts, and error descriptions. SwiftUI view literals (`Text("literal")`, `Button("literal")`) auto-localize. Do NOT localize technical terms (font names, database types, SQL keywords, encoding names). Never use `String(localized:)` with string interpolation — `String(localized: "Preview \(name)")` creates a dynamic key that never matches the strings catalog. Use `String(format: String(localized: "Preview %@"), name)`.

Expand All @@ -212,7 +212,20 @@ These are **non-negotiable** — never skip them:

5. **Lint after changes**: Run `swiftlint lint --strict` to verify compliance.

6. **Commit messages**: Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Single line only, no description body. Examples: `docs: fix installation instructions for unsigned app`, `fix: prevent crash on empty query result`, `feat: add CSV export`.
6. **Commit messages**: Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/). Single line only, no description body. Format: `<type>(<scope>): <description>`. Scope is optional but preferred when the change has a clear domain. Use `!` after type or scope for breaking changes (e.g. `refactor(ai-providers)!: drop OpenAI legacy completion endpoint`).

**Types**: `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `build`, `ci`, `chore`, `style`, `revert`.

**Canonical scopes** (reuse these instead of inventing new ones):
- AI: `ai-chat`, `ai-providers`, `mcp`, `copilot`, `inline-suggest`
- App UI: `editor`, `datagrid`, `tabs`, `coordinator`, `sidebar`, `connections`, `connection-form`, `welcome`, `settings`, `toolbar`, `hig`
- Infra: `ssh`, `ios`, `windows`, `perf`, `launch`, `plugins`
- Plugins: `plugin-<name>` (e.g. `plugin-mongodb`, `plugin-redis`, `plugin-clickhouse`)
- Docs and release: `changelog`, `claude-md`, `docs`, `ci`, `release`

**Examples**: `feat(ai-chat): add /refactor slash command`, `fix(editor): prevent crash on empty query result`, `refactor(mcp): migrate pairing store to actor`, `docs(changelog): adopt Keep a Changelog 1.1.0`.

7. **Atomic API changes**: When you rename, remove, or change a public type, property, or function signature, update every caller AND every test in the same commit. Do not split a rename from "fix tests for rename" into separate commits; the in-between commit is broken, fails CI, and pollutes `git bisect`. If a refactor crosses too many files for one reviewable commit, narrow the change first or stage it behind a typealias the renaming commit removes.

## Performance Pitfalls

Expand Down
12 changes: 11 additions & 1 deletion TablePro/Core/AI/AIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

import Foundation

enum AIProvider {
static let modelListTimeout: TimeInterval = 5.0
}

enum AIProviderError: Error, LocalizedError {
case invalidEndpoint(String)
case authenticationFailed(String)
Expand Down Expand Up @@ -36,11 +40,17 @@ enum AIProviderError: Error, LocalizedError {
}
}

static func mapHTTPError(statusCode: Int, body: String) -> AIProviderError {
static func mapHTTPError(
statusCode: Int,
body: String,
treatForbiddenAsAuthFailure: Bool = false
) -> AIProviderError {
let message = parseErrorMessage(from: body) ?? body
switch statusCode {
case 401:
return .authenticationFailed(message)
case 403 where treatForbiddenAsAuthFailure:
return .authenticationFailed(message)
case 429:
return .rateLimited
case 404:
Expand Down
22 changes: 13 additions & 9 deletions TablePro/Core/AI/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ final class AnthropicProvider: ChatTransport {
private let session: URLSession

init(endpoint: String, apiKey: String, maxOutputTokens: Int = 4_096) {
self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
self.endpoint = endpoint.normalizedEndpoint()
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
self.maxOutputTokens = maxOutputTokens
self.session = URLSession(configuration: .ephemeral)
Expand Down Expand Up @@ -73,16 +73,25 @@ final class AnthropicProvider: ChatTransport {

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = AIProvider.modelListTimeout
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")

let (data, response) = try await session.data(for: request)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
Self.logger.warning("Anthropic model fetch failed; using known models: \(error.localizedDescription, privacy: .public)")
return Self.knownModels
}

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let models = json["data"] as? [[String: Any]]
else {
Self.logger.warning("Anthropic model fetch returned unexpected response; using known models")
return Self.knownModels
}

Expand Down Expand Up @@ -240,7 +249,7 @@ final class AnthropicProvider: ChatTransport {
[
"name": spec.name,
"description": spec.description,
"input_schema": try jsonObject(from: spec.inputSchema)
"input_schema": try spec.inputSchema.jsonObject()
]
}

Expand Down Expand Up @@ -276,7 +285,7 @@ final class AnthropicProvider: ChatTransport {
"type": "tool_use",
"id": toolUse.id,
"name": toolUse.name,
"input": try jsonObject(from: toolUse.input)
"input": try toolUse.input.jsonObject()
]
case .toolResult(let result):
var encoded: [String: Any] = [
Expand All @@ -292,11 +301,6 @@ final class AnthropicProvider: ChatTransport {
return nil
}
}

static func jsonObject(from value: JSONValue) throws -> Any {
let data = try JSONEncoder().encode(value)
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
}
}

/// Mutable state carried across `AnthropicProvider.parseChunk` calls.
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/AI/Chat/ChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import Foundation
protocol ChatTool: Sendable {
var name: String { get }
var description: String { get }
var inputSchema: JSONValue { get }
var inputSchema: JsonValue { get }

func execute(input: JSONValue, context: ChatToolContext) async throws -> ChatToolResult
func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult
}

struct ChatToolResult: Sendable, Equatable, Codable {
Expand Down
18 changes: 9 additions & 9 deletions TablePro/Core/AI/Chat/ChatToolArgumentDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,50 @@

import Foundation

/// Typed decoders for `JSONValue` input arguments coming from the AI.
/// Typed decoders for `JsonValue` input arguments coming from the AI.
/// Mirrors `MCPArgumentDecoder` for the MCP protocol but operates on the
/// chat-side `JSONValue` enum.
/// chat-side `JsonValue` enum.
enum ChatToolArgumentDecoder {
static func requireString(_ args: JSONValue, key: String) throws -> String {
static func requireString(_ args: JsonValue, key: String) throws -> String {
guard case .object(let dict) = args, let value = dict[key], case .string(let str) = value else {
throw ChatToolArgumentError.missingOrInvalid(key: key, expected: "string")
}
return str
}

static func optionalString(_ args: JSONValue, key: String) -> String? {
static func optionalString(_ args: JsonValue, key: String) -> String? {
guard case .object(let dict) = args, let value = dict[key], case .string(let str) = value else {
return nil
}
return str
}

static func requireUUID(_ args: JSONValue, key: String) throws -> UUID {
static func requireUUID(_ args: JsonValue, key: String) throws -> UUID {
let str = try requireString(args, key: key)
guard let uuid = UUID(uuidString: str) else {
throw ChatToolArgumentError.missingOrInvalid(key: key, expected: "UUID string")
}
return uuid
}

static func optionalBool(_ args: JSONValue, key: String, default fallback: Bool = false) -> Bool {
static func optionalBool(_ args: JsonValue, key: String, default fallback: Bool = false) -> Bool {
guard case .object(let dict) = args, let value = dict[key], case .bool(let bool) = value else {
return fallback
}
return bool
}

static func optionalInt(
_ args: JSONValue,
_ args: JsonValue,
key: String,
default fallback: Int,
clamp: ClosedRange<Int>? = nil
) -> Int? {
guard case .object(let dict) = args, let value = dict[key] else { return fallback }
let raw: Int?
switch value {
case .integer(let int): raw = Int(int)
case .number(let double): raw = Int(double)
case .int(let int): raw = int
case .double(let double): raw = Int(double)
default: raw = nil
}
guard let raw else { return fallback }
Expand Down
17 changes: 0 additions & 17 deletions TablePro/Core/AI/Chat/ChatToolJSONFormatter.swift

This file was deleted.

2 changes: 1 addition & 1 deletion TablePro/Core/AI/Chat/ChatToolSpec+Copilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ extension ChatToolSpec {
)
}

private static func normalizeForCopilot(_ schema: JSONValue) -> JSONValue {
private static func normalizeForCopilot(_ schema: JsonValue) -> JsonValue {
guard case .object(var dict) = schema else { return schema }
if dict["required"] == nil {
dict["required"] = .array([])
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/AI/Chat/ChatTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct ChatTransportOptions: Sendable {
struct ChatToolSpec: Codable, Equatable, Sendable {
let name: String
let description: String
let inputSchema: JSONValue
let inputSchema: JsonValue
}

enum ChatStreamEvent: Sendable {
Expand Down
30 changes: 13 additions & 17 deletions TablePro/Core/AI/Chat/ChatTurn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,22 @@ struct ChatTurn: Codable, Equatable, Identifiable, Sendable {

if let decodedBlocks = try container.decodeIfPresent([ChatContentBlock].self, forKey: .blocks) {
blocks = decodedBlocks
} else if let legacyText = try container.decodeIfPresent(String.self, forKey: .content) {
blocks = [.text(legacyText)]
} else {
blocks = []
let legacyContainer = try decoder.container(keyedBy: LegacyKeys.self)
if let legacyText = try legacyContainer.decodeIfPresent(String.self, forKey: .content) {
blocks = [.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)
private enum CodingKeys: String, CodingKey {
case id, role, blocks, timestamp, usage, modelId, providerId
}

private enum CodingKeys: String, CodingKey {
case id, role, blocks, content, timestamp, usage, modelId, providerId
private enum LegacyKeys: String, CodingKey {
case content
}

var plainText: String {
Expand Down Expand Up @@ -139,10 +135,10 @@ enum ChatContentBlock: Codable, Equatable, Sendable {
struct ToolUseBlock: Codable, Equatable, Sendable {
let id: String
let name: String
let input: JSONValue
let input: JsonValue
var approvalState: ToolApprovalState

init(id: String, name: String, input: JSONValue, approvalState: ToolApprovalState = .approved) {
init(id: String, name: String, input: JsonValue, approvalState: ToolApprovalState = .approved) {
self.id = id
self.name = name
self.input = input
Expand All @@ -153,7 +149,7 @@ struct ToolUseBlock: Codable, Equatable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
input = try container.decode(JSONValue.self, forKey: .input)
input = try container.decode(JsonValue.self, forKey: .input)
approvalState = try container.decodeIfPresent(ToolApprovalState.self, forKey: .approvalState) ?? .approved
}

Expand Down
75 changes: 0 additions & 75 deletions TablePro/Core/AI/Chat/JSONValue.swift

This file was deleted.

Loading
Loading