Added skills, shortcuts, and bugifixes++

This commit is contained in:
2026-02-18 11:58:45 +01:00
parent 09463d7620
commit 54a8c47df4
24 changed files with 3172 additions and 239 deletions

View File

@@ -17,6 +17,8 @@ struct ConversationListView: View {
@State private var useSemanticSearch = false
@State private var semanticResults: [Conversation] = []
@State private var isSearching = false
@State private var selectedIndex: Int = 0
@FocusState private var searchFocused: Bool
private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)?
@@ -88,11 +90,34 @@ struct ConversationListView: View {
.foregroundStyle(.secondary)
TextField("Search conversations...", text: $searchText)
.textFieldStyle(.plain)
.focused($searchFocused)
.onChange(of: searchText) {
selectedIndex = 0
if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty {
performSemanticSearch()
}
}
#if os(macOS)
.onKeyPress(.upArrow) {
if selectedIndex > 0 {
selectedIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
if selectedIndex < filteredConversations.count - 1 {
selectedIndex += 1
}
return .handled
}
.onKeyPress(.return, phases: .down) { _ in
guard !isSelecting, !filteredConversations.isEmpty else { return .ignored }
let conv = filteredConversations[min(selectedIndex, filteredConversations.count - 1)]
onLoad?(conv)
dismiss()
return .handled
}
#endif
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
@@ -143,80 +168,98 @@ struct ConversationListView: View {
}
Spacer()
} else {
List {
ForEach(filteredConversations) { conversation in
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
} label: {
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
.font(.title2)
}
.buttonStyle(.plain)
}
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
if isSelecting {
ScrollViewReader { proxy in
List {
ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
} else {
onLoad?(conversation)
dismiss()
} label: {
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
.font(.title2)
}
.buttonStyle(.plain)
}
Spacer()
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
if isSelecting {
toggleSelection(conversation.id)
} else {
selectedIndex = index
onLoad?(conversation)
dismiss()
}
}
if !isSelecting {
Button {
Spacer()
if !isSelecting {
Button {
deleteConversation(conversation)
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
.font(.system(size: 16))
}
.buttonStyle(.plain)
.help("Delete conversation")
}
}
.listRowBackground(
!isSelecting && index == selectedIndex
? Color.oaiAccent.opacity(0.15)
: Color.clear
)
.id(conversation.id)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
.font(.system(size: 16))
Label("Delete", systemImage: "trash")
}
.buttonStyle(.plain)
.help("Delete conversation")
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
.listStyle(.plain)
.onChange(of: selectedIndex) {
guard !filteredConversations.isEmpty else { return }
let clamped = min(selectedIndex, filteredConversations.count - 1)
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(filteredConversations[clamped].id, anchor: .center)
}
}
}
.listStyle(.plain)
}
Divider()
// Bottom bar
HStack {
Text("↑↓ navigate ↩ open")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.onAppear {
loadConversations()
searchFocused = true
}
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
}
@@ -251,6 +294,7 @@ struct ConversationListView: View {
selectedConversations.removeAll()
isSelecting = false
}
selectedIndex = 0
}
private func deleteConversation(_ conversation: Conversation) {
@@ -259,6 +303,7 @@ struct ConversationListView: View {
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1))
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
@@ -274,7 +319,6 @@ struct ConversationListView: View {
Task {
do {
// Use user's selected provider, or fall back to best available
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
Log.api.warning("No embedding providers available - skipping semantic search")
await MainActor.run {
@@ -283,13 +327,11 @@ struct ConversationListView: View {
return
}
// Generate embedding for search query
let embedding = try await EmbeddingService.shared.generateEmbedding(
text: searchText,
provider: provider
)
// Search conversations
let results = try DatabaseService.shared.searchConversationsBySemantic(
queryEmbedding: embedding,
limit: 20
@@ -297,6 +339,7 @@ struct ConversationListView: View {
await MainActor.run {
semanticResults = results.map { $0.0 }
selectedIndex = 0
isSearching = false
Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)")
}
@@ -333,26 +376,47 @@ struct ConversationRow: View {
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy HH:mm:ss"
formatter.dateFormat = "dd.MM.yyyy HH:mm"
return formatter.string(from: conversation.updatedAt)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(conversation.name)
.font(.system(size: 16, weight: .semibold))
/// Strips the provider prefix from OpenRouter-style IDs (e.g. "anthropic/claude-3" "claude-3")
private var modelDisplayName: String? {
guard let model = conversation.primaryModel, !model.isEmpty else { return nil }
if let slash = model.lastIndex(of: "/") {
return String(model[model.index(after: slash)...])
}
return model
}
HStack(spacing: 8) {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.name)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
HStack(spacing: 6) {
Label("\(conversation.messageCount)", systemImage: "message")
.font(.system(size: 13))
Text("\u{2022}")
.font(.system(size: 13))
.font(.system(size: 12))
Text("")
.font(.system(size: 12))
Text(formattedDate)
.font(.system(size: 13))
.font(.system(size: 12))
if let model = modelDisplayName {
Text("")
.font(.system(size: 12))
Text(model)
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.middle)
}
}
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
.padding(.vertical, 5)
}
}