diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c1b7cf9a..625a10b1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index fc3bd041df..d1c2dc298f 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -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) @@ -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() @@ -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) diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index da57d150f1..15570d6696 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -57,6 +57,8 @@ struct AIChatPanelView: View { } inputArea + } else if !viewModel.messages.isEmpty { + noProviderFooter } } .environment(\.chatPrimaryPendingToolUseId, primaryPendingToolUseId) @@ -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", @@ -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 { @@ -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))") } } } @@ -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") } diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index e5f68cb8c1..abbb44c972 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -25,6 +25,7 @@ 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() @@ -32,6 +33,7 @@ struct ChatComposerTextView: NSViewRepresentable { textView.placeholder = placeholder textView.acceptsImagePaste = acceptsImages textView.onPasteImageData = onPasteImageData + textView.onPasteImageFailed = onPasteImageFailed textView.highlightEnabled = highlightEnabled textView.onToggleHighlight = onToggleHighlight @@ -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)? @@ -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) diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index a315f31bd1..9f5dfc77e0 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -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) @@ -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 diff --git a/TablePro/Views/AIChat/ChatImageDropReport.swift b/TablePro/Views/AIChat/ChatImageDropReport.swift new file mode 100644 index 0000000000..449df49cd8 --- /dev/null +++ b/TablePro/Views/AIChat/ChatImageDropReport.swift @@ -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 + ) + } +} diff --git a/TablePro/Views/AIChat/ToolApprovalActionsRow.swift b/TablePro/Views/AIChat/ToolApprovalActionsRow.swift index 19fe280ac6..bf3e3398e9 100644 --- a/TablePro/Views/AIChat/ToolApprovalActionsRow.swift +++ b/TablePro/Views/AIChat/ToolApprovalActionsRow.swift @@ -39,6 +39,7 @@ struct ToolApprovalActionsRow: View { .buttonStyle(.borderedProminent) .controlSize(.small) .keyboardShortcut(takesDefaultAction ? .defaultAction : nil) + .accessibilityLabel(runLabel) if allowsStandingGrant { Button { @@ -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) } diff --git a/TableProTests/Views/AIChat/ChatImageDropReportTests.swift b/TableProTests/Views/AIChat/ChatImageDropReportTests.swift new file mode 100644 index 0000000000..a4646c2906 --- /dev/null +++ b/TableProTests/Views/AIChat/ChatImageDropReportTests.swift @@ -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) + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 2a7d378f44..a57083562f 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -26,7 +26,7 @@ Open **Settings > AI** (`Cmd+,`). **Enable AI Features** at the top gates the wh -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. @@ -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.