Skip to content
Open
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
13 changes: 7 additions & 6 deletions macos/Onit/Data/Model/Model+History.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// Created by Loyd Kim on 3/31/25.
//

import Foundation

extension OnitModel {
func setChat(chat: Chat, index: Int) {
currentChat = chat
Expand All @@ -15,15 +17,14 @@ extension OnitModel {

func deleteChat(chat: Chat) {
do {
deleteChatFailed = false
chatDeletionFailed = false
container.mainContext.delete(chat)
try container.mainContext.save()
} catch {
deleteChatFailed = true

#if DEBUG
print("Chat delete error: \(error)")
#endif
chatDeletionFailed = true
chatDeletionTimePassed = 0
}

chatQueuedForDeletion = nil
}
}
7 changes: 6 additions & 1 deletion macos/Onit/Data/Model/OnitModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ import SwiftUI
var currentChat: Chat?
var currentPrompts: [Prompt]?

var deleteChatFailed: Bool = false
/// Chat deletion state START
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
var pendingInstruction = "" {
Expand Down
8 changes: 8 additions & 0 deletions macos/Onit/General/Styles/AnimationStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// AnimationStyle.swift
// Onit
//
// Created by Loyd Kim on 4/4/25.
//

let animationDuration: Double = 0.15
2 changes: 1 addition & 1 deletion macos/Onit/General/Styles/HoverableButtonStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
145 changes: 145 additions & 0 deletions macos/Onit/UI/History/HistoryDeleteToast.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
40 changes: 23 additions & 17 deletions macos/Onit/UI/History/HistoryRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,22 +21,24 @@ 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()

Group {
if showDelete { deleteButton }
if showDeleteButton { deleteButton }
else { chatResponseCount }
}
}
.padding(.leading, 10)
}
.frame(height: 36)
.buttonStyle(HoverableButtonStyle(background: true))
.onHover { hovering in showDelete = hovering }
.onHover { hovering in showDeleteButton = hovering }
}

var chatResponseCount: some View {
Expand All @@ -47,25 +50,28 @@ struct HistoryRowView: View {
}

var deleteButton: some View {
HStack(alignment: .center) {
Text(model.deleteChatFailed ? "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)
}
.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)
}
.padding(.horizontal, 10)
.foregroundColor(deleteButtonHovered ? .gray100 : .gray200)
}
.buttonStyle(PlainButtonStyle())
.frame(maxHeight: .infinity)
.onHover { hovering in deleteButtonHovered = hovering }
}

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 {
Expand Down
61 changes: 50 additions & 11 deletions macos/Onit/UI/History/HistoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersistentIdentifier> = []

var filteredChats: [Chat] {
let chatsNotQueuedForDeletion = chats.filter { chat in
chat.id != model.chatQueuedForDeletion?.id
}

if searchQuery.isEmpty {
return chats
return chatsNotQueuedForDeletion
} else {
return chats.filter {
return chatsNotQueuedForDeletion.filter {
$0.fullText.localizedCaseInsensitiveContains(searchQuery)
}
}
Expand All @@ -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 }
historyDeleteToast
}
.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)
Expand Down Expand Up @@ -86,9 +97,37 @@ struct HistoryView: View {
.padding(.bottom, 10)
}
}

@ViewBuilder
var historyDeleteToast: some View {
if let chat = model.chatQueuedForDeletion {
ZStack {
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 {
HistoryView()
}