Skip to content

Commit 4fea0a1

Browse files
authored
Merge pull request #46 from mcintyre94/explore-stashing-prompts-in-wisp-6bab63cc
Add prompt stash via long-press on send button
2 parents 12b358b + ea49d41 commit 4fea0a1

File tree

4 files changed

+95
-0
lines changed

4 files changed

+95
-0
lines changed

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ final class ChatViewModel {
6262
private var usedResume = false
6363
var queuedPrompt: String?
6464
var queuedAttachments: [AttachedFile] = []
65+
var stashedDraft: String?
6566
private var retriedAfterTimeout = false
6667
private var turnHasMutations = false
6768
private var pendingForkContext: String?
@@ -210,6 +211,19 @@ final class ChatViewModel {
210211
try? modelContext.save()
211212
}
212213

214+
func stashDraft() {
215+
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
216+
guard !text.isEmpty else { return }
217+
stashedDraft = text
218+
inputText = ""
219+
}
220+
221+
private func restoreStash() {
222+
guard let stash = stashedDraft else { return }
223+
stashedDraft = nil
224+
inputText = stash
225+
}
226+
213227
func fetchRemoteSessions(apiClient: SpritesAPIClient, existingSessionIds: Set<String>) {
214228
guard !isLoadingRemoteSessions else { return }
215229
isLoadingRemoteSessions = true
@@ -479,12 +493,14 @@ final class ChatViewModel {
479493
queuedPrompt = text
480494
queuedAttachments = attachedFiles
481495
attachedFiles = []
496+
restoreStash()
482497
return
483498
}
484499

485500
// Build prompt with attached file paths prepended
486501
let prompt = buildPrompt(text: text, attachments: attachedFiles)
487502
attachedFiles = []
503+
restoreStash()
488504

489505
let isFirstMessage = messages.isEmpty
490506
let userMessage = ChatMessage(role: .user, content: [.text(prompt)])

Wisp/Views/SpriteDetail/Chat/ChatInputBar.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct ChatInputBar: View {
1313
var attachedFiles: [AttachedFile] = []
1414
var onRemoveAttachment: ((AttachedFile) -> Void)? = nil
1515
var lastUploadedFileName: String? = nil
16+
var onStash: (() -> Void)? = nil
1617
var isFocused: FocusState<Bool>.Binding
1718

1819
@State private var showStopConfirmation = false
@@ -93,6 +94,13 @@ struct ChatInputBar: View {
9394
.tint(isEmpty || hasQueuedMessage ? .gray : Color("AccentColor"))
9495
.disabled(isEmpty || hasQueuedMessage)
9596
.buttonStyle(.glass)
97+
.contextMenu {
98+
if let onStash, !isEmpty {
99+
Button("Stash Draft", systemImage: "tray.and.arrow.down") {
100+
onStash()
101+
}
102+
}
103+
}
96104
}
97105
}
98106
.animation(.easeInOut(duration: 0.2), value: attachedFiles.count)

Wisp/Views/SpriteDetail/Chat/ChatView.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ struct ChatView: View {
148148
viewModel.attachedFiles.removeAll { $0.id == file.id }
149149
},
150150
lastUploadedFileName: viewModel.lastUploadedFileName,
151+
onStash: { viewModel.stashDraft() },
151152
isFocused: $isInputFocused
152153
)
153154
}
@@ -244,3 +245,17 @@ struct ChatView: View {
244245
.background(.bar)
245246
}
246247
}
248+
249+
#Preview {
250+
let viewModel = ChatViewModel(
251+
spriteName: "my-sprite",
252+
chatId: UUID(),
253+
currentServiceName: nil,
254+
workingDirectory: "/home/sprite/project"
255+
)
256+
NavigationStack {
257+
ChatView(viewModel: viewModel)
258+
.environment(SpritesAPIClient())
259+
.modelContainer(for: [SpriteChat.self, SpriteSession.self], inMemory: true)
260+
}
261+
}

WispTests/ChatViewModelTests.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,60 @@ struct ChatViewModelTests {
601601

602602
#expect(vm.currentAssistantMessageId == nil)
603603
}
604+
605+
// MARK: - stashDraft
606+
607+
@Test func stashDraft_movesInputTextToStash() throws {
608+
let ctx = try makeModelContext()
609+
let (vm, _) = makeChatViewModel(modelContext: ctx)
610+
611+
vm.inputText = "my long prompt"
612+
vm.stashDraft()
613+
614+
#expect(vm.stashedDraft == "my long prompt")
615+
#expect(vm.inputText == "")
616+
}
617+
618+
@Test func stashDraft_doesNothingWhenEmpty() throws {
619+
let ctx = try makeModelContext()
620+
let (vm, _) = makeChatViewModel(modelContext: ctx)
621+
622+
vm.inputText = " "
623+
vm.stashDraft()
624+
625+
#expect(vm.stashedDraft == nil)
626+
#expect(vm.inputText == " ")
627+
}
628+
629+
@Test func stashDraft_overwritesPreviousStash() throws {
630+
let ctx = try makeModelContext()
631+
let (vm, _) = makeChatViewModel(modelContext: ctx)
632+
633+
vm.inputText = "first draft"
634+
vm.stashDraft()
635+
vm.inputText = "second draft"
636+
vm.stashDraft()
637+
638+
#expect(vm.stashedDraft == "second draft")
639+
#expect(vm.inputText == "")
640+
}
641+
642+
@Test func stashDraft_leavesInputReadyForNextMessage() throws {
643+
let ctx = try makeModelContext()
644+
let (vm, _) = makeChatViewModel(modelContext: ctx)
645+
646+
vm.inputText = "long prompt I want to come back to"
647+
vm.stashDraft()
648+
649+
// After stashing, the field is clear and stash holds the draft
650+
#expect(vm.inputText == "")
651+
#expect(vm.stashedDraft == "long prompt I want to come back to")
652+
653+
// Manually simulate the restore (as sendMessage would do)
654+
vm.inputText = vm.stashedDraft!
655+
vm.stashedDraft = nil
656+
657+
#expect(vm.inputText == "long prompt I want to come back to")
658+
#expect(vm.stashedDraft == nil)
659+
}
604660
}

0 commit comments

Comments
 (0)