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
- On mobile, open a channel that has an agent member.
- Post a top-level message mentioning the agent (e.g.
@agent uname pls).
- Wait for the agent to reply. The agent replies threaded —
["e", <parent>, "", "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):
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):
if (msg.parentId == null || _isBroadcastReply(msg))
Desktop has the identical rule (threadPanel.ts#L445):
.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) 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:
if (thread?.parentId != null) {
// ... invalidate threadRepliesProvider ...
// (drop the _isBroadcastReply early-return)
}
_isBroadcastReply in this file (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
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
@mentionan 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
@agent uname pls).["e", <parent>, "", "reply"], nobroadcasttag.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
_mergeWindowEventIntoStorerejects any reply lacking abroadcasttag, at the store layer (channel_messages_provider.dart#L223):Returning
falsemeansmergeLiveChannelWindowEventis never called, so the event is absent from_windowStoreentirely.flattenChannelWindowEventstherefore never emits it,buildMainTimelineEntriesnever sees it,childrenByParentstays empty, and_buildSummaryproduces no summary for the parent.The
threadRepliesProviderinvalidation 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):Desktop has the identical rule (
threadPanel.ts#L445):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
isBroadcastReplytwice — once at render (correct, matches desktop) and once at storage (has no desktop equivalent, and is the bug). Desktop usesisBroadcastReplyonly 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.dartreturns three commits, the most recent being:Before that port, mobile used the ungated
_mergeEventpath, 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:
Ordinary threaded reply, no
broadcasttag.kind 9is inchannelEventKinds, and the#htag matches, so the event is delivered to the live subscription inchannel_messages_provider.dartand then discarded at line 223.Note the typing indicator is a separate kind-20002 subscription (
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:
_isBroadcastReplyin this file (L464) becomes unused and can go; the copy intimeline_message.dartstays.Worth covering with two tests:
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
main@3a4bf513df0e0c258587bfcbed9463d63723b56b