Skip to content

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

Description

@siddhartpai

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", <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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions