fix(mobile): start no threads in Solo chats (#9123)

Agents in a Solo chat always answer at the chat's top level. On mobile
you can still start a thread in Solo (Reply action, swipe-to-reply,
media reply) or reply inside an existing one, so the agent's answer
lands outside the thread you wrote in. Desktop already avoids this: Solo
shows existing threads but starts none and has no thread composer.

This matches desktop on mobile:

- No Reply quick action, swipe-to-reply or media reply in Solo chats.
- Existing Solo threads still open from their reply summary, read-only
(no composer).
- Every other channel is unchanged.

### Demo

Synthetic Solo chat "QA demo", iPhone 17 Pro simulator. Each clip: right
swipe on an agent message, then long-press for the actions.

Before (main): the swipe shows the reply circle and opens a thread with
a composer; Reply is in the actions.


https://github.com/user-attachments/assets/cc5025a3-b7ba-4ab2-bebb-06c7b54e7906

After (this branch): the swipe does nothing; the actions have no Reply.


https://github.com/user-attachments/assets/b31f9983-8c52-45c2-ae88-1661aa71210e

### Validation

Added tests: a Solo chat's message neither swipes to reply nor offers
Reply in its actions, and a Solo thread opens without a composer.
Existing swipe-reply and Solo channel tests pass, along with the full
mobile suite and analyzer.

Simulator run on an iPhone 17 Pro, signed in to a real community: in an
existing Solo chat, a right swipe on an agent message no longer moves or
opens a thread, and the long-press actions show no Reply (Copy link,
Remind me and the rest are unchanged). Team-channel Reply is covered by
the existing swipe-reply tests, not this run.

A second simulator run on an iPhone 17 Pro, in a synthetic Solo chat,
compared main and this branch: on main the swipe opens a thread with a
composer and the actions list Reply; on this branch neither does (see
Demo). The Demo's "Follow thread" action still appears in a Solo chat's
actions on both builds; this PR does not change it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-09 06:58:53 -08:00
committed by GitHub
parent 733d164e80
commit ad5699d05a
7 changed files with 114 additions and 8 deletions

View File

@@ -39,6 +39,9 @@ class _MessageBubble extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final startsThreads = ref.watch(
channelStartsThreadsProvider(currentChannelId),
);
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final swipeContentKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
@@ -379,7 +382,8 @@ class _MessageBubble extends HookConsumerWidget {
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow:
Grid.gutter,
onMediaReply: allMessages == null
onMediaReply:
allMessages == null || !startsThreads
? null
: () {
if (!context.mounted) return;

View File

@@ -1155,6 +1155,18 @@ final channelByIdProvider = Provider.autoDispose.family<Channel?, String>(
ref.watch(_openedAheadProvider.select((opened) => opened[channelId])),
);
/// Whether this channel can start or continue threads. A Solo chat's agents
/// answer at its top level, so it keeps existing threads readable but starts
/// none and takes no thread replies, as desktop does. A channel not loaded yet
/// keeps the default thread behavior.
final channelStartsThreadsProvider = Provider.autoDispose.family<bool, String>(
(ref, channelId) => !(ref.watch(
channelByIdProvider(
channelId,
).select((channel) => channel?.isSolo ?? false),
)),
);
/// Channels opened from their own metadata before the list had them, as a
/// fast DM open does. Scoped to one relay and identity. Each is dropped once
/// the list has it, so a DM later hidden or left is not found through it.

View File

@@ -599,9 +599,10 @@ class _MessageQuickActionsRow extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final messages = allMessages;
final canRemind = ref.watch(reminderServiceProvider) != null;
final startsThreads = ref.watch(channelStartsThreadsProvider(channelId));
final tiles = <Widget>[
if (messages != null)
if (messages != null && startsThreads)
_MessageQuickActionTile(
icon: BuzzIcons.messageSquareReply,
label: 'Reply',

View File

@@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/buzz_icons.dart';
import '../../shared/theme/theme.dart';
import 'channels_provider.dart';
import 'thread_detail_page.dart';
import 'message_reply_drag_target.dart';
import 'timeline_message.dart';
@@ -55,7 +56,8 @@ class MessageSwipeReply extends HookConsumerWidget {
final cancelled = useRef(false);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final messages = allMessages;
if (messages == null || message.isSystem) return child;
final startsThreads = ref.watch(channelStartsThreadsProvider(channelId));
if (messages == null || message.isSystem || !startsThreads) return child;
void reset() {
pulse.stop();

View File

@@ -116,6 +116,11 @@ class ThreadDetailPage extends HookConsumerWidget {
// signing identity instead of freezing that incomplete route snapshot.
final currentPubkey =
ref.watch(currentPubkeyProvider) ?? this.currentPubkey;
// A Solo chat's threads are read-only: its agents answer at top level.
final canReply =
isMember &&
!isArchived &&
ref.watch(channelStartsThreadsProvider(channelId));
final appView = View.of(context);
final composerDockHeight = useState(0.0);
final composerFocusNode = useFocusNode();
@@ -348,8 +353,7 @@ class ThreadDetailPage extends HookConsumerWidget {
);
bool threadTailIsVisible() {
if (isMember &&
!isArchived &&
if (canReply &&
(!viewportHeight.isFinite ||
viewportHeight <= 0 ||
composerDockHeight.value <= 0)) {
@@ -656,7 +660,7 @@ class ThreadDetailPage extends HookConsumerWidget {
previousReplyCount.value = replies.length;
return null;
}
if (isMember && !isArchived && composerDockHeight.value <= 0) {
if (canReply && composerDockHeight.value <= 0) {
return null;
}
if (!initialTailSettle.isComplete) {
@@ -1059,7 +1063,7 @@ class ThreadDetailPage extends HookConsumerWidget {
),
],
),
if (isMember && !isArchived)
if (canReply)
AndroidImeLift(
child: Align(
alignment: Alignment.bottomCenter,

View File

@@ -40,6 +40,7 @@ class _ThreadMessage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final startsThreads = ref.watch(channelStartsThreadsProvider(channelId));
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final swipeContentKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
@@ -344,7 +345,8 @@ class _ThreadMessage extends HookConsumerWidget {
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow:
Grid.gutter,
onMediaReply: allMessages == null
onMediaReply:
allMessages == null || !startsThreads
? null
: () {
if (!context.mounted) return;

View File

@@ -1,6 +1,87 @@
part of '../channel_detail_page_test.dart';
void swipeReplyTests() {
// Solo agents answer at top level, so a Solo chat starts no threads.
Channel soloChannel() => Channel(
id: _channelId,
name: 'Solo',
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
description: '',
createdBy: 'self',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
);
testWidgets('Solo chat offers no reply by swipe or message actions', (
tester,
) async {
final channel = soloChannel();
final events = [
_textMsg(id: threadIdRoot, pubkey: 'agent', content: 'Agent answer'),
];
await tester.pumpWidget(
_buildTestable(
signingPubkey: 'self',
messages: events,
channel: channel,
personalChat: true,
organization: Stream.value(const MeGroups()),
home: ChannelDetailPage(channel: channel),
),
);
await tester.pumpAndSettle();
final row = find.byKey(const ValueKey('message-row-$threadIdRoot'));
final bubble = find
.descendant(of: row, matching: find.byType(LocalSendBubbleSurface))
.first;
final initial = tester.getTopLeft(row);
final gesture = await tester.startGesture(tester.getCenter(bubble));
await gesture.moveBy(const Offset(25, 0));
await gesture.moveBy(const Offset(150, 0));
await tester.pump();
expect(tester.getTopLeft(row), initial);
expect(find.byKey(const ValueKey('swipe-reply-circle')), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(ThreadDetailPage), findsNothing);
await tester.longPress(bubble);
await tester.pumpAndSettle();
expect(find.text('Copy link'), findsOneWidget);
expect(find.text('Reply'), findsNothing);
});
testWidgets('Solo thread is read-only', (tester) async {
final channel = soloChannel();
final events = [
_textMsg(id: threadIdRoot, pubkey: 'self', content: 'Old thread head'),
];
final messages = formatTimeline(events);
await tester.pumpWidget(
_buildTestable(
signingPubkey: 'self',
messages: events,
channel: channel,
personalChat: true,
organization: Stream.value(const MeGroups()),
home: ThreadDetailPage(
threadHead: messages.first,
allMessages: messages,
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Old thread head'), findsOneWidget);
expect(find.byType(ComposeBar), findsNothing);
});
for (final thread in [false, true]) {
for (final own in [false, true]) {
testWidgets(