The 96-slot message ring can overflow when there is sustained traffic
across many threads. When ring wraparound silently overwrites a channel's
messages, thread.unread stays non-zero but getThreadMessageIndexes
returns 0 — producing a badge with a count but an empty chat on open.
The same phantom persists across reboots because unread is saved to
disk independently of the ring contents.
Fix has three parts:
1. counted_as_unread per UIMessage (RAM-only): set true when a message
is the reason a thread's unread counter was incremented. Cleared when
the thread is marked read, so future evictions of those slots don't
double-decrement.
2. Eviction hook in appendMessage: before overwriting a ring slot,
decrement its thread's unread if the slot was counted_as_unread.
Keeps the counter in sync as the ring wraps.
3. Post-load reconciliation in loadHistoryFromStorage: after restoring
thread+message state from disk, any thread whose persisted unread
count exceeds its actual incoming messages in the ring gets clamped
(or reset to 0 if the ring has nothing for it). Also reconstructs
the counted_as_unread flags so evictions after boot are tracked.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>