Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- 537 driver and import/export strings are now translatable, having only ever shown in English.
- Middle-dot separators dropped from the assistant transcript, slash command list and model picker.
- Every plugin bundle compiled under the same concurrency settings as the app that loads it.
- Release C optimization and link-time optimization scoped to the app, not to its Swift package dependencies.
- Assistant conversations belong to one connection, and outlive the window that opened them.
Expand All @@ -54,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Colored highlight on the AI chat input ignoring Reduce Transparency and Increase Contrast.
- AI chat input focus crossfade playing against Reduce Motion.
- AI chat input announced with no name by VoiceOver.
- Assistant pane left with a transcript, no composer and no explanation after the active AI provider is removed.
- Identical unlabelled **Run** buttons announced for every tool call in a turn that proposes several.
- Images silently dropped from a multi-file drop on the AI chat input when only some of them failed.
- An unreadable image file pasted into the AI chat input inserting its path as text.
- Oracle PL/SQL blocks split at their inner semicolons and sent as fragments, failing with PLS-00103. (#2984)
- Oracle procedures, packages and triggers created from the editor stored INVALID while the run reported success.
- SQL*Plus `/` lines, `q'[…]'` literals and backslashes in strings misread in Oracle scripts.
Expand Down
8 changes: 3 additions & 5 deletions TablePro/Views/AIChat/AIChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,10 @@ struct AIChatMessageView: View, Equatable {
VStack(alignment: .leading, spacing: 4) {
if message.role == .user {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
HStack(spacing: 6) {
Spacer(minLength: 0)
Text("You")
.fontWeight(.medium)
Text("·")
Text(message.timestamp, style: .time)
}
.font(.caption2)
Expand Down Expand Up @@ -99,7 +98,7 @@ struct AIChatMessageView: View, Equatable {
}
Spacer()
if let usage = message.usage {
Text("\(usage.inputTokens) in · \(usage.outputTokens) out")
Text("\(usage.inputTokens) in, \(usage.outputTokens) out")
.font(.caption2)
.foregroundStyle(.tertiary)
.monospacedDigit()
Expand Down Expand Up @@ -159,10 +158,9 @@ struct AIChatMessageView: View, Equatable {
}

private var roleHeader: some View {
HStack(spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.caption2)
Text("·")
Text(message.timestamp, style: .time)
}
.font(.caption2)
Expand Down
33 changes: 30 additions & 3 deletions TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ struct AIChatPanelView: View {
}

inputArea
} else if !viewModel.messages.isEmpty {
noProviderFooter
}
}
.environment(\.chatPrimaryPendingToolUseId, primaryPendingToolUseId)
Expand Down Expand Up @@ -100,6 +102,31 @@ struct AIChatPanelView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

/// A transcript outlives the provider that produced it, so removing the active provider leaves
/// this pane with messages and nothing to send another. Without this the pane keeps the
/// transcript and drops the composer, the model picker and the send button with no reason
/// given and no route back: the "Go to Settings…" affordance lives on the empty-transcript
/// branch alone.
private var noProviderFooter: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.secondary)
Text("No AI provider is active, so this conversation is read-only.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button(String(localized: "Settings…")) {
WindowOpener.shared.openSettings(tab: .ai)
}
.controlSize(.small)
}
.padding(8)
}
}

