From e2fed047e08715d7d85f57d8e49ba39e6de98b70 Mon Sep 17 00:00:00 2001 From: lk340 Date: Fri, 4 Apr 2025 14:45:07 -0400 Subject: [PATCH 1/3] Add undo chat history deletion. Update chat history deletion to track failed deletion individually on a chat-by-chat level. Fix long chat name truncation in history view. --- macos/Onit/Data/Model/Model+History.swift | 57 +++++++- macos/Onit/Data/Model/OnitModel.swift | 18 ++- .../Onit/General/Styles/AnimationStyle.swift | 8 ++ .../General/Styles/HoverableButtonStyle.swift | 2 +- macos/Onit/UI/Components/ProgressBar.swift | 81 +++++++++++ .../History/HistoryDeleteNotification.swift | 133 ++++++++++++++++++ macos/Onit/UI/History/HistoryRowView.swift | 18 +-- macos/Onit/UI/History/HistoryView.swift | 59 ++++++-- 8 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 macos/Onit/General/Styles/AnimationStyle.swift create mode 100644 macos/Onit/UI/Components/ProgressBar.swift create mode 100644 macos/Onit/UI/History/HistoryDeleteNotification.swift diff --git a/macos/Onit/Data/Model/Model+History.swift b/macos/Onit/Data/Model/Model+History.swift index 1d4d5cce..81bf3a9b 100644 --- a/macos/Onit/Data/Model/Model+History.swift +++ b/macos/Onit/Data/Model/Model+History.swift @@ -5,6 +5,8 @@ // Created by Loyd Kim on 3/31/25. // +import Foundation + extension OnitModel { func setChat(chat: Chat, index: Int) { currentChat = chat @@ -13,17 +15,66 @@ extension OnitModel { historyIndex = index } - func deleteChat(chat: Chat) { + /// Delete Queue + + func deleteChat(chat: Chat) async { do { - deleteChatFailed = false + await removeChatFromDeleteFailedQueue(chatId: chat.id, waitSeconds: 0) + + try await Task.sleep(for: .seconds(deleteChatDurationSeconds)) + try Task.checkCancellation() + + removeChatFromDeleteQueue(chatId: chat.id) + container.mainContext.delete(chat) try container.mainContext.save() } catch { - deleteChatFailed = true + addChatToDeleteFailedQueue(chatId: chat.id) #if DEBUG print("Chat delete error: \(error)") #endif } } + + func addChatToDeleteQueue(chat: Chat) { + let deleteChatQueueItem = DeleteChatQueueItem( + name: HistoryRowView.getPromptText(chat: chat), + chatId: chat.id, + startTime: Date(), + deleteChatTask: Task { await deleteChat(chat: chat) } + ) + deleteChatQueue.append(deleteChatQueueItem) + } + + func removeChatFromDeleteQueue(chatId: DeleteChatId) { + if let queueItemIndex = deleteChatQueue.firstIndex(where: { $0.chatId == chatId }) { + deleteChatQueue[queueItemIndex].deleteChatTask.cancel() + deleteChatQueue.remove(at: queueItemIndex) + } + } + + func emptyDeleteChatQueue() { + for item in deleteChatQueue { item.deleteChatTask.cancel() } + deleteChatQueue.removeAll() + } + + /// Delete Failed Queue + + func addChatToDeleteFailedQueue(chatId: DeleteChatId) { + deleteChatFailedQueue[chatId] = Task { + await removeChatFromDeleteFailedQueue(chatId: chatId, waitSeconds: 2) + } + } + + func removeChatFromDeleteFailedQueue(chatId: DeleteChatId, waitSeconds: Int) async { + try? await Task.sleep(for: .seconds(waitSeconds)) + try? Task.checkCancellation() + + if let deleteFailedTask = deleteChatFailedQueue[chatId] { + deleteFailedTask.cancel() + } + + deleteChatFailedQueue.removeValue(forKey: chatId) + } } diff --git a/macos/Onit/Data/Model/OnitModel.swift b/macos/Onit/Data/Model/OnitModel.swift index 07ac844c..ffd53e27 100644 --- a/macos/Onit/Data/Model/OnitModel.swift +++ b/macos/Onit/Data/Model/OnitModel.swift @@ -36,7 +36,23 @@ import SwiftUI var currentChat: Chat? var currentPrompts: [Prompt]? - var deleteChatFailed: Bool = false + /// Chat deletion state START + typealias DeleteChatId = PersistentIdentifier + typealias DeleteChatTask = Task + + struct DeleteChatQueueItem { + var name: String + var chatId: DeleteChatId + var startTime: Date + var deleteChatTask: DeleteChatTask + } + var deleteChatQueue: [DeleteChatQueueItem] = [] + + typealias DeleteChatFailedQueueItem = [DeleteChatId: DeleteChatTask] + var deleteChatFailedQueue: DeleteChatFailedQueueItem = [:] + + var deleteChatDurationSeconds: TimeInterval = 3 + /// Chat deletion state END // User inputs that have not yet been submitted var pendingInstruction = "" { diff --git a/macos/Onit/General/Styles/AnimationStyle.swift b/macos/Onit/General/Styles/AnimationStyle.swift new file mode 100644 index 00000000..53c4dc5b --- /dev/null +++ b/macos/Onit/General/Styles/AnimationStyle.swift @@ -0,0 +1,8 @@ +// +// AnimationStyle.swift +// Onit +// +// Created by Loyd Kim on 4/4/25. +// + +let animationDuration: Double = 0.15 diff --git a/macos/Onit/General/Styles/HoverableButtonStyle.swift b/macos/Onit/General/Styles/HoverableButtonStyle.swift index 6019e62c..fe25c09f 100644 --- a/macos/Onit/General/Styles/HoverableButtonStyle.swift +++ b/macos/Onit/General/Styles/HoverableButtonStyle.swift @@ -60,7 +60,7 @@ struct HoverableButtonStyle: ButtonStyle { RoundedRectangle(cornerRadius: 6) .fill(.gray800) .opacity(hovering ? 1 : 0) - .animation(.easeInOut(duration: 0.15), value: hovering) + .animation(.easeInOut(duration: animationDuration), value: hovering) } } diff --git a/macos/Onit/UI/Components/ProgressBar.swift b/macos/Onit/UI/Components/ProgressBar.swift new file mode 100644 index 00000000..70cb1da8 --- /dev/null +++ b/macos/Onit/UI/Components/ProgressBar.swift @@ -0,0 +1,81 @@ +// +// ProgressBar.swift +// Onit +// +// Created by Loyd Kim on 4/4/25. +// + +import SwiftUI + +struct ProgressBar: View { + struct Manual { + var progressPercentage: Double + } + + struct Timed { + var duration: TimeInterval + var isDecreasing: Bool = false + } + + var manual: Manual? = nil + var timed: Timed? = nil + var height: CGFloat = 4 + + var body: some View { + ZStack { + if let manual = manual { + ManualProgressBar( + progressPercentage: manual.progressPercentage + ) + } else if let timed = timed { + TimedProgressBar( + duration: timed.duration, + isDecreasing: timed.isDecreasing + ) + } + } + .frame(maxWidth: .infinity) + .frame(height: height) + .background(.gray400) + .cornerRadius(999) + } +} + +/// Child Components + +struct ManualProgressBar: View { + var progressPercentage: Double + + var body: some View { + GeometryReader { geometry in + HStack {} + .frame(maxHeight: .infinity) + .frame(width: geometry.size.width * progressPercentage) + .background(.white) + } + } +} + +struct TimedProgressBar: View { + var duration: TimeInterval + var isDecreasing: Bool + + @State private var progress: Double = 0 + + var body: some View { + GeometryReader { geometry in + HStack {} + .frame(maxHeight: .infinity) + .frame(width: geometry.size.width * ( + isDecreasing ? 1 - progress : progress) + ) + .background(.white) + } + .onAppear { + withAnimation(.linear(duration: duration)) { + progress = 1.0 + } + } + } +} + diff --git a/macos/Onit/UI/History/HistoryDeleteNotification.swift b/macos/Onit/UI/History/HistoryDeleteNotification.swift new file mode 100644 index 00000000..3b9e56c9 --- /dev/null +++ b/macos/Onit/UI/History/HistoryDeleteNotification.swift @@ -0,0 +1,133 @@ +// +// HistoryDeleteNotification.swift +// Onit +// +// Created by Loyd Kim on 4/4/25. +// + +import SwiftUI +import SwiftData + +struct HistoryDeleteNotification: View { + @Environment(\.model) var model + + let chatName: String + let chatId: PersistentIdentifier + let startTime: Date + let dismiss: () -> Void + + @State private var isHoveringUndo: Bool = false + @State private var isHoveringDismiss: Bool = false + @State private var progressPercentage: Double = 0 + @State private var timer: Timer? = nil + + let hoverOpacity: Double = 0.5 + + var body: some View { + VStack(spacing: 8) { + HStack { + name + undoButton + dismissButton + } + .frame(alignment: .center) + .foregroundColor(.white) + + ProgressBar(manual: ProgressBar.Manual( + progressPercentage: progressPercentage + )) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(.gray700) + .overlay( + RoundedRectangle(cornerRadius: 8).stroke(.gray200, lineWidth: 1) + ) + .cornerRadius(8) + .shadow(color: .black.opacity(0.4), radius: 11, x: 0, y: 3) + .onAppear { + // 1/60 = 0.016s = 60fps + timer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in + Task { @MainActor in updateProgress() } + } + + if let timer = timer { + RunLoop.current.add(timer, forMode: .common) + } + } + .onDisappear { + // Cleaning up the timer when component unmounts. + timer?.invalidate() + timer = nil + } + } +} + +/// Child Components +extension HistoryDeleteNotification { + var name: some View { + Text("Deleted: \(chatName)") + .frame(maxWidth: .infinity, alignment: .leading) + .font(.system(size: 14)) + .lineLimit(1) + .truncationMode(.tail) + } + + var undoButton: some View { + Button { + undoDelete() + } label: { + Text("Undo").underline().font(.system(size: 14)) + } + .buttonStyle(PlainButtonStyle()) + .padding(.leading, 12) + .padding(.trailing, 8) + .onHover{ hovering in isHoveringUndo = hovering } + .opacity(isHoveringUndo ? hoverOpacity : 1) + .animation(.easeIn(duration: animationDuration), value: isHoveringUndo) + } + + var dismissButton: some View { + Button { + // Cleaning up the timer. + timer?.invalidate() + timer = nil + + dismiss() + } label: { + Image(.smallCross) + .renderingMode(.template) + .foregroundStyle(.white) + .frame(width: 8, height: 8) + } + .buttonStyle(PlainButtonStyle()) + .onHover{ hovering in isHoveringDismiss = hovering } + .opacity(isHoveringDismiss ? hoverOpacity : 1) + .animation(.easeIn(duration: animationDuration), value: isHoveringDismiss) + } +} + +/// Private Functions +extension HistoryDeleteNotification { + private func undoDelete() { + model.removeChatFromDeleteQueue(chatId: chatId) + } + + private func updateProgress() { + let elapsedTime = Date().timeIntervalSince(startTime) + let totalDurationSeconds = model.deleteChatDurationSeconds + let updatedProgressPercentage = max(1 - (elapsedTime / totalDurationSeconds), 0.0) + + // Adding animation here, rather than directly on the ManualProgressBar component, + // because the ManualProgressBar progression animation differs based on used case. + withAnimation(.linear(duration: 0.1)) { + progressPercentage = updatedProgressPercentage + } + + if progressPercentage <= 0 { + timer?.invalidate() + timer = nil + } + } +} diff --git a/macos/Onit/UI/History/HistoryRowView.swift b/macos/Onit/UI/History/HistoryRowView.swift index 1f328f2b..52dadec0 100644 --- a/macos/Onit/UI/History/HistoryRowView.swift +++ b/macos/Onit/UI/History/HistoryRowView.swift @@ -20,9 +20,11 @@ struct HistoryRowView: View { model.setChat(chat: chat, index: index) } label: { HStack { - Text(getPromptText()) + Text(HistoryRowView.getPromptText(chat: chat)) .appFont(.medium16) .foregroundStyle(.FG) + .lineLimit(1) + .truncationMode(.tail) Spacer() @@ -47,25 +49,25 @@ struct HistoryRowView: View { } var deleteButton: some View { - HStack(alignment: .center) { - Text(model.deleteChatFailed ? "Delete failed" : "") + let deletionFailed = model.deleteChatFailedQueue.keys.contains(chat.id) + + return HStack(alignment: .center) { + Text(deletionFailed ? "Delete failed" : "") .foregroundColor(Color.red) Image(systemName: "trash") .frame(width: 14, height: 14) .padding(.trailing, 10) + .opacity(deletionFailed ? 0.5 : 1) } .frame(height: 34) .contentShape(Rectangle()) .onTapGesture { - // We want the padding around the button to also be a tap target - if !model.deleteChatFailed { - model.deleteChat(chat: chat) - } + if !deletionFailed { model.addChatToDeleteQueue(chat: chat) } } } - private func getPromptText() -> String { + static func getPromptText(chat: Chat) -> String { guard let firstPrompt = chat.prompts.first, !firstPrompt.responses.isEmpty, firstPrompt.generationIndex < firstPrompt.responses.count else { diff --git a/macos/Onit/UI/History/HistoryView.swift b/macos/Onit/UI/History/HistoryView.swift index e1d600cb..28aa7b78 100644 --- a/macos/Onit/UI/History/HistoryView.swift +++ b/macos/Onit/UI/History/HistoryView.swift @@ -9,15 +9,22 @@ import SwiftData import SwiftUI struct HistoryView: View { + @Environment(\.model) var model + @Query(sort: \Chat.timestamp, order: .reverse) private var chats: [Chat] @State private var searchQuery: String = "" + @State private var dismissedDeleteNotifications: Set = [] var filteredChats: [Chat] { + let chatsNotQueuedForDeletion = chats.filter { chat in + !model.deleteChatQueue.contains(where: { $0.chatId == chat.id }) + } + if searchQuery.isEmpty { - return chats + return chatsNotQueuedForDeletion } else { - return chats.filter { + return chatsNotQueuedForDeletion.filter { $0.fullText.localizedCaseInsensitiveContains(searchQuery) } } @@ -43,21 +50,25 @@ struct HistoryView: View { var sortedChats: [(key: String, value: [Chat])] { groupedChats.sorted(by: { $0.key > $1.key }) } - + var body: some View { - VStack(spacing: 0) { - HistoryTitle() - HistorySearchView(text: $searchQuery) + ZStack(alignment: .topLeading) { + VStack(spacing: 0) { + HistoryTitle() + HistorySearchView(text: $searchQuery) + + if sortedChats.isEmpty { emptyText } + else { historyRows } + } + .frame(width: 350) + .background(.BG) - if sortedChats.isEmpty { emptyText } - else { historyRows } + historyDeleteNotifications } - .frame(width: 350) - .background(.BG) } var emptyText: some View { - Text("No prompts found") + Text("No chats") .foregroundStyle(.gray200) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 16) @@ -86,6 +97,32 @@ struct HistoryView: View { .padding(.bottom, 10) } } + + @ViewBuilder + var historyDeleteNotifications: some View { + if !model.deleteChatQueue.isEmpty { + ZStack { + ForEach(model.deleteChatQueue, id: \.chatId) { deleteItem in + let notDismissed = !dismissedDeleteNotifications.contains(deleteItem.chatId) + + if notDismissed { + HistoryDeleteNotification( + chatName: deleteItem.name, + chatId: deleteItem.chatId, + startTime: deleteItem.startTime, + dismiss: { + dismissedDeleteNotifications.insert(deleteItem.chatId) + } + ) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + .padding(.horizontal, 12) + } + } } From d75d258e740adddd4689f621e30bea4151be9cac Mon Sep 17 00:00:00 2001 From: lk340 Date: Fri, 4 Apr 2025 15:02:14 -0400 Subject: [PATCH 2/3] Update Model+History `deleteChat()` function to capture CancellationErrors, preventing Onit from mistaking the undoing of chat history deletion as being a legitimate error. Update empty HStacks in ProgressBar to Rectangle for better semantic clarity. --- macos/Onit/Data/Model/Model+History.swift | 3 +++ macos/Onit/UI/Components/ProgressBar.swift | 10 ++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/macos/Onit/Data/Model/Model+History.swift b/macos/Onit/Data/Model/Model+History.swift index 81bf3a9b..682c3001 100644 --- a/macos/Onit/Data/Model/Model+History.swift +++ b/macos/Onit/Data/Model/Model+History.swift @@ -28,6 +28,9 @@ extension OnitModel { container.mainContext.delete(chat) try container.mainContext.save() + } catch is CancellationError { + // This catch clause is only meant to prevent the system from mistaking + // the cancellation of chat deletion tasks as errors. Cancellation is fine. } catch { addChatToDeleteFailedQueue(chatId: chat.id) diff --git a/macos/Onit/UI/Components/ProgressBar.swift b/macos/Onit/UI/Components/ProgressBar.swift index 70cb1da8..b9e13f5b 100644 --- a/macos/Onit/UI/Components/ProgressBar.swift +++ b/macos/Onit/UI/Components/ProgressBar.swift @@ -48,10 +48,9 @@ struct ManualProgressBar: View { var body: some View { GeometryReader { geometry in - HStack {} - .frame(maxHeight: .infinity) + Rectangle() .frame(width: geometry.size.width * progressPercentage) - .background(.white) + .foregroundColor(.white) } } } @@ -64,12 +63,11 @@ struct TimedProgressBar: View { var body: some View { GeometryReader { geometry in - HStack {} - .frame(maxHeight: .infinity) + Rectangle() .frame(width: geometry.size.width * ( isDecreasing ? 1 - progress : progress) ) - .background(.white) + .foregroundColor(.white) } .onAppear { withAnimation(.linear(duration: duration)) { From ee81625cca2672cc721073c827e9da7067799df8 Mon Sep 17 00:00:00 2001 From: lk340 Date: Wed, 9 Apr 2025 16:21:32 -0400 Subject: [PATCH 3/3] Update history deletion logic & UI with designs --- macos/Onit/Data/Model/Model+History.swift | 63 +------- macos/Onit/Data/Model/OnitModel.swift | 19 +-- macos/Onit/UI/Components/ProgressBar.swift | 79 ---------- .../History/HistoryDeleteNotification.swift | 133 ---------------- .../Onit/UI/History/HistoryDeleteToast.swift | 145 ++++++++++++++++++ macos/Onit/UI/History/HistoryRowView.swift | 34 ++-- macos/Onit/UI/History/HistoryView.swift | 46 +++--- 7 files changed, 197 insertions(+), 322 deletions(-) delete mode 100644 macos/Onit/UI/Components/ProgressBar.swift delete mode 100644 macos/Onit/UI/History/HistoryDeleteNotification.swift create mode 100644 macos/Onit/UI/History/HistoryDeleteToast.swift diff --git a/macos/Onit/Data/Model/Model+History.swift b/macos/Onit/Data/Model/Model+History.swift index 682c3001..4e289d15 100644 --- a/macos/Onit/Data/Model/Model+History.swift +++ b/macos/Onit/Data/Model/Model+History.swift @@ -15,69 +15,16 @@ extension OnitModel { historyIndex = index } - /// Delete Queue - - func deleteChat(chat: Chat) async { + func deleteChat(chat: Chat) { do { - await removeChatFromDeleteFailedQueue(chatId: chat.id, waitSeconds: 0) - - try await Task.sleep(for: .seconds(deleteChatDurationSeconds)) - try Task.checkCancellation() - - removeChatFromDeleteQueue(chatId: chat.id) - + chatDeletionFailed = false container.mainContext.delete(chat) try container.mainContext.save() - } catch is CancellationError { - // This catch clause is only meant to prevent the system from mistaking - // the cancellation of chat deletion tasks as errors. Cancellation is fine. } catch { - addChatToDeleteFailedQueue(chatId: chat.id) - - #if DEBUG - print("Chat delete error: \(error)") - #endif - } - } - - func addChatToDeleteQueue(chat: Chat) { - let deleteChatQueueItem = DeleteChatQueueItem( - name: HistoryRowView.getPromptText(chat: chat), - chatId: chat.id, - startTime: Date(), - deleteChatTask: Task { await deleteChat(chat: chat) } - ) - deleteChatQueue.append(deleteChatQueueItem) - } - - func removeChatFromDeleteQueue(chatId: DeleteChatId) { - if let queueItemIndex = deleteChatQueue.firstIndex(where: { $0.chatId == chatId }) { - deleteChatQueue[queueItemIndex].deleteChatTask.cancel() - deleteChatQueue.remove(at: queueItemIndex) - } - } - - func emptyDeleteChatQueue() { - for item in deleteChatQueue { item.deleteChatTask.cancel() } - deleteChatQueue.removeAll() - } - - /// Delete Failed Queue - - func addChatToDeleteFailedQueue(chatId: DeleteChatId) { - deleteChatFailedQueue[chatId] = Task { - await removeChatFromDeleteFailedQueue(chatId: chatId, waitSeconds: 2) - } - } - - func removeChatFromDeleteFailedQueue(chatId: DeleteChatId, waitSeconds: Int) async { - try? await Task.sleep(for: .seconds(waitSeconds)) - try? Task.checkCancellation() - - if let deleteFailedTask = deleteChatFailedQueue[chatId] { - deleteFailedTask.cancel() + chatDeletionFailed = true + chatDeletionTimePassed = 0 } - deleteChatFailedQueue.removeValue(forKey: chatId) + chatQueuedForDeletion = nil } } diff --git a/macos/Onit/Data/Model/OnitModel.swift b/macos/Onit/Data/Model/OnitModel.swift index ffd53e27..078bf6f1 100644 --- a/macos/Onit/Data/Model/OnitModel.swift +++ b/macos/Onit/Data/Model/OnitModel.swift @@ -37,21 +37,10 @@ import SwiftUI var currentPrompts: [Prompt]? /// Chat deletion state START - typealias DeleteChatId = PersistentIdentifier - typealias DeleteChatTask = Task - - struct DeleteChatQueueItem { - var name: String - var chatId: DeleteChatId - var startTime: Date - var deleteChatTask: DeleteChatTask - } - var deleteChatQueue: [DeleteChatQueueItem] = [] - - typealias DeleteChatFailedQueueItem = [DeleteChatId: DeleteChatTask] - var deleteChatFailedQueue: DeleteChatFailedQueueItem = [:] - - var deleteChatDurationSeconds: TimeInterval = 3 + var chatQueuedForDeletion: Chat? = nil + var chatDeletionTimePassed: Int = 0 + var chatDeletionFailed: Bool = false + var historyDeleteToastDismissed: Bool = false /// Chat deletion state END // User inputs that have not yet been submitted diff --git a/macos/Onit/UI/Components/ProgressBar.swift b/macos/Onit/UI/Components/ProgressBar.swift deleted file mode 100644 index b9e13f5b..00000000 --- a/macos/Onit/UI/Components/ProgressBar.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// ProgressBar.swift -// Onit -// -// Created by Loyd Kim on 4/4/25. -// - -import SwiftUI - -struct ProgressBar: View { - struct Manual { - var progressPercentage: Double - } - - struct Timed { - var duration: TimeInterval - var isDecreasing: Bool = false - } - - var manual: Manual? = nil - var timed: Timed? = nil - var height: CGFloat = 4 - - var body: some View { - ZStack { - if let manual = manual { - ManualProgressBar( - progressPercentage: manual.progressPercentage - ) - } else if let timed = timed { - TimedProgressBar( - duration: timed.duration, - isDecreasing: timed.isDecreasing - ) - } - } - .frame(maxWidth: .infinity) - .frame(height: height) - .background(.gray400) - .cornerRadius(999) - } -} - -/// Child Components - -struct ManualProgressBar: View { - var progressPercentage: Double - - var body: some View { - GeometryReader { geometry in - Rectangle() - .frame(width: geometry.size.width * progressPercentage) - .foregroundColor(.white) - } - } -} - -struct TimedProgressBar: View { - var duration: TimeInterval - var isDecreasing: Bool - - @State private var progress: Double = 0 - - var body: some View { - GeometryReader { geometry in - Rectangle() - .frame(width: geometry.size.width * ( - isDecreasing ? 1 - progress : progress) - ) - .foregroundColor(.white) - } - .onAppear { - withAnimation(.linear(duration: duration)) { - progress = 1.0 - } - } - } -} - diff --git a/macos/Onit/UI/History/HistoryDeleteNotification.swift b/macos/Onit/UI/History/HistoryDeleteNotification.swift deleted file mode 100644 index 3b9e56c9..00000000 --- a/macos/Onit/UI/History/HistoryDeleteNotification.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// HistoryDeleteNotification.swift -// Onit -// -// Created by Loyd Kim on 4/4/25. -// - -import SwiftUI -import SwiftData - -struct HistoryDeleteNotification: View { - @Environment(\.model) var model - - let chatName: String - let chatId: PersistentIdentifier - let startTime: Date - let dismiss: () -> Void - - @State private var isHoveringUndo: Bool = false - @State private var isHoveringDismiss: Bool = false - @State private var progressPercentage: Double = 0 - @State private var timer: Timer? = nil - - let hoverOpacity: Double = 0.5 - - var body: some View { - VStack(spacing: 8) { - HStack { - name - undoButton - dismissButton - } - .frame(alignment: .center) - .foregroundColor(.white) - - ProgressBar(manual: ProgressBar.Manual( - progressPercentage: progressPercentage - )) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(.gray700) - .overlay( - RoundedRectangle(cornerRadius: 8).stroke(.gray200, lineWidth: 1) - ) - .cornerRadius(8) - .shadow(color: .black.opacity(0.4), radius: 11, x: 0, y: 3) - .onAppear { - // 1/60 = 0.016s = 60fps - timer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in - Task { @MainActor in updateProgress() } - } - - if let timer = timer { - RunLoop.current.add(timer, forMode: .common) - } - } - .onDisappear { - // Cleaning up the timer when component unmounts. - timer?.invalidate() - timer = nil - } - } -} - -/// Child Components -extension HistoryDeleteNotification { - var name: some View { - Text("Deleted: \(chatName)") - .frame(maxWidth: .infinity, alignment: .leading) - .font(.system(size: 14)) - .lineLimit(1) - .truncationMode(.tail) - } - - var undoButton: some View { - Button { - undoDelete() - } label: { - Text("Undo").underline().font(.system(size: 14)) - } - .buttonStyle(PlainButtonStyle()) - .padding(.leading, 12) - .padding(.trailing, 8) - .onHover{ hovering in isHoveringUndo = hovering } - .opacity(isHoveringUndo ? hoverOpacity : 1) - .animation(.easeIn(duration: animationDuration), value: isHoveringUndo) - } - - var dismissButton: some View { - Button { - // Cleaning up the timer. - timer?.invalidate() - timer = nil - - dismiss() - } label: { - Image(.smallCross) - .renderingMode(.template) - .foregroundStyle(.white) - .frame(width: 8, height: 8) - } - .buttonStyle(PlainButtonStyle()) - .onHover{ hovering in isHoveringDismiss = hovering } - .opacity(isHoveringDismiss ? hoverOpacity : 1) - .animation(.easeIn(duration: animationDuration), value: isHoveringDismiss) - } -} - -/// Private Functions -extension HistoryDeleteNotification { - private func undoDelete() { - model.removeChatFromDeleteQueue(chatId: chatId) - } - - private func updateProgress() { - let elapsedTime = Date().timeIntervalSince(startTime) - let totalDurationSeconds = model.deleteChatDurationSeconds - let updatedProgressPercentage = max(1 - (elapsedTime / totalDurationSeconds), 0.0) - - // Adding animation here, rather than directly on the ManualProgressBar component, - // because the ManualProgressBar progression animation differs based on used case. - withAnimation(.linear(duration: 0.1)) { - progressPercentage = updatedProgressPercentage - } - - if progressPercentage <= 0 { - timer?.invalidate() - timer = nil - } - } -} diff --git a/macos/Onit/UI/History/HistoryDeleteToast.swift b/macos/Onit/UI/History/HistoryDeleteToast.swift new file mode 100644 index 00000000..d384802f --- /dev/null +++ b/macos/Onit/UI/History/HistoryDeleteToast.swift @@ -0,0 +1,145 @@ +// +// HistoryDeleteToast.swift +// Onit +// +// Created by Loyd Kim on 4/4/25. +// + +import SwiftUI +import SwiftData + +struct HistoryDeleteToast: View { + @Environment(\.model) var model + + var text: String + let chat: Chat? + + @State private var isHoveringUndo: Bool = false + @State private var isHoveringDismiss: Bool = false + @State private var deletionTimer: Timer? = nil + // + @State private var fadeOutTimer: Timer? = nil + @State private var fadeOutOpacity: CGFloat = 1 + @State private var topOffset: CGFloat = -8 + @State private var fadeOutUndo: Bool = false + + let hoverOpacity: Double = 0.5 + + var body: some View { + VStack(spacing: 8) { + HStack { + Text(text) + undoButton + dismissButton + } + .frame(alignment: .center) + .foregroundColor(.white) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(.gray700) + .cornerRadius(8) + .shadow(color: .black.opacity(0.4), radius: 11, x: 0, y: 3) + .onChange(of: model.chatDeletionTimePassed) { + if model.chatDeletionTimePassed >= 5 { + fadeOutOpacity = 1 + createFadeOutTimer() + removeDeletionTimer() + } + } + .onChange(of: fadeOutOpacity) { + if fadeOutOpacity <= 0 { + removeFadeOutTimer() + + if fadeOutUndo { model.chatQueuedForDeletion = nil } + else if let chat = chat { model.deleteChat(chat: chat) } + else { model.chatDeletionFailed = false } + } + } + .onAppear { + model.chatDeletionTimePassed = 0 + fadeOutOpacity = 1 + createDeletionTimer() + withAnimation(.easeOut(duration: animationDuration)) { topOffset = 0 } + } + .onDisappear { + removeDeletionTimer() + model.chatDeletionTimePassed = 0 + } + .opacity(model.historyDeleteToastDismissed ? 0 : fadeOutOpacity) + .offset(y: topOffset) + .animation(.easeInOut(duration: animationDuration), value: topOffset) + .animation(.easeInOut(duration: animationDuration), value: model.historyDeleteToastDismissed) + .allowsHitTesting(!model.historyDeleteToastDismissed) + } +} + +/// Child Components +extension HistoryDeleteToast { + @ViewBuilder + var undoButton: some View { + if !model.chatDeletionFailed { + Button { + fadeOutUndo = true + createFadeOutTimer() + } label: { + Text("Undo").underline().font(.system(size: 14)) + } + .buttonStyle(PlainButtonStyle()) + .padding(.leading, 12) + .padding(.trailing, 4) + .onHover{ hovering in isHoveringUndo = hovering } + .opacity(isHoveringUndo ? hoverOpacity : 1) + .animation(.easeIn(duration: animationDuration), value: isHoveringUndo) + } + } + + var dismissButton: some View { + Button { + withAnimation(.easeIn(duration: animationDuration)) { + model.historyDeleteToastDismissed = true + } + } label: { + Image(.smallCross) + .renderingMode(.template) + .foregroundStyle(.white) + .frame(width: 8, height: 8) + } + .buttonStyle(PlainButtonStyle()) + .onHover{ hovering in isHoveringDismiss = hovering } + .opacity(isHoveringDismiss ? hoverOpacity : 1) + .animation(.easeIn(duration: animationDuration), value: isHoveringDismiss) + } +} + + +/// Helper Functions +extension HistoryDeleteToast { + func createDeletionTimer() { + deletionTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + Task { @MainActor in model.chatDeletionTimePassed += 1 } + } + if let deletionTimer = deletionTimer { + RunLoop.current.add(deletionTimer, forMode: .common) + } + } + + func removeDeletionTimer() { + deletionTimer?.invalidate() + deletionTimer = nil + } + + func createFadeOutTimer() { + fadeOutTimer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { _ in + Task { @MainActor in fadeOutOpacity -= 0.1 } + } + if let fadeOutTimer = fadeOutTimer { + RunLoop.current.add(fadeOutTimer, forMode: .common) + } + } + + func removeFadeOutTimer() { + fadeOutTimer?.invalidate() + fadeOutTimer = nil + } +} diff --git a/macos/Onit/UI/History/HistoryRowView.swift b/macos/Onit/UI/History/HistoryRowView.swift index 52dadec0..adcebe6c 100644 --- a/macos/Onit/UI/History/HistoryRowView.swift +++ b/macos/Onit/UI/History/HistoryRowView.swift @@ -10,7 +10,8 @@ import SwiftUI struct HistoryRowView: View { @Environment(\.model) var model - @State private var showDelete: Bool = false + @State private var showDeleteButton: Bool = false + @State private var deleteButtonHovered: Bool = false var chat: Chat var index: Int @@ -29,7 +30,7 @@ struct HistoryRowView: View { Spacer() Group { - if showDelete { deleteButton } + if showDeleteButton { deleteButton } else { chatResponseCount } } } @@ -37,7 +38,7 @@ struct HistoryRowView: View { } .frame(height: 36) .buttonStyle(HoverableButtonStyle(background: true)) - .onHover { hovering in showDelete = hovering } + .onHover { hovering in showDeleteButton = hovering } } var chatResponseCount: some View { @@ -49,22 +50,25 @@ struct HistoryRowView: View { } var deleteButton: some View { - let deletionFailed = model.deleteChatFailedQueue.keys.contains(chat.id) - - return HStack(alignment: .center) { - Text(deletionFailed ? "Delete failed" : "") - .foregroundColor(Color.red) + Button { + if let pendingDeletedHistoryItem = model.chatQueuedForDeletion { + model.deleteChat(chat: pendingDeletedHistoryItem) + } + if !model.chatDeletionFailed { model.chatQueuedForDeletion = chat } + + model.historyDeleteToastDismissed = false + model.chatDeletionTimePassed = 0 + } label: { Image(systemName: "trash") + .resizable() .frame(width: 14, height: 14) - .padding(.trailing, 10) - .opacity(deletionFailed ? 0.5 : 1) - } - .frame(height: 34) - .contentShape(Rectangle()) - .onTapGesture { - if !deletionFailed { model.addChatToDeleteQueue(chat: chat) } + .padding(.horizontal, 10) + .foregroundColor(deleteButtonHovered ? .gray100 : .gray200) } + .buttonStyle(PlainButtonStyle()) + .frame(maxHeight: .infinity) + .onHover { hovering in deleteButtonHovered = hovering } } static func getPromptText(chat: Chat) -> String { diff --git a/macos/Onit/UI/History/HistoryView.swift b/macos/Onit/UI/History/HistoryView.swift index 28aa7b78..82568990 100644 --- a/macos/Onit/UI/History/HistoryView.swift +++ b/macos/Onit/UI/History/HistoryView.swift @@ -18,7 +18,7 @@ struct HistoryView: View { var filteredChats: [Chat] { let chatsNotQueuedForDeletion = chats.filter { chat in - !model.deleteChatQueue.contains(where: { $0.chatId == chat.id }) + chat.id != model.chatQueuedForDeletion?.id } if searchQuery.isEmpty { @@ -63,7 +63,7 @@ struct HistoryView: View { .frame(width: 350) .background(.BG) - historyDeleteNotifications + historyDeleteToast } } @@ -99,31 +99,33 @@ struct HistoryView: View { } @ViewBuilder - var historyDeleteNotifications: some View { - if !model.deleteChatQueue.isEmpty { + var historyDeleteToast: some View { + if let chat = model.chatQueuedForDeletion { ZStack { - ForEach(model.deleteChatQueue, id: \.chatId) { deleteItem in - let notDismissed = !dismissedDeleteNotifications.contains(deleteItem.chatId) - - if notDismissed { - HistoryDeleteNotification( - chatName: deleteItem.name, - chatId: deleteItem.chatId, - startTime: deleteItem.startTime, - dismiss: { - dismissedDeleteNotifications.insert(deleteItem.chatId) - } - ) - .transition(.move(edge: .leading).combined(with: .opacity)) - } - } - } + HistoryDeleteToast( + text: "Item deleted", + chat: chat + ) + }.attachHistoryDeleteToastStyles() + } else if model.chatDeletionFailed { + ZStack { + HistoryDeleteToast( + text: "Failed to delete. Please retry", + chat: nil + ) + }.attachHistoryDeleteToastStyles() + } + } + +} + +extension View { + func attachHistoryDeleteToastStyles() -> some View { + self .frame(maxWidth: .infinity) .padding(.top, 12) .padding(.horizontal, 12) - } } - } #Preview {