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
9 changes: 9 additions & 0 deletions TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ final class AIChatViewModel {
sendWithContext(prompt: prompt, feature: .optimizeQuery)
}

func editMessage(_ message: AIChatMessage) {
guard message.role == .user, !isStreaming else { return }
guard let idx = messages.firstIndex(where: { $0.id == message.id }) else { return }

inputText = message.content
messages.removeSubrange(idx...)
persistCurrentConversation()
}

// MARK: - Constants

/// Maximum number of messages to keep in memory to prevent unbounded growth
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/AIChat/AIChatCodeBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ struct AIChatCodeBlockView: View {
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(actions == nil)
.help(actions == nil
? String(localized: "Focus the query editor to insert")
: String(localized: "Insert into editor"))
}
}
}
Expand Down
50 changes: 33 additions & 17 deletions TablePro/Views/AIChat/AIChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,44 @@ struct AIChatMessageView: View {
let message: AIChatMessage
var onRetry: (() -> Void)?
var onRegenerate: (() -> Void)?
var onEdit: (() -> Void)?

var body: some View {
VStack(alignment: .leading, spacing: 2) {
if message.role == .user {
// User: timestamp header, then message text
HStack(spacing: 4) {
Spacer()
Text("You")
.fontWeight(.medium)
Text("·")
Text(message.timestamp, style: .time)
}
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
// User: timestamp header, then message text in tinted bubble
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Spacer()
Text("You")
.fontWeight(.medium)
Text("·")
Text(message.timestamp, style: .time)
}
.font(.caption2)
.foregroundStyle(.secondary)

Markdown(message.content)
.markdownTheme(.tableProChat)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)

Markdown(message.content)
.markdownTheme(.tableProChat)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 4)
if let onEdit {
HStack {
Spacer()
Button { onEdit() } label: {
Image(systemName: "pencil")
.font(.caption2)
}
.buttonStyle(.plain)
.foregroundStyle(.tertiary)
.help(String(localized: "Edit message"))
}
}
}
.padding(8)
.background(Color.accentColor.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
// Assistant: role header above content
roleHeader
Expand Down
31 changes: 29 additions & 2 deletions TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct AIChatPanelView: View {
@Bindable var viewModel: AIChatViewModel
private let settingsManager = AppSettingsManager.shared
@State private var isUserScrolledUp = false
@State private var scrollProxy: ScrollViewProxy?

private var hasConfiguredProvider: Bool {
settingsManager.ai.providers.contains(where: { $0.isEnabled })
Expand Down Expand Up @@ -110,13 +111,18 @@ struct AIChatPanelView: View {
}
.disabled(viewModel.conversations.isEmpty)
} label: {
HStack(spacing: 4) {
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)
Expand Down Expand Up @@ -160,6 +166,7 @@ struct AIChatPanelView: View {
// MARK: - Message List

private var messageList: some View {
ZStack(alignment: .bottom) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
Expand All @@ -177,7 +184,9 @@ struct AIChatPanelView: View {
AIChatMessageView(
message: message,
onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil,
onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil
onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil,
onEdit: message.role == .user && !viewModel.isStreaming
? { viewModel.editMessage(message) } : nil
)
.padding(.vertical, 4)
.id(message.id)
Expand All @@ -195,6 +204,7 @@ struct AIChatPanelView: View {
}
.scrollIndicators(.hidden)
.onAppear {
scrollProxy = proxy
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.messages.last?.content) {
Expand All @@ -210,6 +220,23 @@ struct AIChatPanelView: View {
scrollToBottom(proxy: proxy)
}
}

if isUserScrolledUp, let proxy = scrollProxy {
Button {
isUserScrolledUp = false
scrollToBottom(proxy: proxy)
} label: {
Image(systemName: "arrow.down.circle.fill")
.font(.title2)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(.bottom, 8)
.transition(.opacity)
.animation(.easeInOut(duration: 0.2), value: isUserScrolledUp)
}
}
}

// MARK: - Error Banner
Expand Down
Loading