diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c7da5362..9a825b464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- AI Chat: panel layout redesign. The right inspector now has a Details / AI Chat segmented picker at the top. The chat tab is composer-focused: empty state is a small icon and one-line title, and all chat actions live in a single-row composer footer (mention, slash commands, mode picker, model picker, history, new conversation, send). The mode picker (Ask / Edit / Agent) is saved to settings but does not yet change provider behavior. - AI Chat: inline model picker in the composer with per-turn model attribution. Switch between configured providers and any of their available models without leaving the chat. The model that produced each assistant turn is shown in the message footer. - AI Chat: slash commands `/explain`, `/optimize`, `/fix`, and `/help`. Type the command in the composer or pick from the slash menu next to the model picker. `/explain`, `/optimize`, and `/fix` operate on the current query in the active editor. `/help` lists the commands inline. - AI Chat: attach context to a message via the `@` menu next to the slash menu, or by typing `@` directly in the composer. diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 5b8b08948..38831a8db 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -10,7 +10,7 @@ // items to — NSToolbar must be constructed directly on NSWindow. // // Each item's content is still authored in SwiftUI (`NSHostingView(rootView:)`) -// so existing subviews (ConnectionStatusView, SafeModeBadgeView, popovers, +// so existing subviews (ConnectionStatusView, SafeModeBadgeView, popovers,x // etc.) are reused verbatim. // @@ -35,7 +35,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// Retain the hosting controllers — without this, NSHostingController /// deallocs immediately and its view becomes orphaned, producing zero-size /// items that get pushed right by flexibleSpace. - private var hostingControllers: [NSToolbarItem.Identifier: NSHostingController] = [:] + internal var hostingControllers: [NSToolbarItem.Identifier: NSHostingController] = [:] private var sidebarButtons: [NSButton] = [] private var sidebarObservationTask: Task? private var splitViewObserver: NSObjectProtocol? @@ -218,7 +218,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { // MARK: - Helpers - private func hostingItem( + internal func hostingItem( id: NSToolbarItem.Identifier, label: String, content: Content @@ -353,18 +353,7 @@ private struct NewTabToolbarButton: View { var body: some View { let state = coordinator.toolbarState Button { - coordinator.commandActions?.newTab() - // Defensive: a new window will become key. Restore its first - // responder so AppKit's responder chain — which SwiftUI uses to - // resolve `@FocusedValue` — points back at MainContentView. - // Belt-and-suspenders for the `.focusable(false)` fix in - // `hostingItem`; covers any path where SwiftUI might still - // briefly retain scene focus on the toolbar's hosting controller. - DispatchQueue.main.async { - if let key = NSApp.keyWindow { - key.makeFirstResponder(key.contentView) - } - } + NSApp.sendAction(#selector(NSWindow.newWindowForTab(_:)), to: nil, from: nil) } label: { Label("New Tab", systemImage: "plus.rectangle") } diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 6d2847d19..6e5885154 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -131,6 +131,32 @@ enum AIConnectionPolicy: String, Codable, CaseIterable, Identifiable, Sendable { } } +// MARK: - AI Chat Mode + +enum AIChatMode: String, Codable, CaseIterable, Identifiable, Sendable { + case ask + case edit + case agent + + var id: String { rawValue } + + var displayName: String { + switch self { + case .ask: return String(localized: "Ask") + case .edit: return String(localized: "Edit") + case .agent: return String(localized: "Agent") + } + } + + var symbolName: String { + switch self { + case .ask: return "questionmark.bubble" + case .edit: return "pencil.and.outline" + case .agent: return "infinity" + } + } +} + // MARK: - AI Settings struct AISettings: Codable, Equatable, Sendable { @@ -143,6 +169,7 @@ struct AISettings: Codable, Equatable, Sendable { var includeQueryResults: Bool var maxSchemaTables: Int var defaultConnectionPolicy: AIConnectionPolicy + var chatMode: AIChatMode static let `default` = AISettings( enabled: true, @@ -153,7 +180,8 @@ struct AISettings: Codable, Equatable, Sendable { includeCurrentQuery: false, includeQueryResults: false, maxSchemaTables: 20, - defaultConnectionPolicy: .askEachTime + defaultConnectionPolicy: .askEachTime, + chatMode: .ask ) init( @@ -165,7 +193,8 @@ struct AISettings: Codable, Equatable, Sendable { includeCurrentQuery: Bool = false, includeQueryResults: Bool = false, maxSchemaTables: Int = 20, - defaultConnectionPolicy: AIConnectionPolicy = .askEachTime + defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, + chatMode: AIChatMode = .ask ) { self.enabled = enabled self.providers = providers @@ -176,6 +205,7 @@ struct AISettings: Codable, Equatable, Sendable { self.includeQueryResults = includeQueryResults self.maxSchemaTables = maxSchemaTables self.defaultConnectionPolicy = defaultConnectionPolicy + self.chatMode = chatMode } init(from decoder: Decoder) throws { @@ -191,6 +221,7 @@ struct AISettings: Codable, Equatable, Sendable { defaultConnectionPolicy = try container.decodeIfPresent( AIConnectionPolicy.self, forKey: .defaultConnectionPolicy ) ?? .askEachTime + chatMode = try container.decodeIfPresent(AIChatMode.self, forKey: .chatMode) ?? .ask } var activeProvider: AIProviderConfig? { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 213174546..6431deefc 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -571,6 +571,25 @@ } } } + }, + "**Available commands:**" : { + + }, + "/%@" : { + + }, + "/%@ · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "/%1$@ · %2$@" + } + } + } + }, + "/%@ needs a query: type one in the editor or after the command." : { + }, "/path/to/agent.sock" : { "localizations" : { @@ -768,6 +787,7 @@ } }, "%@ (Pro)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -817,6 +837,16 @@ } } }, + "%@ • %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ • %2$@" + } + } + } + }, "%@ → %@.%@" : { "localizations" : { "en" : { @@ -910,6 +940,9 @@ } } } + }, + "%@ does not support switching schemas in TablePro." : { + }, "%@ download" : { "localizations" : { @@ -4273,6 +4306,9 @@ } } } + }, + "Add Command" : { + }, "Add Connection" : { "localizations" : { @@ -4725,6 +4761,9 @@ }, "Advanced object-relational SQL" : { + }, + "Agent" : { + }, "Agent Socket" : { "localizations" : { @@ -4857,6 +4896,9 @@ } } } + }, + "AI made too many tool calls in one response. Try simplifying the request." : { + }, "AI Not Configured" : { "localizations" : { @@ -4946,6 +4988,9 @@ } } } + }, + "AI responses may be inaccurate" : { + }, "AI-Powered Assistant" : { "localizations" : { @@ -6598,6 +6643,9 @@ } } } + }, + "Ask" : { + }, "Ask about your database..." : { "localizations" : { @@ -6732,6 +6780,9 @@ } } } + }, + "Attach context" : { + }, "Attachments" : { "localizations" : { @@ -8044,6 +8095,9 @@ } } } + }, + "Calling" : { + }, "Cancel" : { "localizations" : { @@ -8729,6 +8783,9 @@ } } } + }, + "Chat mode" : { + }, "Check for Updates..." : { "localizations" : { @@ -8945,6 +9002,9 @@ }, "Choose a section from the sidebar." : { + }, + "Choose AI provider and model" : { + }, "Choose your client and follow the steps to connect it to TablePro." : { @@ -11457,6 +11517,16 @@ } } }, + "Connection: %@, %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connection: %1$@, %2$@" + } + } + } + }, "Connections" : { "localizations" : { "tr" : { @@ -11633,6 +11703,9 @@ } } } + }, + "Conversation history" : { + }, "Conversation History" : { "extractionState" : "stale", @@ -12863,6 +12936,7 @@ } }, "Create connection..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -12883,6 +12957,9 @@ } } } + }, + "Create Connection..." : { + }, "Create Database" : { "localizations" : { @@ -13131,6 +13208,9 @@ } } } + }, + "Create your own slash commands. Use {{query}}, {{schema}}, {{database}}, or {{body}} in the template to insert chat context at runtime." : { + }, "Created" : { "localizations" : { @@ -13335,6 +13415,9 @@ } } } + }, + "Current Query" : { + }, "Current schema: %@ (⌘K to switch)" : { "extractionState" : "stale", @@ -13622,6 +13705,9 @@ } } } + }, + "Custom Slash Commands" : { + }, "Customization" : { @@ -14156,6 +14242,7 @@ } }, "Database type: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -15519,6 +15606,9 @@ }, "Describe Table" : { + }, + "Describe the columns of a table or view." : { + }, "Description" : { "localizations" : { @@ -18247,6 +18337,9 @@ }, "Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation." : { + }, + "Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation. Pass confirmation_phrase exactly as: I understand this is irreversible" : { + }, "Execute a query to view results as JSON" : { "localizations" : { @@ -18269,6 +18362,9 @@ } } } + }, + "Execute a SQL query against a connection. The connection's safe mode policy applies. Multi-statement queries are rejected. Destructive operations (DROP, TRUNCATE, ALTER...DROP) are blocked here; use confirm_destructive_operation instead." : { + }, "Execute a SQL query. All queries are subject to the connection's safe mode policy. DROP/TRUNCATE/ALTER...DROP must use the confirm_destructive_operation tool." : { @@ -18741,6 +18837,9 @@ } } } + }, + "Explain the current query" : { + }, "Explain this SQL query:\n\n```sql\n%@\n```" : { "extractionState" : "stale", @@ -19437,6 +19536,7 @@ } }, "Export query results and tables to Excel format." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21689,6 +21789,9 @@ } } } + }, + "Fix the last error on the current query" : { + }, "Focus an already-open tab by id (returned from list_recent_tabs)." : { @@ -21719,6 +21822,7 @@ }, "Focus the query editor to insert" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -22360,6 +22464,9 @@ }, "Get Connection Status" : { + }, + "Get detailed status for a specific database connection." : { + }, "Get detailed status of a database connection" : { @@ -22368,6 +22475,7 @@ }, "Get help writing queries, explaining schemas, or fixing errors." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -22438,6 +22546,9 @@ }, "Get the CREATE TABLE DDL statement for a table" : { + }, + "Get the DDL (CREATE statement) for a table." : { + }, "GitHub Repository" : { "localizations" : { @@ -26432,6 +26543,7 @@ } }, "License expired — sync paused" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -26452,6 +26564,9 @@ } } } + }, + "License expired, sync paused" : { + }, "License expires in %lld day(s)" : { "localizations" : { @@ -26860,9 +26975,15 @@ }, "List all databases on the server" : { + }, + "List all saved database connections with their current status." : { + }, "List all saved database connections with their status" : { + }, + "List available commands" : { + }, "List Connections" : { @@ -26872,6 +26993,9 @@ }, "List Databases" : { + }, + "List databases available on a connection." : { + }, "List of all saved database connections with metadata" : { @@ -26881,6 +27005,9 @@ }, "List Schemas" : { + }, + "List schemas available in the active database of a connection." : { + }, "List schemas in a database" : { @@ -26890,6 +27017,9 @@ }, "List tables and views in a database" : { + }, + "List tables and views in the active database of a connection." : { + }, "Load" : { "extractionState" : "stale", @@ -29118,11 +29248,15 @@ } } } + }, + "New" : { + }, "New %@ Connection" : { }, "New Chat" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -29994,6 +30128,9 @@ } } } + }, + "No custom commands yet." : { + }, "No Data" : { "localizations" : { @@ -32140,6 +32277,9 @@ } } } + }, + "Open a connection to insert" : { + }, "Open a table tab in TablePro for the given connection." : { @@ -32854,6 +32994,9 @@ } } } + }, + "Optional one-line description" : { + }, "Options" : { "localizations" : { @@ -35643,6 +35786,9 @@ }, "Prompt for password" : { + }, + "Prompt template" : { + }, "Providers" : { "localizations" : { @@ -36092,6 +36238,9 @@ } } } + }, + "Query Results" : { + }, "Query timeout" : { "localizations" : { @@ -37478,6 +37627,9 @@ } } } + }, + "Remove attachment" : { + }, "Remove filter" : { "localizations" : { @@ -38372,6 +38524,9 @@ }, "Restrict to a specific connection (UUID, optional)" : { + }, + "Result" : { + }, "Results" : { "localizations" : { @@ -38578,6 +38733,9 @@ } } } + }, + "review" : { + }, "Revoke" : { "localizations" : { @@ -39849,6 +40007,9 @@ } } } + }, + "Saved Query" : { + }, "Scale" : { "extractionState" : "stale", @@ -39951,6 +40112,9 @@ } } } + }, + "Schema Switching Not Supported" : { + }, "Schemas" : { "localizations" : { @@ -40815,6 +40979,9 @@ } } } + }, + "Select Model" : { + }, "Select Plugin" : { "localizations" : { @@ -41465,7 +41632,6 @@ } }, "Settings" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -42465,6 +42631,9 @@ } } } + }, + "Slash commands" : { + }, "Slow" : { "extractionState" : "stale", @@ -43305,7 +43474,7 @@ } } }, - "SSH agent did not authenticate. Make sure the right key is loaded (try ssh-add -l)." : { + "SSH agent did not authenticate. Run ssh-add -l to check loaded keys." : { }, "SSH authentication failed. Check your credentials or private key." : { @@ -43489,7 +43658,7 @@ } } }, - "SSH password rejected. Check the password for this connection." : { + "SSH password rejected. Check the password and try again." : { }, "SSH Port" : { @@ -43517,7 +43686,7 @@ "SSH port must be between 1 and 65535" : { }, - "SSH private key rejected. Check the key file, passphrase, or pick a different identity." : { + "SSH private key rejected. Check the key file or passphrase." : { }, "SSH Profile" : { @@ -44714,6 +44883,9 @@ } } } + }, + "Suggest optimizations for the current query" : { + }, "Suggested Actions" : { @@ -45623,6 +45795,7 @@ } }, "TablePro" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -46517,7 +46690,7 @@ } } }, - "The previous code wasn't accepted. Wait for your authenticator to roll over and enter the new code." : { + "The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." : { }, "The server will be accessible from other devices on your network. Authentication and TLS are enabled automatically." : { @@ -49975,6 +50148,9 @@ }, "Use" : { + }, + "Use {{query}} for the current editor query, {{schema}} for the active schema, {{database}} for the active database name, and {{body}} for any text typed after the command." : { + }, "Use ~/.pgpass" : { "extractionState" : "stale", @@ -50511,7 +50687,7 @@ "Verification Code Rejected" : { }, - "Verification code rejected. The code may be wrong or expired — try a fresh code from your authenticator." : { + "Verification code rejected. Get a new code from your authenticator app and try again." : { }, "Verification Code Required" : { diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 672c56771..438b1a25e 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -17,8 +17,8 @@ struct AIChatPanelView: View { private let settingsManager = AppSettingsManager.shared @State private var isUserScrolledUp = false @State private var lastAutoScrollTime: Date = .distantPast - @State private var showClearConfirmation = false @State private var mentionState = MentionPopoverState() + @State private var showClearConfirmation = false private var hasConfiguredProvider: Bool { settingsManager.ai.hasActiveProvider @@ -26,8 +26,6 @@ struct AIChatPanelView: View { var body: some View { VStack(spacing: 0) { - header - if !hasConfiguredProvider && viewModel.messages.isEmpty { noProviderState } else if viewModel.messages.isEmpty { @@ -44,6 +42,17 @@ struct AIChatPanelView: View { inputArea } } + .alert( + String(localized: "Clear All Conversations?"), + isPresented: $showClearConfirmation + ) { + Button(String(localized: "Clear"), role: .destructive) { + viewModel.clearConversation() + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text(String(localized: "This will permanently delete all conversation history.")) + } .onAppear { viewModel.connection = connection } @@ -69,96 +78,22 @@ struct AIChatPanelView: View { } message: { Text(String(localized: "Your database schema and query data will be sent to the AI provider for analysis. Allow for this connection?")) } - .alert(String(localized: "Clear All Conversations?"), isPresented: $showClearConfirmation) { - Button(String(localized: "Clear"), role: .destructive) { - viewModel.clearConversation() - } - Button(String(localized: "Cancel"), role: .cancel) {} - } message: { - Text(String(localized: "This will permanently delete all conversation history.")) - } - } - - // MARK: - Header - - private var header: some View { - HStack(spacing: 0) { - // Left: New conversation button - Button { - viewModel.startNewConversation() - } label: { - Image(systemName: "square.and.pencil") - .foregroundStyle(.secondary) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .help(String(localized: "New Conversation")) - - Spacer() - - // Center: Conversation title as dropdown - Menu { - if !viewModel.conversations.isEmpty { - Section(String(localized: "Recent Conversations")) { - ForEach(viewModel.conversations) { conversation in - Button { - viewModel.switchConversation(to: conversation.id) - } label: { - HStack { - Text(conversation.title.isEmpty - ? String(localized: "Untitled") - : conversation.title) - if conversation.id == viewModel.activeConversationID { - Image(systemName: "checkmark") - } - } - } - } - } - Divider() - } - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label(String(localized: "Clear Recents"), systemImage: "trash") - } - .disabled(viewModel.conversations.isEmpty) - } label: { - VStack(spacing: 2) { - let title = viewModel.conversations - .first(where: { $0.id == viewModel.activeConversationID })?.title - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - Text(title.isEmpty ? String(localized: "New Chat") : title) - .font(.headline) - .foregroundStyle(.primary) - if let connectionName = viewModel.connection?.name { - Text(connectionName) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - } - .menuStyle(.borderlessButton) - .fixedSize() - - Spacer() - - // Right: Spacer to balance layout (history menu removed) - Color.clear - .frame(width: 32, height: 32) - } - .padding(.horizontal, 8) - .padding(.vertical, 8) } // MARK: - Empty States private var emptyState: some View { - EmptyStateView( - icon: "sparkles", - title: String(localized: "Ask AI about your database"), - description: String(localized: "Get help writing queries, explaining schemas, or fixing errors.") - ) + VStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 22)) + .foregroundStyle(.secondary) + Text(String(localized: "Ask AI about your database")) + .font(.callout) + .foregroundStyle(.primary) + Text(String(localized: "AI responses may be inaccurate")) + .font(.caption) + .foregroundStyle(.secondary) + } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -318,37 +253,127 @@ struct AIChatPanelView: View { ) HStack(alignment: .center, spacing: 8) { - modelPicker - slashCommandMenu mentionMenu + slashCommandMenu + modeMenu + modelPicker Spacer() - if viewModel.isStreaming { - Button { - viewModel.cancelStream() - } label: { - Image(systemName: "stop.circle.fill") - .foregroundStyle(Color(nsColor: .systemRed)) - } - .buttonStyle(.plain) - .help(String(localized: "Stop Generating")) - } else { + historyMenu + newConversationButton + sendOrStopButton + } + } + .padding(8) + } + } + + private var modeMenu: some View { + let binding = Binding( + get: { settingsManager.ai.chatMode }, + set: { newValue in + var settings = settingsManager.ai + settings.chatMode = newValue + settingsManager.ai = settings + } + ) + return Menu { + Picker("", selection: binding) { + ForEach(AIChatMode.allCases) { mode in + Label(mode.displayName, systemImage: mode.symbolName) + .tag(mode) + } + } + .pickerStyle(.inline) + .labelsHidden() + } label: { + HStack(spacing: 4) { + Image(systemName: settingsManager.ai.chatMode.symbolName) + Text(settingsManager.ai.chatMode.displayName) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(String(localized: "Chat mode")) + } + + private var newConversationButton: some View { + Button { + viewModel.startNewConversation() + } label: { + Image(systemName: "square.and.pencil") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "New Conversation")) + } + + private var historyMenu: some View { + Menu { + if !viewModel.conversations.isEmpty { + Section(String(localized: "Recent Conversations")) { + ForEach(viewModel.conversations) { conversation in Button { - updateContext() - viewModel.sendMessage() + viewModel.switchConversation(to: conversation.id) } label: { - Image(systemName: "arrow.up.circle.fill") - .foregroundStyle( - viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? .secondary : Color.accentColor - ) + HStack { + Text(conversation.title.isEmpty + ? String(localized: "Untitled") + : conversation.title) + if conversation.id == viewModel.activeConversationID { + Image(systemName: "checkmark") + } + } } - .buttonStyle(.plain) - .disabled(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .help(String(localized: "Send Message")) } } + Divider() } - .padding(8) + Button(role: .destructive) { + showClearConfirmation = true + } label: { + Label(String(localized: "Clear Recents"), systemImage: "trash") + } + .disabled(viewModel.conversations.isEmpty) + } label: { + Image(systemName: "clock") + .font(.caption) + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(String(localized: "Conversation history")) + } + + @ViewBuilder + private var sendOrStopButton: some View { + if viewModel.isStreaming { + Button { + viewModel.cancelStream() + } label: { + Image(systemName: "stop.circle.fill") + .foregroundStyle(Color(nsColor: .systemRed)) + } + .buttonStyle(.plain) + .help(String(localized: "Stop Generating")) + } else { + let isEmpty = viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + Button { + updateContext() + viewModel.sendMessage() + } label: { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(isEmpty ? .secondary : Color.accentColor) + } + .buttonStyle(.plain) + .disabled(isEmpty) + .help(String(localized: "Send Message")) } } @@ -363,7 +388,7 @@ struct AIChatPanelView: View { let selectedProvider = providers.first(where: { $0.id == selectedProviderId }) ?? activeProvider let resolvedModel = viewModel.selectedModel ?? selectedProvider?.model ?? "" let label = selectedProvider.map { provider in - resolvedModel.isEmpty ? provider.displayName : "\(provider.displayName) · \(resolvedModel)" + resolvedModel.isEmpty ? provider.displayName : resolvedModel } ?? String(localized: "Select Model") Menu { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 82e95757f..b73df58ea 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -113,8 +113,7 @@ final class MainContentCoordinator { /// Direct reference to AI chat viewmodel — eliminates notification broadcasts weak var aiViewModel: AIChatViewModel? - /// Direct reference to right panel state — enables showing AI panel programmatically - @ObservationIgnored weak var rightPanelState: RightPanelState? + weak var rightPanelState: RightPanelState? /// Direct reference to the data tab grid delegate — enables row mutation operations to /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index 00e399d45..a35697599 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -2,9 +2,6 @@ // UnifiedRightPanelView.swift // TablePro // -// Unified right panel combining Details and AI Chat into a single -// segmented panel, reducing clutter and preserving AI conversation state. -// import SwiftUI @@ -12,10 +9,49 @@ struct UnifiedRightPanelView: View { @Bindable var state: RightPanelState let connection: DatabaseConnection - private var ctx: InspectorContext { state.inspectorContext } + private let settingsManager = AppSettingsManager.shared + + var body: some View { + Group { + if settingsManager.ai.enabled { + splitContent + } else { + detailsView + } + } + .onChange(of: settingsManager.ai.enabled) { + if !settingsManager.ai.enabled { + state.activeTab = .details + } + } + } + + private var splitContent: some View { + VStack(spacing: 0) { + tabPicker + Divider() + switch state.activeTab { + case .details: detailsView + case .aiChat: aiChatView + } + } + } + + private var tabPicker: some View { + Picker("", selection: $state.activeTab) { + ForEach(RightPanelTab.allCases, id: \.self) { tab in + Text(tab.localizedTitle).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 12) + .padding(.vertical, 6) + } private var detailsView: some View { - RightSidebarView( + let ctx = state.inspectorContext + return RightSidebarView( tableName: ctx.tableName, tableMetadata: ctx.tableMetadata, selectedRowData: ctx.selectedRowData, @@ -26,41 +62,13 @@ struct UnifiedRightPanelView: View { ) } - var body: some View { - VStack(spacing: 0) { - if AppSettingsManager.shared.ai.enabled { - Picker("", selection: $state.activeTab) { - ForEach(RightPanelTab.allCases, id: \.self) { tab in - Label(tab.localizedTitle, systemImage: tab.systemImage) - .tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 12) - .padding(.vertical, 8) - - Divider() - - switch state.activeTab { - case .details: - detailsView - case .aiChat: - AIChatPanelView( - connection: connection, - currentQuery: ctx.currentQuery, - queryResults: ctx.queryResults, - viewModel: state.aiViewModel - ) - } - } else { - detailsView - } - } - .onChange(of: AppSettingsManager.shared.ai.enabled) { - if !AppSettingsManager.shared.ai.enabled { - state.activeTab = .details - } - } + private var aiChatView: some View { + let ctx = state.inspectorContext + return AIChatPanelView( + connection: connection, + currentQuery: ctx.currentQuery, + queryResults: ctx.queryResults, + viewModel: state.aiViewModel + ) } } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cf4b58cd5..5ee408606 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,49 @@ description: "Product updates and announcements for TablePro" rss: true --- + + ### New Features + + - **Linked SQL Folders**: Point TablePro at a folder of `.sql` files for two-way sync with Favorites. Edit in TablePro or your editor; changes flow both ways. Frontmatter sets `@name`, `@keyword`, and `@description`; save-time conflicts show a side-by-side diff. Free. + - **Database Type Chooser**: New Connection now opens a categorised, searchable sheet listing every supported driver (Relational, Document, Key-Value, Analytical, Wide-Column, Cloud Native, Coordination & Config). Editing an existing connection skips the chooser + - **Free XLSX Export**: Excel export no longer requires Pro + - **Free Safe Mode**: Touch ID, Full, and Read Only Safe Mode levels no longer require Pro + + ### Improvements + + - **iOS Streaming Data Layer**: Large tables and queries stream rows instead of buffering the full result set. Memory pressure shrinks the row window automatically; CSV / JSON / SQL export stream too + - **Distinguishable Toolbar**: Multiple windows on the same database (e.g. `prod-safe`, `prod-unsafe`, `staging`) show a tinted engine icon plus connection name instead of duplicate "PostgreSQL 16.x" text (#1044) + - **Connection-Scoped Favorites**: Opening a second tab on the same connection no longer reloads the favorites tree or flashes a spinner. Selection persists across windows + - **Connection Form Redesign**: Rebuilt around macOS HIG sidebar navigation (General, SSH Tunnel, SSL/TLS, Customization, Advanced). Cancel, Save, and Save & Connect live in the native window toolbar; Test Connection inline + - **Connection URL Import**: Moved into the database type chooser; paste a URL there instead of inside the form + - **HIG Polish**: Hero icons scale with system text size, search fields use native `NSSearchField`, validation banners use the standard warning convention, and form sheets switch to grouped Form layouts + - **Native Windows**: Welcome, Connection Form, and Integrations Activity now use SwiftUI scenes, fixing an assertion crash and restoring Integrations Activity at next launch + - **ER Diagram Accessibility**: Diagram nodes scale with system text size + - **Terminology**: "Read-Only" / "Read-Write" renamed to "Read Only" / "Read & Write" to match macOS HIG + + ### Bug Fixes + + - **Schema Switching on SQL Server / Oracle**: Cmd+K Quick Switcher schema selection no longer silently ignored + - **iOS MySQL Crash**: Fixed `EXC_BREAKPOINT` when opening some MySQL tables + - **iOS Local Network**: Connections to `.local` hostnames and local-network addresses (10.x, 192.168.x, link-local, IPv6 ULA) no longer time out silently + - **MariaDB Install Prompt**: New connection chooser no longer falsely prompts to install built-in lazy drivers + - **IME Editor Jump**: SQL editor no longer jumps to the end after committing Chinese, Japanese, or Korean words like "测试" (#1012) + - **Personal Team Builds**: External-contributor builds on personal Apple Developer teams now work without an iCloud entitlement (#1020) + - **SSH Auth Errors**: Auth-failure alerts now point at the actual cause (wrong password, wrong verification code, rejected key) (#1005) + - **TOTP Rotation**: Codes that crossed a 30-second rotation boundary are no longer rejected + - **SSH Password vs Keyboard-Interactive**: Auth Method = Password now works against servers that only advertise keyboard-interactive (typical `pam_google_authenticator` setups) (#1005) + - **SSH + Google Authenticator**: Connections with Password + TOTP no longer fail (#1005) + - **Editor Caret Edge Cases**: Up arrow on the first line, Down on the last line, and Cmd+Left/Right at end-of-line with no trailing newline now work correctly (#1007) + - **Caret Gutter Color**: Caret at end of query keeps its line-number color in the gutter + - **Multi-Window Tab Persistence**: All windows now persist on relaunch instead of all-but-one being dropped + - **Filter Autocomplete Focus**: Popover no longer steals keyboard focus from the text field on Full Keyboard Access + - **Toolbar Database Name**: No longer empty after relaunch when the last-used database is restored + - **Cmd+K Database Switch**: Switching databases no longer reverts in Cmd+T, query history, AI prompts, or several tab-creation paths (#1043) + - **AI Provider Test**: No longer shows `unsupported URL` while editing a draft endpoint + - **Data Grid Header Inset**: Column headers now align with result-cell horizontal inset + - **Toolbar Status Inset**: Connection status keeps its left inset when no connection tag is shown + + ### New Features diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 0d4ca038b..434212a3e 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -43,7 +43,7 @@ Add Copilot like any other provider. The detail sheet runs GitHub's device-flow ## Chat -Press `Cmd+Shift+L`, click the AI toolbar button, or use **View** > **Toggle AI Chat**. +Press `Cmd+Shift+L`, click the inspector toggle and pick **AI Chat**, or use **View** > **Toggle AI Chat**. The right inspector has a Details / AI Chat segmented picker at the top. -Conversations auto-save and auto-title from your first message. Browse, clear, or start a new one from the panel header. +Conversations auto-save and auto-title from your first message. The composer footer's clock icon opens recent conversations and the pencil-and-square icon starts a new one.