Skip to content

Commit a67afa5

Browse files
mcintyre94claude
andcommitted
Restore undelivered user message as draft on reconnect failure
When the app is killed after a user message is persisted but before the service is created on the Sprite, reconnectIfNeeded would silently return leaving a dangling user bubble with no response. Now it detects a trailing user message and restores it to the input box as a draft instead. Also handles the edge case where lastSessionComplete is true but a new user message was appended before saveSession(isComplete:false) ran. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9e403e0 commit a67afa5

File tree

2 files changed

+72
-1
lines changed

2 files changed

+72
-1
lines changed

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,19 +642,41 @@ final class ChatViewModel {
642642
// If the last session completed cleanly, content is already loaded from
643643
// persistence — no need to hit the network at all.
644644
if let chat = fetchChat(modelContext: modelContext), chat.lastSessionComplete {
645+
// Edge case: app killed between persistMessages and saveSession(isComplete:false).
646+
// The session was previously complete but a new user message was appended and
647+
// persisted before the service was created. Restore it as a draft.
648+
restoreUndeliveredDraft(modelContext: modelContext)
645649
return
646650
}
647651

648652
streamTask = Task {
649653
// Only reconnect if the service exists (running or stopped with logs)
650654
guard let serviceInfo = try? await apiClient.getServiceStatus(spriteName: spriteName, serviceName: serviceName)
651-
else { return }
655+
else {
656+
// Service was never created (e.g. app killed before the service call).
657+
// Restore any trailing user message as a draft rather than leaving a
658+
// stale bubble with no response.
659+
restoreUndeliveredDraft(modelContext: modelContext)
660+
return
661+
}
652662

653663
let alreadyStopped = serviceInfo.state.status != "running"
654664
await reconnectToServiceLogs(apiClient: apiClient, modelContext: modelContext, serviceAlreadyStopped: alreadyStopped)
655665
}
656666
}
657667

668+
/// If the last message is a user message with no response, remove it from history
669+
/// and restore its text to the input box as a draft.
670+
func restoreUndeliveredDraft(modelContext: ModelContext) {
671+
guard let last = messages.last, last.role == .user else { return }
672+
let text = last.textContent
673+
messages.removeLast()
674+
persistMessages(modelContext: modelContext)
675+
guard !text.isEmpty, inputText.isEmpty else { return }
676+
inputText = text
677+
saveDraft(modelContext: modelContext)
678+
}
679+
658680
// MARK: - Private
659681

660682
private func executeClaudeCommand(

WispTests/ChatViewModelTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,4 +1296,53 @@ struct ChatViewModelTests {
12961296
#expect(vm.attachedFiles.count == 1)
12971297
#expect(vm.attachedFiles[0].name == "live.py")
12981298
}
1299+
1300+
// MARK: - restoreUndeliveredDraft
1301+
1302+
@Test func restoreUndeliveredDraft_removesTrailingUserMessageAndRestoresInputText() throws {
1303+
let ctx = try makeModelContext()
1304+
let (vm, _) = makeChatViewModel(modelContext: ctx)
1305+
1306+
vm.messages = [
1307+
ChatMessage(role: .user, content: [.text("first message")]),
1308+
ChatMessage(role: .assistant, content: [.text("response")]),
1309+
ChatMessage(role: .user, content: [.text("unsent message")]),
1310+
]
1311+
1312+
vm.restoreUndeliveredDraft(modelContext: ctx)
1313+
1314+
#expect(vm.messages.count == 2)
1315+
#expect(vm.inputText == "unsent message")
1316+
}
1317+
1318+
@Test func restoreUndeliveredDraft_isNoopWhenLastMessageIsAssistant() throws {
1319+
let ctx = try makeModelContext()
1320+
let (vm, _) = makeChatViewModel(modelContext: ctx)
1321+
1322+
vm.messages = [
1323+
ChatMessage(role: .user, content: [.text("hello")]),
1324+
ChatMessage(role: .assistant, content: [.text("hi there")]),
1325+
]
1326+
1327+
vm.restoreUndeliveredDraft(modelContext: ctx)
1328+
1329+
#expect(vm.messages.count == 2)
1330+
#expect(vm.inputText == "")
1331+
}
1332+
1333+
@Test func restoreUndeliveredDraft_doesNotOverwriteExistingInputText() throws {
1334+
let ctx = try makeModelContext()
1335+
let (vm, _) = makeChatViewModel(modelContext: ctx)
1336+
1337+
vm.messages = [
1338+
ChatMessage(role: .user, content: [.text("unsent message")]),
1339+
]
1340+
vm.inputText = "already typing something"
1341+
1342+
vm.restoreUndeliveredDraft(modelContext: ctx)
1343+
1344+
// Message removed but inputText NOT overwritten
1345+
#expect(vm.messages.count == 0)
1346+
#expect(vm.inputText == "already typing something")
1347+
}
12991348
}

0 commit comments

Comments
 (0)