Skip to content

Commit 7619094

Browse files
mcintyre94claude
andcommitted
Add app-wide ChatSessionManager to keep streams alive across navigation
Previously ChatViewModel was recreated on every chat switch, cancelling the active stream and requiring a reconnect on return. ChatSessionManager is an @observable cache keyed by chat UUID, injected app-wide, so VMs (and their streams) survive switching chats or navigating between sprites. switchToChat now looks up the cached VM instead of creating a new one. The scenePhase handler resumes all cached VMs on foreground, not just the active one. clearAllChats detaches all VMs before wiping the chat list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e6a1d18 commit 7619094

File tree

3 files changed

+71
-21
lines changed

3 files changed

+71
-21
lines changed

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/Views/SpriteDetail/SpriteDetailView.swift

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ struct SpriteDetailView: View {
99
@State private var checkpointsViewModel: CheckpointsViewModel
1010
@State private var showChatSwitcher = false
1111
@State private var showStaleChatsAlert = false
12-
@State private var knownStreamingChatIds: Set<UUID> = []
1312
@State private var showCopiedFeedback = false
1413
@State private var pendingFork: (checkpointId: String, messageId: UUID)? = nil
1514
@State private var isForking = false
1615
@Environment(SpritesAPIClient.self) private var apiClient
16+
@Environment(ChatSessionManager.self) private var chatSessionManager
1717
@Environment(\.modelContext) private var modelContext
1818
@Environment(\.scenePhase) private var scenePhase
1919
@Environment(\.horizontalSizeClass) private var sizeClass
@@ -203,15 +203,16 @@ struct SpriteDetailView: View {
203203
}
204204
.onChange(of: scenePhase) { _, newPhase in
205205
if newPhase == .active {
206-
chatViewModel?.resumeAfterBackground(apiClient: apiClient, modelContext: modelContext)
207-
chatViewModel?.reconnectIfNeeded(apiClient: apiClient, modelContext: modelContext)
206+
chatSessionManager.resumeAllAfterBackground(apiClient: apiClient, modelContext: modelContext)
208207
}
209208
}
210209
.sheet(isPresented: $showChatSwitcher) {
211210
ChatSwitcherSheet(viewModel: chatListViewModel)
212211
}
213212
.alert("Sprite Recreated", isPresented: $showStaleChatsAlert) {
214213
Button("Start Fresh", role: .destructive) {
214+
chatSessionManager.detachAll(modelContext: modelContext)
215+
chatViewModel = nil
215216
chatListViewModel.clearAllChats(apiClient: apiClient, modelContext: modelContext)
216217
let chat = chatListViewModel.createChat(modelContext: modelContext)
217218
switchToChat(chat)
@@ -240,29 +241,18 @@ struct SpriteDetailView: View {
240241
private func switchToChat(_ chat: SpriteChat) {
241242
guard chatViewModel?.chatId != chat.id else { return }
242243

243-
// Detach old VM (cancel stream but keep service running)
244-
if let oldVM = chatViewModel {
245-
let wasStreaming = oldVM.detach(modelContext: modelContext)
246-
if wasStreaming {
247-
knownStreamingChatIds.insert(oldVM.chatId)
248-
}
249-
}
250-
251-
let vm = ChatViewModel(
244+
// Look up or create a VM from the app-wide cache — old VM keeps streaming in background
245+
let vm = chatSessionManager.viewModel(
246+
for: chat,
252247
spriteName: sprite.name,
253-
chatId: chat.id,
254-
currentServiceName: chat.currentServiceName,
255-
workingDirectory: chat.workingDirectory
248+
apiClient: apiClient,
249+
modelContext: modelContext
256250
)
257-
vm.loadSession(apiClient: apiClient, modelContext: modelContext)
258251
chatViewModel = vm
259252
chatListViewModel.activeChatId = chat.id
260253

261-
// Always try reconnect — checks service status first, so it's
262-
// cheap for old chats where the service has already stopped.
263-
// This handles both switching between chats and navigating
264-
// back to the sprite after the view was destroyed.
265-
knownStreamingChatIds.remove(chat.id)
254+
// Reconnect if idle with a service that may have new events.
255+
// Guards on !isStreaming internally, so safe to call on an already-streaming VM.
266256
vm.reconnectIfNeeded(apiClient: apiClient, modelContext: modelContext)
267257
}
268258

0 commit comments

Comments
 (0)