private var noProviderState: some View {
EmptyStateView(
icon: "gear",
Expand Down Expand Up @@ -480,7 +507,7 @@ struct AIChatPanelView: View {
updateContext()
viewModel.runSlashCommand(command)
} label: {
Text("/\(command.name) · \(command.description)")
Text("/\(command.name) (\(command.description))")
}
}
if !customCommands.isEmpty {
Expand All @@ -494,7 +521,7 @@ struct AIChatPanelView: View {
if command.description.isEmpty {
Text("/\(command.name)")
} else {
Text("/\(command.name) · \(command.description)")
Text("/\(command.name) (\(command.description))")
}
}
}
Expand Down Expand Up @@ -553,7 +580,7 @@ struct AIChatPanelView: View {
viewModel.selectedModel = model
} label: {
HStack {
Text(showProviderPrefix ? "\(provider.displayName) · \(model)" : model)
Text(showProviderPrefix ? "\(provider.displayName) (\(model))" : model)
if isSelected {
Image(systemName: "checkmark")
}
Expand Down
20 changes: 16 additions & 4 deletions TablePro/Views/AIChat/ChatComposerTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ struct ChatComposerTextView: NSViewRepresentable {
let onTab: () -> Bool
let onEscape: () -> Bool
let onPasteImageData: (Data, String) -> Void
let onPasteImageFailed: (String) -> Void

func makeNSView(context: Context) -> ChatComposerScrollView {
let textView = ChatComposerNSTextView.make()
textView.delegate = context.coordinator
textView.placeholder = placeholder
textView.acceptsImagePaste = acceptsImages
textView.onPasteImageData = onPasteImageData
textView.onPasteImageFailed = onPasteImageFailed
textView.highlightEnabled = highlightEnabled
textView.onToggleHighlight = onToggleHighlight

Expand Down Expand Up @@ -214,6 +216,7 @@ final class ChatComposerNSTextView: NSTextView {
var onSizeChange: (() -> Void)?
var acceptsImagePaste: Bool = false
var onPasteImageData: ((Data, String) -> Void)?
var onPasteImageFailed: ((String) -> Void)?
var highlightEnabled: Bool = true
var onToggleHighlight: (() -> Void)?

Expand Down Expand Up @@ -321,10 +324,19 @@ final class ChatComposerNSTextView: NSTextView {
return
}
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
let fileURL = urls.first(where: { (try? $0.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) ?? false }),
let data = try? Data(contentsOf: fileURL) {
let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.identifier ?? UTType.image.identifier
onPasteImageData(data, uti)
let fileURL = urls.first(where: { (try? $0.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) ?? false }) {
/// Identifying the file and reading it are separate answers. Folding the read into the
/// same `if let` made an unreadable image (an iCloud file still in the cloud, a network
/// volume that went away) fall through to `super.paste`, which pastes its path as text
/// into the prompt. The user asked for the picture and silently got a file URL.
do {
let data = try Data(contentsOf: fileURL)
let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.identifier
?? UTType.image.identifier
onPasteImageData(data, uti)
} catch {
onPasteImageFailed?(error.localizedDescription)
}
return
}
super.paste(sender)
Expand Down
12 changes: 7 additions & 5 deletions TablePro/Views/AIChat/ChatComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ struct ChatComposerView: View {
onArrow: { delta in moveMention(by: delta) },
onTab: { commitMentionIfVisible() },
onEscape: { dismissMention() },
onPasteImageData: handlePastedImageData
onPasteImageData: handlePastedImageData,
onPasteImageFailed: onImageAttachmentFailed
)
.fixedSize(horizontal: false, vertical: true)
.background(composerBackground)
Expand Down Expand Up @@ -120,18 +121,19 @@ struct ChatComposerView: View {
guard acceptsImages, !providers.isEmpty else { return false }
Task { @MainActor in
var collected: [ChatImageInput] = []
var lastError: Error?
var failures: [String] = []
for provider in providers {
do {
collected.append(try await ChatImageConverter.convert(itemProvider: provider))
} catch {
lastError = error
failures.append(error.localizedDescription)
}
}
if !collected.isEmpty {
onAttachImages(collected)
} else if let lastError {
onImageAttachmentFailed(lastError.localizedDescription)
}
if let message = ChatImageDropReport.message(attached: collected.count, failures: failures) {
onImageAttachmentFailed(message)
}
}
return true
Expand Down
31 changes: 31 additions & 0 deletions TablePro/Views/AIChat/ChatImageDropReport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// ChatImageDropReport.swift
// TablePro
//

import Foundation

/// What to tell the user after a drop that carried more than one file. Reporting only when every
/// file failed is what let a mixed drop attach one image and discard the rest in silence, so the
/// prompt went out referring to images the model never received.
internal enum ChatImageDropReport {
static func message(attached: Int, failures: [String]) -> String? {
guard let first = failures.first else { return nil }

if attached == 0 {
guard failures.count > 1 else { return first }
return String(
format: String(localized: "Could not attach %1$d files. %2$@"),
failures.count,
first
)
}

return String(
format: String(localized: "Attached %1$d of %2$d files. %3$@"),
attached,
attached + failures.count,
first
)
}
}
8 changes: 7 additions & 1 deletion TablePro/Views/AIChat/ToolApprovalActionsRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct ToolApprovalActionsRow: View {
.buttonStyle(.borderedProminent)
.controlSize(.small)
.keyboardShortcut(takesDefaultAction ? .defaultAction : nil)
.accessibilityLabel(runLabel)

if allowsStandingGrant {
Button {
Expand Down Expand Up @@ -80,7 +81,12 @@ struct ToolApprovalActionsRow: View {
}

/// Each button names its own call. A turn proposing three writes otherwise hands assistive
/// clients three identical "Run" buttons with nothing to tell them apart.
/// clients three identical "Run" buttons with nothing to tell them apart, and Run is the one
/// that executes the statement.
private var runLabel: String {
String(format: String(localized: "Run %@"), toolName)
}

private var rejectLabel: String {
String(format: String(localized: "Reject %@"), toolName)
}
Expand Down
49 changes: 49 additions & 0 deletions TableProTests/Views/AIChat/ChatImageDropReportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// ChatImageDropReportTests.swift
// TableProTests
//
// A drop that attaches some files and discards the rest has to say so. Reporting only when
// everything failed is what let a mixed drop look like a success, so the prompt went out naming
// images the model never received.
//

import Foundation
@testable import TablePro
import Testing

@Suite("Chat image drop report")
struct ChatImageDropReportTests {
@Test("A drop with nothing to report says nothing")
func noFailuresIsSilent() {
#expect(ChatImageDropReport.message(attached: 3, failures: []) == nil)
#expect(ChatImageDropReport.message(attached: 0, failures: []) == nil)
}

@Test("A single total failure is reported verbatim")
func singleFailureIsTheErrorItself() {
#expect(ChatImageDropReport.message(attached: 0, failures: ["Unsupported image"]) == "Unsupported image")
}

@Test("A partial failure names how many landed and how many were offered")
func partialFailureNamesBothCounts() throws {
let message = try #require(ChatImageDropReport.message(attached: 1, failures: ["Unsupported image"]))
#expect(message.contains("1"))
#expect(message.contains("2"))
#expect(message.contains("Unsupported image"))
}

@Test("Several total failures name the count")
func manyFailuresNameTheCount() throws {
let message = try #require(
ChatImageDropReport.message(attached: 0, failures: ["Unsupported image", "Too large"])
)
#expect(message.contains("2"))
#expect(message.contains("Unsupported image"))
}

/// The regression this type exists for: one success beside two failures used to report nothing.
@Test("A mixed drop is never silent")
func mixedDropAlwaysReports() {
#expect(ChatImageDropReport.message(attached: 1, failures: ["a", "b"]) != nil)
}
}
4 changes: 2 additions & 2 deletions docs/features/ai-assistant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Open **Settings > AI** (`Cmd+,`). **Enable AI Features** at the top gates the wh
</Step>
</Steps>

The **Active Provider** picker decides which one handles requests. Where a model supports reasoning effort, its detail sheet adds a picker for it and replies render their thinking in a collapsible **Reasoning** block.
The **Active Provider** picker decides which one handles requests. Setting it to **None** leaves any open conversation readable but read-only, with a **Settings…** button under it. Where a model supports reasoning effort, its detail sheet adds a picker for it and replies render their thinking in a collapsible **Reasoning** block.

With no preference, start with Claude or OpenAI on an API key: both take a key, tools, and images with no further setup. The others each come with something to know.

Expand Down Expand Up @@ -60,7 +60,7 @@ Type a question and press Return. Code blocks carry **Copy** and **Insert**, and

Conversations save themselves and take their title from your first message. The clock icon in the inspector header opens recent ones; the pencil-and-square icon starts a new one. Editing a message you already sent puts its text and attachments back in the composer and drops that turn and everything after it.

Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent.
Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. A drop that carries files the composer cannot read attaches the rest and says how many it kept.

The focused composer carries a colored highlight. Right-click it and turn **Highlight When Focused** off for the standard macOS focus ring instead. The choice is app-wide and survives restarts. Reduce Transparency and Increase Contrast, both under **System Settings > Accessibility > Display**, also replace the highlight with the focus ring.

Expand Down
Loading