Skip to content

Commit a42bb5d

Browse files
authored
Merge pull request #74 from mcintyre94/discuss-sprite-service-management-6e54d330
Keep chat streams alive across navigation
2 parents 632c2e3 + a67afa5 commit a42bb5d

File tree

9 files changed

+369
-38
lines changed

9 files changed

+369
-38
lines changed

Wisp.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
3855BDFB5819B6D35F57ED9E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 099181E9B8BFE6F3475C5D36 /* Assets.xcassets */; };
1515
3A36571818CB2EBA1ED78500 /* ToolInputDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB04F3FB6C647DE1F6DC0FD0 /* ToolInputDetailView.swift */; };
1616
4FE7C138A35A53B4C38BEF98 /* ClaudeStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F1378232DC7FA71A8B5444 /* ClaudeStreamParser.swift */; };
17+
AA33BB44CC55DD66EE77FF02 /* ChatSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA33BB44CC55DD66EE77FF01 /* ChatSessionManager.swift */; };
1718
5A1F582DDCBB994013E4BA2E /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B99AA33329D50DEDB7343B7 /* DashboardView.swift */; };
1819
5ABE241326BF34FF2E3C3E51 /* CreateSpriteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1634863D8543868BA11B02 /* CreateSpriteSheet.swift */; };
1920
61CBE460AC96CBE6BF8AF0B0 /* SpriteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BE6E0D68BBB35DE56600E1 /* SpriteSession.swift */; };
@@ -45,6 +46,7 @@
4546
AA3400BB00CC00DD00EE0001 /* QuickChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3400BB00CC00DD00EE0002 /* QuickChatView.swift */; };
4647
AA3500BB00CC00DD00EE0001 /* BashQuickView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3500BB00CC00DD00EE0002 /* BashQuickView.swift */; };
4748
AA3700BB00CC00DD00EE0001 /* BashQuickViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3700BB00CC00DD00EE0002 /* BashQuickViewModelTests.swift */; };
49+
AA3800BB00CC00DD00EE0001 /* ChatSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3800BB00CC00DD00EE0002 /* ChatSessionManagerTests.swift */; };
4850
FE9876543210ABCDEF987601 /* QuickChatViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9876543210ABCDEF987602 /* QuickChatViewModelTests.swift */; };
4951
AA00BB11CC22DD3300000001 /* WispAskCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00BB11CC22DD3300000002 /* WispAskCard.swift */; };
5052
AA11BB22CC33DD44EE55FF02 /* GitHubDeviceFlowClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF01 /* GitHubDeviceFlowClient.swift */; };
@@ -210,6 +212,7 @@
210212
C4D5E6F7A8B90A41637E8FA1 /* CheckpointMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointMarkerView.swift; sourceTree = "<group>"; };
211213
C8C4C3A1F2B2D57313431E50 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
212214
C8F1378232DC7FA71A8B5444 /* ClaudeStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeStreamParser.swift; sourceTree = "<group>"; };
215+
AA33BB44CC55DD66EE77FF01 /* ChatSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSessionManager.swift; sourceTree = "<group>"; };
213216
CC11DD22EE33FF4400110002 /* ChatAttachmentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachmentButton.swift; sourceTree = "<group>"; };
214217
CC11DD22EE33FF4400110004 /* SpriteFileBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpriteFileBrowserView.swift; sourceTree = "<group>"; };
215218
CC11DD22EE33FF4400110005 /* FileEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntryTests.swift; sourceTree = "<group>"; };
@@ -249,6 +252,7 @@
249252
AA3400BB00CC00DD00EE0002 /* QuickChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickChatView.swift; sourceTree = "<group>"; };
250253
AA3500BB00CC00DD00EE0002 /* BashQuickView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BashQuickView.swift; sourceTree = "<group>"; };
251254
AA3700BB00CC00DD00EE0002 /* BashQuickViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BashQuickViewModelTests.swift; sourceTree = "<group>"; };
255+
AA3800BB00CC00DD00EE0002 /* ChatSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSessionManagerTests.swift; sourceTree = "<group>"; };
252256
FE9876543210ABCDEF987602 /* QuickChatViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickChatViewModelTests.swift; sourceTree = "<group>"; };
253257
/* End PBXFileReference section */
254258

