block / block/buzz

Mobile: live threaded replies never reach the channel window store, so thread summaries never update (regression from #1518)

Open
#3,293 9 comments 3 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.