@@ -6,7 +6,7 @@ import UIKit
66
77private let logger = Logger ( subsystem: " com.wisp.app " , category: " Chat " )
88
9- enum ChatStatus : Sendable {
9+ enum ChatStatus : Sendable , Equatable {
1010 case idle
1111 case connecting
1212 case streaming
@@ -81,6 +81,17 @@ final class ChatViewModel {
8181 var processedEventUUIDs : Set < String > = [ ]
8282 private var hasPlayedFirstTextHaptic = false
8383
84+ /// When true, handleEvent buffers content changes instead of mutating the
85+ /// @Observable ChatMessage directly. Flushed in one shot after replay ends,
86+ /// reducing the N per-event SwiftUI re-renders to a single update.
87+ private var isReplaying = false
88+ private var replayContentBuffer : [ ChatContent ] = [ ]
89+ private var replayToolUseBuffer : [ String : ( messageIndex: Int , toolName: String ) ] = [ : ]
90+ /// When true (reconnecting to a still-running service), isReplaying is cleared
91+ /// on the first new event so live events stream in incrementally rather than
92+ /// being batched until the connection drops.
93+ private var isReplayingLiveService = false
94+
8495 init ( spriteName: String , chatId: UUID , currentServiceName: String ? , workingDirectory: String ) {
8596 self . spriteName = spriteName
8697 self . chatId = chatId
@@ -553,7 +564,7 @@ final class ChatViewModel {
553564 // Cancel the stale stream and reconnect via service logs
554565 streamTask? . cancel ( )
555566 streamTask = Task {
556- await reconnectToServiceLogs ( apiClient: apiClient, modelContext: modelContext)
567+ await reconnectToServiceLogs ( apiClient: apiClient, modelContext: modelContext, serviceIsRunning : true )
557568 }
558569 }
559570
@@ -600,12 +611,19 @@ final class ChatViewModel {
600611 func reconnectIfNeeded( apiClient: SpritesAPIClient , modelContext: ModelContext ) {
601612 guard !isStreaming, !messages. isEmpty else { return }
602613
614+ // If the last session completed cleanly, content is already loaded from
615+ // persistence — no need to hit the network at all.
616+ if let chat = fetchChat ( modelContext: modelContext) , chat. lastSessionComplete {
617+ return
618+ }
619+
603620 streamTask = Task {
604621 // Only reconnect if the service exists (running or stopped with logs)
605- guard let _ = try ? await apiClient. getServiceStatus ( spriteName: spriteName, serviceName: serviceName)
622+ guard let serviceInfo = try ? await apiClient. getServiceStatus ( spriteName: spriteName, serviceName: serviceName)
606623 else { return }
607624
608- await reconnectToServiceLogs ( apiClient: apiClient, modelContext: modelContext)
625+ let isRunning = serviceInfo. state. status == " running "
626+ await reconnectToServiceLogs ( apiClient: apiClient, modelContext: modelContext, serviceIsRunning: isRunning)
609627 }
610628 }
611629
@@ -632,8 +650,8 @@ final class ChatViewModel {
632650 }
633651 }
634652
635- // Persist the new service name immediately for reconnect
636- saveSession ( modelContext: modelContext)
653+ // Persist the new service name immediately for reconnect; clear any prior completion flag
654+ saveSession ( modelContext: modelContext, isComplete : false )
637655
638656 guard let claudeToken = apiClient. claudeToken else {
639657 status = . error( " No Claude token configured " )
@@ -749,7 +767,7 @@ final class ChatViewModel {
749767 // Attempt reconnection on disconnect
750768 if case . disconnected = streamResult {
751769 logger. info ( " [Chat] Disconnected mid-stream, attempting reconnect via service logs " )
752- await reconnectToServiceLogs ( apiClient: apiClient, modelContext: modelContext)
770+ await reconnectToServiceLogs ( apiClient: apiClient, modelContext: modelContext, serviceIsRunning : true )
753771 return
754772 }
755773
@@ -863,6 +881,15 @@ final class ChatViewModel {
863881 if let uuid = parsedEvent. uuid {
864882 processedEventUUIDs. insert ( uuid)
865883 }
884+ // First new event after skipping historical ones on a live-service reconnect:
885+ // flush the replay buffer (empty, since skipped events don't buffer anything)
886+ // and switch to incremental rendering so live events stream in as they arrive.
887+ if isReplayingLiveService {
888+ applyReplayBuffer ( )
889+ isReplaying = false
890+ isReplayingLiveService = false
891+ if case . reconnecting = status { status = . streaming }
892+ }
866893 handleEvent ( parsedEvent, modelContext: modelContext)
867894 }
868895
@@ -981,8 +1008,16 @@ final class ChatViewModel {
9811008 /// `reconnectToServiceLogs` so it can be tested against a mock API client.
9821009 func runReconnectLoop(
9831010 apiClient: some ServiceLogsProvider ,
984- modelContext: ModelContext
1011+ modelContext: ModelContext ,
1012+ serviceIsRunning: Bool = false
9851013 ) async {
1014+ isReplaying = true
1015+ isReplayingLiveService = serviceIsRunning
1016+ defer {
1017+ applyReplayBuffer ( )
1018+ isReplaying = false
1019+ isReplayingLiveService = false
1020+ }
9861021 status = . reconnecting
9871022 let priorUUIDs = processedEventUUIDs. count
9881023 logger. info ( " [Chat] Reconnecting to service logs ( \( priorUUIDs) prior UUIDs) " )
@@ -1033,6 +1068,9 @@ final class ChatViewModel {
10331068 modelContext: modelContext
10341069 )
10351070
1071+ // Flush buffered content to the observable message in one shot
1072+ applyReplayBuffer ( )
1073+
10361074 let currentUUIDs = processedEventUUIDs. count
10371075 logger. info ( " [Chat] Reconnect stream ended: result= \( streamResult) , content= \( assistantMessage. content. count) , uuids= \( currentUUIDs) " )
10381076
@@ -1079,9 +1117,10 @@ final class ChatViewModel {
10791117
10801118 private func reconnectToServiceLogs(
10811119 apiClient: SpritesAPIClient ,
1082- modelContext: ModelContext
1120+ modelContext: ModelContext ,
1121+ serviceIsRunning: Bool = false
10831122 ) async {
1084- await runReconnectLoop ( apiClient: apiClient, modelContext: modelContext)
1123+ await runReconnectLoop ( apiClient: apiClient, modelContext: modelContext, serviceIsRunning : serviceIsRunning )
10851124
10861125 if let queued = queuedPrompt, !Task. isCancelled {
10871126 let prompt = buildPrompt ( text: queued, attachments: queuedAttachments)
@@ -1110,28 +1149,44 @@ final class ChatViewModel {
11101149 for block in assistantEvent. message. content {
11111150 switch block {
11121151 case . text( let text) :
1113- if !hasPlayedFirstTextHaptic {
1152+ if !isReplaying && ! hasPlayedFirstTextHaptic {
11141153 hasPlayedFirstTextHaptic = true
11151154 fireHaptic ( . medium)
11161155 }
11171156 // Merge consecutive text blocks
1118- if case . text( let existing) = message. content. last {
1119- message. content [ message. content. count - 1 ] = . text( existing + text)
1157+ if isReplaying {
1158+ if case . text( let existing) = replayContentBuffer. last {
1159+ replayContentBuffer [ replayContentBuffer. count - 1 ] = . text( existing + text)
1160+ } else {
1161+ replayContentBuffer. append ( . text( text) )
1162+ }
11201163 } else {
1121- message. content. append ( . text( text) )
1164+ if case . text( let existing) = message. content. last {
1165+ message. content [ message. content. count - 1 ] = . text( existing + text)
1166+ } else {
1167+ message. content. append ( . text( text) )
1168+ }
11221169 }
11231170 case . toolUse( let toolUse) :
11241171 let card = ToolUseCard (
11251172 toolUseId: toolUse. id,
11261173 toolName: toolUse. name,
11271174 input: toolUse. input
11281175 )
1129- message. content. append ( . toolUse( card) )
11301176 logger. info ( " Tool use: \( toolUse. name, privacy: . public) id= \( toolUse. id, privacy: . public) " )
1131- toolUseIndex [ toolUse. id] = (
1132- messageIndex: messages. count - 1 ,
1133- toolName: toolUse. name
1134- )
1177+ if isReplaying {
1178+ replayContentBuffer. append ( . toolUse( card) )
1179+ replayToolUseBuffer [ toolUse. id] = (
1180+ messageIndex: messages. count - 1 ,
1181+ toolName: toolUse. name
1182+ )
1183+ } else {
1184+ message. content. append ( . toolUse( card) )
1185+ toolUseIndex [ toolUse. id] = (
1186+ messageIndex: messages. count - 1 ,
1187+ toolName: toolUse. name
1188+ )
1189+ }
11351190 if [ " Write " , " Edit " ] . contains ( toolUse. name) {
11361191 turnHasMutations = true
11371192 }
@@ -1144,22 +1199,34 @@ final class ChatViewModel {
11441199 guard let message = currentAssistantMessage else { return }
11451200
11461201 for result in toolResultEvent. message. content {
1147- let toolName = toolUseIndex [ result. toolUseId] ? . toolName ?? " Unknown "
1202+ let toolName = replayToolUseBuffer [ result. toolUseId] ? . toolName
1203+ ?? toolUseIndex [ result. toolUseId] ? . toolName
1204+ ?? " Unknown "
11481205 let resultCard = ToolResultCard (
11491206 toolUseId: result. toolUseId,
11501207 toolName: toolName,
11511208 content: result. content ?? . null
11521209 )
1153- message. content. append ( . toolResult( resultCard) )
1154-
1155- // Link result back to matching tool use card
1156- for item in message. content {
1157- if case . toolUse( let toolCard) = item, toolCard. toolUseId == result. toolUseId {
1158- toolCard. result = resultCard
1159- break
1210+ if isReplaying {
1211+ replayContentBuffer. append ( . toolResult( resultCard) )
1212+ // Link result to matching tool use card in the buffer
1213+ for item in replayContentBuffer {
1214+ if case . toolUse( let toolCard) = item, toolCard. toolUseId == result. toolUseId {
1215+ toolCard. result = resultCard
1216+ break
1217+ }
1218+ }
1219+ } else {
1220+ message. content. append ( . toolResult( resultCard) )
1221+ // Link result back to matching tool use card
1222+ for item in message. content {
1223+ if case . toolUse( let toolCard) = item, toolCard. toolUseId == result. toolUseId {
1224+ toolCard. result = resultCard
1225+ break
1226+ }
11601227 }
1228+ fireHaptic ( . light)
11611229 }
1162- fireHaptic ( . light)
11631230 }
11641231
11651232 case . result( let resultEvent) :
@@ -1168,10 +1235,10 @@ final class ChatViewModel {
11681235 }
11691236 receivedResultEvent = true
11701237 sessionId = resultEvent. sessionId
1171- saveSession ( modelContext: modelContext)
1238+ saveSession ( modelContext: modelContext, isComplete : true )
11721239
11731240 let autoCheckpointEnabled = UserDefaults . standard. bool ( forKey: " autoCheckpoint " )
1174- if turnHasMutations, autoCheckpointEnabled, let apiClient {
1241+ if !isReplaying , turnHasMutations, autoCheckpointEnabled, let apiClient {
11751242 let assistantMsg = currentAssistantMessage
11761243 let sprite = spriteName
11771244 Task { [ weak assistantMsg] in
@@ -1429,11 +1496,12 @@ final class ChatViewModel {
14291496 return try ? modelContext. fetch ( descriptor) . first
14301497 }
14311498
1432- private func saveSession( modelContext: ModelContext ) {
1499+ private func saveSession( modelContext: ModelContext , isComplete : Bool ? = nil ) {
14331500 guard let chat = fetchChat ( modelContext: modelContext) else { return }
14341501 chat. claudeSessionId = sessionId
14351502 chat. currentServiceName = serviceName
14361503 chat. lastUsed = Date ( )
1504+ if let isComplete { chat. lastSessionComplete = isComplete }
14371505 try ? modelContext. save ( )
14381506 }
14391507
@@ -1481,6 +1549,27 @@ final class ChatViewModel {
14811549 return !timedOut
14821550 }
14831551
1552+ /// Flush the replay content buffer into the current assistant message in one shot,
1553+ /// replacing N per-event observable mutations with a single array assignment.
1554+ private func applyReplayBuffer( ) {
1555+ guard !replayContentBuffer. isEmpty, let message = currentAssistantMessage else {
1556+ replayContentBuffer = [ ]
1557+ replayToolUseBuffer = [ : ]
1558+ return
1559+ }
1560+ for item in replayContentBuffer {
1561+ if case . text( let newText) = item,
1562+ case . text( let existing) = message. content. last {
1563+ message. content [ message. content. count - 1 ] = . text( existing + newText)
1564+ } else {
1565+ message. content. append ( item)
1566+ }
1567+ }
1568+ toolUseIndex. merge ( replayToolUseBuffer) { _, new in new }
1569+ replayContentBuffer = [ ]
1570+ replayToolUseBuffer = [ : ]
1571+ }
1572+
14841573 private func fireHaptic( _ style: UIImpactFeedbackGenerator . FeedbackStyle ) {
14851574 UIImpactFeedbackGenerator ( style: style) . impactOccurred ( )
14861575 }
0 commit comments