@@ -307,6 +311,7 @@
307311
isa = PBXGroup;
308312
children = (
309313
AA22BB33CC44DD55EE66FF02 /* ClaudeQuestionTool.swift */,
314+
AA33BB44CC55DD66EE77FF01 /* ChatSessionManager.swift */,
310315
C8F1378232DC7FA71A8B5444 /* ClaudeStreamParser.swift */,
311316
2554BB4DE8EC315CA48767BF /* ExecSession.swift */,
312317
BB11CC22DD33EE44FF660A01 /* GitHubAPIClient.swift */,
@@ -471,6 +476,7 @@
471476
EE22FF334455AA66BB770001 /* ExecSessionURLTests.swift */,
472477
CC11DD22EE33FF4400110005 /* FileEntryTests.swift */,
473478
AA3700BB00CC00DD00EE0002 /* BashQuickViewModelTests.swift */,
479+
AA3800BB00CC00DD00EE0002 /* ChatSessionManagerTests.swift */,
474480
FE9876543210ABCDEF987602 /* QuickChatViewModelTests.swift */,
475481
);
476482
path = WispTests;
@@ -728,6 +734,7 @@
728734
D2B1EF5C6A7F8B9304C5E6F7 /* ClaudeModel.swift in Sources */,
729735
2A18611EA392DA2BFFE74250 /* ClaudeStreamEvent.swift in Sources */,
730736
4FE7C138A35A53B4C38BEF98 /* ClaudeStreamParser.swift in Sources */,
737+
AA33BB44CC55DD66EE77FF02 /* ChatSessionManager.swift in Sources */,
731738
99BA5812AF56D1C7501D1F0C /* CreateCheckpointSheet.swift in Sources */,
732739
5ABE241326BF34FF2E3C3E51 /* CreateSpriteSheet.swift in Sources */,
733740
5A1F582DDCBB994013E4BA2E /* DashboardView.swift in Sources */,
@@ -813,6 +820,7 @@
813820
0DFF41BB14AC3922E95DF781 /* WorktreeTests.swift in Sources */,
814821
CC11DD22EE33FF4400110006 /* FileEntryTests.swift in Sources */,
815822
AA3700BB00CC00DD00EE0001 /* BashQuickViewModelTests.swift in Sources */,
823+
AA3800BB00CC00DD00EE0001 /* ChatSessionManagerTests.swift in Sources */,
816824
FE9876543210ABCDEF987601 /* QuickChatViewModelTests.swift in Sources */,
817825
);
818826
runOnlyForDeploymentPostprocessing = 0;

