Skip to content

Commit 72d9737

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 8ca14a9 commit 72d9737

File tree

4 files changed

+75
-21
lines changed

4 files changed

+75
-21
lines changed

Wisp.xcodeproj/project.pbxproj

Lines changed: 4 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 */; };
@@ -210,6 +211,7 @@
210211
C4D5E6F7A8B90A41637E8FA1 /* CheckpointMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointMarkerView.swift; sourceTree = "<group>"; };
211212
C8C4C3A1F2B2D57313431E50 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
212213
C8F1378232DC7FA71A8B5444 /* ClaudeStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeStreamParser.swift; sourceTree = "<group>"; };
214+
AA33BB44CC55DD66EE77FF01 /* ChatSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSessionManager.swift; sourceTree = "<group>"; };
213215
CC11DD22EE33FF4400110002 /* ChatAttachmentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAttachmentButton.swift; sourceTree = "<group>"; };
214216
CC11DD22EE33FF4400110004 /* SpriteFileBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpriteFileBrowserView.swift; sourceTree = "<group>"; };
215217
CC11DD22EE33FF4400110005 /* FileEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntryTests.swift; sourceTree = "<group>"; };
@@ -307,6 +309,7 @@
307309
isa = PBXGroup;
308310
children = (
309311
AA22BB33CC44DD55EE66FF02 /* ClaudeQuestionTool.swift */,
312+
AA33BB44CC55DD66EE77FF01 /* ChatSessionManager.swift */,
310313
C8F1378232DC7FA71A8B5444 /* ClaudeStreamParser.swift */,
311314
2554BB4DE8EC315CA48767BF /* ExecSession.swift */,
312315
BB11CC22DD33EE44FF660A01 /* GitHubAPIClient.swift */,
@@ -728,6 +731,7 @@
728731
D2B1EF5C6A7F8B9304C5E6F7 /* ClaudeModel.swift in Sources */,
729732
2A18611EA392DA2BFFE74250 /* ClaudeStreamEvent.swift in Sources */,
730733
4FE7C138A35A53B4C38BEF98 /* ClaudeStreamParser.swift in Sources */,
734+
AA33BB44CC55DD66EE77FF02 /* ChatSessionManager.swift in Sources */,
731735
99BA5812AF56D1C7501D1F0C /* CreateCheckpointSheet.swift in Sources */,
732736
5ABE241326BF34FF2E3C3E51 /* CreateSpriteSheet.swift in Sources */,
733737
5A1F582DDCBB994013E4BA2E /* DashboardView.swift in Sources */,

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,12 +9,12 @@ 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
@State private var spriteQuickActionsViewModel: QuickActionsViewModel?
1716
@Environment(SpritesAPIClient.self) private var apiClient
17+
@Environment(ChatSessionManager.self) private var chatSessionManager
1818
@Environment(\.modelContext) private var modelContext
1919
@Environment(\.scenePhase) private var scenePhase
2020
@Environment(\.horizontalSizeClass) private var sizeClass
@@ -226,8 +226,7 @@ struct SpriteDetailView: View {
226226
}
227227
.onChange(of: scenePhase) { _, newPhase in
228228
if newPhase == .active {
229-
chatViewModel?.resumeAfterBackground(apiClient: apiClient, modelContext: modelContext)
230-
chatViewModel?.reconnectIfNeeded(apiClient: apiClient, modelContext: modelContext)
229+
chatSessionManager.resumeAllAfterBackground(apiClient: apiClient, modelContext: modelContext)
231230
}
232231
}
233232
.sheet(isPresented: $showChatSwitcher) {
@@ -241,6 +240,8 @@ struct SpriteDetailView: View {
241240
}
242241
.alert("Sprite Recreated", isPresented: $showStaleChatsAlert) {
243242
Button("Start Fresh", role: .destructive) {
243+
chatSessionManager.detachAll(modelContext: modelContext)
244+
chatViewModel = nil
244245
chatListViewModel.clearAllChats(apiClient: apiClient, modelContext: modelContext)
245246
let chat = chatListViewModel.createChat(modelContext: modelContext)
246247
switchToChat(chat)
@@ -277,29 +278,18 @@ struct SpriteDetailView: View {
277278
private func switchToChat(_ chat: SpriteChat) {
278279
guard chatViewModel?.chatId != chat.id else { return }
279280

280-
// Detach old VM (cancel stream but keep service running)
281-
if let oldVM = chatViewModel {
282-
let wasStreaming = oldVM.detach(modelContext: modelContext)
283-
if wasStreaming {
284-
knownStreamingChatIds.insert(oldVM.chatId)
285-
}
286-
}
287-
288-
let vm = ChatViewModel(
281+
// Look up or create a VM from the app-wide cache — old VM keeps streaming in background
282+
let vm = chatSessionManager.viewModel(
283+
for: chat,
289284
spriteName: sprite.name,
290-
chatId: chat.id,
291-
currentServiceName: chat.currentServiceName,
292-
workingDirectory: chat.workingDirectory
285+
apiClient: apiClient,
286+
modelContext: modelContext
293287
)
294-
vm.loadSession(apiClient: apiClient, modelContext: modelContext)
295288
chatViewModel = vm
296289
chatListViewModel.activeChatId = chat.id
297290

298-
// Always try reconnect — checks service status first, so it's
299-
// cheap for old chats where the service has already stopped.
300-
// This handles both switching between chats and navigating
301-
// back to the sprite after the view was destroyed.
302-
knownStreamingChatIds.remove(chat.id)
291+
// Reconnect if idle with a service that may have new events.
292+
// Guards on !isStreaming internally, so safe to call on an already-streaming VM.
303293
vm.reconnectIfNeeded(apiClient: apiClient, modelContext: modelContext)
304294
}
305295

0 commit comments

Comments
 (0)