diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 17ab0c28e4..dd05e81466 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -676,6 +676,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { .read(channelSectionsProvider) .store .assignments[resolvedChannel.id], + personalChat: personalChat, ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(); diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index d09cd2cfc4..3bd79c05b0 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -11,6 +11,7 @@ Future showChannelDetailsPage({ required String? currentPubkey, required void Function(BuildContext context, String pubkey) onMemberTap, String? sectionId, + bool personalChat = false, }) => Navigator.of(context).push( MaterialPageRoute( builder: (_) => CoveredRouteKeyboardInsets( @@ -19,11 +20,39 @@ Future showChannelDetailsPage({ currentPubkey: currentPubkey, onMemberTap: onMemberTap, sectionId: sectionId, + personalChat: personalChat, ), ), ), ); +/// Opens the channel settings page as a bottom sheet, presented like the +/// channel list's long-press actions sheet. +Future showChannelDetailsSheet({ + required BuildContext context, + required Channel channel, + required String? currentPubkey, + required void Function(BuildContext context, String pubkey) onMemberTap, + String? sectionId, + bool personalChat = false, +}) => showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + builder: (_) => ChannelDetailsPage( + channel: channel, + currentPubkey: currentPubkey, + onMemberTap: onMemberTap, + sectionId: sectionId, + personalChat: personalChat, + inSheet: true, + ), +); + /// Combined channel identity, membership, organization, and lifecycle page. class ChannelDetailsPage extends HookConsumerWidget { const ChannelDetailsPage({ @@ -32,6 +61,8 @@ class ChannelDetailsPage extends HookConsumerWidget { required this.currentPubkey, required this.onMemberTap, this.sectionId, + this.personalChat = false, + this.inSheet = false, }); final Channel channel; @@ -39,6 +70,14 @@ class ChannelDetailsPage extends HookConsumerWidget { final void Function(BuildContext context, String pubkey) onMemberTap; final String? sectionId; + /// Solo chats are organized into Solo workspaces, not channel sections, so + /// their details omit the channel-section move. + final bool personalChat; + + /// Renders only the scrolling content, without the page's app bar, for a + /// bottom sheet that supplies its own drag handle. + final bool inSheet; + @override Widget build(BuildContext context, WidgetRef ref) { final displayedChannel = useState(channel); @@ -270,6 +309,304 @@ class ChannelDetailsPage extends HookConsumerWidget { final usesNativeIosGlassBackButton = Navigator.canPop(context) && Theme.of(context).platform == TargetPlatform.iOS; + final content = ListView( + key: const ValueKey('channel-details-page-list'), + controller: scrollController, + shrinkWrap: inSheet, + padding: EdgeInsets.only( + top: inSheet ? 0 : frostedAppBarHeight(context) + Grid.xs, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs, + ), + children: [ + _ChannelDetailsHero( + channel: resolvedChannel, + nameKey: heroNameKey, + displayName: displayName, + avatar: counterpart == null + ? null + : AvatarImage( + key: const ValueKey('dm-details-avatar'), + imageUrl: counterpartProfile?.avatarUrl, + radius: 40, + isAgent: counterpartProfile?.isAgent ?? false, + backgroundColor: context.controlSurface, + fallback: Text( + counterpartProfile?.initial ?? + dmAvatarInitial( + resolvedChannel, + currentPubkey: resolvedCurrentPubkey, + ), + style: context.textTheme.headlineMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + ), + Padding( + key: const ValueKey('channel-details-actions'), + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + _channelDetailsSectionPadding, + Grid.gutter, + _channelDetailsSectionPadding, + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-star-action'), + icon: null, + iconWidget: TablerStarIcon( + filled: isStarred, + color: isStarred + ? context.colors.primary + : context.colors.onSurface, + ), + label: isStarred ? 'Unstar' : 'Star', + onTap: toggleStar, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-mute-action'), + icon: isMuted ? BuzzIcons.bell : BuzzIcons.bellOff, + iconColor: isMuted ? context.colors.primary : null, + label: isMuted ? 'Unmute' : 'Mute', + onTap: toggleMute, + ), + ), + if (!resolvedChannel.isDm) ...[ + const SizedBox(width: Grid.xxs), + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-edit-action'), + icon: BuzzIcons.pencil, + label: 'Edit', + isEnabled: canEdit, + onTap: openManageChannel, + ), + ), + ], + ], + ), + ), + ), + if (!isDirectDm) + AppListCard( + key: const ValueKey('channel-details-members-card'), + label: memberLabel, + dividerIndent: Grid.xs + 40 + Grid.xs, + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (canAddMembers) + AppListRowRaw( + key: const ValueKey('channel-details-add-members-row'), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colors.surfaceContainer, + shape: BoxShape.circle, + ), + child: Icon( + BuzzIcons.plus, + color: context.colors.onSurfaceVariant, + ), + ), + title: Text( + 'Add members', + style: context.textTheme.bodyLarge, + ), + trailing: const _ChannelDetailsChevron(), + onTap: openAddMembers, + verticalPadding: Grid.xxs, + ), + if (membersAsync.isLoading && members.isEmpty) + const AppListRow( + icon: BuzzIcons.loaderCircle, + title: 'Loading members…', + trailing: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading members', + ), + ) + else if (membersAsync.hasError && members.isEmpty) + const AppListRow( + icon: BuzzIcons.triangleAlert, + title: 'Members unavailable', + ) + else ...[ + for (final member in previewMembers) + _ChannelMemberPreviewRow( + key: ValueKey('channel-details-member-${member.pubkey}'), + member: member, + currentPubkey: resolvedCurrentPubkey, + onMemberTap: onMemberTap, + displayName: + userCache[member.pubkey.toLowerCase()]?.displayName, + contextualLabel: memberLabels[member.pubkey.toLowerCase()], + avatarUrl: + userCache[member.pubkey.toLowerCase()]?.avatarUrl, + ), + if (memberCount > _channelMemberPreviewLimit) + AppListRowRaw( + key: const ValueKey('channel-details-members-row'), + leading: const SizedBox.square(dimension: 40), + title: Text('See all', style: context.textTheme.bodyLarge), + trailing: const _ChannelDetailsChevron(), + onTap: openMembers, + verticalPadding: Grid.xxs, + ), + ], + ], + ), + AppListCard( + key: const ValueKey('channel-details-channel-card'), + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (!personalChat) + AppListRow( + icon: BuzzIcons.folderInput, + title: 'Move to section…', + trailing: const _ChannelDetailsChevron(), + onTap: () async { + await _showMoveSectionSheet( + context, + ref, + channel: resolvedChannel, + sectionId: currentSectionId, + ); + }, + ), + AppListRow( + icon: BuzzIcons.copy, + title: 'Copy channel name', + onTap: () { + copyToClipboard( + context, + resolvedChannel.name, + message: 'Channel name copied', + ); + }, + ), + AppListRow( + icon: BuzzIcons.hash, + title: 'Copy channel ID', + onTap: () { + copyToClipboard( + context, + resolvedChannel.id, + message: 'Channel ID copied', + ); + }, + ), + ], + ), + if (resolvedChannel.isMember || + canJoin || + lifecycleCapabilitiesLoading || + lifecycleCapabilitiesUnavailable || + canArchive || + canUnarchive || + canDelete) + AppListCard( + key: const ValueKey('channel-details-lifecycle-card'), + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (canJoin) + AppListRow( + icon: BuzzIcons.logIn, + title: isJoining.value ? 'Joining channel…' : 'Join channel', + onTap: isJoining.value ? null : joinChannel, + ), + if (resolvedChannel.isMember && !resolvedChannel.isArchived) + AppListRow( + icon: BuzzIcons.logOut, + title: 'Leave channel', + titleColor: context.colors.error, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Leave #${resolvedChannel.name}?', + body: 'You’ll stop receiving messages from this channel.', + confirmLabel: 'Leave', + action: () => ref + .read(channelActionsProvider) + .leaveChannel(resolvedChannel.id), + ), + ), + if (lifecycleCapabilitiesLoading) + const AppListRow( + icon: BuzzIcons.loaderCircle, + title: 'Loading channel actions…', + trailing: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading channel actions', + ), + ) + else if (lifecycleCapabilitiesUnavailable) + const AppListRow( + icon: BuzzIcons.triangleAlert, + title: 'Channel actions unavailable', + ) + else ...[ + if (canArchive) + AppListRow( + icon: BuzzIcons.archive, + title: 'Archive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Archive #${resolvedChannel.name}?', + body: 'The channel will become read-only.', + confirmLabel: 'Archive', + action: () => ref + .read(channelActionsProvider) + .archiveChannel(resolvedChannel.id), + ), + ), + if (canUnarchive) + AppListRow( + icon: BuzzIcons.archiveRestore, + title: 'Unarchive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Unarchive #${resolvedChannel.name}?', + body: 'The channel will become active again.', + confirmLabel: 'Unarchive', + action: () => ref + .read(channelActionsProvider) + .unarchiveChannel(resolvedChannel.id), + ), + ), + if (canDelete) + AppListRow( + icon: BuzzIcons.trash2, + title: 'Delete channel', + titleColor: context.colors.error, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Delete #${resolvedChannel.name}?', + body: + 'This permanently deletes the channel and cannot be undone.', + confirmLabel: 'Delete', + action: () => ref + .read(channelActionsProvider) + .deleteChannel(resolvedChannel.id), + ), + ), + ], + ], + ), + ], + ); + if (inSheet) return content; + return FrostedScaffold( useUtilitySurfaceTheme: true, appBar: FrostedAppBar( @@ -314,306 +651,7 @@ class ChannelDetailsPage extends HookConsumerWidget { ), ), ), - body: ListView( - key: const ValueKey('channel-details-page-list'), - controller: scrollController, - padding: EdgeInsets.only( - top: frostedAppBarHeight(context) + Grid.xs, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs, - ), - children: [ - _ChannelDetailsHero( - channel: resolvedChannel, - nameKey: heroNameKey, - displayName: displayName, - avatar: counterpart == null - ? null - : AvatarImage( - key: const ValueKey('dm-details-avatar'), - imageUrl: counterpartProfile?.avatarUrl, - radius: 40, - isAgent: counterpartProfile?.isAgent ?? false, - backgroundColor: context.controlSurface, - fallback: Text( - counterpartProfile?.initial ?? - dmAvatarInitial( - resolvedChannel, - currentPubkey: resolvedCurrentPubkey, - ), - style: context.textTheme.headlineMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - ), - ), - Padding( - key: const ValueKey('channel-details-actions'), - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - _channelDetailsSectionPadding, - Grid.gutter, - _channelDetailsSectionPadding, - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-star-action'), - icon: null, - iconWidget: TablerStarIcon( - filled: isStarred, - color: isStarred - ? context.colors.primary - : context.colors.onSurface, - ), - label: isStarred ? 'Unstar' : 'Star', - onTap: toggleStar, - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-mute-action'), - icon: isMuted ? BuzzIcons.bell : BuzzIcons.bellOff, - iconColor: isMuted ? context.colors.primary : null, - label: isMuted ? 'Unmute' : 'Mute', - onTap: toggleMute, - ), - ), - if (!resolvedChannel.isDm) ...[ - const SizedBox(width: Grid.xxs), - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-edit-action'), - icon: BuzzIcons.pencil, - label: 'Edit', - isEnabled: canEdit, - onTap: openManageChannel, - ), - ), - ], - ], - ), - ), - ), - if (!isDirectDm) - AppListCard( - key: const ValueKey('channel-details-members-card'), - label: memberLabel, - dividerIndent: Grid.xs + 40 + Grid.xs, - verticalPadding: _channelDetailsSectionPadding, - children: [ - if (canAddMembers) - AppListRowRaw( - key: const ValueKey('channel-details-add-members-row'), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.colors.surfaceContainer, - shape: BoxShape.circle, - ), - child: Icon( - BuzzIcons.plus, - color: context.colors.onSurfaceVariant, - ), - ), - title: Text( - 'Add members', - style: context.textTheme.bodyLarge, - ), - trailing: const _ChannelDetailsChevron(), - onTap: openAddMembers, - verticalPadding: Grid.xxs, - ), - if (membersAsync.isLoading && members.isEmpty) - const AppListRow( - icon: BuzzIcons.loaderCircle, - title: 'Loading members…', - trailing: BuzzLoadingIndicator( - size: 20, - semanticLabel: 'Loading members', - ), - ) - else if (membersAsync.hasError && members.isEmpty) - const AppListRow( - icon: BuzzIcons.triangleAlert, - title: 'Members unavailable', - ) - else ...[ - for (final member in previewMembers) - _ChannelMemberPreviewRow( - key: ValueKey('channel-details-member-${member.pubkey}'), - member: member, - currentPubkey: resolvedCurrentPubkey, - onMemberTap: onMemberTap, - displayName: - userCache[member.pubkey.toLowerCase()]?.displayName, - contextualLabel: - memberLabels[member.pubkey.toLowerCase()], - avatarUrl: - userCache[member.pubkey.toLowerCase()]?.avatarUrl, - ), - if (memberCount > _channelMemberPreviewLimit) - AppListRowRaw( - key: const ValueKey('channel-details-members-row'), - leading: const SizedBox.square(dimension: 40), - title: Text( - 'See all', - style: context.textTheme.bodyLarge, - ), - trailing: const _ChannelDetailsChevron(), - onTap: openMembers, - verticalPadding: Grid.xxs, - ), - ], - ], - ), - AppListCard( - key: const ValueKey('channel-details-channel-card'), - verticalPadding: _channelDetailsSectionPadding, - children: [ - AppListRow( - icon: BuzzIcons.folderInput, - title: 'Move to section…', - trailing: const _ChannelDetailsChevron(), - onTap: () async { - await _showMoveSectionSheet( - context, - ref, - channel: resolvedChannel, - sectionId: currentSectionId, - ); - }, - ), - AppListRow( - icon: BuzzIcons.copy, - title: 'Copy channel name', - onTap: () { - copyToClipboard( - context, - resolvedChannel.name, - message: 'Channel name copied', - ); - }, - ), - AppListRow( - icon: BuzzIcons.hash, - title: 'Copy channel ID', - onTap: () { - copyToClipboard( - context, - resolvedChannel.id, - message: 'Channel ID copied', - ); - }, - ), - ], - ), - if (resolvedChannel.isMember || - canJoin || - lifecycleCapabilitiesLoading || - lifecycleCapabilitiesUnavailable || - canArchive || - canUnarchive || - canDelete) - AppListCard( - key: const ValueKey('channel-details-lifecycle-card'), - verticalPadding: _channelDetailsSectionPadding, - children: [ - if (canJoin) - AppListRow( - icon: BuzzIcons.logIn, - title: isJoining.value - ? 'Joining channel…' - : 'Join channel', - onTap: isJoining.value ? null : joinChannel, - ), - if (resolvedChannel.isMember && !resolvedChannel.isArchived) - AppListRow( - icon: BuzzIcons.logOut, - title: 'Leave channel', - titleColor: context.colors.error, - onTap: () => _confirmAndRun( - context, - ref, - title: 'Leave #${resolvedChannel.name}?', - body: 'You’ll stop receiving messages from this channel.', - confirmLabel: 'Leave', - action: () => ref - .read(channelActionsProvider) - .leaveChannel(resolvedChannel.id), - ), - ), - if (lifecycleCapabilitiesLoading) - const AppListRow( - icon: BuzzIcons.loaderCircle, - title: 'Loading channel actions…', - trailing: BuzzLoadingIndicator( - size: 20, - semanticLabel: 'Loading channel actions', - ), - ) - else if (lifecycleCapabilitiesUnavailable) - const AppListRow( - icon: BuzzIcons.triangleAlert, - title: 'Channel actions unavailable', - ) - else ...[ - if (canArchive) - AppListRow( - icon: BuzzIcons.archive, - title: 'Archive channel', - onTap: () => _confirmAndRun( - context, - ref, - title: 'Archive #${resolvedChannel.name}?', - body: 'The channel will become read-only.', - confirmLabel: 'Archive', - action: () => ref - .read(channelActionsProvider) - .archiveChannel(resolvedChannel.id), - ), - ), - if (canUnarchive) - AppListRow( - icon: BuzzIcons.archiveRestore, - title: 'Unarchive channel', - onTap: () => _confirmAndRun( - context, - ref, - title: 'Unarchive #${resolvedChannel.name}?', - body: 'The channel will become active again.', - confirmLabel: 'Unarchive', - action: () => ref - .read(channelActionsProvider) - .unarchiveChannel(resolvedChannel.id), - ), - ), - if (canDelete) - AppListRow( - icon: BuzzIcons.trash2, - title: 'Delete channel', - titleColor: context.colors.error, - onTap: () => _confirmAndRun( - context, - ref, - title: 'Delete #${resolvedChannel.name}?', - body: - 'This permanently deletes the channel and cannot be undone.', - confirmLabel: 'Delete', - action: () => ref - .read(channelActionsProvider) - .deleteChannel(resolvedChannel.id), - ), - ), - ], - ], - ), - ], - ), + body: content, ); } } diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 7ad212d61d..e878a894fc 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -35,7 +35,11 @@ import 'me_assistant_picker_sheet.dart'; import 'new_me_project.dart'; import 'me_workspace_picker.dart'; import 'me_recovery_dialog.dart'; +import '../../profile/user_profile_sheet.dart'; +import '../channel_actions_sheet.dart'; import '../channel_detail_page.dart'; +import '../channel_identity_names_provider.dart'; +import '../channel_member_profile_actions.dart'; import '../channel_management_provider.dart'; import '../channels_provider.dart'; @@ -179,6 +183,21 @@ class MePage extends HookConsumerWidget { ChannelDetailPage(channel: channel, personalChat: true), ), ), + onLongPress: () => showChannelDetailsSheet( + context: context, + channel: channel, + currentPubkey: viewer, + onMemberTap: (context, pubkey) => showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider(channel.id), + contextualActions: (_) => ChannelMemberProfileActions( + channel: channel, + pubkey: pubkey, + ), + ), + personalChat: true, + ), ); final inventorySlivers = [ SliverPadding( diff --git a/mobile/lib/features/channels/me/me_page_helpers.dart b/mobile/lib/features/channels/me/me_page_helpers.dart index a0ee92d285..41214dea03 100644 --- a/mobile/lib/features/channels/me/me_page_helpers.dart +++ b/mobile/lib/features/channels/me/me_page_helpers.dart @@ -20,12 +20,14 @@ class _MeChatRow extends ConsumerWidget { required this.channelId, required this.name, required this.onTap, + required this.onLongPress, required this.isUnread, }); final String channelId; final String name; final bool isUnread; final VoidCallback onTap; + final VoidCallback onLongPress; @override Widget build(BuildContext context, WidgetRef ref) { @@ -35,11 +37,13 @@ class _MeChatRow extends ConsumerWidget { return Semantics( label: '${isUnread ? '$name, unread' : name}$status', onTap: onTap, + onLongPress: onLongPress, excludeSemantics: true, button: true, child: InkWell( borderRadius: BorderRadius.circular(Radii.md), onTap: onTap, + onLongPress: onLongPress, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.xxs, diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart index c67d340641..7bef3f3c7c 100644 --- a/mobile/test/features/channels/me/me_page_test.dart +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_groups.dart'; @@ -117,6 +118,66 @@ void main() { expect(find.text('A place to work with your agents'), findsNothing); }); + testWidgets('long-pressing a chat opens its details as a sheet', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + defaultAgentProvider.overrideWith((ref) => Stream.value(null)), + myPubkeyProvider.overrideWithValue('owner'), + currentPubkeyProvider.overrideWith((ref) => 'owner'), + channelsProvider.overrideWith(LabeledChannels.new), + channelMembersProvider('loose').overrideWith( + (ref) async => [ + ChannelMember( + pubkey: 'owner', + role: 'owner', + joinedAt: DateTime(2026), + ), + ], + ), + agentOwnersProvider.overrideWithValue( + const AsyncValue.data({}), + ), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups(groups: [], assignments: {})), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('Unfiled labeled chat')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.byKey(const ValueKey('channel-details-page-list')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Unfiled labeled chat'), + ), + findsOneWidget, + ); + + await tester.drag( + find.byKey(const ValueKey('channel-details-page-list')), + const Offset(0, -600), + ); + await tester.pumpAndSettle(); + expect(find.text('Leave channel'), findsOneWidget); + // Solo chats move between Solo workspaces, not channel sections. + expect(find.text('Move to section…'), findsNothing); + }); + testWidgets('empty workspace stays centered behind the New chat keyboard', ( tester, ) async {