From d78425cd52fb77ef83f225e465465daf9aed3416 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Sun, 15 Mar 2026 11:09:16 +0000 Subject: [PATCH] Add Quick Messages feature for saving and reusing common prompts Adds a global Quick Messages store (SwiftData) with full CRUD in Settings, and a picker sheet accessible from the chat attachment menu that appends the selected message to the current input. Co-Authored-By: Claude Sonnet 4.6 --- Wisp/App/WispApp.swift | 2 +- Wisp/Models/Local/QuickMessage.swift | 15 +++ .../Settings/QuickMessagesSettingsView.swift | 121 ++++++++++++++++++ Wisp/Views/Settings/SettingsView.swift | 11 ++ .../Chat/ChatAttachmentButton.swift | 9 ++ .../SpriteDetail/Chat/ChatInputBar.swift | 4 +- Wisp/Views/SpriteDetail/Chat/ChatView.swift | 9 +- .../Chat/QuickMessagePickerSheet.swift | 52 ++++++++ 8 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 Wisp/Models/Local/QuickMessage.swift create mode 100644 Wisp/Views/Settings/QuickMessagesSettingsView.swift create mode 100644 Wisp/Views/SpriteDetail/Chat/QuickMessagePickerSheet.swift diff --git a/Wisp/App/WispApp.swift b/Wisp/App/WispApp.swift index 9e01937f..1742249b 100644 --- a/Wisp/App/WispApp.swift +++ b/Wisp/App/WispApp.swift @@ -32,6 +32,6 @@ struct WispApp: App { browserCoordinator.authToken = apiClient.spritesToken } } - .modelContainer(for: [SpriteChat.self, SpriteSession.self]) + .modelContainer(for: [SpriteChat.self, SpriteSession.self, QuickMessage.self]) } } diff --git a/Wisp/Models/Local/QuickMessage.swift b/Wisp/Models/Local/QuickMessage.swift new file mode 100644 index 00000000..a46c606d --- /dev/null +++ b/Wisp/Models/Local/QuickMessage.swift @@ -0,0 +1,15 @@ +import Foundation +import SwiftData + +@Model +final class QuickMessage { + var id: UUID + var text: String + var createdAt: Date + + init(text: String) { + self.id = UUID() + self.text = text + self.createdAt = Date() + } +} diff --git a/Wisp/Views/Settings/QuickMessagesSettingsView.swift b/Wisp/Views/Settings/QuickMessagesSettingsView.swift new file mode 100644 index 00000000..966d56aa --- /dev/null +++ b/Wisp/Views/Settings/QuickMessagesSettingsView.swift @@ -0,0 +1,121 @@ +import SwiftData +import SwiftUI + +struct QuickMessagesSettingsView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \QuickMessage.createdAt) private var quickMessages: [QuickMessage] + @State private var editingMessage: QuickMessage? + @State private var showingAddSheet = false + + var body: some View { + List { + if quickMessages.isEmpty { + ContentUnavailableView( + "No Quick Messages", + systemImage: "text.bubble", + description: Text("Tap + to add messages you send often.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(quickMessages) { message in + Button { + editingMessage = message + } label: { + Text(message.text) + .foregroundStyle(.primary) + .lineLimit(2) + } + .swipeActions(edge: .trailing) { + Button("Delete", role: .destructive) { + modelContext.delete(message) + } + } + .contextMenu { + Button("Edit") { + editingMessage = message + } + Button("Delete", role: .destructive) { + modelContext.delete(message) + } + } + } + } + } + .navigationTitle("Quick Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingAddSheet) { + QuickMessageEditSheet(message: nil) + } + .sheet(item: $editingMessage) { message in + QuickMessageEditSheet(message: message) + } + } +} + +struct QuickMessageEditSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + let message: QuickMessage? + + @State private var text: String = "" + @FocusState private var isTextFocused: Bool + + private var isNew: Bool { message == nil } + + var body: some View { + NavigationStack { + TextEditor(text: $text) + .focused($isTextFocused) + .padding() + .navigationTitle(isNew ? "New Quick Message" : "Edit Quick Message") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .onAppear { + text = message?.text ?? "" + isTextFocused = true + } + } + + private func save() { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if let message { + message.text = trimmed + } else { + modelContext.insert(QuickMessage(text: trimmed)) + } + dismiss() + } +} + +#Preview("List") { + NavigationStack { + QuickMessagesSettingsView() + .modelContainer(for: QuickMessage.self, inMemory: true) + } +} + +#Preview("Edit Sheet") { + QuickMessageEditSheet(message: nil) + .modelContainer(for: QuickMessage.self, inMemory: true) +} diff --git a/Wisp/Views/Settings/SettingsView.swift b/Wisp/Views/Settings/SettingsView.swift index 491c8f4f..bfd89386 100644 --- a/Wisp/Views/Settings/SettingsView.swift +++ b/Wisp/Views/Settings/SettingsView.swift @@ -44,6 +44,7 @@ struct SettingsView: View { gitIdentitySection claudeSection instructionsSection + quickMessagesSection appearanceSection #if DEBUG developerSection @@ -203,6 +204,16 @@ struct SettingsView: View { } } + private var quickMessagesSection: some View { + Section { + NavigationLink { + QuickMessagesSettingsView() + } label: { + Label("Quick Messages", systemImage: "text.bubble") + } + } + } + private var appearanceSection: some View { Section("Appearance") { Picker("Theme", selection: $theme) { diff --git a/Wisp/Views/SpriteDetail/Chat/ChatAttachmentButton.swift b/Wisp/Views/SpriteDetail/Chat/ChatAttachmentButton.swift index 2dcbb8c0..11af66f8 100644 --- a/Wisp/Views/SpriteDetail/Chat/ChatAttachmentButton.swift +++ b/Wisp/Views/SpriteDetail/Chat/ChatAttachmentButton.swift @@ -6,6 +6,7 @@ struct ChatAttachmentButton: View { let onBrowseSpriteFiles: () -> Void let onPickPhoto: () -> Void let onPickFile: () -> Void + var onQuickMessages: (() -> Void)? = nil var body: some View { if isUploading { ProgressView() @@ -30,6 +31,14 @@ struct ChatAttachmentButton: View { Label("Choose File", systemImage: "doc") } + if let onQuickMessages { + Button { + onQuickMessages() + } label: { + Label("Quick Messages", systemImage: "text.bubble") + } + } + } label: { Image(systemName: "plus.circle.fill") .font(.title2) diff --git a/Wisp/Views/SpriteDetail/Chat/ChatInputBar.swift b/Wisp/Views/SpriteDetail/Chat/ChatInputBar.swift index 8f9df9c1..d82bc65e 100644 --- a/Wisp/Views/SpriteDetail/Chat/ChatInputBar.swift +++ b/Wisp/Views/SpriteDetail/Chat/ChatInputBar.swift @@ -17,6 +17,7 @@ struct ChatInputBar: View { var lastUploadedFileName: String? = nil var onStash: (() -> Void)? = nil var onSideChat: (() -> Void)? = nil + var onQuickMessages: (() -> Void)? = nil var isFocused: FocusState.Binding @State private var showStopConfirmation = false @@ -57,7 +58,8 @@ struct ChatInputBar: View { isDisabled: hasQueuedMessage, onBrowseSpriteFiles: onBrowseSpriteFiles, onPickPhoto: onPickPhoto, - onPickFile: onPickFile + onPickFile: onPickFile, + onQuickMessages: onQuickMessages ) } diff --git a/Wisp/Views/SpriteDetail/Chat/ChatView.swift b/Wisp/Views/SpriteDetail/Chat/ChatView.swift index 9de22c37..b4f23a37 100644 --- a/Wisp/Views/SpriteDetail/Chat/ChatView.swift +++ b/Wisp/Views/SpriteDetail/Chat/ChatView.swift @@ -24,6 +24,7 @@ struct ChatView: View { // Quick Actions @State private var quickActionsViewModel: QuickActionsViewModel? + @State private var showingQuickMessages = false var body: some View { ScrollViewReader { proxy in @@ -168,6 +169,7 @@ struct ChatView: View { }, lastUploadedFileName: viewModel.lastUploadedFileName, onStash: { viewModel.stashDraft() }, + onQuickMessages: { showingQuickMessages = true }, onSideChat: { quickActionsViewModel = QuickActionsViewModel( spriteName: viewModel.spriteName, @@ -192,6 +194,11 @@ struct ChatView: View { } } } + .sheet(isPresented: $showingQuickMessages) { + QuickMessagePickerSheet { text in + viewModel.inputText += (viewModel.inputText.isEmpty ? "" : "\n") + text + } + } .sheet(item: $quickActionsViewModel) { vm in QuickActionsView( viewModel: vm, @@ -401,6 +408,6 @@ struct ChatView: View { NavigationStack { ChatView(viewModel: viewModel) .environment(SpritesAPIClient()) - .modelContainer(for: [SpriteChat.self, SpriteSession.self], inMemory: true) + .modelContainer(for: [SpriteChat.self, SpriteSession.self, QuickMessage.self], inMemory: true) } } diff --git a/Wisp/Views/SpriteDetail/Chat/QuickMessagePickerSheet.swift b/Wisp/Views/SpriteDetail/Chat/QuickMessagePickerSheet.swift new file mode 100644 index 00000000..0df3ad1b --- /dev/null +++ b/Wisp/Views/SpriteDetail/Chat/QuickMessagePickerSheet.swift @@ -0,0 +1,52 @@ +import SwiftData +import SwiftUI + +struct QuickMessagePickerSheet: View { + @Environment(\.dismiss) private var dismiss + @Query(sort: \QuickMessage.createdAt) private var quickMessages: [QuickMessage] + let onSelect: (String) -> Void + + var body: some View { + NavigationStack { + if quickMessages.isEmpty { + ContentUnavailableView( + "No Quick Messages", + systemImage: "text.bubble", + description: Text("Add quick messages in Settings.") + ) + .navigationTitle("Quick Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } else { + List(quickMessages) { message in + Button { + onSelect(message.text) + dismiss() + } label: { + Text(message.text) + .foregroundStyle(.primary) + .lineLimit(3) + } + } + .navigationTitle("Quick Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} + +#Preview { + QuickMessagePickerSheet { _ in } + .modelContainer(for: QuickMessage.self, inMemory: true) +}