Wisp/App/WispApp.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import SwiftUI
55
struct WispApp: App {
66
@State private var apiClient = SpritesAPIClient()
77
@State private var browserCoordinator = InAppBrowserCoordinator()
8+
@State private var chatSessionManager = ChatSessionManager()
89
@AppStorage("theme") private var theme: String = "system"
910

1011
init() {
@@ -27,6 +28,7 @@ struct WispApp: App {
2728
RootView()
2829
.environment(apiClient)
2930
.environment(browserCoordinator)
31+
.environment(chatSessionManager)
3032
.preferredColorScheme(preferredColorScheme)
3133
.onChange(of: apiClient.isAuthenticated, initial: true) {
3234
browserCoordinator.authToken = apiClient.spritesToken
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
import SwiftData
3+
4+
/// App-wide cache of ChatViewModels, keyed by chat UUID.
5+
/// Keeps streams alive across chat switches and sprite navigation so connections
6+
/// are only torn down on explicit actions (interrupt, chat deletion, app termination).
7+
@Observable
8+
@MainActor
9+
final class ChatSessionManager {
10+
private var cache: [UUID: ChatViewModel] = [:]
11+
12+
/// Returns the cached VM for a chat, or creates and loads a new one.
13+
func viewModel(
14+
for chat: SpriteChat,
15+
spriteName: String,
16+
apiClient: SpritesAPIClient,
17+
modelContext: ModelContext
18+
) -> ChatViewModel {
19+
if let existing = cache[chat.id] {
20+
return existing
21+
}
22+
let vm = ChatViewModel(
23+
spriteName: spriteName,
24+
chatId: chat.id,
25+
currentServiceName: chat.currentServiceName,
26+
workingDirectory: chat.workingDirectory
27+
)
28+
vm.loadSession(apiClient: apiClient, modelContext: modelContext)
29+
cache[chat.id] = vm
30+
return vm
31+
}
32+
33+
func isStreaming(chatId: UUID) -> Bool {
34+
cache[chatId]?.isStreaming ?? false
35+
}
36+
37+
/// Called when the app returns to the foreground — reconnects any VM that had a live stream.
38+
func resumeAllAfterBackground(apiClient: SpritesAPIClient, modelContext: ModelContext) {
39+
for vm in cache.values {
40+
vm.resumeAfterBackground(apiClient: apiClient, modelContext: modelContext)
41+
vm.reconnectIfNeeded(apiClient: apiClient, modelContext: modelContext)
42+
}
43+
}
44+
45+
/// Removes and detaches a VM when its chat is deleted.
46+
func remove(chatId: UUID, modelContext: ModelContext) {
47+
cache[chatId]?.detach(modelContext: modelContext)
48+
cache.removeValue(forKey: chatId)
49+
}
50+
51+
/// Detaches and removes all cached VMs. Call when clearing all chats.
52+
func detachAll(modelContext: ModelContext) {
53+
for vm in cache.values {
54+
vm.detach(modelContext: modelContext)
55+
}
56+
cache.removeAll()
57+
}
58+
}

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ final class ChatViewModel {
594594
// Cancel the stale stream and reconnect via service logs
595595
streamTask?.cancel()
596596
streamTask = Task {
597-
await reconnectToServiceLogs(apiClient: apiClient, modelContext: modelContext, serviceIsRunning: true)
597+
await reconnectToServiceLogs(apiClient: apiClient, modelContext: modelContext, serviceAlreadyStopped: false)
598598
}
599599
}
600600

@@ -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

653-
let isRunning = serviceInfo.state.status == "running"
654-
await reconnectToServiceLogs(apiClient: apiClient, modelContext: modelContext, serviceIsRunning: isRunning)
663+
let alreadyStopped = serviceInfo.state.status != "running"
664+
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(
@@ -796,7 +818,7 @@ final class ChatViewModel {
796818
// Attempt reconnection on disconnect
797819
if case .disconnected = streamResult {
798820
logger.info("[Chat] Disconnected mid-stream, attempting reconnect via service logs")
799-
await reconnectToServiceLogs(apiClient: apiClient, modelContext: modelContext, serviceIsRunning: true)
821+
await reconnectToServiceLogs(apiClient: apiClient, modelContext: modelContext, serviceAlreadyStopped: false)
800822
return
801823
}
802824

@@ -933,6 +955,7 @@ final class ChatViewModel {
933955
receivedData = true
934956
timeoutTask.cancel()
935957
if case .connecting = status { status = .streaming }
958+
else if case .reconnecting = status { status = .streaming }
936959

937960
// Two-level NDJSON: ServiceLogEvent.data contains Claude NDJSON.
938961
// The logs endpoint prefixes each line with a timestamp
@@ -961,6 +984,7 @@ final class ChatViewModel {
961984
receivedData = true
962985
timeoutTask.cancel()
963986
if case .connecting = status { status = .streaming }
987+
else if case .reconnecting = status { status = .streaming }
964988
if let text = event.data {
965989
logger.warning("Service stderr: \(text.prefix(500), privacy: .public)")
966990
}
@@ -994,6 +1018,7 @@ final class ChatViewModel {
9941018

9951019
case .started:
9961020
if case .connecting = status { status = .streaming }
1021+
else if case .reconnecting = status { status = .streaming }
9971022

9981023
case .stopping, .stopped:
9991024
break
@@ -1038,10 +1063,10 @@ final class ChatViewModel {
10381063
func runReconnectLoop(
10391064
apiClient: some ServiceLogsProvider,
10401065
modelContext: ModelContext,
1041-
serviceIsRunning: Bool = false
1066+
serviceAlreadyStopped: Bool = false
10421067
) async {
10431068
isReplaying = true
1044-
isReplayingLiveService = serviceIsRunning
1069+
isReplayingLiveService = !serviceAlreadyStopped
10451070
defer {
10461071
applyReplayBuffer()
10471072
isReplaying = false
@@ -1060,7 +1085,11 @@ final class ChatViewModel {
10601085
if let existing = currentAssistantMessage {
10611086
assistantMessage = existing
10621087
if !hasPriorEvents { assistantMessage.content = [] }
1063-
} else if let last = messages.last(where: { $0.role == .assistant }) {
1088+
} else if let last = messages.last, last.role == .assistant {
1089+
// Only reuse an assistant message if it's already at the tail — meaning
1090+
// the last exchange ended with a partial response that needs replaying.
1091+
// If the last message is a user message, fall through to create a new
1092+
// assistant message after it (each service is scoped to one user message).
10641093
assistantMessage = last
10651094
if !hasPriorEvents { assistantMessage.content = [] }
10661095
currentAssistantMessage = last
@@ -1108,10 +1137,12 @@ final class ChatViewModel {
11081137
// If we got a result event, Claude is done
11091138
if receivedResultEvent { break }
11101139

1111-
// Check if service is still running
1112-
let isRunning = (try? await apiClient.getServiceStatus(spriteName: spriteName, serviceName: serviceName))?.state.status == "running"
1140+
// If we already knew the service was stopped before entering, no need to re-check
1141+
if serviceAlreadyStopped { break }
11131142

1114-
if isRunning {
1143+
// Check if service is still running before retrying
1144+
if let serviceInfo = try? await apiClient.getServiceStatus(spriteName: spriteName, serviceName: serviceName),
1145+
serviceInfo.state.status == "running" {
11151146
logger.info("[Chat] Service still running, will re-poll after delay")
11161147
try? await Task.sleep(for: .seconds(2))
11171148
continue
@@ -1147,9 +1178,9 @@ final class ChatViewModel {
11471178
private func reconnectToServiceLogs(
11481179
apiClient: SpritesAPIClient,
11491180
modelContext: ModelContext,
1150-
serviceIsRunning: Bool = false
1181+
serviceAlreadyStopped: Bool = false
11511182
) async {
1152-
await runReconnectLoop(apiClient: apiClient, modelContext: modelContext, serviceIsRunning: serviceIsRunning)
1183+
await runReconnectLoop(apiClient: apiClient, modelContext: modelContext, serviceAlreadyStopped: serviceAlreadyStopped)
11531184

11541185
if let queued = queuedPrompt, !Task.isCancelled {
11551186
let prompt = buildPrompt(text: queued, attachments: queuedAttachments)

Wisp/Views/SpriteDetail/Chat/ChatSwitcherSheet.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SwiftData
44
struct ChatSwitcherSheet: View {
55
@Bindable var viewModel: SpriteChatListViewModel
66
@Environment(SpritesAPIClient.self) private var apiClient
7+
@Environment(ChatSessionManager.self) private var chatSessionManager
78
@Environment(\.modelContext) private var modelContext
89
@Environment(\.dismiss) private var dismiss
910
@State private var chatToDelete: SpriteChat?
@@ -39,6 +40,7 @@ struct ChatSwitcherSheet: View {
3940
}
4041
if !chat.isClosed {
4142
Button {
43+
chatSessionManager.remove(chatId: chat.id, modelContext: modelContext)
4244
viewModel.closeChat(chat, apiClient: apiClient, modelContext: modelContext)
4345
} label: {
4446
Label("Close", systemImage: "xmark.circle")
@@ -71,6 +73,7 @@ struct ChatSwitcherSheet: View {
7173

7274
if !chat.isClosed {
7375
Button {
76+
chatSessionManager.remove(chatId: chat.id, modelContext: modelContext)
7477
viewModel.closeChat(chat, apiClient: apiClient, modelContext: modelContext)
7578
} label: {
7679
Label("Close", systemImage: "xmark.circle")
@@ -87,6 +90,7 @@ struct ChatSwitcherSheet: View {
8790
titleVisibility: .visible
8891
) {
8992
Button("Delete", role: .destructive) {
93+
chatSessionManager.remove(chatId: chat.id, modelContext: modelContext)
9094
viewModel.deleteChat(chat, apiClient: apiClient, modelContext: modelContext)
9195
chatToDelete = nil
9296
}

0 commit comments

Comments
 (0)