Skip to content

Commit 5639de6

Browse files
authored
Merge pull request #65 from mcintyre94/chat-reconnect-delay-troubleshoot-cd59686f
Batch reconnect replay into a single observable mutation
2 parents 72472c0 + fdb7549 commit 5639de6

File tree

3 files changed

+365
-31
lines changed

3 files changed

+365
-31
lines changed

Wisp/Models/Local/SpriteChat.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ final class SpriteChat {
2121
var forkContext: String?
2222
var worktreePath: String?
2323
var worktreeBranch: String?
24+
var lastSessionComplete: Bool = false
2425

2526
var displayName: String {
2627
customName ?? "Chat \(chatNumber)"

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 120 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import UIKit
66

77
private 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

Comments
 (0)