Files
buzz/mobile/lib/features/channels/me/new_me_chat.dart
klopez4212 dc25198d53 Add project selection to new Solo chats on mobile (#8546)
New Solo chats can optionally choose a project in the Flutter form and
native iOS sheet. Project appears above the agent picker with matching
neutral text, and the choice survives retries and restarts.

Validation: full Flutter suite passed; analysis and iOS builds passed.
Installed in Pairing Preview on iPhone Air and visually approved,
preserving saved preferences. Successful live chat creation remains
unverified because no agents were available.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-22 15:33:49 -07:00

195 lines
7.6 KiB
Dart

part of 'me_page.dart';
class _NewMeChat extends HookConsumerWidget {
const _NewMeChat();
@override
Widget build(BuildContext context, WidgetRef ref) {
final sync = ref.watch(meSyncProvider);
final pending = useMemoized(() => MeChatCreation.pending(sync));
final name = useTextEditingController(text: pending?.name ?? '');
final selected = useState<String?>(pending?.agent);
final project = useState<String?>(pending?.project);
final groups = ref.watch(meGroupsProvider);
final channelId = useMemoized(() => pending?.id ?? const Uuid().v4());
final frozenName = useState<String?>(pending?.name);
final frozenAgent = useState<String?>(pending?.agent);
final busy = useState(false);
final error = useState<String?>(
pending == null ? null : 'Resume your unfinished chat.',
);
final directory = ref.watch(meAssistantsProvider);
final originalScope = useMemoized(() => (sync.community, sync.pubkey));
if (originalScope != (sync.community, sync.pubkey)) {
return const Padding(
padding: EdgeInsets.all(Grid.gutter),
child: Text(
'Your account or community changed. Return to Solo to start a chat.',
),
);
}
final choices = directory.asData?.value ?? [];
final chosen = choices
.where((agent) => agent.pubkey == selected.value)
.firstOrNull;
final profile = ref.watch(userCacheProvider)[selected.value];
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('new-session-name'),
controller: name,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
enabled: !busy.value && frozenName.value == null,
maxLength: 120,
decoration: const InputDecoration(hintText: 'Chat name'),
onChanged: (_) => error.value = null,
),
const SizedBox(height: Grid.xs),
DropdownButtonFormField<String>(
key: const ValueKey('new-session-project'),
initialValue: project.value ?? '',
isExpanded: true,
decoration: const InputDecoration(labelText: 'Project'),
items: [
const DropdownMenuItem(value: '', child: Text('No project')),
for (final group
in groups.asData?.value.groups ?? <Map<String, dynamic>>[])
DropdownMenuItem(
value: group['id'] as String,
child: Text(group['name'] as String),
),
if (project.value != null &&
!(groups.asData?.value.groups ?? []).any(
(group) => group['id'] == project.value,
))
DropdownMenuItem(
value: project.value,
child: const Text('Unavailable project'),
),
],
onChanged:
busy.value || frozenName.value != null || !groups.hasValue
? null
: (value) => project.value = value == '' ? null : value,
),
const SizedBox(height: Grid.xs),
AppListRowRaw(
key: const ValueKey('new-session-assistant'),
leading: chosen == null
? const Icon(BuzzIcons.plus)
: AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 20,
isAgent: true,
backgroundColor: context.controlSurface,
fallback: Text(
profile?.initial ??
(chosen.displayName?.isNotEmpty == true
? chosen.displayName![0]
: 'A'),
),
),
title: Text(chosen?.displayName ?? 'Choose an agent'),
trailing: const Icon(BuzzIcons.chevronRight, size: 20),
onTap: busy.value || frozenName.value != null
? null
: () async {
FocusScope.of(context).unfocus();
if (!context.mounted) return;
final picked = await showBuzzModalBottomSheet<String>(
context: context,
title: 'Agent',
isScrollControlled: true,
showDragHandle: true,
builder: (_) => MeAssistantList(
choices: choices,
selected: selected.value,
),
);
if (context.mounted && picked != null) {
selected.value = picked;
}
},
),
if (directory.isLoading) const LinearProgressIndicator(),
if (directory.hasError)
TextButton(
onPressed: () {
ref.invalidate(meAssistantsProvider);
},
child: const Text('Reload agents'),
),
if (!directory.isLoading && !directory.hasError && choices.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.gutter),
child: Text(
'No agents are available for this account. Connect an agent on desktop, then refresh.',
),
),
if (error.value != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.gutter),
child: Text(error.value!),
),
const SizedBox(height: Grid.gutter),
FilledButton(
onPressed: busy.value
? null
: () async {
if (name.text.trim().isEmpty || selected.value == null) {
error.value = 'Enter a name and choose an agent.';
return;
}
busy.value = true;
error.value = null;
try {
frozenName.value ??= name.text.trim();
frozenAgent.value ??= selected.value;
final created = await _createMeChat(
ref,
sync,
channelId,
frozenName.value!,
frozenAgent.value!,
project.value,
);
if (context.mounted) {
Navigator.of(
context,
).pop((created, frozenAgent.value!));
}
} catch (e) {
if (e is MeRecoveryConflict && context.mounted) {
await _offerMeRecovery(context, sync);
}
if (context.mounted) {
error.value =
'${e.toString().replaceFirst('Bad state: ', '')}\nRetry continues this same chat.';
}
} finally {
if (context.mounted) busy.value = false;
}
},
child: Text(
busy.value
? 'Creating chat…'
: frozenName.value != null
? 'Retry'
: 'Create chat',
),
),
],
),
);
}
}