diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a825b464..5f4830bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- New tab via Cmd+T no longer flashes focus back to the previous tab in the same window group +- Cmd+X with no selection cuts the current line, matching VS Code, Sublime, and Xcode (#1075) + ### 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: panel layout redesign. The right inspector has a Details / AI Chat segmented picker at the top, with conversation history and new-conversation actions trailing on the same row. The chat tab is composer-focused: composer is a pill-shaped input with an Apple Intelligence focus glow, and a single-row footer (mention, slash commands, mode picker, model picker, 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/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 438b1a25e..43b0f95ba 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -18,7 +18,6 @@ struct AIChatPanelView: View { @State private var isUserScrolledUp = false @State private var lastAutoScrollTime: Date = .distantPast @State private var mentionState = MentionPopoverState() - @State private var showClearConfirmation = false private var hasConfiguredProvider: Bool { settingsManager.ai.hasActiveProvider @@ -42,17 +41,6 @@ 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 } @@ -83,17 +71,11 @@ struct AIChatPanelView: View { // MARK: - Empty States private var emptyState: some View { - 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) - } + EmptyStateView( + icon: "sparkles", + title: String(localized: "Ask AI about your database"), + description: String(localized: "AI responses may be inaccurate") + ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -234,7 +216,7 @@ struct AIChatPanelView: View { onRemove: { viewModel.detach($0) } ) - ChatComposerTextView( + ChatComposerView( text: $viewModel.inputText, placeholder: String(localized: "Ask about your database..."), minLines: 1, @@ -258,8 +240,6 @@ struct AIChatPanelView: View { modeMenu modelPicker Spacer() - historyMenu - newConversationButton sendOrStopButton } } @@ -301,56 +281,6 @@ struct AIChatPanelView: View { .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 { - 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: { - 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 { diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift deleted file mode 100644 index 1d5e88944..000000000 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// ChatComposerTextView.swift -// TablePro -// - -import AppKit -import SwiftUI - -struct ChatComposerTextView: NSViewRepresentable { - @Binding var text: String - let placeholder: String - let minLines: Int - let maxLines: Int - let mentionState: MentionPopoverState - let onTextChange: (String, Int) -> Void - let onSubmit: () -> Void - let onAttach: (ContextItem) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> ChatComposerScrollContainer { - let textView = ChatComposerNSTextView() - textView.delegate = context.coordinator - textView.placeholderString = placeholder - textView.isRichText = false - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isAutomaticTextCompletionEnabled = false - textView.allowsUndo = true - textView.font = .systemFont(ofSize: NSFont.systemFontSize) - textView.textContainerInset = NSSize(width: 6, height: 6) - textView.string = text - - let container = ChatComposerScrollContainer( - textView: textView, - minLines: minLines, - maxLines: maxLines - ) - context.coordinator.scrollContainer = container - return container - } - - func updateNSView(_ container: ChatComposerScrollContainer, context: Context) { - context.coordinator.parent = self - let textView = container.textView - if textView.string != text { - context.coordinator.isUpdatingFromBinding = true - textView.string = text - context.coordinator.isUpdatingFromBinding = false - container.invalidateIntrinsicContentSize() - } - context.coordinator.syncPopover() - } - - @MainActor - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: ChatComposerTextView - weak var scrollContainer: ChatComposerScrollContainer? - var isUpdatingFromBinding = false - private var popover: NSPopover? - private var hostingController: NSHostingController? - - init(parent: ChatComposerTextView) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard !isUpdatingFromBinding, - let textView = notification.object as? NSTextView else { return } - parent.text = textView.string - scrollContainer?.invalidateIntrinsicContentSize() - parent.onTextChange(textView.string, textView.selectedRange().location) - } - - func textViewDidChangeSelection(_ notification: Notification) { - guard !isUpdatingFromBinding, - let textView = notification.object as? NSTextView else { return } - parent.onTextChange(textView.string, textView.selectedRange().location) - } - - func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { - switch selector { - case #selector(NSStandardKeyBindingResponding.insertNewline(_:)): - return handleEnter(in: textView) - case #selector(NSStandardKeyBindingResponding.cancelOperation(_:)): - return handleEscape() - case #selector(NSStandardKeyBindingResponding.moveDown(_:)): - return handleArrow(delta: 1) - case #selector(NSStandardKeyBindingResponding.moveUp(_:)): - return handleArrow(delta: -1) - case #selector(NSStandardKeyBindingResponding.insertTab(_:)): - if parent.mentionState.isVisible { - commitSelectedMention(in: textView) - return true - } - return false - default: - return false - } - } - - func syncPopover() { - guard let textView = scrollContainer?.textView else { return } - if parent.mentionState.isVisible, !parent.mentionState.candidates.isEmpty { - showOrUpdatePopover(textView: textView) - } else { - dismissPopover() - } - } - - private func handleEnter(in textView: NSTextView) -> Bool { - if parent.mentionState.isVisible, !parent.mentionState.candidates.isEmpty { - commitSelectedMention(in: textView) - return true - } - if NSEvent.modifierFlags.contains(.shift) { - return false - } - parent.onSubmit() - return true - } - - private func handleEscape() -> Bool { - guard parent.mentionState.isVisible else { return false } - parent.mentionState.reset() - dismissPopover() - return true - } - - private func handleArrow(delta: Int) -> Bool { - guard parent.mentionState.isVisible, !parent.mentionState.candidates.isEmpty else { - return false - } - parent.mentionState.moveSelection(by: delta) - return true - } - - private func showOrUpdatePopover(textView: NSTextView) { - guard let rect = caretRect(in: textView) else { - dismissPopover() - return - } - if popover == nil { - let listView = MentionSuggestionListView( - state: parent.mentionState, - onSelect: { [weak self, weak textView] index in - guard let self, let textView else { return } - self.parent.mentionState.selectedIndex = index - self.commitSelectedMention(in: textView) - } - ) - let hosting = NSHostingController(rootView: listView) - hosting.sizingOptions = NSHostingSizingOptions.preferredContentSize - let newPopover = NSPopover() - newPopover.behavior = .transient - newPopover.animates = false - newPopover.contentViewController = hosting - self.hostingController = hosting - self.popover = newPopover - } - guard let popover else { return } - if popover.isShown { - popover.positioningRect = rect - } else { - popover.show(relativeTo: rect, of: textView, preferredEdge: .maxY) - } - } - - private func dismissPopover() { - popover?.performClose(nil) - } - - private func caretRect(in textView: NSTextView) -> NSRect? { - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { return nil } - let nsText = textView.string as NSString - let caret = min(textView.selectedRange().location, nsText.length) - let glyphIndex = layoutManager.glyphIndexForCharacter(at: caret) - let glyphRect = layoutManager.boundingRect( - forGlyphRange: NSRange(location: glyphIndex, length: 0), - in: textContainer - ) - let origin = textView.textContainerOrigin - var rect = glyphRect.offsetBy(dx: origin.x, dy: origin.y) - if rect.width < 1 { rect.size.width = 1 } - return rect - } - - private func commitSelectedMention(in textView: NSTextView) { - guard let candidate = parent.mentionState.selectedCandidate else { return } - let range = parent.mentionState.anchorRange - let nsText = textView.string as NSString - guard range.location >= 0, - NSMaxRange(range) <= nsText.length else { - parent.mentionState.reset() - dismissPopover() - return - } - if textView.shouldChangeText(in: range, replacementString: "") { - textView.replaceCharacters(in: range, with: "") - textView.didChangeText() - } - parent.onAttach(candidate.item) - parent.mentionState.reset() - dismissPopover() - } - } -} - -@MainActor -final class ChatComposerNSTextView: NSTextView { - var placeholderString: String = "" { - didSet { needsDisplay = true } - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - guard string.isEmpty, !placeholderString.isEmpty else { return } - let attrs: [NSAttributedString.Key: Any] = [ - .font: font ?? .systemFont(ofSize: NSFont.systemFontSize), - .foregroundColor: NSColor.placeholderTextColor - ] - let inset = textContainerInset - let padding = textContainer?.lineFragmentPadding ?? 5 - let origin = NSPoint(x: inset.width + padding, y: inset.height) - (placeholderString as NSString).draw(at: origin, withAttributes: attrs) - } -} - -@MainActor -final class ChatComposerScrollContainer: NSView { - let textView: ChatComposerNSTextView - private let scrollView: NSScrollView - private let minLines: Int - private let maxLines: Int - - init(textView: ChatComposerNSTextView, minLines: Int, maxLines: Int) { - self.textView = textView - self.minLines = minLines - self.maxLines = maxLines - let scroll = NSScrollView() - scroll.hasVerticalScroller = true - scroll.autohidesScrollers = true - scroll.borderType = .bezelBorder - scroll.drawsBackground = false - scroll.documentView = textView - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.autoresizingMask = [.width] - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize( - width: 0, - height: CGFloat.greatestFiniteMagnitude - ) - self.scrollView = scroll - super.init(frame: .zero) - addSubview(scroll) - scroll.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - scroll.topAnchor.constraint(equalTo: topAnchor), - scroll.leadingAnchor.constraint(equalTo: leadingAnchor), - scroll.trailingAnchor.constraint(equalTo: trailingAnchor), - scroll.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - let lineHeight = textView.font?.boundingRectForFont.height ?? 17 - let insetHeight = textView.textContainerInset.height * 2 - let minHeight = lineHeight * CGFloat(minLines) + insetHeight + 4 - let maxHeight = lineHeight * CGFloat(maxLines) + insetHeight + 4 - guard let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer else { - return NSSize(width: NSView.noIntrinsicMetric, height: ceil(minHeight)) - } - let used = layoutManager.usedRect(for: textContainer).height - let content = used + insetHeight + 4 - let clamped = max(minHeight, min(content, maxHeight)) - return NSSize(width: NSView.noIntrinsicMetric, height: ceil(clamped)) - } -} diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift new file mode 100644 index 000000000..68b0503b1 --- /dev/null +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -0,0 +1,192 @@ +// +// ChatComposerView.swift +// TablePro +// + +import SwiftUI + +struct ChatComposerView: View { + @Binding var text: String + let placeholder: String + let minLines: Int + let maxLines: Int + @Bindable var mentionState: MentionPopoverState + let onTextChange: (String, Int) -> Void + let onSubmit: () -> Void + let onAttach: (ContextItem) -> Void + + @FocusState private var isFocused: Bool + @State private var isCommittingMention = false + + var body: some View { + TextField(placeholder, text: $text, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(minLines...maxLines) + .focused($isFocused) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(composerBackground) + .onChange(of: text) { _, newText in + guard !isCommittingMention else { return } + onTextChange(newText, (newText as NSString).length) + } + .onSubmit(handleSubmit) + .onKeyPress(.upArrow) { handleArrow(by: -1) } + .onKeyPress(.downArrow) { handleArrow(by: 1) } + .onKeyPress(.tab) { handleTab() } + .onKeyPress(.escape) { handleEscape() } + .popover( + isPresented: popoverBinding, + attachmentAnchor: .point(.bottom), + arrowEdge: .top + ) { + MentionSuggestionListView( + state: mentionState, + onSelect: { commitMention(at: $0) } + ) + } + } + + private var composerBackground: some View { + let shape = Capsule(style: .continuous) + return shape + .fill(Color(nsColor: .textBackgroundColor)) + .overlay { + if isFocused { + IntelligenceFocusBorder(shape: shape) + .transition(.opacity) + } else { + shape.stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.25), value: isFocused) + } + + private var popoverBinding: Binding { + Binding( + get: { mentionState.isVisible && !mentionState.candidates.isEmpty }, + set: { newValue in + if !newValue { mentionState.reset() } + } + ) + } + + private func handleSubmit() { + if mentionState.isVisible, !mentionState.candidates.isEmpty { + commitMention(at: mentionState.selectedIndex) + } else { + onSubmit() + } + } + + private func handleArrow(by delta: Int) -> KeyPress.Result { + guard mentionState.isVisible, !mentionState.candidates.isEmpty else { + return .ignored + } + mentionState.moveSelection(by: delta) + return .handled + } + + private func handleTab() -> KeyPress.Result { + guard mentionState.isVisible, !mentionState.candidates.isEmpty else { + return .ignored + } + commitMention(at: mentionState.selectedIndex) + return .handled + } + + private func handleEscape() -> KeyPress.Result { + guard mentionState.isVisible else { return .ignored } + mentionState.reset() + return .handled + } + + private func commitMention(at index: Int) { + guard mentionState.candidates.indices.contains(index) else { return } + let candidate = mentionState.candidates[index] + let nsText = text as NSString + let range = mentionState.anchorRange + guard range.location >= 0, NSMaxRange(range) <= nsText.length else { + mentionState.reset() + return + } + isCommittingMention = true + defer { isCommittingMention = false } + let prefix = nsText.substring(to: range.location) + let suffix = nsText.substring(from: NSMaxRange(range)) + text = prefix + suffix + onAttach(candidate.item) + mentionState.reset() + } +} + +private enum IntelligenceShimmer { + static let palette: [Color] = [ + Color(red: 0.737, green: 0.510, blue: 0.953), + Color(red: 0.961, green: 0.725, blue: 0.918), + Color(red: 0.553, green: 0.624, blue: 1.0), + Color(red: 1.0, green: 0.404, blue: 0.471), + Color(red: 1.0, green: 0.729, blue: 0.443), + Color(red: 0.776, green: 0.525, blue: 1.0) + ] + + struct Layer: Identifiable { + let id: Int + let lineWidth: CGFloat + let blur: CGFloat + let duration: TimeInterval + let opacity: Double + } + + static let layers: [Layer] = [ + Layer(id: 0, lineWidth: 1.5, blur: 0, duration: 0.5, opacity: 1.0), + Layer(id: 1, lineWidth: 5, blur: 4, duration: 0.6, opacity: 0.75), + Layer(id: 2, lineWidth: 9, blur: 10, duration: 0.8, opacity: 0.5), + Layer(id: 3, lineWidth: 14, blur: 16, duration: 1.0, opacity: 0.35) + ] + + static let updateInterval: Duration = .milliseconds(400) + + static func generateStops() -> [Gradient.Stop] { + let shuffled = palette.shuffled() + let lastIndex = max(1, shuffled.count - 1) + return shuffled.enumerated().map { index, color in + let base = Double(index) / Double(lastIndex) + let jitter = Double.random(in: -0.05...0.05) + return Gradient.Stop(color: color, location: min(1, max(0, base + jitter))) + } + } +} + +private struct IntelligenceFocusBorder: View { + let shape: S + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var stops: [Gradient.Stop] = IntelligenceShimmer.generateStops() + + var body: some View { + ZStack { + ForEach(IntelligenceShimmer.layers) { layer in + shape + .stroke( + AngularGradient(gradient: Gradient(stops: stops), center: .center), + lineWidth: layer.lineWidth + ) + .blur(radius: layer.blur) + .opacity(layer.opacity) + .animation( + reduceMotion ? nil : .easeInOut(duration: layer.duration), + value: stops + ) + } + } + .task(id: reduceMotion) { + guard !reduceMotion else { return } + while !Task.isCancelled { + try? await Task.sleep(for: IntelligenceShimmer.updateInterval) + stops = IntelligenceShimmer.generateStops() + } + } + } +} diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index a35697599..10897ccc3 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -10,6 +10,7 @@ struct UnifiedRightPanelView: View { let connection: DatabaseConnection private let settingsManager = AppSettingsManager.shared + @State private var showClearConfirmation = false var body: some View { Group { @@ -24,11 +25,22 @@ struct UnifiedRightPanelView: View { state.activeTab = .details } } + .alert( + String(localized: "Clear All Conversations?"), + isPresented: $showClearConfirmation + ) { + Button(String(localized: "Clear"), role: .destructive) { + state.aiViewModel.clearConversation() + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text(String(localized: "This will permanently delete all conversation history.")) + } } private var splitContent: some View { VStack(spacing: 0) { - tabPicker + inspectorHeader Divider() switch state.activeTab { case .details: detailsView @@ -37,6 +49,19 @@ struct UnifiedRightPanelView: View { } } + private var inspectorHeader: some View { + HStack(alignment: .center, spacing: 4) { + tabPicker + Spacer(minLength: 8) + if state.activeTab == .aiChat { + historyMenu + newConversationButton + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + private var tabPicker: some View { Picker("", selection: $state.activeTab) { ForEach(RightPanelTab.allCases, id: \.self) { tab in @@ -45,8 +70,65 @@ struct UnifiedRightPanelView: View { } .pickerStyle(.segmented) .labelsHidden() - .padding(.horizontal, 12) - .padding(.vertical, 6) + .fixedSize() + } + + private var newConversationButton: some View { + Button { + state.aiViewModel.startNewConversation() + } label: { + inspectorIcon("square.and.pencil") + } + .buttonStyle(.plain) + .frame(width: 24, height: 22) + .contentShape(Rectangle()) + .help(String(localized: "New Conversation")) + } + + private var historyMenu: some View { + Menu { + let viewModel = state.aiViewModel + 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: { + inspectorIcon("clock") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(width: 24, height: 22) + .contentShape(Rectangle()) + .help(String(localized: "Conversation history")) + } + + private func inspectorIcon(_ systemName: String) -> some View { + Image(systemName: systemName) + .font(.system(size: 13, weight: .regular)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private var detailsView: some View {