Mobile: live threaded replies never reach the channel window store, so thread summaries never update (regression from #1518)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
On mobile, a threaded reply that arrives **live** is discarded before it reaches the channel window store. Because it never enters the store, it never feeds the parent message's thread-summary computation, so the channel timeline does not change at all — no new row, no "1 reply" indicator, no reply-count bump.
From the user's side an agent looks like it never answered: you `@mention` an agent, "… is typing" appears and then times out, and the channel stays exactly as it was. Restarting the app makes the reply appear, because the cold-start path rebuilds the timeline from the relay's channel-window query, which *does* include the reply.
Desktop is unaffected. This is a mobile-only regression.
## Reproduction
1. On mobile, open a channel that has an agent member.
2. Post a top-level message mentioning the agent (e.g. `@agent uname pls`).
3. Wait for the agent to reply. The agent replies threaded — `["e", , "", "reply"]`, no `broadcast` tag.
**Expected:** the parent message shows a thread summary ("1 reply") without any manual refresh.
**Actual:** nothing changes in the channel. The typing indicator expires after its 8s TTL and the timeline is untouched. Force-quitting and reopening the app surfaces the reply.
The reply is on the relay and renders correctly on desktop the whole time. Opening the thread on mobile also shows it — only the channel timeline is stale.
## Root cause
`_mergeWindowEventIntoStore` rejects any reply lacking a `broadcast` tag, at the **store** layer ([`channel_messages_provider.dart#L223`](https://github.com/block/buzz/blob/3a4bf513df0e0c258587bfcbed9463d63723b56b/mobile/lib/features/channels/channel_messages_provider.dart#L223)):
```dart
if (thread?.parentId != null) {
// ... invalidate threadRepliesProvider ...
if (!_isBroadcastReply(event)) return false; // event never enters _windowStore
}
```
Returning `false` means `mergeLiveChannelWindowEvent` is never called, so the event is absent from `_windowStore` entirely. `flattenChannelWindowEvents` therefore never emits it, `buildMainTimelineEntries` never sees it, `childrenByParent` stays empty, and `_buildSummary` produces no summary for the parent.
The `threadRepliesProvider` invalidation on the preceding lines is why the reply *is* present the moment you open the thread — that path refetches independently.
### Why the gate is wrong here
Visibility is already correctly enforced one layer up, at render ([`timeline_message.dart#L497`](https://github.com/block/buzz/blob/3a4bf513df0e0c258587bfcbed9463d63723b56b/mobile/lib/features/channels/timeline_message.dart#L497)):
```dart
if (msg.parentId == null || _isBroadcastReply(msg))
```
Desktop has the identical rule ([`threadPanel.ts#L445`](https://github.com/block/buzz/blob/3a4bf513df0e0c258587bfcbed9463d63723b56b/desktop/src/features/messages/lib/threadPanel.ts#L445)):
```ts
.filter((message) => message.parentId == null || isBroadcastReply(message.tags ?? []))
```
The difference is **what gets handed to that filter**. Desktop passes the full message list, replies included, into `buildThreadPanelIndex`, so a reply is excluded as a *row* but still counted for the summary. Mobile drops the reply before it can be counted.
So mobile applies `isBroadcastReply` twice — once at render (correct, matches desktop) and once at storage (has no desktop equivalent, and is the bug). Desktop uses `isBroadcastReply` only for render, unread, and routing decisions; never to decide whether to store an event.
### Regression origin
`git log -L 218,228:mobile/lib/features/channels/channel_messages_provider.dart` returns three commits, the most recent being:
```
6716cd31a Port channel windows to mobile (#1518)
```
Before that port, mobile used the ungated `_mergeEvent` path, replies entered the store, and summaries updated live. The channel-window port introduced a data-layer filter that the code it was porting from did not have.
## Evidence
A representative agent reply as stored by the relay:
```
kind 9
tags: [["h","d44803cb-…"],
["e","14ede3e3…","","reply"],
["auth","ec6cdc65…", …]]
```
Ordinary threaded reply, no `broadcast` tag. `kind 9` is in `channelEventKinds`, and the `#h` tag matches, so the event is delivered to the live subscription in `channel_messages_provider.dart` and then discarded at line 223.
Note the typing indicator is a **separate** kind-20002 subscription ([`channel_typing_provider.dart#L56`](https://github.com/block/buzz/blob/3a4bf513df0e0c258587bfcbed9463d63723b56b/mobile/lib/features/channels/channel_typing_provider.dart#L56)) and is unaffected — which is why it keeps showing "… is typing" and then simply expires, with nothing arriving to supersede it. That combination is what makes it read as "the agent is broken".
## Suggested fix
Let the reply into the store and leave visibility to the render layer, which already gets it right:
```dart
if (thread?.parentId != null) {
// ... invalidate threadRepliesProvider ...
// (drop the _isBroadcastReply early-return)
}
```
`_isBroadcastReply` in this file ([L464](https://github.com/block/buzz/blob/3a4bf513df0e0c258587bfcbed9463d63723b56b/mobile/lib/features/channels/channel_messages_provider.dart#L464)) becomes unused and can go; the copy in `timeline_message.dart` stays.
Worth covering with two tests:
- a live threaded non-broadcast reply bumps the parent's thread-summary reply count 0 → 1;
- the same reply does **not** become a top-level timeline row (guards the render rule).
## Impact
Affects every non-broadcast threaded reply arriving live on mobile, from agents and humans alike. Agent conversations are the most visible case because the user is actively waiting on the reply.
## Scope
This is distinct from #2415 (nested-thread *sends*, caused by the relay's root-scoped thread query) — different code path and different root cause. It may explain the "incoming messages not displayed" half of #2971, though that report is DM-specific and also covers presence.
## Environment
- Mobile (Flutter), reproduced against a self-hosted relay
- Desktop does not reproduce
- Verified against upstream `main` @ `3a4bf513df0e0c258587bfcbed9463d63723b56b`
Contributor guide
Research direction
Start in mobile/lib/features/channels/channel_messages_provider.dart at _mergeWindowEventIntoStore and trace how live events reach the window store and thread summaries. Add coverage for a live non-broadcast threaded reply increasing the parent summary count while remaining absent as a top-level timeline row; verify the existing render rule in mobile/lib/features/channels/timeline_message.dart remains effective.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- flutter
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100