Batch reconnect replay into a single observable mutation#65
Conversation
During reconnect, handleEvent was modifying @observable ChatMessage.content for every replayed event, causing a SwiftUI re-render (and a haptic) per event. For a long chat catching up on many messages this meant hundreds of expensive render passes on the main actor before the user saw anything. Fix: buffer all content changes during runReconnectLoop into local value-type arrays (replayContentBuffer, replayToolUseBuffer), then flush in one shot via applyReplayBuffer() after each stream iteration. Tool-use/result linking is preserved inside the buffer before it is committed. Haptics and the auto-checkpoint are also suppressed during replay. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…g after reconnect - Add `lastSessionComplete` to SpriteChat: when true, skip the network call entirely on reconnect (content is already in persistence) - Pass `serviceIsRunning` through the reconnect path so the replay buffer is flushed and `isReplaying` is cleared on the first new event when the service is still running — fixes live events buffering until the connection drops instead of streaming incrementally - Make `ChatStatus` Equatable to support direct == comparisons in tests - Add 4 tests covering lastSessionComplete and live-service reconnect Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code reviewText fragmentation across retry iterations —
The reconnect loop is designed to run multiple iterations — explicitly, when the service is still running ( This matters because Suggested fix — replace the single // In applyReplayBuffer(), replace:
message.content.append(contentsOf: replayContentBuffer)
// With:
for item in replayContentBuffer {
if case .text(let newText) = item,
case .text(let existing) = message.content.last {
message.content[message.content.count - 1] = .text(existing + newText)
} else {
message.content.append(item)
}
} |
applyReplayBuffer was using append(contentsOf:) which skips the consecutive-text-block merging logic. On the retry-after-service-stop path the reconnect loop runs two iterations, each flushing its own buffer. The leading .text from the second buffer would land as a separate entry instead of being merged with the trailing .text from the first, producing two chat bubbles for a single assistant response. Replace append(contentsOf:) with element-by-element appending that mirrors the live-streaming merge: if the incoming item and the last item in message.content are both .text, concatenate them in place. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mcintyre94
left a comment
There was a problem hiding this comment.
Fixed — replaced append(contentsOf:) with element-by-element appending that mirrors the live-streaming merge logic. Also added a test (runReconnectLoop_mergesTextAcrossRetryIterations) that exercises exactly the two-iteration scenario described in the review.
…m1 events The mergesTextAcrossRetryIterations test was missing uuid fields on its events. Without UUIDs, textLine1 in stream2 isn't recognised as already- processed and gets replayed again, producing "Hello Hello world". Real log events always carry UUIDs — add them to the test fixtures so the deduplication path that the fix in applyReplayBuffer depends on actually fires. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
handleEventwas appending to@Observable ChatMessage.contentfor every replayed event, causing a full SwiftUI re-render (and a haptic) per event. For a chat catching up on many messages this meant N expensive render passes blocked on the main actor before the user saw anything.isReplaying,replayContentBuffer, andreplayToolUseBufferinChatViewModel. WhilerunReconnectLoopis active,.assistantand.userevents accumulate into these local arrays instead of mutating the observable model directly.processServiceStreamiteration,applyReplayBuffer()flushes the buffer with a singleappend(contentsOf:)— one@Observablemutation → one render pass instead of N.replayToolUseBufferis merged into the maintoolUseIndexon flush..mediumfor first text,.lightper tool result) and the auto-checkpoint are suppressed during replay — both are only appropriate for live streaming.Test plan
runReconnectLoop_buffersContentAndAppliesInOneBatchandrunReconnectLoop_buffersToolUseAndResultWithLinkingverify content lands correctly after buffered replayrunReconnectLooptests still pass🤖 Generated with Claude Code